diff --git a/cmd/chat_tools.go b/cmd/chat_tools.go index 49c6d737..32bfbb22 100644 --- a/cmd/chat_tools.go +++ b/cmd/chat_tools.go @@ -53,6 +53,9 @@ func essentialTools() []tool.Tool { tool.ToolSearchTool{}, tool.SkillTool{}, tool.SessionQueryTool{}, + tool.ScheduleCreateTool{}, + tool.ScheduleListTool{}, + tool.ScheduleDeleteTool{}, tool.AgentTool{}, tool.AskUserQuestionTool{}, tool.TodoWriteTool{}, diff --git a/internal/engine/session.go b/internal/engine/session.go index 8950f706..f8678c9d 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -23,6 +23,7 @@ import ( "github.com/GrayCodeAI/hawk/internal/prompts" "github.com/GrayCodeAI/hawk/internal/resilience/ratelimit" "github.com/GrayCodeAI/hawk/internal/sandbox" + "github.com/GrayCodeAI/hawk/internal/schedule" "github.com/GrayCodeAI/hawk/internal/session" "github.com/GrayCodeAI/hawk/internal/snapshot" "github.com/GrayCodeAI/hawk/internal/tool" @@ -138,6 +139,9 @@ type Session struct { // emitted into the transcript (DSH tool-skill catalog digest). lastSkillCatalogDigest string + // scheduleManager coordinates session-log-backed schedule timers. + scheduleManager *schedule.Manager + // Control plane (product modes) — orthogonal to SpecStage and shellmode. workMode WorkMode isolation IsolationProfile @@ -1079,6 +1083,40 @@ func (s *Session) Chat(ctx context.Context, msgs []types.EyrieMessage, opts type return s.ChatLLM().Chat(ctx, msgs, opts) } +// Schedule returns the session's in-conversation schedule manager. +func (s *Session) Schedule() *schedule.Manager { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + if s.scheduleManager == nil { + s.scheduleManager = schedule.NewManager() + var j *eventlog.Log + if p := s.Persistence(); p != nil { + j = p.Journal() + } + s.scheduleManager.Attach(j, func(item schedule.Item) error { + content := fmt.Sprintf("[Scheduled Reminder: %s]\n%s", item.ID, item.Prompt) + if p := s.Persistence(); p != nil { + if sq := p.Steering(); sq != nil { + sq.Enqueue(SteeringMessage{ + Content: content, + Priority: 1, + }) + } else { + p.AppendUserJournaled(types.EyrieMessage{ + Role: "user", + Content: content, + }) + } + } + return nil + }) + } + return s.scheduleManager +} + // RemoveLastExchange removes the last user+assistant message pair. func (s *Session) RemoveLastExchange() { msgs := s.Persistence().RawMessages() diff --git a/internal/eventlog/event.go b/internal/eventlog/event.go index 1d65aaf5..900f720d 100644 --- a/internal/eventlog/event.go +++ b/internal/eventlog/event.go @@ -113,6 +113,14 @@ const ( // ScheduleChange records a schedule configuration change. Ported from // DeepSeek Harness schedule/change. ScheduleChange Type = "schedule.change" + // ScheduleCreate records an in-conversation schedule creation. + ScheduleCreate Type = "schedule.create" + // ScheduleUpdate records an in-conversation schedule update. + ScheduleUpdate Type = "schedule.update" + // ScheduleDelete records an in-conversation schedule deletion. + ScheduleDelete Type = "schedule.delete" + // ScheduleDue records that an in-conversation schedule reminder was triggered. + ScheduleDue Type = "schedule.due" // SessionTitle records a log-backed session title. Ported from DeepSeek // Harness session/title. SessionTitle Type = "session.title" @@ -166,6 +174,7 @@ func (t Type) Known() bool { CompactionStart, CompactionPrune, CompactionEnd, CompactionSummary, SessionEndSeed, TodoWrite, RequestHeader, HookInvoked, HookResult, FeedbackRecord, GoalChange, PermissionPreset, SandboxMode, ScheduleChange, + ScheduleCreate, ScheduleUpdate, ScheduleDelete, ScheduleDue, SessionTitle, SessionTitleLLMRequest, SubagentDescriptor, AgentPresetSelected, AgentInboxSpliced, CommandRun, CommandDone, ToolWorkflowAgentStart, ToolWorkflowAgentEnd, diff --git a/internal/eventlog/lifecycle.go b/internal/eventlog/lifecycle.go index ac734ee4..dc9b26b0 100644 --- a/internal/eventlog/lifecycle.go +++ b/internal/eventlog/lifecycle.go @@ -10,6 +10,8 @@ // plus plugin packages: compaction, hooks, feedback, goal, subagent, shell, preset. package eventlog +import "time" + // --- Compaction lifecycle --- // DeepSeek Harness models compaction as a four-stage lifecycle: // start → prune → end → summary. Hawk previously recorded only a single @@ -599,3 +601,66 @@ func (l *Log) AppendPlanMode(active bool) { } l.Append(PlanMode, PlanModeFact{Active: active}) } + +// --- In-conversation schedule --- + +// ScheduleCreateFact records an in-conversation schedule creation. +type ScheduleCreateFact struct { + ID string `json:"id"` + Prompt string `json:"prompt"` + DueAt time.Time `json:"due_at"` + Interval string `json:"interval,omitempty"` + Recurring bool `json:"recurring,omitempty"` +} + +// AppendScheduleCreate records schedule creation. +func (l *Log) AppendScheduleCreate(fact ScheduleCreateFact) { + if l == nil { + return + } + l.Append(ScheduleCreate, fact) +} + +// ScheduleUpdateFact records an in-conversation schedule update. +type ScheduleUpdateFact struct { + ID string `json:"id"` + Prompt *string `json:"prompt,omitempty"` + DueAt *time.Time `json:"due_at,omitempty"` +} + +// AppendScheduleUpdate records schedule update. +func (l *Log) AppendScheduleUpdate(fact ScheduleUpdateFact) { + if l == nil { + return + } + l.Append(ScheduleUpdate, fact) +} + +// ScheduleDeleteFact records an in-conversation schedule deletion. +type ScheduleDeleteFact struct { + ID string `json:"id"` + Reason string `json:"reason,omitempty"` +} + +// AppendScheduleDelete records schedule deletion. +func (l *Log) AppendScheduleDelete(id, reason string) { + if l == nil { + return + } + l.Append(ScheduleDelete, ScheduleDeleteFact{ID: id, Reason: reason}) +} + +// ScheduleDueFact records that an in-conversation schedule became due and was delivered. +type ScheduleDueFact struct { + ID string `json:"id"` + DeliveredAt time.Time `json:"delivered_at"` + NextDueAt *time.Time `json:"next_due_at,omitempty"` +} + +// AppendScheduleDue records schedule reminder delivery. +func (l *Log) AppendScheduleDue(fact ScheduleDueFact) { + if l == nil { + return + } + l.Append(ScheduleDue, fact) +} diff --git a/internal/eventlog/lifecycle_test.go b/internal/eventlog/lifecycle_test.go index f95aa9cd..ae1cb8a2 100644 --- a/internal/eventlog/lifecycle_test.go +++ b/internal/eventlog/lifecycle_test.go @@ -40,6 +40,10 @@ func TestNewEventTypesKnown(t *testing.T) { "permission.preset": PermissionPreset, "sandbox.mode": SandboxMode, "schedule.change": ScheduleChange, + "schedule.create": ScheduleCreate, + "schedule.update": ScheduleUpdate, + "schedule.delete": ScheduleDelete, + "schedule.due": ScheduleDue, "session.title": SessionTitle, "session.title-llm-request": SessionTitleLLMRequest, "subagent.descriptor": SubagentDescriptor, diff --git a/internal/eventlog/wire.go b/internal/eventlog/wire.go index 9fe3dd16..2fc71123 100644 --- a/internal/eventlog/wire.go +++ b/internal/eventlog/wire.go @@ -286,6 +286,30 @@ func decodePayload(w WireEvent) (any, error) { return nil, err } return p, nil + case ScheduleCreate: + var p ScheduleCreateFact + if err := json.Unmarshal(w.Data, &p); err != nil { + return nil, err + } + return p, nil + case ScheduleUpdate: + var p ScheduleUpdateFact + if err := json.Unmarshal(w.Data, &p); err != nil { + return nil, err + } + return p, nil + case ScheduleDelete: + var p ScheduleDeleteFact + if err := json.Unmarshal(w.Data, &p); err != nil { + return nil, err + } + return p, nil + case ScheduleDue: + var p ScheduleDueFact + if err := json.Unmarshal(w.Data, &p); err != nil { + return nil, err + } + return p, nil case SessionTitle: var p SessionTitleFact if err := json.Unmarshal(w.Data, &p); err != nil { diff --git a/internal/schedule/fold.go b/internal/schedule/fold.go new file mode 100644 index 00000000..faec8bc0 --- /dev/null +++ b/internal/schedule/fold.go @@ -0,0 +1,121 @@ +package schedule + +import ( + "encoding/json" + + "github.com/GrayCodeAI/hawk/internal/eventlog" +) + +// Fold folds a sequence of eventlog events into a map of active ScheduleItems. +func Fold(events []eventlog.Event) map[string]Item { + items := make(map[string]Item) + + for _, ev := range events { + switch ev.Type { + case eventlog.ScheduleCreate: + var fact eventlog.ScheduleCreateFact + if err := decodeFact(ev.Data, &fact); err == nil && fact.ID != "" { + items[fact.ID] = Item{ + ID: fact.ID, + Prompt: fact.Prompt, + DueAt: fact.DueAt, + Interval: fact.Interval, + Recurring: fact.Recurring, + CreatedAt: ev.At, + UpdatedAt: ev.At, + Deleted: false, + } + } + + case eventlog.ScheduleUpdate: + var fact eventlog.ScheduleUpdateFact + if err := decodeFact(ev.Data, &fact); err == nil && fact.ID != "" { + if item, exists := items[fact.ID]; exists && !item.Deleted { + if fact.Prompt != nil { + item.Prompt = *fact.Prompt + } + if fact.DueAt != nil { + item.DueAt = *fact.DueAt + } + item.UpdatedAt = ev.At + items[fact.ID] = item + } + } + + case eventlog.ScheduleDelete: + var fact eventlog.ScheduleDeleteFact + if err := decodeFact(ev.Data, &fact); err == nil && fact.ID != "" { + if item, exists := items[fact.ID]; exists { + item.Deleted = true + item.UpdatedAt = ev.At + items[fact.ID] = item + } + } + + case eventlog.ScheduleDue: + var fact eventlog.ScheduleDueFact + if err := decodeFact(ev.Data, &fact); err == nil && fact.ID != "" { + if item, exists := items[fact.ID]; exists && !item.Deleted { + deliveredAt := fact.DeliveredAt + if deliveredAt.IsZero() { + deliveredAt = ev.At + } + item.LastDueAt = &deliveredAt + item.UpdatedAt = ev.At + + if fact.NextDueAt != nil && !fact.NextDueAt.IsZero() { + item.DueAt = *fact.NextDueAt + } else if !item.Recurring { + // One-shot schedule marked completed/deleted + item.Deleted = true + } + items[fact.ID] = item + } + } + } + } + + // Filter out deleted/completed items + active := make(map[string]Item) + for id, item := range items { + if !item.Deleted { + active[id] = item + } + } + return active +} + +func decodeFact(src any, dst any) error { + if src == nil { + return nil + } + switch s := src.(type) { + case eventlog.ScheduleCreateFact: + if d, ok := dst.(*eventlog.ScheduleCreateFact); ok { + *d = s + return nil + } + case eventlog.ScheduleUpdateFact: + if d, ok := dst.(*eventlog.ScheduleUpdateFact); ok { + *d = s + return nil + } + case eventlog.ScheduleDeleteFact: + if d, ok := dst.(*eventlog.ScheduleDeleteFact); ok { + *d = s + return nil + } + case eventlog.ScheduleDueFact: + if d, ok := dst.(*eventlog.ScheduleDueFact); ok { + *d = s + return nil + } + } + + // Fallback to JSON conversion for map[string]any or wire payloads + data, err := json.Marshal(src) + if err != nil { + return err + } + return json.Unmarshal(data, dst) +} diff --git a/internal/schedule/global.go b/internal/schedule/global.go new file mode 100644 index 00000000..b7197fe0 --- /dev/null +++ b/internal/schedule/global.go @@ -0,0 +1,16 @@ +package schedule + +import "sync" + +var ( + defaultManager *Manager + defaultManagerOnce sync.Once +) + +// DefaultManager returns the global fallback schedule manager. +func DefaultManager() *Manager { + defaultManagerOnce.Do(func() { + defaultManager = NewManager() + }) + return defaultManager +} diff --git a/internal/schedule/manager.go b/internal/schedule/manager.go new file mode 100644 index 00000000..e6275e90 --- /dev/null +++ b/internal/schedule/manager.go @@ -0,0 +1,237 @@ +package schedule + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "sort" + "sync" + "time" + + "github.com/GrayCodeAI/hawk/internal/eventlog" +) + +// DeliverFn delivers a due scheduled reminder into the active conversation. +type DeliverFn func(item Item) error + +// Manager coordinates in-conversation schedule timers and eventlog persistence. +type Manager struct { + mu sync.Mutex + journal *eventlog.Log + deliverFn DeliverFn + timers map[string]*time.Timer + closed bool +} + +// NewManager creates an unattached schedule Manager. +func NewManager() *Manager { + return &Manager{ + timers: make(map[string]*time.Timer), + } +} + +// Attach attaches the manager to a session journal and delivery sink. +// It folds existing events, delivers overdue items immediately (cold session catchup), +// and arms timers for pending future items. +func (m *Manager) Attach(journal *eventlog.Log, deliverFn DeliverFn) { + m.mu.Lock() + defer m.mu.Unlock() + + // Cancel existing timers + for _, t := range m.timers { + t.Stop() + } + m.timers = make(map[string]*time.Timer) + + m.journal = journal + m.deliverFn = deliverFn + m.closed = false + + if journal == nil { + return + } + + // Fold active items + active := Fold(journal.Snapshot()) + now := time.Now() + + for _, item := range active { + if !item.DueAt.After(now) { + // Overdue work from cold session resume + m.triggerDueLocked(item, now) + } else { + // Arm future timer + m.armTimerLocked(item) + } + } +} + +// Create registers a new scheduled reminder on the session log and arms its timer. +func (m *Manager) Create(prompt string, dueAt time.Time, interval string, recurring bool) (*Item, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.closed { + return nil, fmt.Errorf("schedule manager is closed") + } + + id := generateScheduleID() + fact := eventlog.ScheduleCreateFact{ + ID: id, + Prompt: prompt, + DueAt: dueAt, + Interval: interval, + Recurring: recurring, + } + + if m.journal != nil { + m.journal.AppendScheduleCreate(fact) + } + + item := Item{ + ID: id, + Prompt: prompt, + DueAt: dueAt, + Interval: interval, + Recurring: recurring, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + now := time.Now() + if !dueAt.After(now) { + m.triggerDueLocked(item, now) + } else { + m.armTimerLocked(item) + } + + return &item, nil +} + +// List returns active, non-deleted schedules sorted by DueAt ascending. +func (m *Manager) List() []Item { + m.mu.Lock() + defer m.mu.Unlock() + + if m.journal == nil { + return nil + } + + activeMap := Fold(m.journal.Snapshot()) + items := make([]Item, 0, len(activeMap)) + for _, item := range activeMap { + items = append(items, item) + } + + sort.Slice(items, func(i, j int) bool { + return items[i].DueAt.Before(items[j].DueAt) + }) + return items +} + +// Delete cancels an in-conversation schedule and logs a schedule.delete event. +func (m *Manager) Delete(id, reason string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if timer, ok := m.timers[id]; ok { + timer.Stop() + delete(m.timers, id) + } + + if m.journal != nil { + m.journal.AppendScheduleDelete(id, reason) + } + return nil +} + +// Close disposes all running timers. +func (m *Manager) Close() { + m.mu.Lock() + defer m.mu.Unlock() + m.closed = true + for _, t := range m.timers { + t.Stop() + } + m.timers = make(map[string]*time.Timer) +} + +func (m *Manager) armTimerLocked(item Item) { + if m.closed { + return + } + dur := time.Until(item.DueAt) + if dur < 0 { + dur = 0 + } + + timer := time.AfterFunc(dur, func() { + m.mu.Lock() + defer m.mu.Unlock() + if m.closed { + return + } + // Confirm item is still active + if m.journal != nil { + active := Fold(m.journal.Snapshot()) + current, exists := active[item.ID] + if exists && !current.Deleted { + m.triggerDueLocked(current, time.Now()) + } + } + }) + m.timers[item.ID] = timer +} + +func (m *Manager) triggerDueLocked(item Item, triggeredAt time.Time) { + if m.closed { + return + } + + // 1. Deliver in-conversation + if m.deliverFn != nil { + _ = m.deliverFn(item) + } + + // 2. Compute next due time if recurring + var nextDueAt *time.Time + if item.Recurring { + next := calculateNextDue(triggeredAt, item.Interval) + nextDueAt = &next + } + + // 3. Log schedule.due event + if m.journal != nil { + m.journal.AppendScheduleDue(eventlog.ScheduleDueFact{ + ID: item.ID, + DeliveredAt: triggeredAt, + NextDueAt: nextDueAt, + }) + } + + delete(m.timers, item.ID) + + // 4. Rearm timer if recurring + if item.Recurring && nextDueAt != nil { + updatedItem := item + updatedItem.DueAt = *nextDueAt + m.armTimerLocked(updatedItem) + } +} + +func calculateNextDue(from time.Time, interval string) time.Time { + if interval == "" { + return from.Add(5 * time.Minute) + } + // Parse simple durations like "5m", "1h", "30s" + if dur, err := time.ParseDuration(interval); err == nil && dur > 0 { + return from.Add(dur) + } + return from.Add(5 * time.Minute) +} + +func generateScheduleID() string { + b := make([]byte, 4) + _, _ = rand.Read(b) + return fmt.Sprintf("sched-%x", hex.EncodeToString(b)) +} diff --git a/internal/schedule/schedule_test.go b/internal/schedule/schedule_test.go new file mode 100644 index 00000000..bae16675 --- /dev/null +++ b/internal/schedule/schedule_test.go @@ -0,0 +1,179 @@ +package schedule + +import ( + "sync" + "testing" + "time" + + "github.com/GrayCodeAI/hawk/internal/eventlog" +) + +func TestFoldEvents(t *testing.T) { + due1 := time.Now().Add(10 * time.Minute) + due2 := time.Now().Add(20 * time.Minute) + + log := eventlog.New(nil) + log.AppendScheduleCreate(eventlog.ScheduleCreateFact{ + ID: "sched-1", + Prompt: "Check build metrics", + DueAt: due1, + Recurring: false, + }) + log.AppendScheduleCreate(eventlog.ScheduleCreateFact{ + ID: "sched-2", + Prompt: "Poll cluster status", + DueAt: due2, + Recurring: true, + Interval: "5m", + }) + + // Initial fold -> 2 active items + active := Fold(log.Snapshot()) + if len(active) != 2 { + t.Fatalf("expected 2 active schedules, got %d", len(active)) + } + if active["sched-1"].Prompt != "Check build metrics" { + t.Errorf("sched-1 prompt mismatch: %s", active["sched-1"].Prompt) + } + + // Update sched-1 prompt + newPrompt := "Check build metrics and memory profile" + log.AppendScheduleUpdate(eventlog.ScheduleUpdateFact{ + ID: "sched-1", + Prompt: &newPrompt, + }) + active = Fold(log.Snapshot()) + if active["sched-1"].Prompt != newPrompt { + t.Errorf("expected updated prompt %q, got %q", newPrompt, active["sched-1"].Prompt) + } + + // Mark sched-1 as due (one-shot) -> should be removed from active + log.AppendScheduleDue(eventlog.ScheduleDueFact{ + ID: "sched-1", + DeliveredAt: time.Now(), + }) + active = Fold(log.Snapshot()) + if _, exists := active["sched-1"]; exists { + t.Errorf("expected one-shot sched-1 to be inactive after delivery") + } + + // Delete sched-2 + log.AppendScheduleDelete("sched-2", "user cancelled") + active = Fold(log.Snapshot()) + if len(active) != 0 { + t.Fatalf("expected 0 active schedules after deletion, got %d", len(active)) + } +} + +func TestManager_CreateListDelete(t *testing.T) { + log := eventlog.New(nil) + mgr := NewManager() + defer mgr.Close() + + mgr.Attach(log, nil) + + item1, err := mgr.Create("Run tests", time.Now().Add(1*time.Hour), "", false) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + item2, err := mgr.Create("Check memory", time.Now().Add(2*time.Hour), "30m", true) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + items := mgr.List() + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + if items[0].ID != item1.ID || items[1].ID != item2.ID { + t.Errorf("unexpected list ordering: %#v", items) + } + + // Delete item1 + if err := mgr.Delete(item1.ID, "done"); err != nil { + t.Fatalf("Delete failed: %v", err) + } + + itemsAfter := mgr.List() + if len(itemsAfter) != 1 || itemsAfter[0].ID != item2.ID { + t.Fatalf("expected 1 remaining item %s, got %#v", item2.ID, itemsAfter) + } +} + +func TestManager_ColdSessionOverdueResume(t *testing.T) { + log := eventlog.New(nil) + overdueTime := time.Now().Add(-10 * time.Minute) + + // Simulate an overdue schedule created in a previous session turn + log.AppendScheduleCreate(eventlog.ScheduleCreateFact{ + ID: "sched-cold-1", + Prompt: "Resume long running experiment review", + DueAt: overdueTime, + Recurring: false, + }) + + var delivered []Item + var mu sync.Mutex + + mgr := NewManager() + defer mgr.Close() + + // Attaching should trigger immediate catchup delivery + mgr.Attach(log, func(item Item) error { + mu.Lock() + defer mu.Unlock() + delivered = append(delivered, item) + return nil + }) + + mu.Lock() + defer mu.Unlock() + if len(delivered) != 1 { + t.Fatalf("expected 1 overdue item delivered on cold attach, got %d", len(delivered)) + } + if delivered[0].ID != "sched-cold-1" { + t.Errorf("expected sched-cold-1, got %s", delivered[0].ID) + } + + // Check that schedule.due fact was recorded in log + active := Fold(log.Snapshot()) + if len(active) != 0 { + t.Fatalf("expected one-shot overdue schedule to be completed in log, active: %#v", active) + } +} + +func TestManager_TimerDueDelivery(t *testing.T) { + log := eventlog.New(nil) + var delivered []Item + var mu sync.Mutex + doneCh := make(chan struct{}) + + mgr := NewManager() + defer mgr.Close() + + mgr.Attach(log, func(item Item) error { + mu.Lock() + delivered = append(delivered, item) + mu.Unlock() + close(doneCh) + return nil + }) + + // Create a short timer (30ms) + dueAt := time.Now().Add(30 * time.Millisecond) + item, err := mgr.Create("Ping backend health", dueAt, "", false) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + select { + case <-doneCh: + mu.Lock() + defer mu.Unlock() + if len(delivered) != 1 || delivered[0].ID != item.ID { + t.Fatalf("unexpected delivered items: %#v", delivered) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for scheduled delivery") + } +} diff --git a/internal/schedule/types.go b/internal/schedule/types.go new file mode 100644 index 00000000..25d1fdac --- /dev/null +++ b/internal/schedule/types.go @@ -0,0 +1,18 @@ +package schedule + +import ( + "time" +) + +// Item represents an in-conversation scheduled reminder or task. +type Item struct { + ID string `json:"id"` + Prompt string `json:"prompt"` + DueAt time.Time `json:"due_at"` + Interval string `json:"interval,omitempty"` + Recurring bool `json:"recurring"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastDueAt *time.Time `json:"last_due_at,omitempty"` + Deleted bool `json:"deleted"` +} diff --git a/internal/tool/schedule.go b/internal/tool/schedule.go new file mode 100644 index 00000000..51ab3f4c --- /dev/null +++ b/internal/tool/schedule.go @@ -0,0 +1,210 @@ +package tool + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/GrayCodeAI/hawk/internal/schedule" +) + +// ScheduleCreateTool creates an in-conversation scheduled reminder or task. +type ScheduleCreateTool struct { + Manager *schedule.Manager +} + +func (ScheduleCreateTool) Name() string { return "ScheduleCreate" } +func (ScheduleCreateTool) Aliases() []string { + return []string{"schedule_create", "schedule-create", "remind"} +} + +func (ScheduleCreateTool) Description() string { + return "Create an in-conversation scheduled reminder or recurring task whose state lives in the session log." +} + +func (ScheduleCreateTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "prompt": map[string]interface{}{ + "type": "string", + "description": "Reminder or instruction prompt to deliver into the conversation when due", + }, + "duration": map[string]interface{}{ + "type": "string", + "description": "Relative duration from now until due (e.g. '10m', '1h', '30s')", + }, + "at": map[string]interface{}{ + "type": "string", + "description": "Optional absolute due time in RFC3339 format (e.g. '2026-08-20T10:00:00Z')", + }, + "recurring": map[string]interface{}{ + "type": "boolean", + "description": "Whether the reminder should repeat periodically", + }, + "interval": map[string]interface{}{ + "type": "string", + "description": "Repeat interval if recurring (e.g. '15m', '1h', '24h')", + }, + }, + "required": []string{"prompt"}, + } +} + +func (t ScheduleCreateTool) Execute(_ context.Context, input json.RawMessage) (string, error) { + var p struct { + Prompt string `json:"prompt"` + Duration string `json:"duration"` + At string `json:"at"` + Recurring bool `json:"recurring"` + Interval string `json:"interval"` + } + if len(input) > 0 { + if err := json.Unmarshal(input, &p); err != nil { + return "", fmt.Errorf("invalid parameters: %w", err) + } + } + + if strings.TrimSpace(p.Prompt) == "" { + return "", fmt.Errorf("prompt is required") + } + + mgr := t.Manager + if mgr == nil { + mgr = schedule.DefaultManager() + } + + now := time.Now() + var dueAt time.Time + + if p.At != "" { + parsed, err := time.Parse(time.RFC3339, p.At) + if err != nil { + return "", fmt.Errorf("invalid 'at' timestamp format (expected RFC3339): %w", err) + } + dueAt = parsed + } else if p.Duration != "" { + dur, err := time.ParseDuration(p.Duration) + if err != nil { + return "", fmt.Errorf("invalid duration (e.g. '10m', '1h'): %w", err) + } + dueAt = now.Add(dur) + } else { + // Default 5 minutes if neither at nor duration specified + dueAt = now.Add(5 * time.Minute) + } + + item, err := mgr.Create(p.Prompt, dueAt, p.Interval, p.Recurring) + if err != nil { + return "", err + } + + recurringNotice := "" + if item.Recurring { + recurringNotice = fmt.Sprintf(" (repeating every %s)", item.Interval) + } + + return fmt.Sprintf("Scheduled in-conversation reminder `%s` due at %s%s.\nPrompt: %s", + item.ID, item.DueAt.Format(time.RFC3339), recurringNotice, item.Prompt), nil +} + +// ScheduleListTool lists active in-conversation reminders. +type ScheduleListTool struct { + Manager *schedule.Manager +} + +func (ScheduleListTool) Name() string { return "ScheduleList" } +func (ScheduleListTool) Aliases() []string { return []string{"schedule_list", "schedule-list"} } +func (ScheduleListTool) Description() string { + return "List all active in-conversation scheduled reminders and tasks for this session." +} + +func (ScheduleListTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + } +} + +func (t ScheduleListTool) Execute(_ context.Context, _ json.RawMessage) (string, error) { + mgr := t.Manager + if mgr == nil { + mgr = schedule.DefaultManager() + } + + items := mgr.List() + if len(items) == 0 { + return "No active in-conversation schedules found for this session.", nil + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Active schedules (%d):\n\n", len(items))) + for _, item := range items { + recurring := "" + if item.Recurring { + recurring = fmt.Sprintf(" [Recurring: %s]", item.Interval) + } + sb.WriteString(fmt.Sprintf("- **`%s`** due at %s%s\n Prompt: %s\n", + item.ID, item.DueAt.Format(time.RFC3339), recurring, item.Prompt)) + } + + return strings.TrimRight(sb.String(), "\n"), nil +} + +// ScheduleDeleteTool deletes/cancels an in-conversation reminder. +type ScheduleDeleteTool struct { + Manager *schedule.Manager +} + +func (ScheduleDeleteTool) Name() string { return "ScheduleDelete" } +func (ScheduleDeleteTool) Aliases() []string { return []string{"schedule_delete", "schedule-delete"} } + +func (ScheduleDeleteTool) Description() string { + return "Cancel/delete an in-conversation scheduled reminder by ID." +} + +func (ScheduleDeleteTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{ + "type": "string", + "description": "Schedule ID to cancel (e.g. 'sched-1234abcd')", + }, + "reason": map[string]interface{}{ + "type": "string", + "description": "Optional cancellation reason", + }, + }, + "required": []string{"id"}, + } +} + +func (t ScheduleDeleteTool) Execute(_ context.Context, input json.RawMessage) (string, error) { + var p struct { + ID string `json:"id"` + Reason string `json:"reason"` + } + if len(input) > 0 { + if err := json.Unmarshal(input, &p); err != nil { + return "", fmt.Errorf("invalid parameters: %w", err) + } + } + + if strings.TrimSpace(p.ID) == "" { + return "", fmt.Errorf("id parameter is required") + } + + mgr := t.Manager + if mgr == nil { + mgr = schedule.DefaultManager() + } + + if err := mgr.Delete(p.ID, p.Reason); err != nil { + return "", err + } + + return fmt.Sprintf("Cancelled scheduled reminder `%s`.", p.ID), nil +} diff --git a/internal/tool/schedule_test.go b/internal/tool/schedule_test.go new file mode 100644 index 00000000..4570b4a5 --- /dev/null +++ b/internal/tool/schedule_test.go @@ -0,0 +1,119 @@ +package tool + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/GrayCodeAI/hawk/internal/eventlog" + "github.com/GrayCodeAI/hawk/internal/schedule" +) + +func TestScheduleTools_RoundTrip(t *testing.T) { + log := eventlog.New(nil) + mgr := schedule.NewManager() + defer mgr.Close() + mgr.Attach(log, nil) + + createTool := ScheduleCreateTool{Manager: mgr} + listTool := ScheduleListTool{Manager: mgr} + deleteTool := ScheduleDeleteTool{Manager: mgr} + ctx := context.Background() + + // 1. Initially empty list + outListEmpty, err := listTool.Execute(ctx, nil) + if err != nil { + t.Fatalf("ListTool failed: %v", err) + } + if !strings.Contains(outListEmpty, "No active") { + t.Fatalf("expected 'No active', got %s", outListEmpty) + } + + // 2. Create a 1h reminder + inCreate, _ := json.Marshal(map[string]interface{}{ + "prompt": "Review memory leak report", + "duration": "1h", + }) + outCreate, err := createTool.Execute(ctx, inCreate) + if err != nil { + t.Fatalf("CreateTool failed: %v", err) + } + if !strings.Contains(outCreate, "Scheduled in-conversation reminder") { + t.Fatalf("unexpected create output: %s", outCreate) + } + + // 3. List active schedules + outList, err := listTool.Execute(ctx, nil) + if err != nil { + t.Fatalf("ListTool failed: %v", err) + } + if !strings.Contains(outList, "Review memory leak report") { + t.Fatalf("expected prompt in list output: %s", outList) + } + + // Extract schedule ID + items := mgr.List() + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + schedID := items[0].ID + + // 4. Delete the schedule + inDelete, _ := json.Marshal(map[string]interface{}{ + "id": schedID, + "reason": "completed early", + }) + outDelete, err := deleteTool.Execute(ctx, inDelete) + if err != nil { + t.Fatalf("DeleteTool failed: %v", err) + } + if !strings.Contains(outDelete, "Cancelled scheduled reminder") { + t.Fatalf("unexpected delete output: %s", outDelete) + } + + // 5. Verify list is empty again + itemsAfter := mgr.List() + if len(itemsAfter) != 0 { + t.Fatalf("expected 0 items after deletion, got %d", len(itemsAfter)) + } +} + +func TestScheduleCreateTool_Validation(t *testing.T) { + createTool := ScheduleCreateTool{} + ctx := context.Background() + + // Empty prompt + inEmpty, _ := json.Marshal(map[string]interface{}{ + "prompt": "", + }) + _, err := createTool.Execute(ctx, inEmpty) + if err == nil { + t.Fatal("expected error for empty prompt, got nil") + } + + // Invalid timestamp + inBadAt, _ := json.Marshal(map[string]interface{}{ + "prompt": "Test", + "at": "not-a-timestamp", + }) + _, err = createTool.Execute(ctx, inBadAt) + if err == nil { + t.Fatal("expected error for invalid 'at' timestamp, got nil") + } + + // Valid absolute RFC3339 timestamp + validAt := time.Now().Add(2 * time.Hour).Format(time.RFC3339) + inGoodAt, _ := json.Marshal(map[string]interface{}{ + "prompt": "Test RFC3339", + "at": validAt, + }) + out, err := createTool.Execute(ctx, inGoodAt) + if err != nil { + t.Fatalf("unexpected error for valid timestamp: %v", err) + } + if !strings.Contains(out, "Scheduled in-conversation reminder") { + t.Fatalf("expected success, got %s", out) + } +}