Skip to content

Commit 75a9ed8

Browse files
authored
feat(engine): add tool-result clearing and approval pause timing (#254)
1 parent 384224d commit 75a9ed8

6 files changed

Lines changed: 246 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,8 @@ Features adopted from open-source agent projects. All are off by default unless
295295
| Smart turn routing | `internal/smartrouting` | Deterministic simple/strong turn classifier with fail-toward-strong safety. Wired into per-turn model selection (`settings.smart_routing`) |
296296
| 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) |
297297
| Relevance pruning | `internal/relevanceprune` | Token-budgeted context pruning preserving recent turns/tool calls/errors. Wired into compaction as a `relevance` strategy |
298+
| Tool-result clearing | `internal/engine` (`ClearOldToolResults`) | Two-tier context management: at 80% of the context window, stale tool-result content is replaced with `[output cleared]` placeholders (tool_use kept intact) before compacting — a gentler tier below compaction |
299+
| Approval pause timing | `internal/permissions` | Approval requests record decision timestamp + human deliberation duration (`DecisionAt`/`PauseDuration`) for approval-latency observability |
298300

299301
## Usage
300302

‎internal/engine/compact_clear.go‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package engine
2+
3+
import (
4+
"context"
5+
"sort"
6+
7+
"github.com/GrayCodeAI/hawk/internal/types"
8+
)
9+
10+
// Two-tier context management (adopted from herm): before compacting, "clear"
11+
// the content of old tool results into a short placeholder to reclaim context
12+
// gently. The tool_use blocks are left intact, so the model still knows what was
13+
// called, and can re-read files if needed. Only when clearing is insufficient
14+
// does the compaction strategy chain run.
15+
16+
const (
17+
// clearThresholdFraction is the fraction of the context window at which old
18+
// tool results start getting cleared. 0.8 = clear when input tokens > 80%.
19+
clearThresholdFraction = 0.8
20+
// clearKeepRecent is the number of most-recent tool-result messages kept intact.
21+
clearKeepRecent = 4
22+
// outputClearedPlaceholder replaces cleared tool-result content.
23+
outputClearedPlaceholder = "[output cleared]"
24+
)
25+
26+
// clearOldToolResults returns a copy of msgs with the content of old tool
27+
// results replaced by a short placeholder, biggest-first, keeping the most
28+
// recent clearKeepRecent tool-result messages intact, until estimatedTokens
29+
// drops below threshold. It returns the new messages and the estimated tokens
30+
// freed. Adopted from herm's clearOldToolResults.
31+
func clearOldToolResults(msgs []types.EyrieMessage, estimatedTokens, threshold int) ([]types.EyrieMessage, int) {
32+
if threshold <= 0 || estimatedTokens <= 0 || len(msgs) == 0 {
33+
return msgs, 0
34+
}
35+
type candidate struct {
36+
idx int
37+
size int
38+
}
39+
var candidates []candidate
40+
for i := range msgs {
41+
if size := toolResultBytes(msgs[i]); size > 0 {
42+
candidates = append(candidates, candidate{idx: i, size: size})
43+
}
44+
}
45+
if len(candidates) <= clearKeepRecent {
46+
return msgs, 0
47+
}
48+
clearable := candidates[:len(candidates)-clearKeepRecent]
49+
sort.Slice(clearable, func(i, j int) bool { return clearable[i].size > clearable[j].size })
50+
51+
out := cloneToolResults(msgs)
52+
tokens := estimatedTokens
53+
freed := 0
54+
for _, c := range clearable {
55+
if tokens < threshold {
56+
break
57+
}
58+
f := clearMessageToolResults(&out[c.idx])
59+
if f == 0 {
60+
continue
61+
}
62+
tokens -= f
63+
freed += f
64+
}
65+
return out, freed
66+
}
67+
68+
// ClearOldToolResults is the session-level entry point: it clears old tool
69+
// results when token usage crosses 80% of the context window. Returns true when
70+
// anything was cleared. It is a gentler tier below compaction.
71+
func (s *Session) ClearOldToolResults(ctx context.Context) bool {
72+
if s == nil {
73+
return false
74+
}
75+
raw := s.Persistence().RawMessages()
76+
if len(raw) == 0 {
77+
return false
78+
}
79+
window := s.ContextWindowSize()
80+
if window <= 0 {
81+
return false
82+
}
83+
tokens := EstimateTokens(raw)
84+
threshold := int(float64(window) * clearThresholdFraction)
85+
if tokens < threshold {
86+
return false
87+
}
88+
cleared, freed := clearOldToolResults(raw, tokens, threshold)
89+
if freed > 0 {
90+
s.Persistence().SetRawMessages(cleared)
91+
return true
92+
}
93+
return false
94+
}
95+
96+
// toolResultBytes returns the total byte size of a message's tool results.
97+
func toolResultBytes(m types.EyrieMessage) int {
98+
total := 0
99+
for _, tr := range m.ToolResults {
100+
total += len(tr.Content)
101+
}
102+
return total
103+
}
104+
105+
// clearMessageToolResults replaces each non-empty tool-result content with the
106+
// placeholder and returns the estimated tokens freed (~4 bytes per token).
107+
func clearMessageToolResults(m *types.EyrieMessage) int {
108+
freedBytes := 0
109+
for i := range m.ToolResults {
110+
c := m.ToolResults[i].Content
111+
if c == "" || c == outputClearedPlaceholder {
112+
continue
113+
}
114+
freedBytes += len(c) - len(outputClearedPlaceholder)
115+
m.ToolResults[i].Content = outputClearedPlaceholder
116+
}
117+
return freedBytes / 4
118+
}
119+
120+
// cloneToolResults copies msgs, deep-copying the ToolResults slices so callers
121+
// can mutate the copy without touching the persisted originals.
122+
func cloneToolResults(msgs []types.EyrieMessage) []types.EyrieMessage {
123+
out := make([]types.EyrieMessage, len(msgs))
124+
for i, m := range msgs {
125+
out[i] = m
126+
if m.ToolResults != nil {
127+
out[i].ToolResults = append([]types.ToolResult(nil), m.ToolResults...)
128+
}
129+
}
130+
return out
131+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package engine
2+
3+
import (
4+
"testing"
5+
6+
"github.com/GrayCodeAI/hawk/internal/types"
7+
)
8+
9+
func mkMsg(role string, trs []types.ToolResult) types.EyrieMessage {
10+
return types.EyrieMessage{Role: role, ToolResults: trs}
11+
}
12+
13+
func TestClearOldToolResultsNoOpWhenBelowThreshold(t *testing.T) {
14+
msgs := []types.EyrieMessage{
15+
mkMsg("user", []types.ToolResult{{Content: "aaaa"}}),
16+
}
17+
out, freed := clearOldToolResults(msgs, 100, 1000)
18+
if freed != 0 || len(out) != 1 {
19+
t.Fatalf("freed=%d len=%d, want no-op", freed, len(out))
20+
}
21+
}
22+
23+
func TestClearOldToolResultsClearsBiggestFirst(t *testing.T) {
24+
// 6 tool-result messages; threshold low so clearing triggers; keep last 4.
25+
msgs := make([]types.EyrieMessage, 6)
26+
for i := range msgs {
27+
msgs[i] = mkMsg("user", []types.ToolResult{{Content: "12345678"}}) // 8 bytes
28+
}
29+
out, freed := clearOldToolResults(msgs, 100000, 50000)
30+
if freed == 0 {
31+
t.Fatal("expected tokens freed")
32+
}
33+
// Keep last 4 intact; first 2 clearable messages get cleared.
34+
if out[0].ToolResults[0].Content != outputClearedPlaceholder {
35+
t.Fatalf("oldest result not cleared: %q", out[0].ToolResults[0].Content)
36+
}
37+
// Most recent 4 intact.
38+
if out[5].ToolResults[0].Content == outputClearedPlaceholder {
39+
t.Fatal("most recent result should be kept intact")
40+
}
41+
// Original slice untouched (we cloned).
42+
if msgs[0].ToolResults[0].Content == outputClearedPlaceholder {
43+
t.Fatal("input slice must not be mutated")
44+
}
45+
}
46+
47+
func TestClearOldToolResultsStopsWhenUnderThreshold(t *testing.T) {
48+
// Huge contents, small threshold: it clears until under threshold, then stops.
49+
msgs := make([]types.EyrieMessage, 6)
50+
for i := range msgs {
51+
msgs[i] = mkMsg("user", []types.ToolResult{{Content: "12345678"}})
52+
}
53+
out, _ := clearOldToolResults(msgs, 48, 20)
54+
cleared := 0
55+
for i := 0; i < 2; i++ {
56+
if out[i].ToolResults[0].Content == outputClearedPlaceholder {
57+
cleared++
58+
}
59+
}
60+
if cleared == 0 {
61+
t.Fatalf("expected at least the oldest result cleared: %+v", out[0])
62+
}
63+
}
64+
65+
func TestClearOldToolResultsSkipsAlreadyCleared(t *testing.T) {
66+
msgs := []types.EyrieMessage{
67+
mkMsg("user", []types.ToolResult{{Content: outputClearedPlaceholder}}),
68+
mkMsg("user", []types.ToolResult{{Content: outputClearedPlaceholder}}),
69+
mkMsg("user", []types.ToolResult{{Content: outputClearedPlaceholder}}),
70+
mkMsg("user", []types.ToolResult{{Content: outputClearedPlaceholder}}),
71+
mkMsg("user", []types.ToolResult{{Content: outputClearedPlaceholder}}),
72+
mkMsg("user", []types.ToolResult{{Content: outputClearedPlaceholder}}),
73+
}
74+
out, freed := clearOldToolResults(msgs, 100, 50)
75+
if freed != 0 {
76+
t.Fatalf("already-cleared results should free nothing, freed=%d", freed)
77+
}
78+
_ = out
79+
}

‎internal/engine/context_governor.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,11 @@ func (s *Session) ManageContextBeforeTurn(ctx context.Context) (strategy string,
129129
}
130130
s.Persistence().SetRawMessages(ctxmgr.CollapseRepeatedMessages(s.Persistence().RawMessages()))
131131

132+
// Clear tier (0.8): replace old tool-result content with a placeholder before
133+
// compacting, so short-term overflow is reclaimed gently without a full
134+
// compaction. Only when clearing is insufficient does compaction run.
135+
s.ClearOldToolResults(ctx)
136+
132137
s.EnsureAutoCompactor()
133138
if compactStrategy, ok := s.Persistence().AutoCompactor().AutoCompactIfNeeded(ctx, s); ok {
134139
return compactStrategy, true // recordCompaction emitted inside AutoCompactIfNeeded

‎internal/permissions/approval_workflow.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ type ApprovalRequest struct {
2020
Status string // "pending", "approved", "denied", "expired"
2121
ExpiresAt time.Time
2222
Reason string
23+
// DecisionAt and PauseDuration record how long the human deliberated before
24+
// deciding, for approval-latency observability (adopted from herm).
25+
DecisionAt time.Time
26+
PauseDuration time.Duration
2327
}
2428

2529
// ApprovalPolicy defines rules for how approval requests are handled.
@@ -190,6 +194,8 @@ func (wf *ApprovalWorkflow) Approve(id, reason string) error {
190194
}
191195
req.Status = "approved"
192196
req.Reason = reason
197+
req.DecisionAt = time.Now()
198+
req.PauseDuration = req.DecisionAt.Sub(req.CreatedAt)
193199
wf.Pending = append(wf.Pending[:i], wf.Pending[i+1:]...)
194200
wf.History = append(wf.History, req)
195201
return nil
@@ -210,6 +216,8 @@ func (wf *ApprovalWorkflow) Deny(id, reason string) error {
210216
}
211217
req.Status = "denied"
212218
req.Reason = reason
219+
req.DecisionAt = time.Now()
220+
req.PauseDuration = req.DecisionAt.Sub(req.CreatedAt)
213221
wf.Pending = append(wf.Pending[:i], wf.Pending[i+1:]...)
214222
wf.History = append(wf.History, req)
215223
return nil

‎internal/permissions/approval_workflow_test.go‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,3 +545,24 @@ func TestApprovalConcurrentAccess(t *testing.T) {
545545
t.Errorf("expected 10 history entries, got %d", len(wf.History))
546546
}
547547
}
548+
549+
func TestApprovalRecordsPauseDuration(t *testing.T) {
550+
wf := NewApprovalWorkflow(nil)
551+
req, err := wf.RequestApproval("Bash", map[string]interface{}{"command": "make build"}, "MEDIUM")
552+
if err != nil {
553+
t.Fatalf("unexpected error: %v", err)
554+
}
555+
if err := wf.Approve(req.ID, "ok"); err != nil {
556+
t.Fatalf("approve error: %v", err)
557+
}
558+
// The request should now carry decision metadata (non-zero duration).
559+
if req.Status != "approved" {
560+
t.Fatalf("status = %s", req.Status)
561+
}
562+
if req.DecisionAt.IsZero() {
563+
t.Fatal("expected DecisionAt to be set")
564+
}
565+
if req.PauseDuration <= 0 {
566+
t.Fatalf("expected positive pause duration, got %s", req.PauseDuration)
567+
}
568+
}

0 commit comments

Comments
 (0)