Files
odidere/internal/job/job.go

208 lines
5.7 KiB
Go
Raw Normal View History

2026-06-02 12:03:03 +00:00
// 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"
2026-06-19 14:31:41 +00:00
"code.chimeric.al/chimerical/odidere/internal/config"
2026-06-02 12:03:03 +00:00
"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 {
2026-06-19 14:31:41 +00:00
// cron is the cron.Schedule derived from
// schedule during construction.
2026-06-02 12:03:03 +00:00
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
2026-06-19 14:31:41 +00:00
// model is the model ID to pass to the LLM client (without provider
// prefix, which is resolved at construction time).
model string
2026-06-02 12:03:03 +00:00
// 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.
2026-06-19 14:31:41 +00:00
// 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().
2026-06-02 12:03:03 +00:00
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)),
2026-06-19 14:31:41 +00:00
llm: llmc,
model: model,
2026-06-02 12:03:03 +00:00
maxIterations: cfg.MaxIterations,
name: cfg.Name,
schedule: cfg.Schedule,
systemMessage: cfg.SystemMessage,
task: cfg.Task,
timeout: timeout,
}, nil
}
// Cron returns the parsed cron schedule.
2026-06-19 14:31:41 +00:00
func (j *Job) Cron() cron.Schedule { return j.cron }
2026-06-02 12:03:03 +00:00
2026-06-19 14:31:41 +00:00
// MaxIterations returns the configured max iterations.
func (j *Job) MaxIterations() int { return j.maxIterations }
2026-06-02 12:03:03 +00:00
2026-06-19 14:31:41 +00:00
// Model returns the model ID used by the job.
func (j *Job) Model() string { return j.model }
2026-06-02 12:03:03 +00:00
// Name returns the job name.
2026-06-19 14:31:41 +00:00
func (j *Job) Name() string { return j.name }
2026-06-02 12:03:03 +00:00
2026-06-19 14:31:41 +00:00
// Schedule returns the job schedule string.
func (j *Job) Schedule() string { return j.schedule }
2026-06-02 12:03:03 +00:00
// SystemMessage returns the job's system message.
2026-06-19 14:31:41 +00:00
func (j *Job) SystemMessage() string { return j.systemMessage }
2026-06-02 12:03:03 +00:00
// Task returns the job's task description.
2026-06-19 14:31:41 +00:00
func (j *Job) Task() string { return j.task }
2026-06-02 12:03:03 +00:00
// Timeout returns the job's timeout duration.
2026-06-19 14:31:41 +00:00
func (j *Job) Timeout() time.Duration { return j.timeout }
2026-06-02 12:03:03 +00:00
// 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 []llm.Message
2026-06-02 12:03:03 +00:00
// Error is set if the job failed.
Error error
}
// Run executes the job: builds messages from the job config, calls the LLM,
2026-06-19 14:31:41 +00:00
// and logs the result. The agent uses tools for any additional output.
2026-06-02 12:03:03 +00:00
// 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 := []llm.Message{
2026-06-02 12:03:03 +00:00
{
Role: llm.RoleUser,
ContentParts: []llm.ContentPart{
{
Text: j.Task(),
Type: llm.ContentTypeText,
},
},
2026-06-02 12:03:03 +00:00
},
}
// Apply per-job timeout if set.
if j.Timeout() > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, j.Timeout())
defer cancel()
}
2026-06-27 00:13:16 +00:00
res, err := j.llm.Completions(
2026-06-02 12:03:03 +00:00
ctx,
2026-06-19 14:31:41 +00:00
j.model,
2026-06-02 12:03:03 +00:00
j.SystemMessage(),
j.MaxIterations(),
messages,
2026-06-02 12:03:03 +00:00
)
2026-06-19 14:31:41 +00:00
2026-06-02 12:03:03 +00:00
duration := time.Since(start)
if err != nil {
2026-06-19 14:31:41 +00:00
log.ErrorContext(
ctx,
"job failed",
2026-06-02 12:03:03 +00:00
slog.Any("error", err),
slog.Duration("duration", duration),
)
return Result{
Name: j.Name(),
Duration: duration,
Error: fmt.Errorf("job %q: %w", j.Name(), err),
}
}
2026-06-27 00:13:16 +00:00
if len(res.Messages) == 0 {
2026-06-19 14:31:41 +00:00
log.ErrorContext(
ctx,
"job returned no messages",
2026-06-02 12:03:03 +00:00
slog.Duration("duration", duration),
)
return Result{
Name: j.Name(),
Duration: duration,
Error: fmt.Errorf(
"job %q: no response from LLM", j.Name(),
),
}
}
2026-06-19 14:31:41 +00:00
log.InfoContext(
ctx,
"job completed",
2026-06-02 12:03:03 +00:00
slog.Duration("duration", duration),
2026-06-27 00:13:16 +00:00
slog.Int("messages", len(res.Messages)),
2026-06-02 12:03:03 +00:00
)
return Result{
Name: j.Name(),
Duration: duration,
2026-06-27 00:13:16 +00:00
Messages: res.Messages,
2026-06-02 12:03:03 +00:00
}
}