Skip to content

Commit 70640ce

Browse files
cnjackt
andcommitted
Harden tools, compaction, and prompt layers for long-horizon autonomous runs (#118)
* fix(agent): harden tools, compaction, and prompt for long-horizon runs Audit of jcode vs Claude Code and codex (32 findings, tracked with per-case status in internal-doc/tooling-audit.md); all confirmed cases fixed with TDD across four waves: Tools - execute: head+tail truncation with dropped-bytes marker, full output spilled to ~/.jcode/tasks and the path returned to the model - exec lifecycle: process-group kill via new internal/procutil (Setpgid + Kill(-pid)), WaitDelay=2s with ErrWaitDelay folded to success, SSH SIGTERM->SIGKILL grace, Docker in-container pidfile tree kill - read: 200KB output budget with offset continuation + 2000-byte per-line truncation (UTF-8 safe) - glob: find -name replaced with cd+rg --files --glob --sortr=modified (fixes silently-broken ** recursion, adds deterministic mtime order) - grep: global (not per-file) content limit via streaming early-stop, honest capped footer, output_mode enum validation - edit/write: read-before-edit enforced (ConflictNeverRead; FileTracker now actually wired in NewEnv), atomic writes (temp+fsync+rename, SSH base64+mv -f), multi-edit overlap/ambiguity guards + per-op replace_all, item schemas for edits/todos arrays - background tasks: head+tail truncation, task log always written and its path surfaced to the model - errors: Fatal/IsFatal layer aborts runs on dead container/SSH, subagent safe middleware (panic recovery + error folding + parent propagation, async panic no longer crashes the process), ToolError hints on high-frequency failures Compaction - occupancy reminder now uses last-call total (GetLastTotal) instead of the cumulative ledger — fixes permanently >100% "wrap up" pressure after any compaction - crude 500-char threshold strategy demoted to fallback-only behind the eino summarizer; compact failure fuse (3 strikes, fail-open) - output-reserve headroom (EffectiveContextLimit) for trigger math - compaction persists the kept tail; TUI resume no longer loses it - reduction config single-sourced (BuildReductionConfig) across TUI/ACP/Web, calibrated token estimator (ASCII/3.6 + CJK-aware + EMA self-calibration from provider usage) replaces len/4, per-turn aggregate tool-result budget (150k chars, copy-on-write) Prompt - env/git drift re-collected every 5 iterations and injected as a diff (includes date rollover); AGENTS.md hot-reloaded on mtime+hash change; externally-modified files reported with a re-read instruction - system.md: parallel tool-call batching directive + Verification discipline section Verified by ~90 new TDD tests (red first), full-repo go test -count=1, -race on tools/agent, GOOS=windows build, and a new agent-eval ACP e2e (robust_huge_output: seq 1 200000 truncated to marker+tail, session survives, oracle-checked answer) passing against a live model. Generated with Jack AI bot * chore: resolve golangci-lint findings in new code errcheck on discarded Count returns, De Morgan simplification, embedded-field selector cleanup, and two unparam removals (execLocal constant timeout, buildRgArgs unused maxResults). Generated with Jack AI bot * fix(git): scrub inherited GIT_* env from every git subprocess git exports an absolute GIT_DIR to hook subprocesses when pushing from a linked worktree. jcode's git calls (envinfo, web git API, session baseline/diff stats) and the web test helper inherited it, silently operating on the outer repository instead of the -C/cwd-selected one — the web suite's `git init` in a temp dir re-initialized the pushing repo as bare when run under the pre-push hook. Add util.ScrubbedGitEnv() (drops GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE/ GIT_COMMON_DIR and friends), apply it at all production git call sites, and pin GIT_DIR/GIT_WORK_TREE to the temp repo in the web test helper. Regression-tested by running the web suite under a hostile GIT_DIR against a victim repo (stays non-bare) plus unit tests for the scrubber and for gitCommand under an inherited GIT_DIR. Recorded as finding #33 in internal-doc/tooling-audit.md. Generated with Jack AI bot * fix(agent): address review findings — live-env reminders, calibration recovery, docker fatal coverage - reminder: read remote-ness from the live *tools.Env each round (switch_env mutates it in place without an agent rebuild on ACP/web); pause the env-drift / AGENTS.md / external-file sweeps while remote so local-host state is never reported as drift of the remote machine, and resume with the same baselines after switching back - token estimate: a sustained streak of below-peak counts adopts the shrunk window as the new full baseline, so calibration resumes after compaction instead of freezing on the stale peak - docker exec: route stream-copy and inspect errors through wrapDockerRunErr so a container removed mid-exec is classified Fatal like create/attach - glob: resolve a relative search path against the tool workspace instead of the process cwd; make the mtime-order test deterministic via os.Chtimes - compaction: refuse to apply an empty strategy result (counts toward the fuse instead of wiping the conversation) Generated with Jack AI bot --------- Co-authored-by: t <t@example.com>
1 parent 7b89f16 commit 70640ce

72 files changed

Lines changed: 6146 additions & 328 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

agent-eval/suite/testcases.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,6 +1007,23 @@
10071007
"path": "hook-ran.txt"
10081008
}
10091009
]
1010+
},
1011+
{
1012+
"id": "robust_huge_output",
1013+
"title": "Huge command output is truncated (head+tail+spill), session survives and stays correct",
1014+
"category": "robustness",
1015+
"tier": "core",
1016+
"prompt": "Run the shell command `seq 1 200000` exactly as written (do not paraphrase it, do not pipe it through tail or head on the first run). Its output may be truncated by the tool; the truncation notice preserves the tail of the output and tells you where the full output was saved. Then create a file named answer.txt whose exact contents are the last number that command printed, with no trailing newline.",
1017+
"fixtures": {},
1018+
"timeout": 300,
1019+
"expect_tool_use": true,
1020+
"oracles": [
1021+
{
1022+
"type": "file_equals",
1023+
"path": "answer.txt",
1024+
"expected": "200000"
1025+
}
1026+
]
10101027
}
10111028
]
10121029
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ require (
1212
github.com/cloudwego/eino-ext/components/tool/mcp v0.0.8
1313
github.com/cloudwego/eino-ext/libs/acl/langfuse v0.1.1
1414
github.com/coder/acp-go-sdk v0.13.5
15+
github.com/containerd/errdefs v1.0.0
1516
github.com/creack/pty v1.1.24
1617
github.com/docker/docker v28.5.2+incompatible
1718
github.com/google/uuid v1.6.0
@@ -48,7 +49,6 @@ require (
4849
github.com/clipperhouse/displaywidth v0.11.0 // indirect
4950
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
5051
github.com/cloudwego/base64x v0.1.6 // indirect
51-
github.com/containerd/errdefs v1.0.0 // indirect
5252
github.com/containerd/errdefs/pkg v0.3.0 // indirect
5353
github.com/containerd/log v0.1.0 // indirect
5454
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect

internal-doc/tooling-audit.md

Lines changed: 307 additions & 0 deletions
Large diffs are not rendered by default.

internal/agent/compaction.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,9 @@ func (s *ThresholdCompactionStrategy) Compact(ctx context.Context, messages []*s
101101
summaryMsg, err := s.summarizer.Generate(ctx, summaryInput)
102102
if err != nil {
103103
config.Logger().Printf("[compaction] summarisation failed: %v", err)
104-
return messages, nil // fail-open: return original messages
104+
// Propagate the error so the middleware can count it towards its
105+
// fuse; the caller keeps the original messages (fail-open).
106+
return messages, err
105107
}
106108

107109
summaryMessage := &schema.Message{
@@ -125,13 +127,18 @@ func truncate(s string, maxLen int) string {
125127
return s[:maxLen] + "…"
126128
}
127129

130+
// maxConsecutiveCompactFails is the fuse limit: after this many consecutive
131+
// compaction failures (summarizer errors or no-shrink results) the middleware
132+
// stops attempting automatic compaction for the rest of the session, instead
133+
// of burning tokens and latency on a summarizer that keeps failing.
134+
const maxConsecutiveCompactFails = 3
135+
128136
// CompactionState tracks compaction history for diagnostics.
129137
type CompactionState struct {
130138
mu sync.Mutex
131139
compactionCount int
132140
savedTokens int
133141
consecutiveFails int
134-
tripped bool
135142
}
136143

137144
// CompactionCount returns how many compactions have occurred.
@@ -157,6 +164,9 @@ type compactionMiddleware struct {
157164
contextLimit int
158165
onCompact func(savedTokens int)
159166
tokenUsage *internalmodel.TokenUsage
167+
// fuseLogOnce ensures the "compaction disabled" log fires once per session
168+
// instead of on every model call after the fuse blows.
169+
fuseLogOnce sync.Once
160170
}
161171

162172
// NewCompactionMiddleware creates a ChatModelAgentMiddleware that monitors
@@ -193,6 +203,18 @@ func (m *compactionMiddleware) BeforeModelRewriteState(
193203
return ctx, state, nil
194204
}
195205

206+
// Fuse: after repeated failures, stop retrying the summarizer for the
207+
// rest of the session — each retry costs tokens and latency for nothing.
208+
m.state.mu.Lock()
209+
fails := m.state.consecutiveFails
210+
m.state.mu.Unlock()
211+
if fails >= maxConsecutiveCompactFails {
212+
m.fuseLogOnce.Do(func() {
213+
config.Logger().Printf("[compaction] disabled for this session after %d consecutive failures", fails)
214+
})
215+
return ctx, state, nil
216+
}
217+
196218
config.Logger().Printf("[compaction] triggered: tokens=%d, limit=%d", currentTokens, m.contextLimit)
197219

198220
beforeLen := len(state.Messages)
@@ -206,13 +228,23 @@ func (m *compactionMiddleware) BeforeModelRewriteState(
206228
}
207229

208230
saved := beforeLen - len(compacted)
231+
if saved <= 0 || len(compacted) == 0 {
232+
// Compacted nothing (e.g. a single poison message larger than the
233+
// window) — or a strategy returned an empty slice with a nil error,
234+
// which would silently wipe the conversation if applied. Both are
235+
// non-error failures that still count towards the fuse.
236+
config.Logger().Printf("[compaction] no shrink: %d → %d messages", beforeLen, len(compacted))
237+
m.state.mu.Lock()
238+
m.state.consecutiveFails++
239+
m.state.mu.Unlock()
240+
return ctx, state, nil
241+
}
209242
state.Messages = compacted
210243

211244
m.state.mu.Lock()
212245
m.state.compactionCount++
213246
m.state.savedTokens += saved
214247
m.state.consecutiveFails = 0
215-
m.state.tripped = true
216248
m.state.mu.Unlock()
217249

218250
config.Logger().Printf("[compaction] compacted %d messages → %d (saved %d)", beforeLen, len(compacted), saved)

internal/agent/compaction_test.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package agent
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
8+
"github.com/cloudwego/eino/adk"
9+
einomodel "github.com/cloudwego/eino/components/model"
10+
"github.com/cloudwego/eino/schema"
11+
)
12+
13+
// stubStrategy is a CompactionStrategy whose Compact behaviour is scripted per
14+
// call, for driving the middleware directly (same style as budget_test.go).
15+
type stubStrategy struct {
16+
compactCalls int
17+
fn func(call int, msgs []*schema.Message) ([]*schema.Message, error)
18+
}
19+
20+
func (s *stubStrategy) ShouldCompact(currentTokens, limit int) bool { return true }
21+
22+
func (s *stubStrategy) Compact(ctx context.Context, msgs []*schema.Message, keepRecent int) ([]*schema.Message, error) {
23+
s.compactCalls++
24+
return s.fn(s.compactCalls, msgs)
25+
}
26+
27+
// errGenModel is a ToolCallingChatModel whose Generate always fails, standing
28+
// in for a broken summarizer.
29+
type errGenModel struct{}
30+
31+
func (errGenModel) Generate(context.Context, []*schema.Message, ...einomodel.Option) (*schema.Message, error) {
32+
return nil, errors.New("summarizer boom")
33+
}
34+
35+
func (errGenModel) Stream(context.Context, []*schema.Message, ...einomodel.Option) (*schema.StreamReader[*schema.Message], error) {
36+
panic("Stream is not used by the compaction strategy")
37+
}
38+
39+
func (m errGenModel) WithTools([]*schema.ToolInfo) (einomodel.ToolCallingChatModel, error) {
40+
return m, nil
41+
}
42+
43+
func newTestState(n int) *adk.ChatModelAgentState {
44+
msgs := make([]adk.Message, 0, n)
45+
for i := 0; i < n; i++ {
46+
if i%2 == 0 {
47+
msgs = append(msgs, schema.UserMessage("u"))
48+
} else {
49+
msgs = append(msgs, &schema.Message{Role: schema.Assistant, Content: "a"})
50+
}
51+
}
52+
return &adk.ChatModelAgentState{Messages: msgs}
53+
}
54+
55+
func asCompactionMiddleware(t *testing.T, strategy CompactionStrategy) *compactionMiddleware {
56+
t.Helper()
57+
mw := NewCompactionMiddleware(strategy, 100000, nil, nil)
58+
m, ok := mw.(*compactionMiddleware)
59+
if !ok {
60+
t.Fatalf("NewCompactionMiddleware returned %T, want *compactionMiddleware", mw)
61+
}
62+
return m
63+
}
64+
65+
// TestCompactionMiddleware_FuseAfterConsecutiveFails: after 3 consecutive
66+
// compaction failures the middleware must stop calling the summarizer for the
67+
// rest of the session (fail-open every time, never an error to the run).
68+
func TestCompactionMiddleware_FuseAfterConsecutiveFails(t *testing.T) {
69+
strategy := &stubStrategy{fn: func(int, []*schema.Message) ([]*schema.Message, error) {
70+
return nil, errors.New("boom")
71+
}}
72+
m := asCompactionMiddleware(t, strategy)
73+
state := newTestState(6)
74+
75+
for i := 0; i < 6; i++ {
76+
_, got, err := m.BeforeModelRewriteState(context.Background(), state, nil)
77+
if err != nil {
78+
t.Fatalf("call %d: err = %v, want nil (fail-open)", i+1, err)
79+
}
80+
if len(got.Messages) != 6 {
81+
t.Fatalf("call %d: messages mutated on failure: %d, want 6", i+1, len(got.Messages))
82+
}
83+
}
84+
85+
if strategy.compactCalls != 3 {
86+
t.Errorf("Compact called %d times, want exactly 3 (fused from the 4th trigger on)", strategy.compactCalls)
87+
}
88+
m.state.mu.Lock()
89+
fails := m.state.consecutiveFails
90+
m.state.mu.Unlock()
91+
if fails != 3 {
92+
t.Errorf("consecutiveFails = %d, want 3", fails)
93+
}
94+
}
95+
96+
// TestCompactionMiddleware_FuseResetOnSuccess: failures below the fuse limit
97+
// followed by a success must reset the counter and keep compaction available.
98+
func TestCompactionMiddleware_FuseResetOnSuccess(t *testing.T) {
99+
strategy := &stubStrategy{fn: func(call int, msgs []*schema.Message) ([]*schema.Message, error) {
100+
if call <= 2 {
101+
return nil, errors.New("boom")
102+
}
103+
return msgs[:len(msgs)-1], nil // success: shrink by one
104+
}}
105+
m := asCompactionMiddleware(t, strategy)
106+
state := newTestState(6)
107+
108+
for i := 0; i < 4; i++ {
109+
_, got, err := m.BeforeModelRewriteState(context.Background(), state, nil)
110+
if err != nil {
111+
t.Fatalf("call %d: err = %v, want nil", i+1, err)
112+
}
113+
state = got
114+
}
115+
116+
if strategy.compactCalls != 4 {
117+
t.Errorf("Compact called %d times, want 4 (2 fails below the fuse + 2 successes)", strategy.compactCalls)
118+
}
119+
if len(state.Messages) != 4 {
120+
t.Errorf("messages after two successful compactions = %d, want 4", len(state.Messages))
121+
}
122+
m.state.mu.Lock()
123+
fails, count := m.state.consecutiveFails, m.state.compactionCount
124+
m.state.mu.Unlock()
125+
if fails != 0 {
126+
t.Errorf("consecutiveFails = %d, want 0 after success", fails)
127+
}
128+
if count != 2 {
129+
t.Errorf("compactionCount = %d, want 2", count)
130+
}
131+
}
132+
133+
// TestCompactionMiddleware_NoShrinkCountsAsFail: a Compact that returns no
134+
// fewer messages (compressed nothing) counts towards the fuse instead of being
135+
// celebrated as a successful compaction.
136+
func TestCompactionMiddleware_NoShrinkCountsAsFail(t *testing.T) {
137+
strategy := &stubStrategy{fn: func(_ int, msgs []*schema.Message) ([]*schema.Message, error) {
138+
return msgs, nil // same length, nil error
139+
}}
140+
m := asCompactionMiddleware(t, strategy)
141+
state := newTestState(6)
142+
143+
_, got, err := m.BeforeModelRewriteState(context.Background(), state, nil)
144+
if err != nil {
145+
t.Fatalf("err = %v, want nil", err)
146+
}
147+
if len(got.Messages) != 6 {
148+
t.Fatalf("messages = %d, want 6 (unchanged)", len(got.Messages))
149+
}
150+
151+
m.state.mu.Lock()
152+
fails, count := m.state.consecutiveFails, m.state.compactionCount
153+
m.state.mu.Unlock()
154+
if fails != 1 {
155+
t.Errorf("consecutiveFails = %d, want 1 (no-shrink counts as failure)", fails)
156+
}
157+
if count != 0 {
158+
t.Errorf("compactionCount = %d, want 0", count)
159+
}
160+
}
161+
162+
// TestThresholdStrategy_PropagatesSummarizerError: the strategy must surface a
163+
// summarizer error to its caller (the middleware counts it towards the fuse)
164+
// instead of swallowing it and reporting a successful no-op.
165+
func TestThresholdStrategy_PropagatesSummarizerError(t *testing.T) {
166+
strategy := NewThresholdCompactionStrategy(0.75, errGenModel{}, 2)
167+
168+
msgs := make([]*schema.Message, 0, 10)
169+
for i := 0; i < 10; i++ {
170+
if i%2 == 0 {
171+
msgs = append(msgs, schema.UserMessage("u"))
172+
} else {
173+
msgs = append(msgs, &schema.Message{Role: schema.Assistant, Content: "a"})
174+
}
175+
}
176+
177+
got, err := strategy.Compact(context.Background(), msgs, 0)
178+
if err == nil {
179+
t.Fatal("Compact swallowed the summarizer error, want it propagated")
180+
}
181+
if len(got) != len(msgs) {
182+
t.Errorf("Compact returned %d messages on error, want the original %d (fail-open)", len(got), len(msgs))
183+
}
184+
}

internal/agent/history.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ func SyncSummarization(cap *SummarizationCapture, history []adk.Message, rec *se
121121
newHistory = append(newHistory, kept...)
122122

123123
if rec != nil {
124-
rec.RecordCompact(summary, compactedN)
124+
rec.RecordCompact(summary, compactedN, len(kept))
125125
}
126126
config.Logger().Printf("[summarization] synced history: %d → %d messages", len(history), len(newHistory))
127127
return newHistory

0 commit comments

Comments
 (0)