diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc4ffd..076db0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 的随机遍历,会导致它们在列表里频繁互换位置。 diff --git a/agent/blackboard_inheritance_test.go b/agent/blackboard_inheritance_test.go index 343d422..fcbcb6f 100644 --- a/agent/blackboard_inheritance_test.go +++ b/agent/blackboard_inheritance_test.go @@ -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"]) + } +} diff --git a/agent/tools.go b/agent/tools.go index 3d5ad2e..f8493ab 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -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) } diff --git a/server/orchestration.go b/server/orchestration.go index c282921..9057ea7 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -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)