Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions cmd/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
14 changes: 14 additions & 0 deletions cmd/chat_commands_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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}
}
}
Expand Down
5 changes: 5 additions & 0 deletions cmd/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()},
Expand Down
2 changes: 2 additions & 0 deletions internal/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"`
Expand Down
7 changes: 7 additions & 0 deletions internal/conversationarc/arc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
7 changes: 7 additions & 0 deletions internal/conversationarc/arc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
20 changes: 20 additions & 0 deletions internal/engine/arc_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions internal/engine/compact/strategy.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package compact

import (
"strings"
"time"

"github.com/GrayCodeAI/hawk/internal/types"
)
Expand All @@ -20,6 +21,7 @@ type CompactConfig struct {
AutoCompactBuffer int
MaxOutputTokens int
MaxFailures int
Cooldown time.Duration
}

func DefaultCompactConfig() CompactConfig {
Expand All @@ -29,6 +31,7 @@ func DefaultCompactConfig() CompactConfig {
AutoCompactBuffer: 13000,
MaxOutputTokens: 20000,
MaxFailures: 3,
Cooldown: 5 * time.Minute,
}
}

Expand Down
30 changes: 18 additions & 12 deletions internal/engine/compact_auto.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,27 @@ 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.
func NewAutoCompactor(config CompactConfig) *AutoCompactor {
return &AutoCompactor{
registry: NewStrategyRegistry(config),
config: config,
breaker: circuitbreaker.New(config.MaxFailures, config.Cooldown),
}
}

Expand All @@ -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.
Expand All @@ -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
}

Expand All @@ -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())
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
96 changes: 96 additions & 0 deletions internal/engine/compact_relevance.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
Loading
Loading