Add support for multiple providers
This commit is contained in:
@@ -15,99 +15,41 @@ import (
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
|
||||
"code.chimeric.al/chimerical/odidere/internal/config"
|
||||
"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 is the cron.Schedule derived from
|
||||
// schedule during construction.
|
||||
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
|
||||
|
||||
// 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
|
||||
@@ -115,12 +57,10 @@ type Job struct {
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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(
|
||||
@@ -138,16 +78,11 @@ func New(cfg Config, log *slog.Logger, llmClient *llm.Client) (*Job, error) {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
llm: llmc,
|
||||
model: model,
|
||||
maxIterations: cfg.MaxIterations,
|
||||
name: cfg.Name,
|
||||
schedule: cfg.Schedule,
|
||||
@@ -158,45 +93,28 @@ func New(cfg Config, log *slog.Logger, llmClient *llm.Client) (*Job, error) {
|
||||
}
|
||||
|
||||
// Cron returns the parsed cron schedule.
|
||||
func (j *Job) Cron() cron.Schedule {
|
||||
return j.cron
|
||||
}
|
||||
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.
|
||||
func (j *Job) MaxIterations() int { return j.maxIterations }
|
||||
|
||||
// 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
|
||||
}
|
||||
// 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
|
||||
}
|
||||
func (j *Job) Name() string { return j.name }
|
||||
|
||||
// Schedule returns the cron schedule string.
|
||||
func (j *Job) Schedule() string {
|
||||
return j.schedule
|
||||
}
|
||||
// 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
|
||||
}
|
||||
func (j *Job) SystemMessage() string { return j.systemMessage }
|
||||
|
||||
// Task returns the job's task description.
|
||||
func (j *Job) Task() string {
|
||||
return j.task
|
||||
}
|
||||
func (j *Job) Task() string { return j.task }
|
||||
|
||||
// Timeout returns the job's timeout duration.
|
||||
func (j *Job) Timeout() time.Duration {
|
||||
return j.timeout
|
||||
}
|
||||
func (j *Job) Timeout() time.Duration { return j.timeout }
|
||||
|
||||
// Result holds the outcome of a job execution.
|
||||
type Result struct {
|
||||
@@ -212,7 +130,7 @@ type Result struct {
|
||||
}
|
||||
|
||||
// Run executes the job: builds messages from the job config, calls the LLM,
|
||||
// and logs progress. The agent uses tools for any additional output.
|
||||
// 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()
|
||||
@@ -237,13 +155,16 @@ func (j *Job) Run(ctx context.Context) Result {
|
||||
msgs, err := j.llm.Query(
|
||||
ctx,
|
||||
messages,
|
||||
"",
|
||||
j.model,
|
||||
j.SystemMessage(),
|
||||
j.MaxIterations(),
|
||||
)
|
||||
|
||||
duration := time.Since(start)
|
||||
if err != nil {
|
||||
log.Error("job failed",
|
||||
log.ErrorContext(
|
||||
ctx,
|
||||
"job failed",
|
||||
slog.Any("error", err),
|
||||
slog.Duration("duration", duration),
|
||||
)
|
||||
@@ -254,7 +175,9 @@ func (j *Job) Run(ctx context.Context) Result {
|
||||
}
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
log.Error("job returned no messages",
|
||||
log.ErrorContext(
|
||||
ctx,
|
||||
"job returned no messages",
|
||||
slog.Duration("duration", duration),
|
||||
)
|
||||
return Result{
|
||||
@@ -265,11 +188,13 @@ func (j *Job) Run(ctx context.Context) Result {
|
||||
),
|
||||
}
|
||||
}
|
||||
log.Info("job completed",
|
||||
|
||||
log.InfoContext(
|
||||
ctx,
|
||||
"job completed",
|
||||
slog.Duration("duration", duration),
|
||||
slog.Int("messages", len(msgs)),
|
||||
)
|
||||
|
||||
return Result{
|
||||
Name: j.Name(),
|
||||
Duration: duration,
|
||||
|
||||
@@ -4,22 +4,24 @@ import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.chimeric.al/chimerical/odidere/internal/config"
|
||||
)
|
||||
|
||||
func TestConfigValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config Config
|
||||
config config.JobConfig
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty config",
|
||||
config: Config{},
|
||||
config: config.JobConfig{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing name",
|
||||
config: Config{
|
||||
config: config.JobConfig{
|
||||
Schedule: "0 9 * * *",
|
||||
Task: "do something",
|
||||
},
|
||||
@@ -27,7 +29,7 @@ func TestConfigValidate(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "missing task",
|
||||
config: Config{
|
||||
config: config.JobConfig{
|
||||
Name: "test",
|
||||
Schedule: "0 9 * * *",
|
||||
},
|
||||
@@ -35,7 +37,7 @@ func TestConfigValidate(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "missing schedule",
|
||||
config: Config{
|
||||
config: config.JobConfig{
|
||||
Name: "test",
|
||||
Task: "do something",
|
||||
},
|
||||
@@ -43,7 +45,7 @@ func TestConfigValidate(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "negative max iterations",
|
||||
config: Config{
|
||||
config: config.JobConfig{
|
||||
Name: "test",
|
||||
Task: "do something",
|
||||
Schedule: "0 9 * * *",
|
||||
@@ -53,7 +55,7 @@ func TestConfigValidate(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "minimal valid config",
|
||||
config: Config{
|
||||
config: config.JobConfig{
|
||||
Name: "test",
|
||||
Task: "do something",
|
||||
Schedule: "0 9 * * *",
|
||||
@@ -62,7 +64,7 @@ func TestConfigValidate(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "full valid config",
|
||||
config: Config{
|
||||
config: config.JobConfig{
|
||||
Name: "test",
|
||||
Task: "do something",
|
||||
Schedule: "0 9 * * *",
|
||||
@@ -84,31 +86,9 @@ func TestConfigValidate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigIsEnabled(t *testing.T) {
|
||||
// nil enabled → defaults to true
|
||||
cfg := Config{Name: "test"}
|
||||
if !cfg.IsEnabled() {
|
||||
t.Error("expected nil enabled to be true")
|
||||
}
|
||||
|
||||
// explicit true
|
||||
enabled := true
|
||||
cfg.Enabled = &enabled
|
||||
if !cfg.IsEnabled() {
|
||||
t.Error("expected explicit true")
|
||||
}
|
||||
|
||||
// explicit false
|
||||
disabled := false
|
||||
cfg.Enabled = &disabled
|
||||
if cfg.IsEnabled() {
|
||||
t.Error("expected explicit false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigMaxIterations(t *testing.T) {
|
||||
// Zero means unlimited
|
||||
cfg := Config{Name: "test"}
|
||||
cfg := config.JobConfig{Name: "test"}
|
||||
if got := cfg.MaxIterations; got != 0 {
|
||||
t.Errorf("default max iterations = %d, want 0", got)
|
||||
}
|
||||
@@ -126,7 +106,7 @@ func TestConfigMaxIterations(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfigTimeout(t *testing.T) {
|
||||
cfg := Config{Name: "test"}
|
||||
cfg := config.JobConfig{Name: "test"}
|
||||
if cfg.Timeout != "" {
|
||||
t.Errorf("default timeout = %q, want empty", cfg.Timeout)
|
||||
}
|
||||
@@ -142,7 +122,7 @@ func TestConfigTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
cfg := Config{
|
||||
cfg := config.JobConfig{
|
||||
Name: "test",
|
||||
Task: "do something",
|
||||
Schedule: "0 9 * * *",
|
||||
@@ -151,7 +131,7 @@ func TestNew(t *testing.T) {
|
||||
SystemMessage: "You are an assistant.",
|
||||
}
|
||||
|
||||
j, err := New(cfg, slog.Default(), nil)
|
||||
j, err := New(cfg, slog.Default(), nil, "test-model")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
@@ -162,9 +142,6 @@ func TestNew(t *testing.T) {
|
||||
if j.Schedule() != "0 9 * * *" {
|
||||
t.Errorf("Schedule() = %q, want %q", j.Schedule(), "0 9 * * *")
|
||||
}
|
||||
if !j.IsEnabled() {
|
||||
t.Error("IsEnabled() = false, want true")
|
||||
}
|
||||
if j.SystemMessage() != "You are an assistant." {
|
||||
t.Errorf("SystemMessage() = %q, want %q", j.SystemMessage(), "You are an assistant.")
|
||||
}
|
||||
@@ -177,52 +154,36 @@ func TestNew(t *testing.T) {
|
||||
if j.MaxIterations() != 20 {
|
||||
t.Errorf("MaxIterations() = %d, want 20", j.MaxIterations())
|
||||
}
|
||||
if j.Model() != "test-model" {
|
||||
t.Errorf("Model() = %q, want %q", j.Model(), "test-model")
|
||||
}
|
||||
if j.Cron() == nil {
|
||||
t.Error("Cron() = nil, want non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_Disabled(t *testing.T) {
|
||||
disabled := false
|
||||
cfg := Config{
|
||||
Name: "test",
|
||||
Task: "do something",
|
||||
Schedule: "0 9 * * *",
|
||||
Enabled: &disabled,
|
||||
}
|
||||
|
||||
j, err := New(cfg, slog.Default(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
if j.IsEnabled() {
|
||||
t.Error("IsEnabled() = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_InvalidSchedule(t *testing.T) {
|
||||
cfg := Config{
|
||||
cfg := config.JobConfig{
|
||||
Name: "test",
|
||||
Task: "do something",
|
||||
Schedule: "not-a-cron",
|
||||
}
|
||||
|
||||
_, err := New(cfg, slog.Default(), nil)
|
||||
_, err := New(cfg, slog.Default(), nil, "test-model")
|
||||
if err == nil {
|
||||
t.Fatal("New() expected error for invalid schedule")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_InvalidTimeout(t *testing.T) {
|
||||
cfg := Config{
|
||||
cfg := config.JobConfig{
|
||||
Name: "test",
|
||||
Task: "do something",
|
||||
Schedule: "0 9 * * *",
|
||||
Timeout: "not-a-duration",
|
||||
}
|
||||
|
||||
_, err := New(cfg, slog.Default(), nil)
|
||||
_, err := New(cfg, slog.Default(), nil, "test-model")
|
||||
if err == nil {
|
||||
t.Fatal("New() expected error for invalid timeout")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user