// 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/config" "code.chimeric.al/chimerical/odidere/internal/llm" ) // 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. cron cron.Schedule // 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 // model is the model ID to pass to the LLM client (without provider // prefix, which is resolved at construction time). model string // 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. // llmc is the LLM client used to execute the job. // model is the model ID (without provider prefix) to pass to the client. func New(cfg config.JobConfig, log *slog.Logger, llmc *llm.Client, model string) (*Job, error) { // Validation is handled by config.Config.Validate(). 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, ) } } return &Job{ cron: sched, log: log.With(slog.String("job", cfg.Name)), llm: llmc, model: model, 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 } // MaxIterations returns the configured max iterations. func (j *Job) MaxIterations() int { return j.maxIterations } // Model returns the model ID used by the job. func (j *Job) Model() string { return j.model } // Name returns the job name. func (j *Job) Name() string { return j.name } // Schedule returns the job 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 the result. 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.model, j.SystemMessage(), j.MaxIterations(), ) duration := time.Since(start) if err != nil { log.ErrorContext( ctx, "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.ErrorContext( ctx, "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.InfoContext( ctx, "job completed", slog.Duration("duration", duration), slog.Int("messages", len(msgs)), ) return Result{ Name: j.Name(), Duration: duration, Messages: msgs, } }