// Package job provides cron-based scheduling for autonomous LLM tasks. // // Jobs are defined in the YAML configuration with a cron expression, // a system message, and a task description. When the service fires a // job, it constructs messages and calls the LLM agent loop to execute the // task. package job import ( "context" "fmt" "log/slog" "time" "github.com/robfig/cron/v3" "github.com/sashabaranov/go-openai" "code.chimeric.al/chimerical/odidere/internal/llm" ) // Config holds the user-facing configuration for a job. // It is unmarshaled from YAML and passed to New, which validates and // parses it into an immutable Job. type Config struct { // Enabled controls whether the job is active. Defaults to true. Enabled *bool `yaml:"enabled,omitempty"` // MaxIterations caps the number of agent loop iterations. // A value of 0 means unlimited (no cap). MaxIterations int `yaml:"max_iterations,omitempty"` // Name uniquely identifies the job within the config. Name string `yaml:"name"` // Schedule is a cron expression (e.g., "0 9 * * *"). Schedule string `yaml:"schedule"` // SystemMessage overrides the global system message for this job. SystemMessage string `yaml:"system_message,omitempty"` // Task is the user-level instruction to give the LLM. Task string `yaml:"task"` // Timeout limits the job execution time (e.g., "10m"). // Defaults to the global LLM timeout if empty. Timeout string `yaml:"timeout,omitempty"` } // IsEnabled returns whether the job should be scheduled. // If Enabled is nil, the job is considered enabled by default. func (cfg Config) IsEnabled() bool { if cfg.Enabled == nil { return true } return *cfg.Enabled } // Validate checks that required fields are present and constraints hold. // It does not parse values — that is the responsibility of New. func (cfg Config) Validate() error { if cfg.Name == "" { return fmt.Errorf("missing name") } if cfg.Task == "" { return fmt.Errorf("missing task") } if cfg.Schedule == "" { return fmt.Errorf("missing schedule") } if cfg.MaxIterations < 0 { return fmt.Errorf("max_iterations must be non-negative") } return nil } // Job defines a scheduled autonomous task. // // Job is immutable after construction via New. All fields are private and // accessed through getter methods. This ensures that a Job's configuration // cannot change once it is handed to the service. type Job struct { // cron is the cron.Schedule derived from schedule during // construction. This is what the robfig/cron package uses to determine // when the job should fire. cron cron.Schedule // enabled indicates whether the job should be registered with the cron // scheduler. enabled bool // log is the structured logger used for job execution. log *slog.Logger // llm is the LLM client used to execute the job. llm *llm.Client // maxIterations caps the agent loop iterations for this job. Zero means // unlimited. maxIterations int // name uniquely identifies the job within the config. It is used for // logging, scheduling, and lookup. name string // schedule is the original cron expression string from the config // (e.g. "0 9 * * *"). Stored for logging and display purposes. schedule string // systemMessage is the per-job system prompt that overrides the global // LLM system message. Empty string means use the default. systemMessage string // task is the user-level instruction passed to the LLM when the job // fires. This is the core of what the job does. task string // timeout is the parsed execution time limit. Zero means no per-job // timeout (falls back to the global LLM timeout). timeout time.Duration } // New creates a new Job from a Config. // log is the structured logger used for job execution. // llm is the LLM client used to execute the job. func New(cfg Config, log *slog.Logger, llmClient *llm.Client) (*Job, error) { if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("invalid config: %w", err) } sched, err := cron.ParseStandard(cfg.Schedule) if err != nil { return nil, fmt.Errorf( "invalid schedule %q: %w", cfg.Schedule, err, ) } var timeout time.Duration if cfg.Timeout != "" { timeout, err = time.ParseDuration(cfg.Timeout) if err != nil { return nil, fmt.Errorf( "invalid timeout %q: %w", cfg.Timeout, err, ) } } enabled := true if cfg.Enabled != nil { enabled = *cfg.Enabled } return &Job{ cron: sched, enabled: enabled, log: log.With(slog.String("job", cfg.Name)), llm: llmClient, maxIterations: cfg.MaxIterations, name: cfg.Name, schedule: cfg.Schedule, systemMessage: cfg.SystemMessage, task: cfg.Task, timeout: timeout, }, nil } // Cron returns the parsed cron schedule. func (j *Job) Cron() cron.Schedule { return j.cron } // IsEnabled returns whether the job should be scheduled. func (j *Job) IsEnabled() bool { return j.enabled } // MaxIterations returns the configured max iterations. A value of 0 // means unlimited (no cap on agent loop iterations). func (j *Job) MaxIterations() int { return j.maxIterations } // Name returns the job name. func (j *Job) Name() string { return j.name } // Schedule returns the cron schedule string. func (j *Job) Schedule() string { return j.schedule } // SystemMessage returns the job's system message. func (j *Job) SystemMessage() string { return j.systemMessage } // Task returns the job's task description. func (j *Job) Task() string { return j.task } // Timeout returns the job's timeout duration. func (j *Job) Timeout() time.Duration { return j.timeout } // Result holds the outcome of a job execution. type Result struct { // Name is the job name. Name string // Duration is how long the job took to run. Duration time.Duration // Messages is the full conversation returned by the LLM agent loop, // including tool calls and tool results. Empty if the job failed. Messages []openai.ChatCompletionMessage // Error is set if the job failed. Error error } // Run executes the job: builds messages from the job config, calls the LLM, // and logs progress. The agent uses tools for any additional output. // Uses j.log and j.llm internally. func (j *Job) Run(ctx context.Context) Result { start := time.Now() log := j.log.With(slog.String("job", j.Name())) log.Info("starting job") // Build messages from job config. messages := []openai.ChatCompletionMessage{ { Role: openai.ChatMessageRoleUser, Content: j.Task(), }, } // Apply per-job timeout if set. if j.Timeout() > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, j.Timeout()) defer cancel() } msgs, err := j.llm.Query( ctx, messages, "", j.SystemMessage(), j.MaxIterations(), ) duration := time.Since(start) if err != nil { log.Error("job failed", slog.Any("error", err), slog.Duration("duration", duration), ) return Result{ Name: j.Name(), Duration: duration, Error: fmt.Errorf("job %q: %w", j.Name(), err), } } if len(msgs) == 0 { log.Error("job returned no messages", slog.Duration("duration", duration), ) return Result{ Name: j.Name(), Duration: duration, Error: fmt.Errorf( "job %q: no response from LLM", j.Name(), ), } } log.Info("job completed", slog.Duration("duration", duration), slog.Int("messages", len(msgs)), ) return Result{ Name: j.Name(), Duration: duration, Messages: msgs, } }