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:
Vendored
+1
-1
Submodule deps/agent-native updated: 9f5751c205...6fd1d441a9
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user