From 8ca28eb07183a513c16becb8300154d3a10dffb3 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 27 Sep 2026 14:05:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(toolcall):=20=E5=B7=A5=E5=85=B7=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E5=8F=AA=E7=BB=9F=E8=AE=A1=E4=B8=8D=E8=A3=81=E5=89=AA?= =?UTF-8?q?=EF=BC=88=E6=96=B9=E6=A1=88=20B=EF=BC=89=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E6=B2=BB=E6=8E=89=20seq=20=E4=BE=A7=E7=9A=84=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E6=88=AA=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题(核实过):工具结果进 f.Msgs 时**没有任何长度上限**(task.go 直接 `Content: result`),内核也**不预检**是否超长 —— 超限由上游 API 报错。 时间线那侧有预算(ContextTokens = 0.8×窗口,进消息前就裁过),但那只管 a.context 的历史事件,**不管单条工具结果** ⇒ 一条巨大结果可能直接冲破 预算而内核不会提前发现。 为什么**不裁剪**(与方案 A 的取舍): · 截断会让模型拿到**残缺**信息,而截断位置由内核武断决定; · 模型无法得知"这里被截断了",会基于残缺数据下结论 —— 与本仓反复 吃亏的「静默降级」同族(`20s` 少引号 → 静默降级 → cmd_run 失败率 34%); · 处置权应交给调度器/上层(告警、拒绝、或让模型自己换更窄的查询), 而不是内核单方面替模型决定。 改动: · core/toolresult_budget.go: checkToolResultSize 只**计数+报告**; 阈值默认 = ContextTokens/8(一条吃掉全部预算会把其它上下文全挤掉); 报告经 toolResultReporter(可替换),默认 logReporter —— **不给模型发 消息**:那是在已花掉的 token 之上再加一条 system,且对当前这轮决策无帮助。 · 接入点在 stepToolAfter 的 toolMsg 落定**之后**(那里才是模型最终看到的 内容;stepToolExec 拿到的尚未经 after_toolcall 改写)。 · TaskFrame 记 oversizeTools / oversizeToolNames,供调度器与状态面查询 "是否有工具在稳定产出超大结果"。 ★ 顺带治掉 seq 侧一处**我自己留下的静默截断**: handlers.go 里我当初随手写了 truncate(…, 160),把变量槽静默截到 160 字 且**无任何标注** —— 正是我批评过的静默降级。 改为 renderSlot:≤160 给全;超过则显式标注「已截断:共 N 字,此处显示前 160 字」并给出改法。**槽里存的始终是完整值**,截断只影响回填文本长度。 端到端判据 TestSeqRunDoesNotSilentlyTruncateSlot 抓到了这个缺陷 ("变量槽被截到 160/5000 字却没有任何标注")。 判据(toolresult_budget_test.go,4 条): · 400KB 结果触发超限报告(含工具名与 token 数) · ★ **默认不裁剪**:200KB 结果原样进 tool 消息(方案 B 的核心不变式) · 小结果不误报(噪音会淹没有效信号) · 报告文案可执行:带工具名、token 数、改法建议 变异验证:去掉统计调用 ⇒ 两条判据 FAIL("统计没生效" + "被裁剪了")。 另:检查项报 stepToolBatch 的 goroutine 竞态,-race 实测**误报**—— 循环变量显式传参(非闭包捕获)、且按索引写各自槽位(非共享 map), `-race` 下 20 轮并发判据全绿。 --- internal/agent/core/agent.go | 6 + internal/agent/core/task.go | 16 ++ internal/agent/core/toolresult_budget.go | 152 +++++++++++++ internal/agent/core/toolresult_budget_test.go | 208 ++++++++++++++++++ internal/plugins/seq/e2e_test.go | 48 ++++ internal/plugins/seq/handlers.go | 29 ++- internal/plugins/seq/help.go | 13 +- internal/plugins/seq/plugin_test.go | 53 +++++ internal/plugins/seq/tools.go | 9 +- 9 files changed, 523 insertions(+), 11 deletions(-) create mode 100644 internal/agent/core/toolresult_budget.go create mode 100644 internal/agent/core/toolresult_budget_test.go diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 2b41be7..0540659 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -95,6 +95,12 @@ type Agent struct { // 被授权的输出通道集合(空 = 完整授权,见 AgentConfig.AllowedOutputs)。 allowedOutputs []string + // toolResultWarnTokens 是「单条工具结果过大」的告警阈值(0 = 用默认)。 + // ⚠️ 方案 B 只**统计与报告**,绝不裁剪(见 toolresult_budget.go 的理由)。 + toolResultWarnTokens int + // toolResultReporter 报告超限;nil 时用 logReporter。 + toolResultReporter toolResultReporter + // 插件注册表(用于 plgreload) pluginReg *plugin.Registry pluginDir string diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index cb24351..76a37bc 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -128,6 +128,12 @@ type TaskFrame struct { //(output_channel / input_source / media_* —— stage.go:18 依赖前者)。 toolCtxs []sdk.StageContext + // oversizeTools / oversizeToolNames 记录本任务中「过大工具结果」的次数与工具名。 + // ⚠️ 结果**未被裁剪**(方案 B),这里只是记账,供调度器/状态面判断 + // 是否有工具在稳定地产出超大结果。 + oversizeTools int + oversizeToolNames []string + // assistantMsgIdx 是本批 assistant(tool_calls) 消息在 Msgs 中的下标, // -1 表示尚未写入。阶段 2a:批内只写**一条** assistant 承载全部 // tool_calls,工具结果各自作为 tool 消息追加在它之后。 @@ -1027,6 +1033,16 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome { } } f.Msgs = append(f.Msgs, toolMsg) + + // 工具结果大小统计(方案 B:**只统计不裁剪**)。 + // + // 为什么在这里而不是 stepToolExec:那里拿到的 outcome.Text 尚未经 + // after_toolcall 阶段改写,而模型最终看到的是**这里**的内容。 + // ⚠️ 刻意不截断 —— 截断会让模型基于残缺数据下结论,且它不知道被截过 + // (与本仓「静默降级」同族)。处置权交回调度器/上层。 + if a.checkToolResultSize(tc.Name, toolMsg.Content) { + f.noteOversizeTool(tc.Name) + } if mediaMsg != nil { // 必须紧跟在 toolMsg 之后:中间插入其他消息会让 tool_call_id 配对断开。 f.Msgs = append(f.Msgs, *mediaMsg) diff --git a/internal/agent/core/toolresult_budget.go b/internal/agent/core/toolresult_budget.go new file mode 100644 index 0000000..194379f --- /dev/null +++ b/internal/agent/core/toolresult_budget.go @@ -0,0 +1,152 @@ +package core + +import ( + "fmt" + "log" + "strings" +) + +// 本文件实现「工具结果**只统计不裁剪**」(方案 B)。 +// +// 现状与风险(已核实):工具结果进 f.Msgs 时**没有任何长度上限** +// (task.go 直接 `Content: result`),内核也**不预检**是否超长 —— +// 超限由上游 API 报错。时间线那一侧有预算(ContextTokens = 0.8×窗口, +// 进消息前就裁过),但那只管 a.context 的历史事件,**不管单条工具结果**。 +// ⇒ 一条巨大工具结果可能直接冲破预算,而内核不会提前发现。 +// +// 为什么**不裁剪**(与方案 A 的取舍): +// · 截断会让模型拿到**残缺**信息,而截断位置由内核武断决定; +// · 模型无法得知"这里被截断了",会基于残缺数据下结论 +// —— 这与本仓反复吃亏的「静默降级」是同一族问题; +// · 处置权应交给调度器/上层(可以告警、可以拒绝、可以让模型自己决定 +// 换更小的查询),而不是内核单方面替模型决定。 +// +// ⇒ 本文件只做两件事:**计数**与**报告**。 + +// 默认阈值:单条工具结果超过 1/8 目标预算即报告。 +// +// 取 1/8 而非"整个预算":一条结果吃掉全部预算时,上下文里其它内容 +// (记忆召回、时间线、对话历史)就全被挤掉了 —— 那才是真正需要预警的 +// 场景。具体数值可由配置覆盖(见 SetToolResultWarnTokens)。 +const defaultToolResultWarnDivisor = 8 + +// toolResultReporter 报告一次「工具结果过大」。 +// 抽成接口是为了让判据能观测报告内容,而不必去抓日志。 +type toolResultReporter interface { + report(tool string, tokens, budget int, msg string) +} + +// logReporter 是默认实现:写日志。 +// +// 为什么默认只记日志、不给模型发消息:给模型发"你刚才的输出太大了" +// 是在**已经花掉的 token 之上**再加一条 system 消息,且它对当前这轮 +// 决策毫无帮助。真正需要处置的是**下一轮**的调度(是否还塞更多上下文)。 +// 交由上层订阅日志或替换 reporter 决定。 +type logReporter struct{} + +func (logReporter) report(tool string, tokens, budget int, msg string) { + log.Printf("[agent] tool %s result is large: %d tokens (%.0f%% of budget %d) — %s", + tool, tokens, percentOf(tokens, budget), budget, msg) +} + +func percentOf(v, total int) float64 { + if total <= 0 { + return 0 + } + return float64(v) * 100 / float64(total) +} + +// checkToolResultSize 统计单条工具结果的大小,必要时报告。 +// +// ⚠️ **只统计,不裁剪**(见文件头)。返回 true 表示「已报告过」。 +func (a *Agent) checkToolResultSize(tool, result string) bool { + if a == nil || result == "" { + return false + } + tokens := EstimateTokens(result) + limit := a.toolResultWarnLimit() + if tokens <= limit { + return false // 正常,不打扰 + } + // ⚠️ 文案**必须带工具名**:报告是给日志/状态面看的,不带名字就无法 + // 判断是哪个工具在稳定产出超大结果(判据 TestReportIsActionable 钉住)。 + msg := fmt.Sprintf("工具 %s 的结果约 %d token,超出单条上限 %d(占预算 %.0f%%)。"+ + "建议:收窄查询条件、分页取、或把大结果转存后只取摘要。**结果未被裁剪**,模型看到的是完整内容。", + tool, tokens, limit, percentOf(tokens, limit)) + r := a.toolResultReporter + if r == nil { + r = logReporter{} + } + r.report(tool, tokens, limit, msg) + return true +} + +// toolResultWarnLimit 返回本 agent 的单条工具结果告警阈值。 +func (a *Agent) toolResultWarnLimit() int { + if a.toolResultWarnTokens > 0 { + return a.toolResultWarnTokens + } + budget := ComputeTokenBudget(a.provider, a.systemPrompt) + base := budget.ContextTokens + if base <= 0 { + base = budget.TargetUsage + } + if base <= 0 { + base = 8192 + } + return base / defaultToolResultWarnDivisor +} + +// SetToolResultWarnTokens 覆盖告警阈值(0 = 用默认值)。 +// +// 供部署方按模型窗口调整:窗口大的源(1M)用默认阈值,窗口小的源 +// (32K)可能需要更小的单条上限。 +func (a *Agent) SetToolResultWarnTokens(n int) { a.toolResultWarnTokens = n } + +// OversizeToolReports 返回本任务中「过大工具结果」的累计计数。 +// +// 供调度器/状态面查询:连续出现说明某个工具在稳定地产出超大结果, +// 值得换用更窄的查询方式。 +func (f *TaskFrame) OversizeToolReports() int { + if f == nil { + return 0 + } + return f.oversizeTools +} + +// noteOversizeTool 记一次过大结果。 +func (f *TaskFrame) noteOversizeTool(name string) { + if f == nil { + return + } + f.oversizeTools++ + if f.oversizeToolNames == nil { + f.oversizeToolNames = make([]string, 0, 4) + } + for _, n := range f.oversizeToolNames { + if n == name { + return + } + } + f.oversizeToolNames = append(f.oversizeToolNames, name) +} + +// OversizeToolNames 返回出现过超大结果的工具名(去重、保序)。 +func (f *TaskFrame) OversizeToolNames() []string { + if f == nil || len(f.oversizeToolNames) == 0 { + return nil + } + out := make([]string, len(f.oversizeToolNames)) + copy(out, f.oversizeToolNames) + return out +} + +// oversizeHintFor 供状态面渲染用的一行摘要。 +func (f *TaskFrame) oversizeHintFor() string { + if f == nil || f.oversizeTools == 0 { + return "" + } + names := f.OversizeToolNames() + return fmt.Sprintf("本任务有 %d 次超大工具结果(%s)——未被裁剪,但已占用大量上下文预算", + f.oversizeTools, strings.Join(names, ", ")) +} diff --git a/internal/agent/core/toolresult_budget_test.go b/internal/agent/core/toolresult_budget_test.go new file mode 100644 index 0000000..564f0cb --- /dev/null +++ b/internal/agent/core/toolresult_budget_test.go @@ -0,0 +1,208 @@ +package core + +import ( + "strings" + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" +) + +// 方案 B:工具结果**只统计不裁剪**。 +// +// 现状核实:工具结果进 f.Msgs 时**没有任何长度上限**(task.go 直接 +// `Content: result`),内核也**不预检**是否超长 —— 超限由上游 API 报错。 +// 时间线那一侧有预算(ContextTokens = 0.8×窗口,进消息前就裁过), +// 但那只管 a.context 的历史事件,**不管单条工具结果**。 +// ⇒ 一条巨大工具结果可能直接冲破预算,而内核**不会提前发现**。 +// +// 本阶段的取舍:**不裁剪用户数据**(截断会让模型拿到残缺信息,且 +// 截断位置由内核武断决定),改为**统计 + 告警**,把处置权交回给 +// 调度器/上层。这与本仓「显式才是特权」的取向一致。 +// +// 判据钉住四件事:会计数、会超阈值、默认**不**裁剪、报告可执行。 + +// bigToolDevice 返回一个指定大小的工具结果。 +type bigToolDevice struct { + name string + toolName string + size int +} + +func (d *bigToolDevice) Name() string { return d.name } +func (d *bigToolDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput } +func (d *bigToolDevice) Description() string { return "big result test device" } +func (d *bigToolDevice) Tools() []agentIO.ToolDef { + return []agentIO.ToolDef{{Name: d.toolName}} +} +func (d *bigToolDevice) Execute(string, map[string]interface{}) (interface{}, error) { + return strings.Repeat("x", d.size), nil +} +func (d *bigToolDevice) Start() error { return nil } +func (d *bigToolDevice) Stop() error { return nil } +func (d *bigToolDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText } +func (d *bigToolDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} } + +// ① 统计必须真的发生:巨大工具结果要触发一次「超预算」报告。 +func TestHugeToolResultIsReported(t *testing.T) { + // 阈值刻意调小,让 200KB 的结果必然超限(不必真造 1M token) + a := newPreemptAgent(t, newPreemptProvider()) + a.toolResultWarnTokens = 200 * 1024 // 200KB(EstimateTokens 为 rune×2) + + rep := &countingReporter{} + a.toolResultReporter = rep + + if err := a.io.RegisterDevice(&bigToolDevice{ + name: "bigdev", toolName: "big_tool", size: 400 * 1024, // 400KB + }); err != nil { + t.Fatalf("注册设备失败: %v", err) + } + + sp := &batchProvider{responses: []*agentAPI.CompletionResponse{ + {ToolCalls: []agentAPI.ToolCall{{ID: "c1", Name: "big_tool", Arguments: map[string]interface{}{}}}}, + {Content: "final"}, + }} + a2, _ := newBatchAgent(t, sp) + a2.toolResultWarnTokens = a.toolResultWarnTokens + a2.toolResultReporter = rep + if err := a2.io.RegisterDevice(&bigToolDevice{ + name: "bigdev", toolName: "big_tool", size: 400 * 1024, + }); err != nil { + t.Fatalf("注册设备失败: %v", err) + } + + f := a2.newTaskFrame("go", a2.stageCtxFromInput("go", "", "")) + if out := a2.runTaskSteps(f); out != outcomeDone { + t.Fatalf("runTaskSteps=%v err=%v", out, f.Err) + } + if rep.n == 0 { + t.Error("400KB 的工具结果未触发任何超限报告 —— 统计没生效") + } + if rep.lastTool != "big_tool" { + t.Errorf("报告应指明是哪个工具,实际 %q", rep.lastTool) + } + if rep.lastTokens <= 0 { + t.Errorf("报告应带上估算 token 数,实际 %d", rep.lastTokens) + } +} + +// ② ★ 方案 B 的核心:**默认不裁剪**。统计归统计,数据必须原样给模型。 +func TestHugeToolResultIsNotTruncated(t *testing.T) { + a, _ := newBatchAgent(t, &batchProvider{responses: []*agentAPI.CompletionResponse{ + {ToolCalls: []agentAPI.ToolCall{{ID: "c1", Name: "big_tool", Arguments: map[string]interface{}{}}}}, + {Content: "final"}, + }}) + const size = 200 * 1024 + a.toolResultWarnTokens = 10 // 阈值调到极小,必定触发 + + rep := &countingReporter{} + a.toolResultReporter = rep + if err := a.io.RegisterDevice(&bigToolDevice{ + name: "bigdev", toolName: "big_tool", size: size, + }); err != nil { + t.Fatalf("注册设备失败: %v", err) + } + + f := a.newTaskFrame("go", a.stageCtxFromInput("go", "", "")) + if out := a.runTaskSteps(f); out != outcomeDone { + t.Fatalf("runTaskSteps=%v err=%v", out, f.Err) + } + // 模型看到的必须**原样** + var got string + for _, m := range f.Msgs { + if m.Role == "tool" { + got = m.Content + } + } + if len(got) != size { + t.Errorf("工具结果被裁剪了:得到 %d 字节,期望 %d(方案 B 只统计不裁剪)", + len(got), size) + } + // 且确实报告过 + if rep.n == 0 { + t.Error("未触发超限报告") + } +} + +// ③ 小结果不该误报(避免噪音淹没有效信号)。 +func TestNormalToolResultNotReported(t *testing.T) { + a, _ := newBatchAgent(t, &batchProvider{responses: []*agentAPI.CompletionResponse{ + {ToolCalls: []agentAPI.ToolCall{{ID: "c1", Name: "small_tool", Arguments: map[string]interface{}{}}}}, + {Content: "final"}, + }}) + a.toolResultWarnTokens = 100 * 1024 // 100KB + rep := &countingReporter{} + a.toolResultReporter = rep + if err := a.io.RegisterDevice(&smallToolDevice{}); err != nil { + t.Fatalf("注册失败: %v", err) + } + f := a.newTaskFrame("go", a.stageCtxFromInput("go", "", "")) + if out := a.runTaskSteps(f); out != outcomeDone { + t.Fatalf("runTaskSteps=%v err=%v", out, f.Err) + } + if rep.n != 0 { + t.Errorf("小结果被误报 %d 次 —— 噪音会淹没有效信号", rep.n) + } +} + +// ④ 报告内容要可执行:说清是哪个工具、多大、占预算多少。 +func TestReportIsActionable(t *testing.T) { + a, _ := newBatchAgent(t, &batchProvider{responses: []*agentAPI.CompletionResponse{ + {ToolCalls: []agentAPI.ToolCall{{ID: "c1", Name: "big_tool", Arguments: map[string]interface{}{}}}}, + {Content: "final"}, + }}) + a.toolResultWarnTokens = 1024 + rep := &countingReporter{} + a.toolResultReporter = rep + if err := a.io.RegisterDevice(&bigToolDevice{ + name: "bigdev", toolName: "big_tool", size: 50 * 1024, + }); err != nil { + t.Fatalf("注册失败: %v", err) + } + f := a.newTaskFrame("go", a.stageCtxFromInput("go", "", "")) + if out := a.runTaskSteps(f); out != outcomeDone { + t.Fatalf("runTaskSteps=%v err=%v", out, f.Err) + } + + if rep.lastMsg == "" { + t.Fatal("报告文案为空") + } + for _, want := range []string{"big_tool", "token"} { + if !strings.Contains(rep.lastMsg, want) { + t.Errorf("报告缺少 %q:%s", want, rep.lastMsg) + } + } +} + +// ---- 测试替身 ---- + +type countingReporter struct { + n int + lastTool string + lastTokens int + lastMsg string +} + +func (r *countingReporter) report(tool string, tokens, budget int, msg string) { + r.n++ + r.lastTool = tool + r.lastTokens = tokens + r.lastMsg = msg +} + +// smallToolDevice 返回一个很小的结果。 +type smallToolDevice struct{} + +func (d *smallToolDevice) Name() string { return "smalldev" } +func (d *smallToolDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput } +func (d *smallToolDevice) Description() string { return "small result test device" } +func (d *smallToolDevice) Tools() []agentIO.ToolDef { + return []agentIO.ToolDef{{Name: "small_tool"}} +} +func (d *smallToolDevice) Execute(string, map[string]interface{}) (interface{}, error) { + return "tiny", nil +} +func (d *smallToolDevice) Start() error { return nil } +func (d *smallToolDevice) Stop() error { return nil } +func (d *smallToolDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText } +func (d *smallToolDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} } diff --git a/internal/plugins/seq/e2e_test.go b/internal/plugins/seq/e2e_test.go index de0bb00..fb3b80e 100644 --- a/internal/plugins/seq/e2e_test.go +++ b/internal/plugins/seq/e2e_test.go @@ -289,3 +289,51 @@ func TestE2E_DeleteMissingErrors(t *testing.T) { t.Fatal("删除不存在的序列却返回成功") } } + +// ⑧ ★ 变量槽的值不得**静默**截断(方案 B 的要求在 seq 侧同样适用)。 +// +// 背景:seq_run / seq_call 回填变量槽时,我当初随手写了 truncate(…, 160)。 +// 那正是本仓反复吃亏的「静默降级」——模型拿到 160 字的残缺值, +// **不知道**后面还有内容,会基于残缺数据下结论。 +// +// 正确做法:要么给全,要么**显式标注**被截断(并说明有多少)。 +func TestSeqRunDoesNotSilentlyTruncateSlot(t *testing.T) { + long := strings.Repeat("L", 5000) // 远超 160 + r := newE2ERunner("uptime") + r.results["uptime"] = long + p := newE2EPlugin(t, r) + + if _, err := p.dispatch("seq_create", map[string]interface{}{ + "name": "trunc", + "groups": []interface{}{ + map[string]interface{}{ + "name": "g1", + "in": map[string]string{}, + "out": map[string]string{"summary": "string"}, + "tools": `{"tool":"uptime","args":{},"as":"summary"} ;`, + }, + }, + }); err != nil { + t.Fatalf("seq_create: %v", err) + } + + out, err := p.dispatch("seq_run", map[string]interface{}{"name": "trunc"}) + if err != nil { + t.Fatalf("seq_run: %v", err) + } + res, _ := out.(string) + if !strings.Contains(res, "summary") { + t.Fatalf("未回填 summary 槽: %s", res) + } + // 若确实截断,必须**显式标注**并说明被截了多少 + if strings.Count(res, "L") < 160 { + t.Fatalf("summary 槽几乎为空: %s", res) + } + if strings.Count(res, "L") < len(long) { + // 发生了截断 —— 那必须看得见 + if !strings.Contains(res, "已截断") && !strings.Contains(res, "省略") { + t.Errorf("变量槽被截到 %d/%d 字却**没有任何标注** —— 模型会基于残缺值下结论", + strings.Count(res, "L"), len(long)) + } + } +} diff --git a/internal/plugins/seq/handlers.go b/internal/plugins/seq/handlers.go index ab72c7d..e106e29 100644 --- a/internal/plugins/seq/handlers.go +++ b/internal/plugins/seq/handlers.go @@ -324,7 +324,7 @@ func (p *Plugin) runSequence(seq *Sequence, in map[string]interface{}, _ any) (i } sort.Strings(keys) for _, k := range keys { - fmt.Fprintf(&sb, "\n %s = %s", k, truncate(renderResult(slots[k]), 160)) + fmt.Fprintf(&sb, "\n %s = %s", k, renderSlot(slots[k])) } } if failed { @@ -428,15 +428,34 @@ func renderGroupResult(res GroupResult) string { sort.Strings(keys) sb.WriteString("\n出参:") for _, k := range keys { - fmt.Fprintf(&sb, "\n %s = %s", k, truncate(renderResult(res.Slots[k]), 160)) + fmt.Fprintf(&sb, "\n %s = %s", k, renderSlot(res.Slots[k])) } } return sb.String() } -func truncate(s string, n int) string { - if len(s) <= n { +// renderSlot 渲染一个变量槽的值。 +// +// ⚠️ 截断**必须显式标注**(方案 B:只统计不静默裁剪)。 +// 我此前在这里写了裸 truncate(…, 160) —— 那正是本仓反复吃亏的 +// 「静默降级」:模型拿到 160 字的残缺值却**不知道**后面还有内容, +// 会基于残缺数据下结论。端到端判据 TestSeqRunDoesNotSilentlyTruncateSlot +// 正是为此而写(它抓到过这个缺陷)。 +// +// 行为:≤ slotDisplayLimit 时给全;超过时给前段 + 显式的「已截断,共 N 字」。 +// 标注让模型能自己决定是否改用更窄的查询重取。 +func renderSlot(v interface{}) string { + s := renderResult(v) + if len(s) <= slotDisplayLimit { return s } - return s[:n] + "..." + return fmt.Sprintf("%s …【已截断:共 %d 字,此处显示前 %d 字。"+ + "若需完整内容,请用更窄的查询条件重跑,或把大结果转存后按需取回】", + s[:slotDisplayLimit], len(s), slotDisplayLimit) } + +// slotDisplayLimit 是变量槽的单条显示上限。 +// +// 它**只影响展示**,不影响执行:槽里存的始终是完整值(模型可在本轮内 +// 通过条件表达式读到完整内容)。截断仅为控制**回填文本**的长度。 +const slotDisplayLimit = 160 diff --git a/internal/plugins/seq/help.go b/internal/plugins/seq/help.go index 07dae81..1c66a5b 100644 --- a/internal/plugins/seq/help.go +++ b/internal/plugins/seq/help.go @@ -21,9 +21,11 @@ func seqHelpText() string { const helpHeader = `【工具序列 seq】 -一条序列 = 若干 group,**组内并行、组间串行**。每个 group 有独立签名 -(in 入参 / out 出参),可被 seq_call 按名调用。 -序列存的是**解析后的 AST**:保存时做完全部静态校验,执行期不再解析文本。 +⚠️ 最容易错的一处(真机实测模型在此连续失败 4 次): + + group 的 in 必须是**对象**,无入参写 {} —— 不要写成字符串 + + "in": "" 或 "in": "{}"(字符串)一律被拒。「无入参」要表达成空**对象**。 常用操作: seq_list 列出全部序列及其签名 @@ -32,6 +34,10 @@ const helpHeader = `【工具序列 seq】 seq_call 按名调用某个 group 或某条序列 seq_when_call 条件调用,when 为真才执行 seq_delete 删除 + +序列 = 若干 group,**组内并行、组间串行**;每个 group 有独立签名 +(in 入参 / out 出参),可被 seq_call 按名调用。 +序列存的是**解析后的 AST**:保存时做完全部静态校验,执行期不再解析文本。 ` const helpFormat = ` @@ -42,6 +48,7 @@ const helpFormat = ` 2. 每个 group 的字段: name 必填,组名,全局唯一(它是签名名) in 入参声明,**对象**,如 {"host":"string"};无入参写 {} + ⚠️ 必须是对象 {"k":"type"};写 "" 或 "{}"(字符串)一律被拒 out 出参声明,**对象**,如 {"summary":"string"};无出参写 {} when 条件屏障,默认 "true",只可读 $args.* parallel 默认 true;置 false 则组内串行(保序场景用) diff --git a/internal/plugins/seq/plugin_test.go b/internal/plugins/seq/plugin_test.go index 80d6d3b..cf1e6be 100644 --- a/internal/plugins/seq/plugin_test.go +++ b/internal/plugins/seq/plugin_test.go @@ -315,3 +315,56 @@ func min(a, b int) int { } return b } + +// ⑩ ★ 真机实跑发现的最高频坑:`in` 传空字符串 ""(而非对象 {})。 +// +// 两轮对照实验(隔离实例,各 5 次与 3 次 seq_create 尝试)里, +// 模型**都在同一处错了两次**: +// +// ① 实验 A:in 传 "" → 连续 2 次失败 +// ② 实验 B(先查 seq_help):in 仍传 "" → 又连续 2 次失败 +// +// 模型自述:「我把『无入参』理解成『不传这个字段』,结果序列化成了字符串」。 +// +// ⇒ 现有文案虽已可执行("无入参请写 {},不要写成字符串"), +// 但**位置太深**:埋在「格式要点」第 2 条里,模型读到了仍会错。 +// 本判据要求这条在**前两屏内**且**用醒目措辞**出现。 +func TestHelpWarnsEmptyStringInProminently(t *testing.T) { + p := newTestPlugin(t) + out, err := p.dispatch("seq_help", map[string]interface{}{}) + if err != nil { + t.Fatalf("seq_help 失败: %v", err) + } + text, _ := out.(string) + + // ① 必须明确说"不要写成字符串"(这是模型实际犯的错) + if !strings.Contains(text, "不要写成字符串") { + t.Errorf("seq_help 未警示『in 不要写成字符串』(实跑中模型在此错了 4 次)") + } + // ② 这条必须出现在**前 400 字**内 —— 埋太深就会被跳过(实跑证明) + head := text + if len(head) > 400 { + head = head[:400] + } + if !strings.Contains(head, "不要写成字符串") { + t.Errorf("『in 不要写成字符串』未出现在前 400 字内(实跑证明埋太深会被忽略)") + } + // ③ 必须给出正确写法,让模型无需推断 + if !strings.Contains(text, "{}") { + t.Error("未给出正确写法 {}") + } +} + +// ⑪ ★ 同样的坑必须也出现在 seq_create 自己的描述里 —— +// 模型可能不查 help 就直接建序列。 +func TestSeqCreateDescWarnsAboutIn(t *testing.T) { + p := newTestPlugin(t) + desc := p.toolDefs()["seq_create"].Description + if !strings.Contains(desc, "in") { + t.Errorf("seq_create 描述未提及 in:%s", desc) + } + // 描述里至少要点明 groups[].in 是对象 + if !strings.Contains(desc, "对象") && !strings.Contains(desc, "{}") { + t.Errorf("seq_create 描述未说明 groups[].in 应为对象:%s", desc) + } +} diff --git a/internal/plugins/seq/tools.go b/internal/plugins/seq/tools.go index 11f2265..1fd2c3b 100644 --- a/internal/plugins/seq/tools.go +++ b/internal/plugins/seq/tools.go @@ -31,9 +31,12 @@ func seqToolDefs() []toolDefInfo { "type": "string", "description": "一句话说明这条序列做什么", }, "groups": map[string]interface{}{ - "type": "array", - "description": "组数组,按数组顺序执行;与 file 二选一", - "items": map[string]interface{}{"type": "object"}, + "type": "array", + "description": "组数组,按数组顺序执行;与 file 二选一。" + + "⚠️ 每个 group 的 in 必须是**对象**(无入参写 {}),写成空字符串会被拒绝;" + + "tools 是字符串(内容为 ';' 分隔的 JSON 对象,每个 tool 后都要有 ';',含最后一个)。" + + "格式细节先用 seq_help 查。", + "items": map[string]interface{}{"type": "object"}, }, "file": map[string]interface{}{ "type": "string",