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 <cursoragent@cursor.com>
This commit is contained in:
2026-10-03 16:21:02 +00:00
co-authored by Cursor
parent 4e00c98cb5
commit 78ce78a7d3
9 changed files with 149 additions and 9 deletions
+50 -8
View File
@@ -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)
}
+17
View File
@@ -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")
}
}
+29
View File
@@ -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) {
+6
View File
@@ -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,
+4
View File
@@ -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 {
+18
View File
@@ -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)
}
}
+9
View File
@@ -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}
+15
View File
@@ -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
}