diff --git a/README.md b/README.md index ba8c9769..460432f7 100644 --- a/README.md +++ b/README.md @@ -291,10 +291,10 @@ Features adopted from open-source agent projects. All are off by default unless | Atomic install transactions | `internal/installtxn` | Cross-process staged install/remove with rollback. Wired into skill install (atomic `SKILL.md` publish) | | Stale-lock reclaim | `internal/lockutil` | Race-correct atomic reclaim of O_EXCL lock files with live-restore (ready for O_EXCL lock sites) | | Test command discovery | `internal/testrunner` | Auto-detect test/verify commands (Go/npm/bun/pnpm/yarn/pytest/cargo) and parse runner output into structured results. Wired into `hawk verify` | -| Circuit breaker | `internal/circuitbreaker` | Closed/open/half-open retry-storm protection with cooldown — generalized primitive (auto-compact style: 3 failures → cooldown → half-open probe) | -| Smart turn routing | `internal/smartrouting` | Deterministic simple/strong turn classifier with fail-toward-strong safety (cheap model for trivial turns) | -| Conversation arc | `internal/conversationarc` | Durable sidecar memory of goals/decisions/milestones/phase with a byte-stable model-visible summary | -| Relevance pruning | `internal/relevanceprune` | Token-budgeted context pruning that keeps relevant history while preserving recent turns/tool calls/errors | +| Circuit breaker | `internal/circuitbreaker` | Closed/open/half-open retry-storm protection with cooldown. Wired into auto-compaction (cooldown + half-open auto-retry) | +| Smart turn routing | `internal/smartrouting` | Deterministic simple/strong turn classifier with fail-toward-strong safety. Wired into per-turn model selection (`settings.smart_routing`) | +| Conversation arc | `internal/conversationarc` | Durable sidecar memory of goals/decisions/milestones/phase with a byte-stable summary. Wired into sessions (loaded on open, saved on close, injected into the system prompt) | +| Relevance pruning | `internal/relevanceprune` | Token-budgeted context pruning preserving recent turns/tool calls/errors. Wired into compaction as a `relevance` strategy | ## Usage diff --git a/cmd/chat.go b/cmd/chat.go index 63cfca47..e0af1c40 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -41,6 +41,8 @@ import ( "github.com/GrayCodeAI/hawk/internal/system/staleness" "github.com/GrayCodeAI/hawk/internal/tool" "github.com/GrayCodeAI/hawk/internal/ui/icons" + + "github.com/GrayCodeAI/hawk/internal/conversationarc" ) // Types, styles, and model struct are in chat_model.go @@ -173,6 +175,13 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco } startup.EndPhase("newChatModel:prepareSession") + // Conversation arc: durable per-session sidecar of goals/decisions/milestones. + arc, _ := conversationarc.Load(sessionArcDir(sid)) + if arc == nil { + arc = conversationarc.New() + } + sess.SetArc(arc) + // Initialize conversation DAG for branching support startup.MarkPhase("newChatModel:dag") graphPath := filepath.Join(hawkstorage.SessionsDir(), "conversations", sid+".json") diff --git a/cmd/chat_commands_session.go b/cmd/chat_commands_session.go index bf0cc373..446ac1b5 100644 --- a/cmd/chat_commands_session.go +++ b/cmd/chat_commands_session.go @@ -21,6 +21,12 @@ type sessionSaveResultMsg struct { err error } +// sessionArcDir returns the per-session directory holding the conversation-arc +// sidecar (.arc.json). +func sessionArcDir(id string) string { + return filepath.Join(storage.SessionsDir(), id) +} + // saveSession persists the current session to disk. func (m *chatModel) saveSession() { raw := m.session.RawMessages() @@ -41,6 +47,10 @@ func (m *chatModel) saveSession() { } else if err != nil { m.recordWALError(err) } + // Conversation arc sidecar (best-effort, only when it has content). + if arc := m.session.Arc(); arc != nil && !arc.IsEmpty() { + _ = arc.Save(sessionArcDir(m.sessionID)) + } } // saveSessionCmd returns a background tea.Cmd that persists the session. It @@ -60,11 +70,15 @@ func (m *chatModel) saveSessionCmd() tea.Cmd { msgs := session.FromRuntimeMessages(raw) createdAt := time.Now() seq := m.walSeq + arc := m.session.Arc() return func() tea.Msg { err := session.Save(&session.Session{ ID: id, Model: modelName, Provider: provider, Messages: msgs, CreatedAt: createdAt, }) + if arc != nil && !arc.IsEmpty() { + _ = arc.Save(sessionArcDir(id)) + } return sessionSaveResultMsg{id: id, seq: seq, err: err} } } diff --git a/cmd/options.go b/cmd/options.go index 9e686207..110a088d 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -381,6 +381,11 @@ func configureSessionStartup(sess *engine.Session, settings hawkconfig.Settings, cascade.FrugalMode = settings.Frugal sess.LifecycleSvc().SetCascade(cascade) + // Smart turn routing: opt-in cheap-simple / strong per-turn model choice. + if settings.SmartRouting != nil { + sess.LifecycleSvc().SetSmartRouting(settings.SmartRouting) + } + // Session lifecycle: self-improvement loop (learn from sessions) sess.LifecycleSvc().SetLifecycle(&lifecycle.SessionLifecycle{ Memory: &lifecycle.EvolvingMemoryAdapter{EM: memory.NewEvolvingMemory()}, diff --git a/internal/config/settings.go b/internal/config/settings.go index bde73a38..d9ab5dcc 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -16,6 +16,7 @@ import ( "github.com/GrayCodeAI/hawk/internal/provider/gateway" "github.com/GrayCodeAI/hawk/internal/provider/routing" "github.com/GrayCodeAI/hawk/internal/safewrite" + "github.com/GrayCodeAI/hawk/internal/smartrouting" "github.com/GrayCodeAI/hawk/internal/storage" "github.com/GrayCodeAI/hawk/internal/types" @@ -58,6 +59,7 @@ type Settings struct { ContainerNetwork string `json:"container_network,omitempty"` // container network mode: none, bridge, isolated SpecAllowTests bool `json:"spec_allow_tests,omitempty"` // allow safe test commands during spec stage ModelRoles *routing.ModelRoles `json:"model_roles,omitempty"` // per-role model overrides + SmartRouting *smartrouting.Config `json:"smart_routing,omitempty"` // cheap-simple / strong turn routing (opt-in) AutoCompactThresholdPct int `json:"auto_compact_threshold_pct,omitempty"` // token % to trigger auto-compact (default 85) Frugal bool `json:"frugal,omitempty"` // aggressive cost optimization: cascade to cheap models, lower max_tokens, earlier compaction Attribution *Attribution `json:"attribution,omitempty"` diff --git a/internal/conversationarc/arc.go b/internal/conversationarc/arc.go index 690fb304..4648ec61 100644 --- a/internal/conversationarc/arc.go +++ b/internal/conversationarc/arc.go @@ -205,6 +205,13 @@ func (a *Arc) AddMilestone(description string) Milestone { return m } +// IsEmpty reports whether the arc has no tracked content worth summarizing +// (no goals/decisions/milestones and still in the init phase). Callers use it +// to skip injecting an empty summary. +func (a *Arc) IsEmpty() bool { + return len(a.Goals) == 0 && len(a.Decisions) == 0 && len(a.Milestones) == 0 && a.CurrentPhase == PhaseInit +} + // Summary renders a model-visible, byte-stable arc summary. The volatile // timestamp line is normalized so an unchanged arc produces identical output // across turns (no prompt-cache churn). diff --git a/internal/conversationarc/arc_test.go b/internal/conversationarc/arc_test.go index 4c6da431..39879f9b 100644 --- a/internal/conversationarc/arc_test.go +++ b/internal/conversationarc/arc_test.go @@ -15,6 +15,13 @@ func TestNewArcInit(t *testing.T) { if len(a.Goals) != 0 { t.Fatal("expected no goals") } + if !a.IsEmpty() { + t.Fatal("a fresh arc should be empty") + } + a.AddGoal("x") + if a.IsEmpty() { + t.Fatal("arc with a goal should not be empty") + } } func TestDetectAndAdvancePhase(t *testing.T) { diff --git a/internal/engine/arc_test.go b/internal/engine/arc_test.go new file mode 100644 index 00000000..90a74418 --- /dev/null +++ b/internal/engine/arc_test.go @@ -0,0 +1,20 @@ +package engine + +import ( + "testing" + + "github.com/GrayCodeAI/hawk/internal/conversationarc" +) + +func TestSessionArcAccessors(t *testing.T) { + sess := NewSessionWithClient(NewMockClientForTest(), "test", "test-model", "", nil, false) + if sess.Arc() != nil { + t.Fatal("expected nil arc by default") + } + a := conversationarc.New() + a.AddGoal("implement X") + sess.SetArc(a) + if got := sess.Arc(); got == nil || len(got.Goals) != 1 { + t.Fatalf("arc not set: %+v", got) + } +} diff --git a/internal/engine/compact/strategy.go b/internal/engine/compact/strategy.go index c7fe7464..0e95a8ad 100644 --- a/internal/engine/compact/strategy.go +++ b/internal/engine/compact/strategy.go @@ -2,6 +2,7 @@ package compact import ( "strings" + "time" "github.com/GrayCodeAI/hawk/internal/types" ) @@ -20,6 +21,7 @@ type CompactConfig struct { AutoCompactBuffer int MaxOutputTokens int MaxFailures int + Cooldown time.Duration } func DefaultCompactConfig() CompactConfig { @@ -29,6 +31,7 @@ func DefaultCompactConfig() CompactConfig { AutoCompactBuffer: 13000, MaxOutputTokens: 20000, MaxFailures: 3, + Cooldown: 5 * time.Minute, } } diff --git a/internal/engine/compact_auto.go b/internal/engine/compact_auto.go index a58089d4..41e3c46e 100644 --- a/internal/engine/compact_auto.go +++ b/internal/engine/compact_auto.go @@ -5,17 +5,19 @@ import ( "errors" "log" "sync" + "time" + "github.com/GrayCodeAI/hawk/internal/circuitbreaker" "github.com/GrayCodeAI/hawk/internal/types" ) // AutoCompactor orchestrates compaction with circuit breaker protection. type AutoCompactor struct { - mu sync.Mutex - registry *StrategyRegistry - config CompactConfig - consecutiveFailures int - lastStrategy string + mu sync.Mutex + registry *StrategyRegistry + config CompactConfig + breaker *circuitbreaker.Breaker + lastStrategy string } // NewAutoCompactor creates an auto-compactor with the given config. @@ -23,6 +25,7 @@ func NewAutoCompactor(config CompactConfig) *AutoCompactor { return &AutoCompactor{ registry: NewStrategyRegistry(config), config: config, + breaker: circuitbreaker.New(config.MaxFailures, config.Cooldown), } } @@ -35,6 +38,7 @@ func (ac *AutoCompactor) Configure(config CompactConfig) { defer ac.mu.Unlock() ac.config = config ac.registry = NewStrategyRegistry(config) + ac.breaker = circuitbreaker.New(config.MaxFailures, config.Cooldown) } // GetAutoCompactThreshold returns the token count at which auto-compaction triggers. @@ -51,8 +55,10 @@ func (ac *AutoCompactor) ShouldAutoCompact(sess *Session) bool { return false } - if ac.consecutiveFailures >= ac.config.MaxFailures { - log.Printf("Auto-compact paused after %d consecutive failures.", ac.consecutiveFailures) + // Circuit breaker: skip once the breaker is open (too many consecutive + // failures) until its cooldown elapses, then re-arm half-open. + if !ac.breaker.ShouldAllow(time.Now()).Allow { + log.Printf("Auto-compact paused by circuit breaker.") return false } @@ -72,11 +78,11 @@ func (ac *AutoCompactor) AutoCompactIfNeeded(ctx context.Context, sess *Session) strategy, err := ac.RunCompaction(ctx, sess) if err != nil { ac.mu.Lock() - ac.consecutiveFailures++ + ac.breaker.RecordFailure(time.Now()) ac.mu.Unlock() sess.Logger().Warn("auto-compact failed", map[string]any{ "error": err.Error(), - "failures": ac.consecutiveFailures, + "failures": ac.breaker.ConsecutiveFailures(), }) sess.compact(ctx) tokensAfter := EstimateTokens(sess.Persistence().RawMessages()) @@ -89,7 +95,7 @@ func (ac *AutoCompactor) AutoCompactIfNeeded(ctx context.Context, sess *Session) // Strategy ran but produced no reduction (e.g. LLM summary was // rejected, or messages were not reduced); fall back to truncation. ac.mu.Lock() - ac.consecutiveFailures++ + ac.breaker.RecordFailure(time.Now()) ac.mu.Unlock() sess.Logger().Warn("auto-compact produced no reduction, falling back to truncation", map[string]any{ "tokens_before": tokensBefore, @@ -102,7 +108,7 @@ func (ac *AutoCompactor) AutoCompactIfNeeded(ctx context.Context, sess *Session) } ac.mu.Lock() - ac.consecutiveFailures = 0 + ac.breaker.RecordSuccess() ac.mu.Unlock() sess.recordCompaction(strategy, tokensBefore, tokensAfter, false) return strategy, true @@ -160,7 +166,7 @@ func (ac *AutoCompactor) LastStrategy() string { func (ac *AutoCompactor) ResetFailures() { ac.mu.Lock() defer ac.mu.Unlock() - ac.consecutiveFailures = 0 + ac.breaker.RecordSuccess() } // SmartCompactStrategy uses LLM to generate a conversation summary. diff --git a/internal/engine/compact_relevance.go b/internal/engine/compact_relevance.go new file mode 100644 index 00000000..6e7ff84b --- /dev/null +++ b/internal/engine/compact_relevance.go @@ -0,0 +1,96 @@ +package engine + +import ( + "context" + "time" + + "github.com/GrayCodeAI/hawk/internal/relevanceprune" + "github.com/GrayCodeAI/hawk/internal/types" +) + +// RelevancePruneStrategy prunes context by relevance: it scores older messages +// against the most recent user turn, keeps the highest-relevance groups up to a +// token budget, and always preserves the recent tail plus tool calls and errors. +// It slots into the compaction registry as a deterministic alternative to the +// LLM-summary (smart) and boundary-truncation strategies, and degrades to +// truncation when it cannot reduce the transcript. Port of OpenClaude's +// relevance-based context pruning. +type RelevancePruneStrategy struct { + // TargetTokens is the budget to fit the kept history under. Zero means the + // package default (5000). + TargetTokens int +} + +func (s *RelevancePruneStrategy) Name() string { return "relevance" } + +func (s *RelevancePruneStrategy) ShouldTrigger(msgs []types.EyrieMessage, tokenCount, threshold int) bool { + return tokenCount >= threshold && len(msgs) >= 20 +} + +func (s *RelevancePruneStrategy) Compact(ctx context.Context, sess *Session) (*CompactResult, error) { + tokensBefore := EstimateTokens(sess.Persistence().RawMessages()) + raw := sess.Persistence().RawMessages() + + taskContext := lastUserText(raw) + pruned := relevanceprune.PruneByRelevance( + toPruneMessages(raw, time.Now()), + relevanceprune.Options{ + TargetTokens: s.TargetTokens, + TaskContext: taskContext, + PreserveRecent: relevanceprune.DefaultCompactTailTurns, + PreserveTools: true, + PreserveErrors: true, + }, + ) + + out := toEyrieMessages(pruned) + sess.Persistence().SetMessages(out) + tokensAfter := EstimateTokens(sess.Persistence().RawMessages()) + return &CompactResult{ + Messages: out, + TokensBefore: tokensBefore, + TokensAfter: tokensAfter, + Strategy: "relevance", + }, nil +} + +// toPruneMessages adapts raw session messages to the relevance-prune shape. +func toPruneMessages(raw []types.EyrieMessage, now time.Time) []relevanceprune.Message { + out := make([]relevanceprune.Message, len(raw)) + for i, m := range raw { + out[i] = relevanceprune.Message{ + Role: m.Role, + Content: m.Content, + Timestamp: now, + HasToolCall: len(m.ToolUse) > 0 || len(m.ToolResults) > 0, + IsError: hasErrorResult(m), + } + } + return out +} + +func toEyrieMessages(msgs []relevanceprune.Message) []types.EyrieMessage { + out := make([]types.EyrieMessage, len(msgs)) + for i, m := range msgs { + out[i] = types.EyrieMessage{Role: m.Role, Content: m.Content} + } + return out +} + +func hasErrorResult(m types.EyrieMessage) bool { + for _, tr := range m.ToolResults { + if tr.IsError { + return true + } + } + return false +} + +func lastUserText(raw []types.EyrieMessage) string { + for i := len(raw) - 1; i >= 0; i-- { + if raw[i].Role == "user" { + return raw[i].Content + } + } + return "" +} diff --git a/internal/engine/compact_relevance_test.go b/internal/engine/compact_relevance_test.go new file mode 100644 index 00000000..24295ba2 --- /dev/null +++ b/internal/engine/compact_relevance_test.go @@ -0,0 +1,72 @@ +package engine + +import ( + "context" + "testing" + "time" + + "github.com/GrayCodeAI/hawk/internal/types" +) + +func TestRelevancePruneStrategy_ShouldTrigger(t *testing.T) { + s := &RelevancePruneStrategy{} + if s.ShouldTrigger(makeMessages(5), 200000, 100000) { + t.Error("should not trigger with few messages") + } + if !s.ShouldTrigger(makeMessages(30), 200000, 100000) { + t.Error("should trigger with many messages over threshold") + } +} + +func TestRelevancePruneStrategy_Compact(t *testing.T) { + sess := NewSessionWithClient(NewMockClientForTest(), "test", "test-model", "", nil, false) + // A long transcript; last user message is the task context. + var msgs []types.EyrieMessage + for i := 0; i < 60; i++ { + msgs = append(msgs, types.EyrieMessage{Role: "user", Content: "unrelated filler content that has no shared keywords"}) + } + msgs = append(msgs, types.EyrieMessage{Role: "user", Content: "refactor the payment module and update all tests"}) + sess.Persistence().SetRawMessages(msgs) + + s := &RelevancePruneStrategy{TargetTokens: 200} + result, err := s.Compact(context.Background(), sess) + if err != nil { + t.Fatal(err) + } + if result.Strategy != "relevance" { + t.Fatalf("strategy = %q, want relevance", result.Strategy) + } + // It must not crash and must return a sane result; the final user message + // should survive. + after := sess.Persistence().RawMessages() + if len(after) == 0 { + t.Fatal("no messages after pruning") + } + found := false + for _, m := range after { + if m.Content == "refactor the payment module and update all tests" { + found = true + } + } + if !found { + t.Fatal("task-context user message was dropped") + } +} + +func TestToPruneMessagesAdapter(t *testing.T) { + msgs := []types.EyrieMessage{ + {Role: "user", Content: "hi"}, + {Role: "assistant", Content: "ok", ToolUse: []types.ToolCall{{Name: "Bash"}}}, + {Role: "user", Content: "err", ToolResults: []types.ToolResult{{IsError: true}}}, + } + pruned := toPruneMessages(msgs, time.Now()) + if len(pruned) != 3 { + t.Fatalf("len = %d", len(pruned)) + } + if !pruned[1].HasToolCall { + t.Fatal("tool-use message should be flagged as tool call") + } + if !pruned[2].IsError { + t.Fatal("error result should be flagged as error") + } +} diff --git a/internal/engine/compact_strategy_engine.go b/internal/engine/compact_strategy_engine.go index ceae14e7..ace86f8d 100644 --- a/internal/engine/compact_strategy_engine.go +++ b/internal/engine/compact_strategy_engine.go @@ -19,11 +19,13 @@ type StrategyRegistry struct { func NewStrategyRegistry(config CompactConfig) *StrategyRegistry { r := &StrategyRegistry{config: config} + target := config.ContextWindowSize - config.AutoCompactBuffer - config.MaxOutputTokens r.strategies = []CompactStrategy{ &ProviderNativeCompactStrategy{}, &MicroCompactStrategy{}, &SessionMemoryStrategy{}, &SmartCompactStrategy{}, + &RelevancePruneStrategy{TargetTokens: target}, &TruncateStrategy{}, } return r diff --git a/internal/engine/compact_strategy_test.go b/internal/engine/compact_strategy_test.go index e228d54a..7b5c6f50 100644 --- a/internal/engine/compact_strategy_test.go +++ b/internal/engine/compact_strategy_test.go @@ -4,6 +4,7 @@ import ( "context" "strings" "testing" + "time" "github.com/GrayCodeAI/hawk/internal/types" ) @@ -24,7 +25,9 @@ func TestAutoCompactor_CircuitBreaker(t *testing.T) { cfg.MaxOutputTokens = 100 ac := NewAutoCompactor(cfg) - ac.consecutiveFailures = 2 + now := time.Now() + ac.breaker.RecordFailure(now) + ac.breaker.RecordFailure(now) sess := NewSessionWithClient(NewMockClientForTest(), "test", "test-model", "", nil, false) sess.Persistence().SetRawMessages(makeMessages(200)) diff --git a/internal/engine/lifecycle_service.go b/internal/engine/lifecycle_service.go index f0bb8200..2843aaca 100644 --- a/internal/engine/lifecycle_service.go +++ b/internal/engine/lifecycle_service.go @@ -11,6 +11,7 @@ import ( "github.com/GrayCodeAI/hawk/internal/observability/logger" "github.com/GrayCodeAI/hawk/internal/plugin" "github.com/GrayCodeAI/hawk/internal/prompts" + "github.com/GrayCodeAI/hawk/internal/smartrouting" "github.com/GrayCodeAI/hawk/internal/types" ) @@ -30,6 +31,8 @@ import ( type LifecycleService struct { // model selection. cascade *branching.CascadeRouter + // smart turn routing (simple/strong per turn). + smartRouting *smartrouting.Config // limit tracking. limits *LimitTracker // doom-loop / snowball / loop detection. @@ -248,6 +251,7 @@ func (s *LifecycleService) SnapshotTurnProgress(tokens int, progress float64) { // to wire optional collaborators. All nil-safe. func (s *LifecycleService) SetCascade(c *branching.CascadeRouter) { s.cascade = c } +func (s *LifecycleService) SetSmartRouting(c *smartrouting.Config) { s.smartRouting = c } func (s *LifecycleService) SetLifecycle(l *SessionLifecycle) { s.lifecycle = l } func (s *LifecycleService) SetReflector(r *Reflector) { s.reflector = r } func (s *LifecycleService) SetCritic(c *Critic) { s.critic = c } @@ -268,6 +272,7 @@ func (s *LifecycleService) Critic() *Critic { return s.c func (s *LifecycleService) Shadow() *branching.ShadowWorkspace { return s.shadow } func (s *LifecycleService) Reflector() *Reflector { return s.reflector } func (s *LifecycleService) Cascade() *branching.CascadeRouter { return s.cascade } +func (s *LifecycleService) SmartRouting() *smartrouting.Config { return s.smartRouting } func (s *LifecycleService) FewShotStore() *FewShotStore { return s.fewShotStore } func (s *LifecycleService) AdaptivePrompt() *AdaptivePrompt { return s.adaptivePrompt } func (s *LifecycleService) Activity() *memory.ActivityTracker { return s.activity } diff --git a/internal/engine/session.go b/internal/engine/session.go index 36982c76..d5ba7930 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -11,22 +11,22 @@ import ( "time" agentcontracts "github.com/GrayCodeAI/hawk-core-contracts/agent" + "github.com/GrayCodeAI/hawk/internal/conversationarc" "github.com/GrayCodeAI/hawk/internal/engine/planning" "github.com/GrayCodeAI/hawk/internal/eventlog" - "github.com/GrayCodeAI/hawk/internal/provider/gateway" - "github.com/GrayCodeAI/hawk/internal/types" - "github.com/GrayCodeAI/hawk/internal/observability/logger" "github.com/GrayCodeAI/hawk/internal/observability/metrics" "github.com/GrayCodeAI/hawk/internal/observability/oteltrace" "github.com/GrayCodeAI/hawk/internal/plugin" "github.com/GrayCodeAI/hawk/internal/prompts" + "github.com/GrayCodeAI/hawk/internal/provider/gateway" "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" + "github.com/GrayCodeAI/hawk/internal/types" ) // MemoryRecaller abstracts memory recall/remember so engine avoids importing memory directly. @@ -79,6 +79,9 @@ type Session struct { persist *PersistenceService goals *planning.GoalTracker // optional goal tracker; emits goal.change lifecycle events tools *ToolService + // arc is the optional durable conversation-arc sidecar (goals/decisions/ + // milestones/phase) loaded per session. See conversationarc package. + arc *conversationarc.Arc // incremental is the opt-in incremental system-context reconciler for // dynamic sections (e.g. memories). Nil unless HAWK_INCREMENTAL_CONTEXT=1. // See incremental.go. @@ -433,6 +436,27 @@ func (s *Session) SetLearnFn(fn func(what, why, lesson, category string)) { s.mu.Unlock() } +// SetArc attaches the session's durable conversation-arc sidecar. +func (s *Session) SetArc(a *conversationarc.Arc) { + if s == nil { + return + } + s.mu.Lock() + s.arc = a + s.mu.Unlock() +} + +// Arc returns the attached conversation arc, or nil when none is set. +func (s *Session) Arc() *conversationarc.Arc { + if s == nil { + return nil + } + s.mu.RLock() + a := s.arc + s.mu.RUnlock() + return a +} + // Learn persists a lesson through the configured callback. Safe to call with // nil session or no callback installed. func (s *Session) Learn(what, why, lesson, category string) { diff --git a/internal/engine/smartrouting_test.go b/internal/engine/smartrouting_test.go new file mode 100644 index 00000000..b4827648 --- /dev/null +++ b/internal/engine/smartrouting_test.go @@ -0,0 +1,34 @@ +package engine + +import ( + "testing" + + "github.com/GrayCodeAI/hawk/internal/smartrouting" +) + +func TestLifecycleServiceSmartRouting(t *testing.T) { + sess := NewSessionWithClient(NewMockClientForTest(), "test", "test-model", "", nil, false) + ls := sess.LifecycleSvc() + if ls.SmartRouting() != nil { + t.Fatal("expected nil smart routing by default") + } + cfg := &smartrouting.Config{Enabled: true, SimpleModel: "mini", StrongModel: "main"} + ls.SetSmartRouting(cfg) + if got := ls.SmartRouting(); got == nil || !got.Enabled { + t.Fatalf("smart routing not set: %+v", got) + } +} + +func TestSmartRoutingReroutesModel(t *testing.T) { + cfg := smartrouting.Config{Enabled: true, SimpleModel: "mini", StrongModel: "main"} + // A trivial turn should route to the simple model. + d := smartrouting.Route(smartrouting.Input{UserText: "ok", TurnNumber: 2}, cfg) + if d.Model != "mini" { + t.Fatalf("expected mini, got %q (%s)", d.Model, d.Complexity) + } + // A planning turn should stay on the strong model. + d2 := smartrouting.Route(smartrouting.Input{UserText: "plan the refactor", TurnNumber: 2}, cfg) + if d2.Model != "main" { + t.Fatalf("expected main, got %q (%s)", d2.Model, d2.Complexity) + } +} diff --git a/internal/engine/stream.go b/internal/engine/stream.go index 9b26897b..c5457bf8 100644 --- a/internal/engine/stream.go +++ b/internal/engine/stream.go @@ -9,6 +9,7 @@ import ( "time" "github.com/GrayCodeAI/hawk/internal/provider/gateway" + "github.com/GrayCodeAI/hawk/internal/smartrouting" "github.com/GrayCodeAI/hawk/internal/types" "github.com/GrayCodeAI/hawk/internal/engine/branching" @@ -62,6 +63,13 @@ func (s *Session) buildTurnOptions(tc turnContext) types.ChatOptions { opts.System += "\n\n## Agent Beliefs\n" + summary } } + // Conversation arc: durable goals/decisions/milestones/phase, injected + // ephemerally. Byte-stable, so unchanged arcs don't churn the prompt cache. + if s.Arc() != nil && !s.Arc().IsEmpty() { + if sum := s.Arc().Summary(); sum != "" { + opts.System += "\n\n## Conversation Arc\n" + sum + } + } // Activity nudge: remind agent to persist learnings if idle. Injected // ephemerally (not persisted) so it never accumulates across turns. if s.MemorySvc().Activity() != nil { @@ -338,15 +346,26 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { if activeModel == "" { activeModel = strings.TrimSpace(s.ChatLLM().Model()) } + userMsg := "" + for i := len(s.Persistence().RawMessages()) - 1; i >= 0; i-- { + if s.Persistence().RawMessages()[i].Role == "user" { + userMsg = s.Persistence().RawMessages()[i].Content + break + } + } if s.LifecycleSvc().Cascade() != nil && s.LifecycleSvc().Cascade().Enabled { - lastUserMsg := "" - for i := len(s.Persistence().RawMessages()) - 1; i >= 0; i-- { - if s.Persistence().RawMessages()[i].Role == "user" { - lastUserMsg = s.Persistence().RawMessages()[i].Content - break - } + activeModel = s.LifecycleSvc().Cascade().SelectModel(userMsg, activeModel, "") + } + // Smart routing: cheap simple model for trivial turns, strong otherwise. + if sr := s.LifecycleSvc().SmartRouting(); sr != nil && sr.Enabled { + d := smartrouting.Route(smartrouting.Input{ + UserText: userMsg, + HasNonText: false, + TurnNumber: turnCount, + }, *sr) + if d.Model != "" { + activeModel = d.Model } - activeModel = s.LifecycleSvc().Cascade().SelectModel(lastUserMsg, activeModel, "") } if strings.TrimSpace(activeModel) == "" { emit(StreamEvent{Type: "error", Content: "no model selected — open /config → Models and pick one"})