209 lines
5.8 KiB
Go
209 lines
5.8 KiB
Go
// 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"
|
|
|
|
"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 []llm.Message
|
|
// 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 := []llm.Message{
|
|
{
|
|
Role: llm.RoleUser,
|
|
ContentParts: []llm.ContentPart{
|
|
{
|
|
Text: j.Task(),
|
|
Type: llm.ContentTypeText,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Apply per-job timeout if set.
|
|
if j.Timeout() > 0 {
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeout(ctx, j.Timeout())
|
|
defer cancel()
|
|
}
|
|
|
|
res, err := j.llm.Completions(
|
|
ctx, llm.CompletionsParams{
|
|
Model: j.model,
|
|
SystemMessage: j.SystemMessage(),
|
|
MaxIterations: j.MaxIterations(),
|
|
Messages: messages,
|
|
},
|
|
)
|
|
|
|
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(res.Messages) == 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(res.Messages)),
|
|
)
|
|
return Result{
|
|
Name: j.Name(),
|
|
Duration: duration,
|
|
Messages: res.Messages,
|
|
}
|
|
}
|