feat(agent): get_worker_trace 的 step_ids 超限改为降级返回而非报错

原先 step_ids 超过 5 个直接报错,逼 Agent 重新组织调用、白跑一轮。改为优雅
降级:先去重、剔除非法 id,超出 5 个则返回前 5 步的完整内容,并在结果里用
returned_step_ids / omitted_step_ids / notice 告知本次取了哪些、还有哪些没取,
并提示「若已足够则无需再取剩余」。Agent 立刻拿到数据,是否再取由它自行判断。

保留数量上限的原因不变:detail 是完整原文、不截断,上限用于兜住单个 tool
result 的大小。get_task_worker_trace 经 delegateToTask 复用同一实现,一并生效;
两处工具描述同步更新。

验证: 新增 TestGetWorkerTraceStepIDsDegradeGracefully(含去重/非法 id/前5/
omitted/notice/在限内不加提示),连本地 PG 实跑通过;agent 全量无回归。
This commit is contained in:
Autumn-27 committed 2026-08-24 11:35:49 -04:00
1 parent 6a8e13fcbd
commit 412a993c25
4 files changed
+106 -9

No files matched your search

+6
View File
@@ -14,6 +14,12 @@
- 新建任务的「任务分类」由下拉选择改为可搜索输入框:输入即过滤已有分类,库里没有的名称回车(或点下拉里的「创建」)即时新建并选中,选中项以可移除的 tag 展示;仍限单个分类。
### Agent
#### 修改的功能
- `get_worker_trace` / `get_task_worker_trace` 的 `step_ids` 超过一次上限(5 个)时不再直接报错,改为返回前 5 步的完整内容,并在结果中通过 `returned_step_ids` / `omitted_step_ids` / `notice` 告知本次取了哪些、还有哪些未取,并提示「若已足够则无需再取」。传入的重复与非法 id 会先去重、剔除后再计数。
#### 修复的问题
- 任务列表改为按任务 id 降序(最新创建在最前),替换原先的按创建时间排序。同一时刻创建的多个任务时间戳相同、排序不稳定,配合每 10 秒轮询和内存 map 的随机遍历,会导致它们在列表里频繁互换位置。
+72
View File
@@ -272,3 +272,75 @@ func TestBlackboardToolsReadDirectSources(t *testing.T) {
t.Fatalf("inherited trace search: %#v", hits)
}
}
// TestGetWorkerTraceStepIDsDegradeGracefully pins the over-cap behaviour: instead
// of erroring, get_worker_trace returns the first 5 requested steps and tells the
// model which ids it deferred, after de-duplicating and dropping invalid ids.
func TestGetWorkerTraceStepIDsDegradeGracefully(t *testing.T) {
d := testDB(t)
defer d.Close()
task, err := d.CreateTask("trace-cap", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
store := d.Exploration(task.ExplorationID)
intent, err := store.AddIntent(map[string]any{"summary": "cap work"}, 1, nil, "planner")
if err != nil {
t.Fatal(err)
}
var stepIDs []int64
for i := range 6 {
sid, err := store.AppendActivity(db.Activity{
NodeID: &intent, Worker: "w", Kind: "result", Tool: "HTTP",
Summary: fmt.Sprintf("step %d", i), Detail: fmt.Sprintf("detail %d", i),
})
if err != nil {
t.Fatal(err)
}
stepIDs = append(stepIDs, sid)
}
tools := NewToolSet(store, "worker")
tools.SetAssetStore(d.Assets(), d.Companies())
// Request 7 ids: a duplicate of the first, an invalid 0, then all 6 real ids.
// After dedup/cleanup that is 6 valid ids — one over the cap.
requested := []int64{stepIDs[0], stepIDs[0], 0, stepIDs[1], stepIDs[2], stepIDs[3], stepIDs[4], stepIDs[5]}
input, _ := json.Marshal(map[string]any{"intent_id": intent, "step_ids": requested})
res := callReadJSON(t, tools.getWorkerTrace(), string(input)).(map[string]any)
returned, _ := res["returned_step_ids"].([]any)
if len(returned) != 5 {
t.Fatalf("returned_step_ids=%v, want the first 5", res["returned_step_ids"])
}
// First 5 distinct valid ids, in request order.
wantReturned := []int64{stepIDs[0], stepIDs[1], stepIDs[2], stepIDs[3], stepIDs[4]}
for i, raw := range returned {
if int64(raw.(float64)) != wantReturned[i] {
t.Fatalf("returned[%d]=%v, want %d", i, raw, wantReturned[i])
}
}
omitted, _ := res["omitted_step_ids"].([]any)
if len(omitted) != 1 || int64(omitted[0].(float64)) != stepIDs[5] {
t.Fatalf("omitted_step_ids=%v, want [%d]", res["omitted_step_ids"], stepIDs[5])
}
if notice, _ := res["notice"].(string); notice == "" {
t.Fatalf("notice missing — model would not know a step was deferred")
}
if steps, _ := res["steps"].([]any); len(steps) != 5 {
t.Fatalf("steps=%d, want 5 detail rows", len(steps))
}
// At or under the cap: no notice, no omitted list.
okInput, _ := json.Marshal(map[string]any{"intent_id": intent, "step_ids": stepIDs[:3]})
okRes := callReadJSON(t, tools.getWorkerTrace(), string(okInput)).(map[string]any)
if _, hasNotice := okRes["notice"]; hasNotice {
t.Fatalf("notice present for an in-cap request: %#v", okRes["notice"])
}
if _, hasOmitted := okRes["omitted_step_ids"]; hasOmitted {
t.Fatalf("omitted_step_ids present for an in-cap request: %#v", okRes["omitted_step_ids"])
}
}
+26 -7
View File
@@ -1686,12 +1686,12 @@ func (t *ToolSet) getWorkerTrace() actool.CoreTool {
"查看某条意图(work)的【执行过程】(区别于 get_worker_output 只给最终结论)。三种用法:\n"+
"① 只传 intent_id → 返回该 work 每一步的摘要流(summary≤100字,含 step_id;只是动作轮廓,不含完整输出);\n"+
"② intent_id + q → 只返回命中关键字的步骤摘要(在摘要和完整输出里都搜;仍只给 summary,要看内容用③);\n"+
"③ intent_id + step_ids(最多5个)→ 返回这些步骤的完整内容(detail)。\n"+
"③ intent_id + step_ids → 返回这些步骤的完整内容(detail);一次最多取 5 个,超出只返回前 5 个并在 notice/omitted_step_ids 里告知未取的。\n"+
"典型流程:先①/②定位可疑步骤的 step_id,再用③取其完整输出。不含思考(thinking)步骤。支持直接关联任务的历史 trace;其结果带 source_task_id/inherited=true 且只读。",
obj(map[string]any{
"intent_id": idp("意图 id(= work 句柄)"),
"q": str("关键字:只返回摘要/完整输出命中它的步骤(可选;与 step_ids 互斥)"),
"step_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "要取完整内容的 step_id(来自①/②返回,一次最多 5 个)"},
"step_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "要取完整内容的 step_id(来自①/②返回;一次最多取 5 个,多传只返回前 5 个,其余在 omitted_step_ids 里列出)"},
"limit": intp("摘要流/检索的返回上限(可选)"),
}, "intent_id"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
@@ -1713,16 +1713,26 @@ func (t *ToolSet) getWorkerTrace() actool.CoreTool {
if intentNode == nil || intentNode.Kind != db.KindIntent {
return actool.Errorf("intent_id 不属于本任务或其直接关联任务"), nil
}
// ③ detail drill-down by step ids (max 5), thinking excluded by the store.
// ③ detail drill-down by step ids, thinking excluded by the store.
if len(a.StepIDs) > 0 {
// Dedup + drop invalid ids first so garbage/duplicates don't eat into
// the per-call cap. detail is returned in full (untruncated), so the
// cap bounds one tool result; over the cap we serve the first N and
// tell the model exactly which ids were deferred, instead of erroring
// and forcing it to re-plan the call.
const maxStepIDs = 5
var ids []int64
seen := make(map[int64]bool)
for _, raw := range a.StepIDs {
if v := pid(raw); v > 0 {
if v := pid(raw); v > 0 && !seen[v] {
seen[v] = true
ids = append(ids, v)
}
}
if len(ids) > 5 {
return actool.Errorf("step_ids 一次最多 5 个(先用不带 step_ids 的调用定位,再分批取)"), nil
var omitted []int64
if len(ids) > maxStepIDs {
omitted = append(omitted, ids[maxStepIDs:]...)
ids = ids[:maxStepIDs]
}
acts, err := t.ts.ActivityByIDsWithSources(ids)
if err != nil {
@@ -1743,7 +1753,16 @@ func (t *ToolSet) getWorkerTrace() actool.CoreTool {
}
steps = append(steps, step)
}
result := map[string]any{"intent_id": id, "steps": steps}
result := map[string]any{"intent_id": id, "steps": steps, "returned_step_ids": ids}
if len(omitted) > 0 {
// returned_step_ids/omitted_step_ids let the model decide programmatically
// whether another call is worth it; the notice states the same in prose.
result["omitted_step_ids"] = omitted
result["notice"] = fmt.Sprintf(
"每次最多取 %d 个步骤的完整内容,本次已返回前 %d 个(%v),未取的 %d 个为 %v。"+
"若这些内容已足够定位,则无需再取剩余步骤;确需继续时,用这些 step_id 再调一次。",
maxStepIDs, len(ids), ids, len(omitted), omitted)
}
if intentNode.Inherited {
inheritedMap(result, intentNode.SourceTaskID)
}
+2 -2
View File
@@ -345,11 +345,11 @@ func (s *Server) toolAddHint() actool.CoreTool {
func (s *Server) toolGetWorkerTrace() actool.CoreTool {
return roTool("get_task_worker_trace",
"看指定任务里某个 work(意图)的执行过程:get_task_worker_trace(task_id, intent_id) 看步骤摘要;再带 step_ids=[...] 取那几步完整内容(一次≤5)。",
"看指定任务里某个 work(意图)的执行过程:get_task_worker_trace(task_id, intent_id) 看步骤摘要;再带 step_ids=[...] 取那几步完整内容(一次最多 5 个,多传只返回前 5 个)。",
objSchema(map[string]any{
"task_id": strParam("任务 id"),
"intent_id": map[string]any{"type": "integer", "description": "意图 id(该任务里的 work)"},
"step_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "可选:要取完整内容的步骤 id(≤5)"},
"step_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "可选:要取完整内容的步骤 id(一次最多 5 个,多传只返回前 5 个,其余在 omitted_step_ids 里列出)"},
}, "task_id", "intent_id"),
func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
return s.delegateToTask(ctx, in, (*agent.ToolSet).GetWorkerTraceTool)