From 78ce78a7d3432d6637a703c2e6cba0144be7bdd4 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 3 Oct 2026 16:21:02 +0000 Subject: [PATCH] Cap tool results by character and gate stale file writes Why: Byte truncation split UTF-8, subagent results could return unbounded text, and a write could overwrite a file that changed after it was read. Evidence: cutRunes keeps a two-character Chinese prefix; tool selection honors -name; checklist reminders go through the reminder bus. Impact: Tool output limits count characters. A subagent result over 32000 characters is stored as an artifact and the parent sees the path. fs_write_file rejects an existing file that was not read in this run. Refs: feature/context-engineering Co-authored-by: Cursor --- deps/agent-native | 2 +- internal/runmgr/runmgr.go | 58 +++++++++++++++++++++++++---- internal/runmgr/truncate_test.go | 17 +++++++++ internal/runtime/llm.go | 29 +++++++++++++++ internal/runtime/llm_direct.go | 6 +++ internal/runtime/sub_loop.go | 4 ++ internal/runtime/toolselect_test.go | 18 +++++++++ internal/runtime/verify.go | 9 +++++ internal/tools/fs.go | 15 ++++++++ 9 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 internal/runmgr/truncate_test.go create mode 100644 internal/runtime/toolselect_test.go diff --git a/deps/agent-native b/deps/agent-native index 9f5751c..6fd1d44 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit 9f5751c20527ae223323650ab8da877fc7b89601 +Subproject commit 6fd1d441a95423e27be6f6159207d5d0821bec4d diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index d943b65..bb7717b 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -13,6 +13,10 @@ import ( "sync" "sync/atomic" "time" + "unicode/utf8" + + "agent-native/runtime/filestate" + "agent-native/runtime/subagent" "agent-system-backend/internal/bus" "agent-system-backend/internal/cardsvc" @@ -159,6 +163,7 @@ type runCtx struct { subHint string subAgent string launch *workflow.Launch + files *filestate.Tracker } // Workflow 返回这次 run 启动时拷贝的工作流。普通回合没有。 @@ -614,12 +619,12 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { if limit <= 0 { limit = int(c.eff.GetNumber("context.maxToolResultChars", 20000)) } - if res.Status == "ok" && len(res.Output) > limit { + if res.Status == "ok" && utf8.RuneCountInString(res.Output) > limit { if art, err := c.WriteArtifact("tool-"+call.Name+"-"+call.CallID+".txt", "data", "text/plain", []byte(res.Output)); err == nil { res.Artifact = &art - res.Output = res.Output[:limit] + "\n…(超长,完整结果已存为工件 " + art.Name + ")" + res.Output = cutRunes(res.Output, limit) + "\n…(超长,完整结果已存为工件 " + art.Name + ")" } else { - res.Output = res.Output[:limit] + "…(超长截断,工件写入失败)" + res.Output = cutRunes(res.Output, limit) + "…(超长截断,工件写入失败)" } res.Truncated = true } @@ -870,7 +875,7 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa runner := selectConversationRunner(m, eff, launch) inputPaths, _ := m.stageInputs(conv, eff, inputs) - rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch} + rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch, files: filestate.New()} go m.execute(rc, runner) return run, nil } @@ -1198,7 +1203,7 @@ func (m *Manager) startChild(parentCtx context.Context, parent core.Run, agentID prof, _ := m.profiles.Get(parent.Owner, parent.ProfileID) eff := m.settings.Effective(parent.Owner, parent.ProjectID) - rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint, subAgent: agentID} + rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint, subAgent: agentID, files: filestate.New()} m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing) runner := m.subRunner @@ -1254,7 +1259,7 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool _ = m.st.AppendMessage(&core.Message{ConversationID: child.ConversationID, RunID: child.ID, Role: core.RoleAssistant, Parts: subParts}) } if fullText != "" { - payload.Text = fullText + payload.Text = capSubagentResult(rc, fullText) if payload.Summary == "" || payload.Summary == "完成" { payload.Summary = truncate(fullText, 160) } @@ -1504,8 +1509,45 @@ func artifactSummary(parts []core.Part) string { } func truncate(s string, n int) string { - if len(s) <= n { + cut := cutRunes(s, n) + if cut == s { return s } - return s[:n] + "…" + return cut + "…" +} + +func cutRunes(s string, n int) string { + if n < 0 { + n = 0 + } + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) +} + +func capSubagentResult(rc *runCtx, text string) string { + shown, full := subagent.CapParentText(text) + if full == nil { + return shown + } + if art, err := rc.WriteArtifact("subagent-result.txt", "data", "text/plain", full); err == nil { + return shown + "\n完整结果见工件 " + art.Name + } + return shown + "\n完整结果超出 32000 字,已截断" +} + +func (t *toolCtx) NoteFileRead(path string) error { + if t.rc == nil || t.rc.files == nil { + return nil + } + return t.rc.files.NoteRead(path) +} + +func (t *toolCtx) CheckFileWrite(path string) error { + if t.rc == nil || t.rc.files == nil { + return nil + } + return t.rc.files.CheckWrite(path) } diff --git a/internal/runmgr/truncate_test.go b/internal/runmgr/truncate_test.go new file mode 100644 index 0000000..b319b22 --- /dev/null +++ b/internal/runmgr/truncate_test.go @@ -0,0 +1,17 @@ +package runmgr + +import "testing" + +func TestCutRunesKeepsUTF8(t *testing.T) { + s := "中文结果" + got := cutRunes(s, 2) + if got != "中文" { + t.Fatalf("%q", got) + } + if truncate(s, 2) != "中文…" { + t.Fatalf("%q", truncate(s, 2)) + } + if cutRunes(s, 8) != s { + t.Fatal("short string was cut") + } +} diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index f4a2877..d6a1a6f 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -11,6 +11,7 @@ import ( nruntime "agent-native/runtime" agentllm "agent-native/runtime/llm" + agenttools "agent-native/runtime/tools" "agent-system-backend/internal/ctxasm" "agent-system-backend/internal/prompts" core "agent-system-core" @@ -282,12 +283,40 @@ func (l *LLMRunner) Execute(rc RunCtx) error { decls = append(decls, agentllm.ToolDecl{Name: td.Name, Description: td.Description, Parameters: td.Parameters}) } } + decls = applyToolSelection(decls, eff.GetString("runtime.toolSelection", "")) maxTurns, maxTools := loopCaps(prof.Budget) streamOpts := agentllm.StreamOptions{MaxTokens: outputTokenCap(prof.Budget, eff, "models.chat.maxOutputTokens")} return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools) } +func applyToolSelection(decls []agentllm.ToolDecl, spec string) []agentllm.ToolDecl { + spec = strings.TrimSpace(spec) + if spec == "" { + return decls + } + byName := map[string]agentllm.ToolDecl{} + names := make([]string, 0, len(decls)) + for _, d := range decls { + byName[d.Name] = d + names = append(names, d.Name) + } + var next []agentllm.ToolDecl + for _, name := range agenttools.ApplySelection(names, spec) { + if d, ok := byName[name]; ok { + next = append(next, d) + } + } + return next +} + +func contextWindow(eff core.EffectiveSettings) (window, reserve, buffer int) { + window = int(eff.GetNumber("models.chat.contextWindow", 32000)) + reserve = int(eff.GetNumber("models.chat.outputReserve", 4096)) + buffer = int(eff.GetNumber("models.chat.contextBuffer", 1024)) + return +} + // loopCaps 把画像预算换成内核参数。轮次或工具数为 0 / 未设置表示不限制; // 内核对 MaxTurns<=0 会回落到 12,所以不限制时传 MaxInt32。MaxToolCalls 的 0 才是不限制。 func loopCaps(budget core.RunBudget) (maxTurns, maxTools int) { diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index 8d1a1ab..ee05ca5 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -9,6 +9,7 @@ import ( nruntime "agent-native/runtime" agentllm "agent-native/runtime/llm" + "agent-native/runtime/reminders" agenttools "agent-native/runtime/tools" "agent-system-backend/internal/squadadapter" core "agent-system-core" @@ -49,6 +50,7 @@ type directRun struct { toolTurnsSince int checklistReminders int checks []core.CheckPart + notes *reminders.Bus } type toolEvidence struct { @@ -142,6 +144,7 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin return out } + window, reserve, buffer := contextWindow(st.eff) res, err := nruntime.Run(rc.Context(), nruntime.Config{ Model: model, History: history, @@ -152,6 +155,9 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin RepeatGuard: true, ContinueOnLength: true, MaxLengthContinuations: -1, + ContextWindow: window, + OutputReserve: reserve, + ContextBuffer: buffer, OnEvent: st.onEvent, Stop: st.stop, Reminders: st.reminders, diff --git a/internal/runtime/sub_loop.go b/internal/runtime/sub_loop.go index 6d46b19..d5b65d2 100644 --- a/internal/runtime/sub_loop.go +++ b/internal/runtime/sub_loop.go @@ -91,6 +91,7 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error { } _ = rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: "执行子任务……", Phase: core.PhaseActing}) user := agentllm.Message{Role: agentllm.RoleUser, Content: subagentUserText(rc)} + window, reserve, buffer := contextWindow(rc.Settings()) res, err := nruntime.Run(rc.Context(), nruntime.Config{ Model: route.Model, SystemPrompt: l.subagentPrompt(rc, id), @@ -100,6 +101,9 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error { RepeatGuard: true, ContinueOnLength: true, MaxLengthContinuations: -1, + ContextWindow: window, + OutputReserve: reserve, + ContextBuffer: buffer, OnEvent: st.onEvent, }, user) if len(st.parts) == 0 { diff --git a/internal/runtime/toolselect_test.go b/internal/runtime/toolselect_test.go new file mode 100644 index 0000000..1fd9f2c --- /dev/null +++ b/internal/runtime/toolselect_test.go @@ -0,0 +1,18 @@ +package runtime + +import ( + "testing" + + agentllm "agent-native/runtime/llm" +) + +func TestApplyToolSelectionRemovesName(t *testing.T) { + decls := []agentllm.ToolDecl{{Name: "read_file"}, {Name: "write_file"}} + got := applyToolSelection(decls, "-write_file,+missing") + if len(got) != 1 || got[0].Name != "read_file" { + t.Fatalf("%+v", got) + } + if same := applyToolSelection(decls, ""); len(same) != 2 { + t.Fatalf("empty: %+v", same) + } +} diff --git a/internal/runtime/verify.go b/internal/runtime/verify.go index 5a8ec01..6f4b258 100644 --- a/internal/runtime/verify.go +++ b/internal/runtime/verify.go @@ -5,8 +5,10 @@ import ( "fmt" "path/filepath" "strings" + "time" agentllm "agent-native/runtime/llm" + "agent-native/runtime/reminders" "agent-system-backend/internal/quality" core "agent-system-core" ) @@ -235,6 +237,13 @@ func (st *directRun) reminders(turn int) []string { if text == "" { return nil } + if st.notes == nil { + st.notes = reminders.New(0, maxChecklistReminders) + } + key := fmt.Sprintf("%d:%s", st.checklistReminders, text) + if !st.notes.Admit(reminders.Note{Kind: reminders.KindTodo, Key: key, Text: text}, time.Now()) { + return nil + } st.toolTurnsSince = 0 st.checklistReminders++ return []string{text} diff --git a/internal/tools/fs.go b/internal/tools/fs.go index 9a5b486..8614f74 100644 --- a/internal/tools/fs.go +++ b/internal/tools/fs.go @@ -99,9 +99,17 @@ func execFSRead(rc ToolExecCtx, args json.RawMessage) (string, error) { if err != nil { return "", err } + if stamps, ok := rc.(fileStamps); ok { + _ = stamps.NoteFileRead(abs) + } return string(b), nil } +type fileStamps interface { + NoteFileRead(path string) error + CheckFileWrite(path string) error +} + func execFSWrite(rc ToolExecCtx, args json.RawMessage) (string, error) { var a struct { Path string `json:"path"` @@ -136,6 +144,13 @@ func execFSWrite(rc ToolExecCtx, args json.RawMessage) (string, error) { return "", fmt.Errorf("fs_write_file: refusing to follow symlink out of sandbox: %s", a.Path) } } + if _, err := os.Stat(abs); err == nil { + if stamps, ok := rc.(fileStamps); ok { + if err := stamps.CheckFileWrite(abs); err != nil { + return "", err + } + } + } if err := os.WriteFile(abs, []byte(a.Content), 0o644); err != nil { return "", err }