feat(tasks): add inherited context and resilient LLM execution
Add read-only task context inheritance, ordered task-level LLM failover, live Worker controls, cascading task cleanup, FIFO task concurrency, cancellation diagnostics, UI updates, tests, build script, and changelog.
This commit is contained in:
1 parent
7090b034f8
commit
9793b522d9
82 files changed
+11094
-1712
No files matched your search
@@ -0,0 +1,198 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
func callReadJSON(t *testing.T, tool actool.CoreTool, input string) any {
|
||||
t.Helper()
|
||||
result, err := tool.Call(context.Background(), json.RawMessage(input), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("tool call: %v", err)
|
||||
}
|
||||
var out any
|
||||
if err := json.Unmarshal([]byte(result.Flatten()), &out); err != nil {
|
||||
t.Fatalf("decode tool result: %v; raw=%s", err, result.Flatten())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestBlackboardToolsReadDirectSources(t *testing.T) {
|
||||
d := testDB(t)
|
||||
defer d.Close()
|
||||
|
||||
grand, err := d.CreateTask("grand", "grand goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source, err := d.CreateTaskWithOptions("source", "source goal", db.TaskCreateOptions{SourceTaskIDs: []int64{grand.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current, err := d.CreateTaskWithOptions("current", "current goal", db.TaskCreateOptions{SourceTaskIDs: []int64{source.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = d.DeleteTask(current.ID)
|
||||
_ = d.DeleteTask(source.ID)
|
||||
_ = d.DeleteTask(grand.ID)
|
||||
})
|
||||
|
||||
grandStore := d.Exploration(grand.ExplorationID)
|
||||
sourceStore := d.Exploration(source.ExplorationID)
|
||||
currentStore := d.Exploration(current.ExplorationID)
|
||||
grandFact, err := grandStore.AddNode(db.KindFact, map[string]any{"summary": "indirect-only"}, 0, "confirmed", "worker", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sourceFact, err := sourceStore.AddNode(db.KindFact, map[string]any{"summary": "shared fact", "confidence": "verified"}, 0, "confirmed", "worker", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sourceIntent, err := sourceStore.AddIntent(map[string]any{"summary": "shared work"}, 1, nil, "planner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sourceStore.SetIntentState(sourceIntent, "done"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sourceFinding, err := sourceStore.AddNode(db.KindFinding, map[string]any{
|
||||
"summary": "shared finding", "vulnclass": "idor", "severity": "high",
|
||||
}, 9, "confirmed", "worker", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sourceStore.Link(sourceIntent, db.RelYields, sourceFinding); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stepID, err := sourceStore.AppendActivity(db.Activity{
|
||||
NodeID: &sourceIntent, Worker: "source-worker", Kind: "result", Tool: "HTTP",
|
||||
Summary: "shared trace marker", Detail: "shared trace full detail",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 301; i++ {
|
||||
if _, err := sourceStore.AddIntent(map[string]any{"summary": fmt.Sprintf("newer live source work %d", i)}, 1, nil, "planner"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
assets := d.Assets()
|
||||
host := fmt.Sprintf("overview-host-%d.invalid", source.ID)
|
||||
assetID, err := assets.UpsertRootDomain(db.UpsertRootDomainReq{Domain: host, TaskID: source.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = assets.DeleteByIDs([]int64{assetID}) })
|
||||
if _, err := sourceStore.AddNode(db.KindFact, map[string]any{"summary": "source host anchor"}, 0, "confirmed", "worker", []int64{assetID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tools := NewToolSet(currentStore, "worker")
|
||||
tools.SetTaskID(current.ID)
|
||||
tools.SetAssetStore(assets, assets.Companies())
|
||||
parentID, _ := json.Marshal(sourceFact)
|
||||
derivedID, err := tools.addOneIntent(intentItem{
|
||||
Summary: "derive locally from shared fact", ParentIDs: []json.RawMessage{parentID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("derive from inherited fact: %v", err)
|
||||
}
|
||||
if derived, err := currentStore.GetNode(derivedID); err != nil || derived == nil {
|
||||
t.Fatalf("derived intent must be local: node=%+v err=%v", derived, err)
|
||||
}
|
||||
beforeFacts, _ := currentStore.ListByKind(db.KindFact, 100)
|
||||
sourceIntentID, _ := json.Marshal(sourceIntent)
|
||||
if _, err := tools.recordOneFact(factItem{Summary: "must not attach to inherited intent", IntentID: json.RawMessage(sourceIntentID)}, sourceIntent); err == nil {
|
||||
t.Fatal("record_fact must reject inherited intent")
|
||||
}
|
||||
afterFacts, _ := currentStore.ListByKind(db.KindFact, 100)
|
||||
if len(afterFacts) != len(beforeFacts) {
|
||||
t.Fatalf("rejected inherited write persisted a fact: before=%d after=%d", len(beforeFacts), len(afterFacts))
|
||||
}
|
||||
overview := tools.graphOverviewData()
|
||||
related, ok := overview["related_tasks"].([]map[string]any)
|
||||
if !ok || len(related) != 1 {
|
||||
t.Fatalf("related_tasks: %#v", overview["related_tasks"])
|
||||
}
|
||||
if related[0]["source_task_id"] != source.ID || related[0]["inherited"] != true {
|
||||
t.Fatalf("related source provenance: %#v", related[0])
|
||||
}
|
||||
if facts, ok := related[0]["recent_facts"].([]map[string]any); !ok || len(facts) == 0 || facts[0]["id"] != sourceFact {
|
||||
t.Fatalf("related fact summary: %#v", related[0]["recent_facts"])
|
||||
}
|
||||
if findings, ok := related[0]["recent_findings"].([]map[string]any); !ok || len(findings) == 0 || findings[0]["id"] != sourceFinding {
|
||||
t.Fatalf("related finding summary: %#v", related[0]["recent_findings"])
|
||||
}
|
||||
results, ok := related[0]["recent_intent_results"].([]map[string]any)
|
||||
if !ok || len(results) == 0 || results[0]["id"] != sourceIntent {
|
||||
t.Fatalf("terminal source intent was starved by newer live work: %#v", related[0]["recent_intent_results"])
|
||||
}
|
||||
if results[0]["result_summary"] != "shared trace marker" {
|
||||
t.Fatalf("terminal source intent result was not distilled: %#v", results[0])
|
||||
}
|
||||
coverage, ok := overview["coverage"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("coverage missing from overview: %#v", overview["coverage"])
|
||||
}
|
||||
hosts, ok := coverage["hosts"].([]string)
|
||||
if !ok || len(hosts) != 1 || hosts[0] != host || coverage["host_count"] != 1 {
|
||||
t.Fatalf("inherited host context missing: %#v", coverage)
|
||||
}
|
||||
|
||||
facts := callReadJSON(t, tools.listFacts(), `{}`).([]any)
|
||||
seenSource, seenGrand := false, false
|
||||
for _, raw := range facts {
|
||||
item := raw.(map[string]any)
|
||||
id := int64(item["id"].(float64))
|
||||
if id == sourceFact {
|
||||
seenSource = item["inherited"] == true && int64(item["source_task_id"].(float64)) == source.ID
|
||||
}
|
||||
seenGrand = seenGrand || id == grandFact
|
||||
}
|
||||
if !seenSource || seenGrand {
|
||||
t.Fatalf("list_facts direct-only: source=%v grand=%v payload=%#v", seenSource, seenGrand, facts)
|
||||
}
|
||||
|
||||
findings := callReadJSON(t, tools.listFindings(), `{}`).([]any)
|
||||
if len(findings) != 1 {
|
||||
t.Fatalf("list_findings: %#v", findings)
|
||||
}
|
||||
finding := findings[0].(map[string]any)
|
||||
if finding["inherited"] != true || int64(finding["task_id"].(float64)) != source.ID || int64(finding["intent_id"].(float64)) != sourceIntent {
|
||||
t.Fatalf("inherited finding provenance: %#v", finding)
|
||||
}
|
||||
|
||||
detailInput, _ := json.Marshal(map[string]any{"id": sourceFact})
|
||||
detail := callReadJSON(t, tools.nodeDetail(), string(detailInput)).(map[string]any)
|
||||
if detail["inherited"] != true || int64(detail["source_task_id"].(float64)) != source.ID {
|
||||
t.Fatalf("inherited node detail: %#v", detail)
|
||||
}
|
||||
|
||||
traceInput, _ := json.Marshal(map[string]any{"intent_id": sourceIntent})
|
||||
trace := callReadJSON(t, tools.getWorkerTrace(), string(traceInput)).(map[string]any)
|
||||
if trace["inherited"] != true || int64(trace["source_task_id"].(float64)) != source.ID {
|
||||
t.Fatalf("inherited trace: %#v", trace)
|
||||
}
|
||||
steps := trace["steps"].([]any)
|
||||
if len(steps) != 1 || int64(steps[0].(map[string]any)["step_id"].(float64)) != stepID {
|
||||
t.Fatalf("inherited trace steps: %#v", steps)
|
||||
}
|
||||
output := callReadJSON(t, tools.getWorkerOutput(), string(traceInput)).(map[string]any)
|
||||
if output["inherited"] != true || output["final_text"] != "shared trace full detail" {
|
||||
t.Fatalf("inherited worker output: %#v", output)
|
||||
}
|
||||
|
||||
search := callReadJSON(t, tools.searchAllWorkerTraces(), `{"q":"shared trace marker"}`).(map[string]any)
|
||||
hits := search["hits"].([]any)
|
||||
if len(hits) != 1 || hits[0].(map[string]any)["inherited"] != true {
|
||||
t.Fatalf("inherited trace search: %#v", hits)
|
||||
}
|
||||
}
|
||||
+49
-75
@@ -6,25 +6,12 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// AbortCause names WHY a planner/worker run's context was cancelled.
|
||||
//
|
||||
// Background: when a run's ctx is cancelled mid-flight the harness finishes with
|
||||
// ReasonAbortedStreaming / ReasonAbortedTools and Terminal.Err = context.Canceled.
|
||||
// ARTEX cancels that ctx from a dozen different places (pause, delete, kill_work,
|
||||
// goal-met, settle drain backstop, chat stop, process shutdown, hard run timeout,
|
||||
// …) but by the time it reaches captureRun they all collapse into one bare
|
||||
// context.Canceled — so the trace could only print a guess-list, not a diagnosis.
|
||||
//
|
||||
// Fix: every cancel site uses context.WithCancelCause / WithTimeoutCause and
|
||||
// attaches one of the causes below; captureRun recovers it via context.Cause and
|
||||
// prints exactly who cancelled the run, why, and what happens to the intent.
|
||||
//
|
||||
// When you add a new cancel site, add its cause HERE — a site that cancels
|
||||
// without one shows up in the trace as "canceled_no_cause", by design.
|
||||
// AbortCause names why an agent run's context was cancelled. Every cancellation
|
||||
// site should attach one so the activity trace can report the real initiator.
|
||||
type AbortCause struct {
|
||||
Code string // stable machine-readable id (logs / future filtering)
|
||||
Short string // 一句话,进折叠摘要行
|
||||
Text string // 完整说明:谁取消的、为什么、这条意图接下来会怎样
|
||||
Code string
|
||||
Short string
|
||||
Text string
|
||||
}
|
||||
|
||||
func (c *AbortCause) Error() string { return c.Text }
|
||||
@@ -33,84 +20,71 @@ func cause(code, short, text string) *AbortCause {
|
||||
return &AbortCause{Code: code, Short: short, Text: text}
|
||||
}
|
||||
|
||||
// Causef builds an ad-hoc cause with runtime detail (e.g. which intent, which
|
||||
// deadline). Use the predeclared vars below when there is nothing dynamic to add.
|
||||
// Causef builds a cause that includes runtime-specific detail.
|
||||
func Causef(code, short, format string, args ...any) *AbortCause {
|
||||
return &AbortCause{Code: code, Short: short, Text: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// The full enumeration of cancel sites, grouped by which context gets cancelled.
|
||||
var (
|
||||
// ---- 任务级 exec ctx(Engine.cancelExec:取消该任务在跑的 planner + 全部 worker)----
|
||||
|
||||
// Task-level execution context.
|
||||
AbortPausedByUser = cause("paused_by_user", "用户暂停了任务",
|
||||
"用户暂停了任务(前端「暂停」按钮 / POST /api/tasks/{id}/state action=pause)。本次运行被主动取消,该意图已退回 frontier(open),恢复任务后会被重新领取、从头重跑")
|
||||
|
||||
AbortPausedByOrchestrator = cause("paused_by_orchestrator", "编排 agent 调用 pause_task 暂停了任务",
|
||||
"编排 agent 调用了 pause_task 工具暂停本任务。本次运行被主动取消,该意图已退回 frontier(open),恢复后重跑")
|
||||
|
||||
"用户通过任务控制接口(POST /api/tasks/{id}/control,action=pause)暂停了任务。本次 Planner/Worker 运行被主动取消;运行中的意图会退回 frontier(open),恢复任务后重新领取并从头执行")
|
||||
AbortPausedByOrchestrator = cause("paused_by_orchestrator", "编排 Agent 暂停了任务",
|
||||
"编排 Agent 调用了 pause_task 工具暂停本任务。本次 Planner/Worker 运行被主动取消;运行中的意图会退回 frontier(open),恢复后重新执行")
|
||||
AbortTaskDeleted = cause("task_deleted", "任务被删除",
|
||||
"任务被删除(DELETE /api/tasks/{id})——删除前先取消在跑的 planner/worker。本次运行的结果不会再被使用")
|
||||
"任务正在删除(DELETE /api/tasks/{id}),删除屏障已取消该任务正在运行的 Planner、Worker 和主 Agent;本次运行结果不会再被使用")
|
||||
AbortPausedOnReload = cause("paused_on_reload", "后端恢复了任务的暂停状态",
|
||||
"后端启动时根据数据库中持久化的状态恢复了任务暂停。本次运行被取消;正常情况下恢复阶段没有正在运行的 Agent")
|
||||
AbortGoalMet = cause("goal_met", "规划者判定任务目标已达成",
|
||||
"规划者判定任务目标已达成并将任务置为 done,随后取消仍在运行的 Worker;这些意图会标记为 stopped,而不是失败")
|
||||
AbortSettleDrainTimeout = cause("settle_drain_timeout", "任务超时收尾的等待时间已用尽",
|
||||
"任务到达 timeout 后等待正在运行的 Worker 优雅收尾,但 90 秒 drain 宽限仍不足,因此执行硬取消;意图会标记为 exhausted,收尾阶段已经写入的事实和资产会保留")
|
||||
|
||||
AbortPausedOnReload = cause("paused_on_reload", "后端启动时恢复了「已暂停」状态",
|
||||
"后端启动时按库里持久化的「已暂停」状态恢复暂停。本次运行被取消(正常情况下此时并没有在跑的 run)")
|
||||
// Per-work context.
|
||||
AbortKilledByPlanner = cause("killed_by_planner", "规划者终止了这条意图",
|
||||
"规划者调用 kill_work 主动终止了这条意图,通常表示方向跑偏或已无继续价值;意图会标记为 stopped,不会自动重新领取")
|
||||
AbortWorkPausedByUser = cause("work_paused_by_user", "用户暂停了这条 Worker 意图",
|
||||
"用户暂停了正在运行的 Worker。本次调用被取消,意图转为 paused;已经登记的意图、事实、漏洞和活动记录全部保留,恢复后从头重新执行")
|
||||
AbortWorkCancelledByUser = cause("work_cancelled_by_user", "用户取消了这条 Worker 意图",
|
||||
"用户取消了正在运行的 Worker。本次调用被取消;Worker 退出写入区后,服务端会事务性删除该意图及其直接产生的事实、漏洞和执行记录")
|
||||
AbortWorkFinished = cause("work_finished", "Worker 已正常结束并释放 context",
|
||||
"Worker 已正常结束,引擎在 detachWork 中释放其 context 资源。这不是运行中断;若它出现在中断消息中,说明取消与收场事件发生了竞态")
|
||||
AbortPausedRaceGuard = cause("paused_race_guard", "任务暂停期间拒绝启动新运行",
|
||||
"任务处于暂停状态时,引擎拒绝发出新的执行 context,用于防止 claim 与暂停之间的竞态导致 Worker 继续启动;已领取的意图会退回 frontier")
|
||||
|
||||
AbortGoalMet = cause("goal_met", "规划者判定目标已达成,任务落终态 done",
|
||||
"规划者判定任务目标已达成、任务落终态 done,随即取消所有在跑的 worker——它们手头的意图跑完也没意义了。该意图标记为 stopped,不是失败")
|
||||
// Main Agent and standalone conversation contexts.
|
||||
AbortChatStoppedByUser = cause("chat_stopped_by_user", "用户停止了本轮对话",
|
||||
"用户点击了停止,主动中止本轮主 Agent 或会话 Agent 运行。已经产生的活动记录会保留,可以继续发送下一条消息")
|
||||
AbortChatPausedWithTask = cause("chat_paused_with_task", "任务暂停并中止了主 Agent 对话",
|
||||
"用户暂停任务时,正在运行的主 Agent 对话也被同步取消。已经产生的活动记录会保留;恢复任务后不会自动重放本轮消息")
|
||||
AbortChatTurnFinished = cause("chat_turn_finished", "本轮对话已正常结束并释放 context",
|
||||
"本轮对话已正常结束,服务端正在释放该轮 context 资源。这不是运行中断;若它出现在中断消息中,说明取消与收场事件发生了竞态")
|
||||
|
||||
AbortSettleDrainTimeout = cause("settle_drain_timeout", "任务级超时收尾:drain 宽限用尽后强制取消",
|
||||
"任务级超时收尾的硬兜底:到达任务 timeout 后等在跑 worker 优雅收尾,超过 90s drain 宽限仍未结束 → 强制取消。该意图标记为 exhausted,收尾阶段通常已把事实/资产写回")
|
||||
|
||||
// ---- 单个 work 的 ctx(Engine.workCancel[intentID])----
|
||||
|
||||
AbortKilledByPlanner = cause("killed_by_planner", "规划者用 kill_work 终止了这条意图",
|
||||
"规划者调用 kill_work 主动终止了这条意图(判定方向跑偏/无意义,止损)。该意图标记为 stopped,不会被自动重领")
|
||||
|
||||
AbortWorkFinished = cause("work_finished", "work 已正常结束,引擎释放其 context",
|
||||
"work 已正常结束,引擎在 unregisterWork 里释放其 context 资源。这不是中断——若它出现在中断消息里,说明取消与收场事件存在竞态")
|
||||
|
||||
AbortPausedRaceGuard = cause("paused_race_guard", "任务处于暂停态,引擎拒绝启动新 run",
|
||||
"任务处于暂停态时请求执行上下文,引擎直接返回一个已取消的 context(守卫 claim→Execute 的竞态,避免暂停后仍启动新 run)。该意图会退回 frontier")
|
||||
|
||||
// ---- 对话 / 主 Agent 的单轮 ctx(Server.chatCancel[busyKey])----
|
||||
|
||||
AbortChatStoppedByUser = cause("chat_stopped_by_user", "用户点了「停止」中止本轮对话",
|
||||
"用户点了「停止」,主动中止本轮对话运行(POST .../chat/stop 或 .../conversations/{id}/stop)。已产生的步骤都保留,可以直接发下一条消息")
|
||||
|
||||
AbortChatTurnFinished = cause("chat_turn_finished", "本轮对话已正常结束,服务端释放其 context",
|
||||
"本轮对话已正常结束,服务端在 defer 里释放其 context 资源。这不是中断——若它出现在中断消息里,说明取消与收场事件存在竞态")
|
||||
|
||||
// ---- 进程级 ----
|
||||
|
||||
AbortShutdown = cause("shutdown", "后端进程正在关停(重启 / 更新 / Ctrl-C)",
|
||||
"后端进程收到 SIGINT/SIGTERM 正在关停(重启 / 更新 / 手动 Ctrl-C)。所有在跑的 planner/worker 一并取消;重启后残留的 running 意图会被重置为 open 重跑")
|
||||
|
||||
// ---- 单次 run 级(worker.Execute / planner.Plan 的硬兜底)----
|
||||
|
||||
AbortRunHardTimeout = cause("run_hard_timeout", "单次运行的硬超时兜底触发:某一步卡死了",
|
||||
"单次运行的硬超时兜底被触发:正常情况下软墙钟预算(MaxDuration)会在回合边界优雅收尾,只有当某一步(模型请求或某个工具)自身卡死、久久不返回、导致回合边界检查根本跑不到时,才会走到「软预算 + 90s 宽限」的硬 cancel。请重点看中断前最后一个 tool_use——多半是它挂住了")
|
||||
// Process-level and per-run hard backstop.
|
||||
AbortShutdown = cause("shutdown", "后端进程正在关闭",
|
||||
"后端进程收到 SIGINT 或 SIGTERM,正在重启、更新或关闭。所有运行中的 Agent 会被取消;重启后残留的 running 意图会重置为 open 并重新执行")
|
||||
AbortRunHardTimeout = cause("run_hard_timeout", "单次运行的硬超时兜底已触发",
|
||||
"单次运行超过软墙钟预算及额外宽限,说明模型请求或某个工具长时间没有返回,导致正常的回合边界收尾无法执行。请重点检查中断前最后一个未返回的工具调用")
|
||||
)
|
||||
|
||||
// AbortReason resolves the cause attached to a cancelled run context. ok=false
|
||||
// means the ctx carries no cause at all (nothing was cancelled).
|
||||
// AbortReason resolves the named cause attached to a cancelled run context.
|
||||
func AbortReason(ctx context.Context) (code, short, text string, ok bool) {
|
||||
c := context.Cause(ctx)
|
||||
if c == nil {
|
||||
return "", "", "", false
|
||||
}
|
||||
if ac, ok := errors.AsType[*AbortCause](c); ok {
|
||||
var ac *AbortCause
|
||||
if errors.As(c, &ac) {
|
||||
return ac.Code, ac.Short, ac.Text, true
|
||||
}
|
||||
switch {
|
||||
case errors.Is(c, context.DeadlineExceeded):
|
||||
return "deadline_exceeded", "上游 context 到达 deadline(未挂具名原因)",
|
||||
"上游 context 到达 deadline,但设置方没有用 WithTimeoutCause 挂具名原因: " + c.Error(), true
|
||||
return "deadline_exceeded", "上游 context 到达 deadline",
|
||||
"上游 context 到达 deadline,但设置方没有通过 WithTimeoutCause 附加具名原因: " + c.Error(), true
|
||||
case errors.Is(c, context.Canceled):
|
||||
// a plain Canceled means some cancel site still lacks a cause — say so
|
||||
// plainly instead of inventing one.
|
||||
return "canceled_no_cause", "取消方未挂具名原因(漏网的取消点)",
|
||||
"上游 context 被取消,但取消方没有用 context.WithCancelCause 挂上具名原因。" +
|
||||
"这属于漏网的取消点——请在 agent/cancelcause.go 补一条枚举并接到该取消处", true
|
||||
return "canceled_no_cause", "取消方未附加具名原因",
|
||||
"上游 context 被取消,但取消方没有通过 context.WithCancelCause 附加具名原因;请在 agent/cancelcause.go 登记原因并接入该取消点", true
|
||||
default:
|
||||
return "other", firstLine(c.Error(), 80), c.Error(), true
|
||||
}
|
||||
return "other", firstLine(c.Error(), 80), c.Error(), true
|
||||
}
|
||||
+4
-9
@@ -6,10 +6,10 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/norma/agentcore"
|
||||
"github.com/Autumn-27/norma/harness"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// captureRun drives one agent turn-to-completion over Session.Prompt and emits a
|
||||
@@ -65,9 +65,6 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
|
||||
tkind = kind
|
||||
tbuf.WriteString(text)
|
||||
}
|
||||
|
||||
// breadcrumbs for the terminal message: when a run is aborted or times out,
|
||||
// "which tool was still running, for how long" is the whole diagnosis.
|
||||
lastTool := &runTrace{startedAt: time.Now()}
|
||||
|
||||
var finalText string
|
||||
@@ -75,7 +72,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
|
||||
for ev, err := range s.Prompt(ctx, input) {
|
||||
if err != nil {
|
||||
flush()
|
||||
if ctx.Err() != nil { // ctx cancelled = stopped by the engine, not a failure
|
||||
if ctx.Err() != nil { // engine/user cancellation, not a provider failure
|
||||
sum, detail := terminalText(ctx, &harness.Terminal{Reason: reason, Err: ctx.Err()}, lastTool)
|
||||
rec(db.Activity{Kind: "result", Summary: firstLine(sum, 400), Detail: detail})
|
||||
return finalText, reason, ctx.Err()
|
||||
@@ -130,7 +127,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
|
||||
}
|
||||
flush() // flush any trailing thinking / non-final text
|
||||
sum, detail := ev.Terminal.Text, ev.Terminal.Text
|
||||
if sum == "" {
|
||||
if sum == "" || ev.Terminal.Reason == harness.ReasonAbortedTools || ev.Terminal.Reason == harness.ReasonAbortedStreaming {
|
||||
sum, detail = terminalText(ctx, ev.Terminal, lastTool)
|
||||
}
|
||||
u := ev.Terminal.Usage // cumulative token usage for this session
|
||||
@@ -162,9 +159,7 @@ func blocksText(blocks []llm.ContentBlock) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// firstLine returns a single-line, length-capped preview for the summary column.
|
||||
// max counts RUNES, not bytes: these previews are mostly 中文, and byte-slicing
|
||||
// used to cut a multi-byte rune in half and render as "恢�…".
|
||||
// firstLine returns a single-line, rune-capped preview for the summary column.
|
||||
func firstLine(s string, max int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if before, _, found := strings.Cut(s, "\n"); found {
|
||||
|
||||
@@ -84,6 +84,16 @@ type GoalSpec struct {
|
||||
// runtime). The returned specs are read back from the store so callers can emit
|
||||
// per-goal activity and detect the "LLM produced nothing" case for their fallback.
|
||||
func DecomposeGoals(ctx context.Context, prov llm.Provider, dataDir, goalText, desc string, as *db.AssetStore, ts *db.ExplorationStore, taskID int64, emit func(db.Activity)) []GoalSpec {
|
||||
if prov == nil {
|
||||
return nil
|
||||
}
|
||||
return DecomposeGoalsWithProvider(ctx, prov, dataDir, goalText, desc, as, ts, taskID, emit)
|
||||
}
|
||||
|
||||
// DecomposeGoalsWithProvider is the task-runtime variant used when a task has an
|
||||
// ordered provider chain. It preserves the same tools and write behavior while
|
||||
// letting the caller own provider selection/failover.
|
||||
func DecomposeGoalsWithProvider(ctx context.Context, prov llm.Provider, dataDir, goalText, desc string, as *db.AssetStore, ts *db.ExplorationStore, taskID int64, emit func(db.Activity)) []GoalSpec {
|
||||
if prov == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
+16
-6
@@ -21,6 +21,7 @@ type MainAgent struct {
|
||||
model string
|
||||
tx *transcript.Store // raw LLM conversation persistence (nil = off)
|
||||
window int // context window in tokens (for compaction)
|
||||
windowFn func() int // optional dynamic task-chain minimum
|
||||
maxTurns int // max agent turns per run (0 = unlimited)
|
||||
proxyAddr string // recording proxy for WebFetch (empty = direct)
|
||||
proxyCACert string // recording proxy's CA cert path (HTTPS verify)
|
||||
@@ -32,6 +33,15 @@ func NewMainAgent(prov llm.Provider, model, workDir string, tx *transcript.Store
|
||||
return &MainAgent{prov: prov, model: model, workDir: workDir, tx: tx, window: window, maxTurns: maxTurns}
|
||||
}
|
||||
|
||||
func (m *MainAgent) SetCompactionWindowResolver(fn func() int) { m.windowFn = fn }
|
||||
|
||||
func (m *MainAgent) compactionWindow() int {
|
||||
if m.windowFn != nil {
|
||||
return m.windowFn()
|
||||
}
|
||||
return m.window
|
||||
}
|
||||
|
||||
// SetProxy points the main agent's WebFetch at the recording proxy plus the CA
|
||||
// cert it trusts to verify HTTPS through it (empty addr = direct).
|
||||
func (m *MainAgent) SetProxy(addr, caCert string) { m.proxyAddr, m.proxyCACert = addr, caCert }
|
||||
@@ -70,9 +80,9 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t
|
||||
tsx.SetAssetStore(as, as.Companies())
|
||||
}
|
||||
tsx.SetTaskID(taskID)
|
||||
tsx.SetNotify(notify) // add_hint wakes this task's planner (debounced)
|
||||
tsx.SetResumeTask(resume) // set_goals 新增目标 → 把已完成/暂停的任务拉回 running
|
||||
tsx.SetNotifyGoal(notifyGoal) // set_goals 新增目标 → 给 planner 记一条「人新增了目标:…」触发
|
||||
tsx.SetNotify(notify) // add_hint wakes this task's planner (debounced)
|
||||
tsx.SetResumeTask(resume) // set_goals 新增目标 → 把已完成/暂停的任务拉回 running
|
||||
tsx.SetNotifyGoal(notifyGoal) // set_goals 新增目标 → 给 planner 记一条「人新增了目标:…」触发
|
||||
// 领域工具 + 基础默认工具集(Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash)
|
||||
base := append(tsx.MainAgentTools(), actool.DefaultTools()...)
|
||||
tools, def, cleanup := AugmentTools(ctx, "mainagent", base)
|
||||
@@ -101,9 +111,9 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t
|
||||
BashEnv: proxyEnv(m.proxyAddr, m.proxyCACert), // Bash 子命令默认走代理+信任 CA
|
||||
WorkingDir: mainDir, // 本任务工作目录 <workDir>/tasks/<taskID>
|
||||
ToolOutputDir: cmdOutDir(mainDir),
|
||||
MaxTurns: m.maxTurns, // 0 = unlimited (configurable in agent management)
|
||||
Compaction: compactionConfig(m.window), // long chats stay within the window
|
||||
Todos: actool.NewTodoStore(), // 会话级临时待办(TodoWrite),纯规划用,退出即丢
|
||||
MaxTurns: m.maxTurns, // 0 = unlimited (configurable in agent management)
|
||||
Compaction: compactionConfig(m.compactionWindow()), // long chats stay within the window
|
||||
Todos: actool.NewTodoStore(), // 会话级临时待办(TodoWrite),纯规划用,退出即丢
|
||||
// 命中预算(步数)→ SDK 跑收尾:向用户输出一句进展总结。Prompt 与收尾轮数可后台编辑(默认 10 轮)。
|
||||
Settlement: wrapupSettlement("mainagent", nil),
|
||||
}
|
||||
|
||||
+16
-5
@@ -24,6 +24,7 @@ type Planner struct {
|
||||
model string
|
||||
tx *transcript.Store // raw LLM conversation persistence (nil = off)
|
||||
window int // context window in tokens (for compaction)
|
||||
windowFn func() int // optional dynamic task-chain minimum
|
||||
maxTurns int // max agent turns per run (0 = unlimited)
|
||||
killWork func(intentID int64) error // engine callback to terminate a running work (nil = off)
|
||||
steerWork func(intentID int64, msg string) error // engine callback to steer a running work mid-run (nil = off)
|
||||
@@ -44,6 +45,15 @@ func NewPlanner(prov llm.Provider, model, workDir string, tx *transcript.Store,
|
||||
return &Planner{prov: prov, model: model, workDir: workDir, tx: tx, window: window, maxTurns: maxTurns, todos: map[int64]*actool.TodoStore{}}
|
||||
}
|
||||
|
||||
func (p *Planner) SetCompactionWindowResolver(fn func() int) { p.windowFn = fn }
|
||||
|
||||
func (p *Planner) compactionWindow() int {
|
||||
if p.windowFn != nil {
|
||||
return p.windowFn()
|
||||
}
|
||||
return p.window
|
||||
}
|
||||
|
||||
// SetProxy points the planner's WebFetch at the recording proxy plus the CA cert
|
||||
// it trusts to verify HTTPS through it (empty addr = direct).
|
||||
func (p *Planner) SetProxy(addr, caCert string) { p.proxyAddr, p.proxyCACert = addr, caCert }
|
||||
@@ -94,10 +104,11 @@ func renderPlannerTodos(items []actool.Todo) string {
|
||||
// TriggerEvent describes what concretely caused this planning round to fire, so
|
||||
// the planner looks first at the actual change instead of re-scanning the whole
|
||||
// overview. Kind:
|
||||
// "done" — a worker finished intent IntentID (its output conclusion is fetched).
|
||||
// "finding" — a worker reported a finding on intent IntentID (Detail = 摘要).
|
||||
// "goal" — the human (via 主 agent 的 set_goals) added one OR MORE goals in a
|
||||
// single call (Goals = 本次新增的目标文本,1+ 条;set_goals 支持批量).
|
||||
//
|
||||
// "done" — a worker finished intent IntentID (its output conclusion is fetched).
|
||||
// "finding" — a worker reported a finding on intent IntentID (Detail = 摘要).
|
||||
// "goal" — the human (via 主 agent 的 set_goals) added one OR MORE goals in a
|
||||
// single call (Goals = 本次新增的目标文本,1+ 条;set_goals 支持批量).
|
||||
type TriggerEvent struct {
|
||||
Kind string
|
||||
IntentID int64
|
||||
@@ -308,7 +319,7 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
|
||||
ToolOutputDir: cmdOutDir(taskDir),
|
||||
MaxTurns: p.maxTurns, // 0 = unlimited (configurable in agent management)
|
||||
MaxDuration: maxDur, // 0=不限;有 deadline 时=距 deadline 剩余
|
||||
Compaction: compactionConfig(p.window),
|
||||
Compaction: compactionConfig(p.compactionWindow()),
|
||||
// 跨唤醒共享的规划待办:让串行链在多轮之间保留(session 是新的,store 不是)。
|
||||
Todos: p.todoFor(ts.ID()),
|
||||
// 命中【本轮】步数预算→ SDK 跑收尾:把本轮已想清楚的结论落地(该派的 add_intent、
|
||||
|
||||
+102
-5
@@ -8,8 +8,14 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -167,12 +173,16 @@ func (c Config) Provider() string {
|
||||
// limiter lives on the single provider instance — so planner + all workers +
|
||||
// main agent (which share this provider) are bounded by one shared rate limit.
|
||||
func (c Config) NewProvider() (llm.Provider, error) {
|
||||
client, err := quotaAwareHTTPClient(c.Proxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lc := llm.Config{
|
||||
Format: c.Format,
|
||||
BaseURL: c.BaseURL,
|
||||
APIKey: c.APIKey,
|
||||
Model: c.Model,
|
||||
Proxy: c.Proxy,
|
||||
Format: c.Format,
|
||||
BaseURL: c.BaseURL,
|
||||
APIKey: c.APIKey,
|
||||
Model: c.Model,
|
||||
HTTPClient: client,
|
||||
}
|
||||
// Derive the provider thinking params from the single UI selector:
|
||||
// "" → omit both (provider default); "off" → disable; else → enable + effort.
|
||||
@@ -191,6 +201,93 @@ func (c Config) NewProvider() (llm.Provider, error) {
|
||||
return llm.NewProvider(lc)
|
||||
}
|
||||
|
||||
// IsQuotaExhaustedMessage deliberately recognizes only explicit balance,
|
||||
// billing, credit, or quota-exhaustion signals. Generic 429/rate-limit text,
|
||||
// authentication failures, network errors, and server failures are excluded.
|
||||
var nonFailoverHTTPStatus = regexp.MustCompile(`(?:status(?:\s+code)?|http(?:\s+status)?)\s*[=:]?\s*(?:401|403|5\d\d)\b`)
|
||||
var transientQuotaLimit = regexp.MustCompile(`(?i)(?:\b(?:rpm|tpm|rpd|qps)\b|quota[_\s-]*metric|rate[_\s-]*limit|too many requests|(?:requests?|tokens?)\s+(?:per|/)\s*(?:second|minute)|(?:per|/)\s*(?:second|minute)\s+(?:requests?|tokens?)|generate[_\s-]*requests[_\s-]*per[_\s-]*(?:minute|second)|tokens?[_\s-]*per[_\s-]*(?:minute|second))`)
|
||||
|
||||
func IsQuotaExhaustedMessage(message string) bool {
|
||||
message = strings.ToLower(message)
|
||||
// Authentication/authorization and provider-side 5xx failures never rotate,
|
||||
// even when a gateway happens to echo a quota-looking phrase in the body.
|
||||
if nonFailoverHTTPStatus.MatchString(message) {
|
||||
return false
|
||||
}
|
||||
// Provider APIs frequently describe an ordinary rate limit as "quota
|
||||
// exceeded", especially Google-style responses containing a quota metric.
|
||||
// These limits recover with time and must stay on the current provider.
|
||||
if transientQuotaLimit.MatchString(message) {
|
||||
return false
|
||||
}
|
||||
markers := []string{
|
||||
"insufficient_quota", "quota_exceeded", "quota exceeded", "quota exhausted",
|
||||
"exceeded your current quota", "billing_hard_limit_reached",
|
||||
"billing hard limit", "billing_not_active", "credit balance", "insufficient credit",
|
||||
"insufficient balance", "balance is too low", "payment required", "status 402",
|
||||
"余额不足", "额度不足", "额度已用尽", "欠费",
|
||||
}
|
||||
for _, marker := range markers {
|
||||
if strings.Contains(message, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// gRPC RESOURCE_EXHAUSTED is overloaded for both account quota and ordinary
|
||||
// request-rate limiting. Preserve it as an explicit exhaustion signal only
|
||||
// when the same error does not identify a transient rate limit.
|
||||
return strings.Contains(message, "resource_exhausted") &&
|
||||
!strings.Contains(message, "rate limit") &&
|
||||
!strings.Contains(message, "too many requests")
|
||||
}
|
||||
|
||||
// quotaAwareTransport preserves Norma's normal retry behavior except for a 429
|
||||
// whose body explicitly says the account quota/balance is exhausted. Norma's
|
||||
// retry loop treats every 429 as transient; normalizing only that response to
|
||||
// 402 lets a task router fail over immediately while retaining the original
|
||||
// response body for provider-specific classification and audit logs.
|
||||
type quotaAwareTransport struct{ base http.RoundTripper }
|
||||
|
||||
func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := t.base.RoundTrip(req)
|
||||
if err != nil || resp == nil || resp.StatusCode != http.StatusTooManyRequests {
|
||||
return resp, err
|
||||
}
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
resp.ContentLength = int64(len(body))
|
||||
if readErr != nil {
|
||||
return resp, nil
|
||||
}
|
||||
if IsQuotaExhaustedMessage(string(body)) {
|
||||
resp.StatusCode = http.StatusPaymentRequired
|
||||
resp.Status = "402 Payment Required"
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func quotaAwareHTTPClient(proxy string) (*http.Client, error) {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
proxy = strings.TrimSpace(proxy)
|
||||
if proxy == "" {
|
||||
transport.Proxy = http.ProxyFromEnvironment
|
||||
} else {
|
||||
proxyURL, err := url.Parse(proxy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: invalid proxy %q: %w", proxy, err)
|
||||
}
|
||||
switch proxyURL.Scheme {
|
||||
case "http", "https", "socks5":
|
||||
case "":
|
||||
return nil, fmt.Errorf("llm: proxy %q missing scheme (use http://, https:// or socks5://)", proxy)
|
||||
default:
|
||||
return nil, fmt.Errorf("llm: unsupported proxy scheme %q (use http, https or socks5)", proxyURL.Scheme)
|
||||
}
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
}
|
||||
return &http.Client{Transport: quotaAwareTransport{base: transport}}, nil
|
||||
}
|
||||
|
||||
// TestConnection makes a minimal real completion to verify the provider/model/
|
||||
// endpoint/key actually work. Returns the round-trip latency.
|
||||
func TestConnection(ctx context.Context, c Config) (time.Duration, error) {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
)
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||
|
||||
func TestIsQuotaExhaustedMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
positive := []string{
|
||||
`{"error":{"code":"insufficient_quota"}}`,
|
||||
`RESOURCE_EXHAUSTED`,
|
||||
`You exceeded your current quota, please check your plan and billing details.`,
|
||||
`billing_not_active`,
|
||||
`credit balance is too low`,
|
||||
`账户余额不足,请充值`,
|
||||
}
|
||||
for _, message := range positive {
|
||||
if !IsQuotaExhaustedMessage(message) {
|
||||
t.Errorf("expected quota classification for %q", message)
|
||||
}
|
||||
}
|
||||
|
||||
negative := []string{
|
||||
`status 429: rate limit exceeded`,
|
||||
`RESOURCE_EXHAUSTED: rate limit exceeded`,
|
||||
`too many requests per minute`,
|
||||
`quota exceeded for quota metric GenerateRequestsPerMinutePerProjectPerBaseModel`,
|
||||
`RESOURCE_EXHAUSTED: TPM quota exceeded`,
|
||||
`tokens per minute quota exceeded`,
|
||||
`rate_limit_exceeded: requests per second`,
|
||||
`status 401: invalid api key`,
|
||||
`status 401: insufficient_quota`,
|
||||
`HTTP 403: billing_hard_limit_reached`,
|
||||
`status 500: internal server error`,
|
||||
`status=503: insufficient_quota`,
|
||||
`context length exceeded`,
|
||||
}
|
||||
for _, message := range negative {
|
||||
if IsQuotaExhaustedMessage(message) {
|
||||
t.Errorf("unexpected quota classification for %q", message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotaAwareTransportOnlyNormalizesExplicitQuota429(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
wantStatus int
|
||||
}{
|
||||
{name: "quota", status: http.StatusTooManyRequests, body: `{"code":"insufficient_quota"}`, wantStatus: http.StatusPaymentRequired},
|
||||
{name: "ordinary rate limit", status: http.StatusTooManyRequests, body: `{"message":"rate limit exceeded"}`, wantStatus: http.StatusTooManyRequests},
|
||||
{name: "server error", status: http.StatusInternalServerError, body: `insufficient_quota`, wantStatus: http.StatusInternalServerError},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
transport := quotaAwareTransport{base: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: tt.status,
|
||||
Status: http.StatusText(tt.status),
|
||||
Body: io.NopCloser(strings.NewReader(tt.body)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})}
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.invalid", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != tt.wantStatus {
|
||||
t.Fatalf("status=%d, want %d", resp.StatusCode, tt.wantStatus)
|
||||
}
|
||||
gotBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(gotBody) != tt.body {
|
||||
t.Fatalf("body=%q, want %q", gotBody, tt.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderDoesNotRetryExplicitQuota429(t *testing.T) {
|
||||
t.Parallel()
|
||||
var requests atomic.Int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
requests.Add(1)
|
||||
w.Header().Set("content-type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = io.WriteString(w, `{"error":{"code":"insufficient_quota","message":"You exceeded your current quota"}}`)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
provider, err := ConfigFrom("openai", "test-model", upstream.URL, "test-key", "").NewProvider()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var streamErr error
|
||||
for _, err := range provider.Stream(context.Background(), llm.CompletionRequest{
|
||||
Messages: []llm.Message{llm.UserText("ping")},
|
||||
}) {
|
||||
if err != nil {
|
||||
streamErr = err
|
||||
}
|
||||
}
|
||||
if streamErr == nil || !IsQuotaExhaustedMessage(streamErr.Error()) {
|
||||
t.Fatalf("expected explicit quota error, got %v", streamErr)
|
||||
}
|
||||
if got := requests.Load(); got != 1 {
|
||||
t.Fatalf("explicit quota request retried %d times, want exactly one request", got)
|
||||
}
|
||||
}
|
||||
+68
-67
@@ -9,26 +9,15 @@ import (
|
||||
"github.com/Autumn-27/norma/harness"
|
||||
)
|
||||
|
||||
// This file turns a run's terminal state into something a human can act on.
|
||||
//
|
||||
// Before: a cancelled run produced one fixed sentence — "(运行被中断:任务暂停 /
|
||||
// 终止 / 重启所致;未完成)" — a guess-list, not a diagnosis. The information to do
|
||||
// better was already there (the cancel cause, the terminal reason, the turn
|
||||
// count, the tool that never returned); it was just being thrown away.
|
||||
//
|
||||
// terminalText produces a self-contained ONE-LINE summary (the transcript's
|
||||
// collapsed row, cut at the first newline by firstLine) plus a multi-line detail
|
||||
// block rendered in full when the row is expanded.
|
||||
|
||||
// runTrace records which tool call is in flight, so an aborted / timed-out run can
|
||||
// name the step it died on — the single most useful fact when a run hangs.
|
||||
// runTrace retains the latest tool call so an interrupted run can identify the
|
||||
// operation that was still in flight.
|
||||
type runTrace struct {
|
||||
startedAt time.Time
|
||||
id string // tool_use id of the most recent call
|
||||
name string // its tool name
|
||||
input string // its raw input JSON
|
||||
id string
|
||||
name string
|
||||
input string
|
||||
at time.Time
|
||||
pending bool // true = no tool_result arrived for it yet
|
||||
pending bool
|
||||
}
|
||||
|
||||
func (t *runTrace) start(id, name, input string) {
|
||||
@@ -41,73 +30,73 @@ func (t *runTrace) done(id string) {
|
||||
}
|
||||
}
|
||||
|
||||
// reasonHint explains each harness terminal reason in plain 中文: what it means,
|
||||
// whether results were still written back, and what the engine does next. Covers
|
||||
// every TerminalReason the harness can emit.
|
||||
var reasonHint = map[harness.TerminalReason]string{
|
||||
harness.ReasonCompleted: "模型主动结束了本轮(正常收场),但没有留下文字总结。事实/资产以工具调用为准,看上面的 tool_use 记录",
|
||||
harness.ReasonMaxTurns: "撞到步数上限(MaxTurns):SDK 已自动跑了一轮收尾(settlement)把事实/资产写回,只是缺一段文字总结。" +
|
||||
"意图标记为 exhausted——表示「这个方向试过但没做完」,规划者会据此换角度,不是失败",
|
||||
harness.ReasonTimeout: "撞到单次运行的软墙钟预算(MaxDuration):SDK 在回合边界优雅收尾并写回事实/资产,只是缺文字总结。" +
|
||||
"意图标记为 exhausted,不是失败",
|
||||
harness.ReasonModelError: "模型/API 调用失败(网络、鉴权、限流、供应商 5xx 等)。引擎会退避后自动重跑几次;重试用尽仍失败则意图标记为 blocked",
|
||||
harness.ReasonBlockingLimit: "上下文长度撞硬上限,请求在发出前就被拦下。通常是单次运行塞进了过多工具输出——考虑收窄意图粒度或压缩工具返回",
|
||||
harness.ReasonPromptTooLong: "提示词过长且压缩(compaction)重试已用尽,无法继续。同样指向单次运行累积的上下文过大",
|
||||
harness.ReasonImageError: "多模态内容不被当前模型支持(截图/图片块)。换支持视觉的模型,或让工具别回图",
|
||||
harness.ReasonStopHookPrevented: "Stop 钩子阻止了本轮结束(守卫判定还不能收场),且随后没能继续下去。" +
|
||||
"检查任务的 Guard 规则是否过严",
|
||||
harness.ReasonHookStopped: "某个工具/钩子主动叫停了继续执行(守卫拦截,如越界目标、禁用命令)。看上面最后一条 tool_result 里的拦截说明",
|
||||
harness.ReasonAbortedStreaming: "运行在【模型输出流式生成阶段】被取消",
|
||||
harness.ReasonAbortedTools: "运行在【工具执行阶段】被取消",
|
||||
harness.ReasonCompleted: "模型正常结束了本轮,但没有留下文字总结;事实和资产以本轮工具调用记录为准",
|
||||
harness.ReasonMaxTurns: "达到步数上限(MaxTurns):SDK 已执行收尾并写回事实和资产,意图会标记为 exhausted,供规划者换方向继续,而不是作为失败处理",
|
||||
harness.ReasonTimeout: "达到单次运行的软墙钟预算(MaxDuration):SDK 已在回合边界收尾并写回事实和资产,意图会标记为 exhausted",
|
||||
harness.ReasonModelError: "模型或 API 调用失败(网络、鉴权、限流、供应商 5xx 等);引擎会按策略退避重试,重试用尽后意图标记为 blocked",
|
||||
harness.ReasonBlockingLimit: "上下文长度达到硬上限,请求在发出前被拦截;应收窄意图粒度或压缩工具返回",
|
||||
harness.ReasonPromptTooLong: "提示词过长且上下文压缩重试已经用尽,无法继续执行",
|
||||
harness.ReasonImageError: "当前模型不支持本轮多模态内容;请切换支持视觉的模型或避免工具返回图片",
|
||||
harness.ReasonStopHookPrevented: "Stop 钩子阻止本轮结束,随后未能继续;请检查任务 Guard 规则是否过严",
|
||||
harness.ReasonHookStopped: "工具或钩子主动停止继续执行,例如越界目标或禁用命令;请检查最后一条 tool_result 的拦截说明",
|
||||
harness.ReasonAbortedStreaming: "运行在模型输出流式生成阶段被取消",
|
||||
harness.ReasonAbortedTools: "运行在工具执行阶段被取消",
|
||||
}
|
||||
|
||||
// terminalText renders a terminal event with no final text into (summary, detail).
|
||||
// summary is one self-contained line; detail is the expandable breakdown.
|
||||
// terminalText renders a terminal event with no final text into a compact summary
|
||||
// and a Markdown detail block.
|
||||
func terminalText(ctx context.Context, term *harness.Terminal, tr *runTrace) (string, string) {
|
||||
reason := term.Reason
|
||||
aborted := reason == harness.ReasonAbortedStreaming || reason == harness.ReasonAbortedTools
|
||||
// Prompt may return ctx.Err directly without a terminal event. Preserve the
|
||||
// cancellation cause instead of falling back to an empty/unknown terminal reason.
|
||||
if reason == "" && ctx.Err() != nil {
|
||||
aborted = true
|
||||
}
|
||||
|
||||
// ---- one-line summary: cause first (that's what the reader is after), then
|
||||
// just enough run shape to judge it without expanding. ----
|
||||
var sum string
|
||||
switch {
|
||||
case aborted:
|
||||
if aborted {
|
||||
_, short, _, ok := AbortReason(ctx)
|
||||
if !ok {
|
||||
short = "取不到取消原因(run 的 context 上没有挂 cause)"
|
||||
short = "未能取得取消原因"
|
||||
}
|
||||
stage := "工具执行阶段"
|
||||
if reason == harness.ReasonAbortedStreaming {
|
||||
stage := "执行过程中"
|
||||
switch reason {
|
||||
case harness.ReasonAbortedStreaming:
|
||||
stage = "模型输出阶段"
|
||||
case harness.ReasonAbortedTools:
|
||||
stage = "工具执行阶段"
|
||||
}
|
||||
sum = "(运行被中断:" + short + ";停在" + stage + progressSuffix(term, tr) + ",未完成)"
|
||||
case reason == harness.ReasonMaxTurns || reason == harness.ReasonTimeout:
|
||||
sum = "(达运行预算上限(" + string(reason) + "),已收尾写回事实" + progressSuffix(term, tr) + ";本次无文字总结)"
|
||||
default:
|
||||
hint := reasonHint[reason]
|
||||
if hint == "" {
|
||||
hint = "未知终态(harness 新增了终态但这里还没登记,请补 reasonHint)"
|
||||
}
|
||||
sum = "(无文字总结,终态 " + string(reason) + ":" + firstLine(hint, 80) + ")"
|
||||
} else if reason == harness.ReasonMaxTurns || reason == harness.ReasonTimeout {
|
||||
sum = "(达到运行预算上限(" + string(reason) + "),已收尾写回事实" + progressSuffix(term, tr) + ";本次无文字总结)"
|
||||
} else {
|
||||
hint := terminalReasonHint(reason)
|
||||
sum = "(无文字总结,终态 " + terminalReasonLabel(reason) + ":" + firstLine(hint, 80) + ")"
|
||||
}
|
||||
|
||||
// ---- expandable detail ----
|
||||
var b strings.Builder
|
||||
b.WriteString(sum)
|
||||
b.WriteString("\n\n")
|
||||
fmt.Fprintf(&b, "- **终态**: `%s` — %s\n", reason, reasonHint[reason])
|
||||
displayReason := terminalReasonLabel(reason)
|
||||
fmt.Fprintf(&b, "- **终态**: `%s` - %s\n", displayReason, terminalReasonHint(reason))
|
||||
if aborted {
|
||||
code, _, why, ok := AbortReason(ctx)
|
||||
if ok {
|
||||
fmt.Fprintf(&b, "- **中断原因** (`%s`): %s\n", code, why)
|
||||
} else {
|
||||
b.WriteString("- **中断原因**: 取不到——取消方没有用 context.WithCancelCause 挂上具名原因。" +
|
||||
"这属于漏网的取消点,请补 agent/cancelcause.go 里的枚举\n")
|
||||
b.WriteString("- **中断原因**: 无法取得;取消方可能没有通过 context.WithCancelCause 附加具名原因\n")
|
||||
}
|
||||
}
|
||||
if term.Err != nil {
|
||||
fmt.Fprintf(&b, "- **底层错误**: `%v`\n", term.Err)
|
||||
}
|
||||
if aborted && strings.TrimSpace(term.Text) != "" {
|
||||
b.WriteString("- **取消前已生成的部分输出**:\n\n")
|
||||
b.WriteString(term.Text)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
if term.Turns > 0 {
|
||||
fmt.Fprintf(&b, "- **已执行**: %d 轮模型回合\n", term.Turns)
|
||||
}
|
||||
@@ -118,21 +107,34 @@ func terminalText(ctx context.Context, term *harness.Terminal, tr *runTrace) (st
|
||||
fmt.Fprintf(&b, "- **累计 token**: 输入 %d / 输出 %d / 缓存读 %d / 缓存写 %d\n",
|
||||
u.InputTokens, u.OutputTokens, u.CacheReadTokens, u.CacheWriteTokens)
|
||||
}
|
||||
if tr.name != "" {
|
||||
if tr.pending {
|
||||
fmt.Fprintf(&b, "- **中断时正在执行的工具**: `%s`(已跑 %s,**未返回结果**) — 若是硬超时/长时间无进展,问题多半就在这一步\n ```\n %s\n ```\n",
|
||||
tr.name, roundDur(time.Since(tr.at)), firstLine(tr.input, 300))
|
||||
} else {
|
||||
fmt.Fprintf(&b, "- **中断前最后一个工具**: `%s`(已正常返回)\n", tr.name)
|
||||
}
|
||||
if tr.name == "" {
|
||||
b.WriteString("- **工具调用**: 本次运行还没有发出工具调用就结束了\n")
|
||||
} else if tr.pending {
|
||||
fmt.Fprintf(&b, "- **中断时正在执行的工具**: `%s`(已运行 %s,**未返回结果**)\n\n ```json\n %s\n ```\n",
|
||||
tr.name, roundDur(time.Since(tr.at)), firstLine(tr.input, 300))
|
||||
} else {
|
||||
b.WriteString("- **工具调用**: 本次运行还没发出任何工具调用就结束了\n")
|
||||
fmt.Fprintf(&b, "- **中断前最后一个工具**: `%s`(已正常返回)\n", tr.name)
|
||||
}
|
||||
return sum, b.String()
|
||||
}
|
||||
|
||||
// progressSuffix adds "已跑 N 轮 / 12m30s" to a summary line — how far the run got
|
||||
// before it died, which decides whether an abort cost anything worth re-running.
|
||||
func terminalReasonLabel(reason harness.TerminalReason) string {
|
||||
if reason == "" {
|
||||
return "context_canceled"
|
||||
}
|
||||
return string(reason)
|
||||
}
|
||||
|
||||
func terminalReasonHint(reason harness.TerminalReason) string {
|
||||
if hint := reasonHint[reason]; hint != "" {
|
||||
return hint
|
||||
}
|
||||
if reason == "" {
|
||||
return "运行的 context 已取消,但底层没有产生 Terminal 事件"
|
||||
}
|
||||
return "未知终态;harness 可能新增了 TerminalReason,请补充 reasonHint"
|
||||
}
|
||||
|
||||
func progressSuffix(term *harness.Terminal, tr *runTrace) string {
|
||||
var parts []string
|
||||
if term.Turns > 0 {
|
||||
@@ -144,10 +146,9 @@ func progressSuffix(term *harness.Terminal, tr *runTrace) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return ",已跑 " + strings.Join(parts, " / ")
|
||||
return ",已运行 " + strings.Join(parts, " / ")
|
||||
}
|
||||
|
||||
// roundDur formats a duration at a readable granularity (no 3.400213s noise).
|
||||
func roundDur(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
|
||||
@@ -12,8 +12,6 @@ import (
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
)
|
||||
|
||||
// every TerminalReason the harness can emit must have an explanation — otherwise
|
||||
// a run silently falls back to "未知终态", which is the bug this file exists to fix.
|
||||
func TestReasonHintCoversEveryTerminalReason(t *testing.T) {
|
||||
all := []harness.TerminalReason{
|
||||
harness.ReasonCompleted, harness.ReasonBlockingLimit, harness.ReasonImageError,
|
||||
@@ -21,152 +19,151 @@ func TestReasonHintCoversEveryTerminalReason(t *testing.T) {
|
||||
harness.ReasonPromptTooLong, harness.ReasonStopHookPrevented, harness.ReasonHookStopped,
|
||||
harness.ReasonMaxTurns, harness.ReasonTimeout,
|
||||
}
|
||||
for _, r := range all {
|
||||
if strings.TrimSpace(reasonHint[r]) == "" {
|
||||
t.Errorf("terminal reason %q 没有中文说明", r)
|
||||
for _, reason := range all {
|
||||
if strings.TrimSpace(reasonHint[reason]) == "" {
|
||||
t.Errorf("terminal reason %q has no explanation", reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// the cause attached at cancel time must survive the ctx chain the engine builds:
|
||||
// task exec ctx -> per-work child -> value ctx -> the run's hard-timeout backstop.
|
||||
func TestAbortCausePropagatesThroughRunContextChain(t *testing.T) {
|
||||
type key struct{}
|
||||
exec, cancelExec := context.WithCancelCause(context.Background())
|
||||
work, cancelWork := context.WithCancelCause(exec)
|
||||
execCtx, cancelExec := context.WithCancelCause(context.Background())
|
||||
workCtx, cancelWork := context.WithCancelCause(execCtx)
|
||||
defer cancelWork(nil)
|
||||
valued := context.WithValue(work, key{}, "task-1")
|
||||
run, cancelRun := context.WithTimeoutCause(valued, time.Hour, AbortRunHardTimeout)
|
||||
valued := context.WithValue(workCtx, key{}, "task-1")
|
||||
runCtx, cancelRun := context.WithTimeoutCause(valued, time.Hour, AbortRunHardTimeout)
|
||||
defer cancelRun()
|
||||
|
||||
cancelExec(AbortPausedByUser)
|
||||
|
||||
code, _, text, ok := AbortReason(run)
|
||||
code, _, text, ok := AbortReason(runCtx)
|
||||
if !ok || code != "paused_by_user" {
|
||||
t.Fatalf("code=%q ok=%v,期望 paused_by_user", code, ok)
|
||||
t.Fatalf("code=%q ok=%v, want paused_by_user", code, ok)
|
||||
}
|
||||
if !strings.Contains(text, "退回 frontier") {
|
||||
t.Errorf("原因文案没传下来: %q", text)
|
||||
if !strings.Contains(text, "frontier") {
|
||||
t.Fatalf("cause detail did not propagate: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbortReasonFallbacks(t *testing.T) {
|
||||
if _, _, _, ok := AbortReason(context.Background()); ok {
|
||||
t.Error("未取消的 ctx 不应报出中断原因")
|
||||
t.Fatal("live context must not report an abort reason")
|
||||
}
|
||||
// a cancel site that forgot to attach a cause must be called out, not guessed at
|
||||
plain, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
code, _, _, ok := AbortReason(plain)
|
||||
if !ok || code != "canceled_no_cause" {
|
||||
t.Errorf("code=%q ok=%v,期望 canceled_no_cause", code, ok)
|
||||
if code, _, _, ok := AbortReason(plain); !ok || code != "canceled_no_cause" {
|
||||
t.Fatalf("code=%q ok=%v, want canceled_no_cause", code, ok)
|
||||
}
|
||||
|
||||
timed, cancelT := context.WithTimeout(context.Background(), time.Nanosecond)
|
||||
defer cancelT()
|
||||
timed, cancelTimed := context.WithTimeout(context.Background(), time.Nanosecond)
|
||||
defer cancelTimed()
|
||||
<-timed.Done()
|
||||
if code, _, _, _ := AbortReason(timed); code != "deadline_exceeded" {
|
||||
t.Errorf("code=%q,期望 deadline_exceeded", code)
|
||||
t.Fatalf("code=%q, want deadline_exceeded", code)
|
||||
}
|
||||
}
|
||||
|
||||
// an aborted run's one-line summary must name the actual cause, and the detail
|
||||
// must pin down the tool that never came back.
|
||||
func TestTerminalTextAbortedNamesCauseAndHangingTool(t *testing.T) {
|
||||
ctx, cancel := context.WithCancelCause(context.Background())
|
||||
cancel(AbortKilledByPlanner)
|
||||
|
||||
tr := &runTrace{startedAt: time.Now().Add(-90 * time.Second)}
|
||||
tr.start("tu_1", "Bash", `{"command":"nmap -p- 10.0.0.1"}`)
|
||||
|
||||
trace := &runTrace{startedAt: time.Now().Add(-90 * time.Second)}
|
||||
trace.start("tu_1", "Bash", `{"command":"nmap -p- 10.0.0.1"}`)
|
||||
term := &harness.Terminal{
|
||||
Reason: harness.ReasonAbortedTools,
|
||||
Err: context.Canceled,
|
||||
Turns: 7,
|
||||
Usage: llm.Usage{InputTokens: 1200, OutputTokens: 340},
|
||||
}
|
||||
sum, detail := terminalText(ctx, term, tr)
|
||||
|
||||
if !strings.Contains(sum, "kill_work") {
|
||||
t.Errorf("摘要没点出取消方: %q", sum)
|
||||
}
|
||||
if strings.Contains(sum, "\n") {
|
||||
t.Errorf("摘要必须是单行(前端按首行截断): %q", sum)
|
||||
summary, detail := terminalText(ctx, term, trace)
|
||||
if !strings.Contains(summary, "规划者") || strings.Contains(summary, "\n") {
|
||||
t.Fatalf("unexpected summary: %q", summary)
|
||||
}
|
||||
for _, want := range []string{"killed_by_planner", "aborted_tools", "7 轮", "Bash", "未返回结果", "1200"} {
|
||||
if !strings.Contains(detail, want) {
|
||||
t.Errorf("详情缺少 %q:\n%s", want, detail)
|
||||
t.Errorf("detail missing %q:\n%s", want, detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// a tool that DID return must not be reported as the hang site.
|
||||
func TestTerminalTextDirectContextCancellationKeepsCause(t *testing.T) {
|
||||
ctx, cancel := context.WithCancelCause(context.Background())
|
||||
cancel(AbortChatStoppedByUser)
|
||||
summary, detail := terminalText(ctx, &harness.Terminal{Err: context.Canceled}, &runTrace{startedAt: time.Now()})
|
||||
if !strings.Contains(summary, "用户停止") || !strings.Contains(detail, "chat_stopped_by_user") {
|
||||
t.Fatalf("direct context cancellation lost cause: %q\n%s", summary, detail)
|
||||
}
|
||||
if !strings.Contains(detail, "context_canceled") {
|
||||
t.Fatalf("missing synthesized terminal label: %s", detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalTextAbortedPreservesPartialOutput(t *testing.T) {
|
||||
ctx, cancel := context.WithCancelCause(context.Background())
|
||||
cancel(AbortPausedByUser)
|
||||
summary, detail := terminalText(ctx, &harness.Terminal{
|
||||
Reason: harness.ReasonAbortedStreaming,
|
||||
Text: "已经生成的半段回答",
|
||||
}, &runTrace{startedAt: time.Now()})
|
||||
if !strings.Contains(summary, "用户暂停") {
|
||||
t.Fatalf("abort summary lost cause: %q", summary)
|
||||
}
|
||||
if !strings.Contains(detail, "已经生成的半段回答") {
|
||||
t.Fatalf("abort detail lost partial output: %s", detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalTextCompletedToolNotBlamed(t *testing.T) {
|
||||
ctx, cancel := context.WithCancelCause(context.Background())
|
||||
cancel(AbortShutdown)
|
||||
tr := &runTrace{startedAt: time.Now()}
|
||||
tr.start("tu_1", "Read", `{"path":"/etc/hosts"}`)
|
||||
tr.done("tu_1")
|
||||
|
||||
_, detail := terminalText(ctx, &harness.Terminal{Reason: harness.ReasonAbortedStreaming}, tr)
|
||||
if strings.Contains(detail, "未返回结果") {
|
||||
t.Errorf("已返回的工具被误报为卡住:\n%s", detail)
|
||||
}
|
||||
if !strings.Contains(detail, "已正常返回") {
|
||||
t.Errorf("详情没记录最后一个工具:\n%s", detail)
|
||||
trace := &runTrace{startedAt: time.Now()}
|
||||
trace.start("tu_1", "Read", `{"path":"/etc/hosts"}`)
|
||||
trace.done("tu_1")
|
||||
_, detail := terminalText(ctx, &harness.Terminal{Reason: harness.ReasonAbortedStreaming}, trace)
|
||||
if strings.Contains(detail, "未返回结果") || !strings.Contains(detail, "已正常返回") {
|
||||
t.Fatalf("completed tool was blamed:\n%s", detail)
|
||||
}
|
||||
}
|
||||
|
||||
// non-abort terminals keep their own explanation instead of the interruption text.
|
||||
func TestTerminalTextNonAbortReasons(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tr := &runTrace{startedAt: time.Now()}
|
||||
|
||||
sum, _ := terminalText(ctx, &harness.Terminal{Reason: harness.ReasonMaxTurns}, tr)
|
||||
if !strings.Contains(sum, "达运行预算上限") || strings.Contains(sum, "中断") {
|
||||
t.Errorf("max_turns 摘要不对: %q", sum)
|
||||
trace := &runTrace{startedAt: time.Now()}
|
||||
summary, _ := terminalText(context.Background(), &harness.Terminal{Reason: harness.ReasonMaxTurns}, trace)
|
||||
if !strings.Contains(summary, "运行预算上限") || strings.Contains(summary, "中断") {
|
||||
t.Fatalf("unexpected max_turns summary: %q", summary)
|
||||
}
|
||||
|
||||
sum, detail := terminalText(ctx, &harness.Terminal{
|
||||
Reason: harness.ReasonModelError, Err: errors.New("429 rate limited")}, tr)
|
||||
if !strings.Contains(sum, "model_error") {
|
||||
t.Errorf("model_error 摘要不对: %q", sum)
|
||||
}
|
||||
if !strings.Contains(detail, "429 rate limited") {
|
||||
t.Errorf("详情没带上底层错误:\n%s", detail)
|
||||
summary, detail := terminalText(context.Background(), &harness.Terminal{
|
||||
Reason: harness.ReasonModelError, Err: errors.New("429 rate limited"),
|
||||
}, trace)
|
||||
if !strings.Contains(summary, "model_error") || !strings.Contains(detail, "429 rate limited") {
|
||||
t.Fatalf("model_error detail incomplete: %q\n%s", summary, detail)
|
||||
}
|
||||
}
|
||||
|
||||
// every enumerated cause must carry both a compact headline (for the collapsed
|
||||
// row) and a full explanation (for the expanded detail).
|
||||
func TestAbortCausesAreWellFormed(t *testing.T) {
|
||||
all := []*AbortCause{
|
||||
AbortPausedByUser, AbortPausedByOrchestrator, AbortTaskDeleted, AbortPausedOnReload,
|
||||
AbortGoalMet, AbortSettleDrainTimeout, AbortKilledByPlanner, AbortWorkFinished,
|
||||
AbortPausedRaceGuard, AbortChatStoppedByUser, AbortChatTurnFinished,
|
||||
AbortGoalMet, AbortSettleDrainTimeout, AbortKilledByPlanner, AbortWorkPausedByUser,
|
||||
AbortWorkCancelledByUser, AbortWorkFinished, AbortPausedRaceGuard,
|
||||
AbortChatStoppedByUser, AbortChatPausedWithTask, AbortChatTurnFinished,
|
||||
AbortShutdown, AbortRunHardTimeout,
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range all {
|
||||
for _, abort := range all {
|
||||
switch {
|
||||
case c.Code == "" || seen[c.Code]:
|
||||
t.Errorf("code 缺失或重复: %q", c.Code)
|
||||
case c.Short == "" || utf8.RuneCountInString(c.Short) > 40:
|
||||
t.Errorf("%s: Short 必须存在且 ≤40 字(进折叠摘要行): %q", c.Code, c.Short)
|
||||
case utf8.RuneCountInString(c.Text) <= utf8.RuneCountInString(c.Short):
|
||||
t.Errorf("%s: Text 应比 Short 更完整", c.Code)
|
||||
case abort.Code == "" || seen[abort.Code]:
|
||||
t.Errorf("missing or duplicate code: %q", abort.Code)
|
||||
case abort.Short == "" || utf8.RuneCountInString(abort.Short) > 40:
|
||||
t.Errorf("%s has invalid short text: %q", abort.Code, abort.Short)
|
||||
case utf8.RuneCountInString(abort.Text) <= utf8.RuneCountInString(abort.Short):
|
||||
t.Errorf("%s detail must be longer than short text", abort.Code)
|
||||
}
|
||||
seen[c.Code] = true
|
||||
seen[abort.Code] = true
|
||||
}
|
||||
}
|
||||
|
||||
// summaries are mostly 中文; capping by bytes used to slice a rune in half.
|
||||
func TestFirstLineCapsByRuneNotByte(t *testing.T) {
|
||||
got := firstLine(strings.Repeat("恢复", 10), 5)
|
||||
if got != "恢复恢复恢…" {
|
||||
t.Errorf("firstLine 按字节截断了: %q", got)
|
||||
if got := firstLine(strings.Repeat("恢复", 10), 5); got != "恢复恢复恢…" {
|
||||
t.Fatalf("firstLine split a Unicode character: %q", got)
|
||||
}
|
||||
if got := firstLine("头\n尾", 100); got != "头" {
|
||||
t.Errorf("多行没截到首行: %q", got)
|
||||
t.Fatalf("firstLine did not stop at newline: %q", got)
|
||||
}
|
||||
}
|
||||
+552
-145
@@ -23,6 +23,10 @@ func compactIntents(ns []*db.Node, parentsOf, yieldsOf map[int64][]int64) []map[
|
||||
var p map[string]any
|
||||
_ = json.Unmarshal(n.Payload, &p)
|
||||
m := map[string]any{"id": n.ID, "summary": p["summary"], "state": n.State}
|
||||
if n.Inherited {
|
||||
m["source_task_id"] = n.SourceTaskID
|
||||
m["inherited"] = true
|
||||
}
|
||||
// asset_ids is the structured "which assets this direction covers" signal for
|
||||
// dedup; fall back to legacy payload keys (target_ids plural, then target_id
|
||||
// single) so intents stored before the rename still surface their anchors.
|
||||
@@ -265,132 +269,438 @@ func (t *ToolSet) graphOverview() actool.CoreTool {
|
||||
// the model needn't spend a turn calling the tool — every plan round starts with
|
||||
// an empty context and always needs this first).
|
||||
func (t *ToolSet) graphOverviewData() map[string]any {
|
||||
{
|
||||
out := map[string]any{}
|
||||
// goals summary folded in so the planner needn't call list_goals each round.
|
||||
goals, _ := t.ts.ListByKind(db.KindGoal, 100)
|
||||
gsum := make([]map[string]any, 0, len(goals))
|
||||
for _, g := range goals {
|
||||
var p map[string]any
|
||||
_ = json.Unmarshal(g.Payload, &p)
|
||||
gsum = append(gsum, map[string]any{"id": g.ID, "state": g.State, "text": p["text"]})
|
||||
out := map[string]any{}
|
||||
// goals summary folded in so the planner needn't call list_goals each round.
|
||||
goals, _ := t.ts.ListByKind(db.KindGoal, 100)
|
||||
gsum := make([]map[string]any, 0, len(goals))
|
||||
for _, g := range goals {
|
||||
var p map[string]any
|
||||
_ = json.Unmarshal(g.Payload, &p)
|
||||
gsum = append(gsum, map[string]any{"id": g.ID, "state": g.State, "text": p["text"]})
|
||||
}
|
||||
out["goals"] = gsum
|
||||
// hints: 人类/主 agent 通过 add_hint 挂上图的战略提示;folded in so the
|
||||
// planner reads them every round when generating intents (否则只写不读).
|
||||
hints, _ := t.ts.ListByKind(db.KindHint, 50)
|
||||
hsum := make([]map[string]any, 0, len(hints))
|
||||
for _, h := range hints {
|
||||
var p map[string]any
|
||||
_ = json.Unmarshal(h.Payload, &p)
|
||||
hsum = append(hsum, map[string]any{"id": h.ID, "state": h.State, "text": p["text"]})
|
||||
}
|
||||
out["hints"] = hsum
|
||||
// lineage from the exploration edges: an intent's parents (what it
|
||||
// derived_from — possibly several facts combined) and its yields (the
|
||||
// facts/findings it produced). factFrom maps a fact → the intent that
|
||||
// produced it. This is the relationship layer the flat lists lacked.
|
||||
edges, _ := t.ts.Edges(5000)
|
||||
parentsOf := map[int64][]int64{}
|
||||
yieldsOf := map[int64][]int64{}
|
||||
factFrom := map[int64]int64{}
|
||||
for _, e := range edges {
|
||||
switch e.Rel {
|
||||
case db.RelDerivedFrom, db.RelSpawns: // upstream: derived_from (fact/finding/intent→intent) or spawns (origin fact→goal, legacy begin→intent)
|
||||
parentsOf[e.To] = append(parentsOf[e.To], e.From)
|
||||
case db.RelYields: // intent --yields--> fact/finding
|
||||
yieldsOf[e.From] = append(yieldsOf[e.From], e.To)
|
||||
factFrom[e.To] = e.From
|
||||
}
|
||||
out["goals"] = gsum
|
||||
// hints: 人类/主 agent 通过 add_hint 挂上图的战略提示;folded in so the
|
||||
// planner reads them every round when generating intents (否则只写不读).
|
||||
hints, _ := t.ts.ListByKind(db.KindHint, 50)
|
||||
hsum := make([]map[string]any, 0, len(hints))
|
||||
for _, h := range hints {
|
||||
var p map[string]any
|
||||
_ = json.Unmarshal(h.Payload, &p)
|
||||
hsum = append(hsum, map[string]any{"id": h.ID, "state": h.State, "text": p["text"]})
|
||||
}
|
||||
fr, _ := t.ts.Frontier(100)
|
||||
out["open_intents"] = compactIntents(fr, parentsOf, yieldsOf)
|
||||
all, _ := t.ts.ListByKind(db.KindIntent, 300)
|
||||
var running, recentDone []*db.Node
|
||||
for _, n := range all {
|
||||
switch n.State {
|
||||
case "running":
|
||||
running = append(running, n)
|
||||
case "done", "blocked", "exhausted":
|
||||
if len(recentDone) < 15 {
|
||||
recentDone = append(recentDone, n)
|
||||
}
|
||||
}
|
||||
out["hints"] = hsum
|
||||
// lineage from the exploration edges: an intent's parents (what it
|
||||
// derived_from — possibly several facts combined) and its yields (the
|
||||
// facts/findings it produced). factFrom maps a fact → the intent that
|
||||
// produced it. This is the relationship layer the flat lists lacked.
|
||||
edges, _ := t.ts.Edges(5000)
|
||||
}
|
||||
out["running_intents"] = compactIntents(running, parentsOf, yieldsOf)
|
||||
out["recent_done_intents"] = compactIntents(recentDone, parentsOf, yieldsOf)
|
||||
out["frontier_open"] = len(fr)
|
||||
// findings (confirmed vulns) and facts (worker exploration results) are
|
||||
// now distinct node kinds. recent_facts surfaces fact summaries (esp.
|
||||
// negative results) so the planner sees them in one call; full content
|
||||
// via node_detail(id).
|
||||
vulnNodes, _ := t.ts.ListByKind(db.KindFinding, 1000)
|
||||
factNodes, _ := t.ts.ListByKind(db.KindFact, 1000) // newest first
|
||||
out["findings"] = len(vulnNodes) // 确认漏洞数(目标判定看它)
|
||||
out["facts"] = len(factNodes) // 探索事实/结论数(含否定结论)
|
||||
recentFacts := make([]map[string]any, 0, 20)
|
||||
for _, n := range factNodes {
|
||||
if len(recentFacts) >= 20 {
|
||||
break
|
||||
}
|
||||
m := compactNode(n)
|
||||
if from := factFrom[n.ID]; from > 0 {
|
||||
m["from_intent"] = from // 本事实由哪个意图产生
|
||||
}
|
||||
// confidence 带进概览:让规划者一眼看出哪条结论只是 inferred(尤其否定结论
|
||||
// 别当铁案);evidence 较长,留给 node_detail(id)。
|
||||
var fp map[string]any
|
||||
if json.Unmarshal(n.Payload, &fp) == nil {
|
||||
if c, ok := fp["confidence"].(string); ok && c != "" {
|
||||
m["confidence"] = c
|
||||
}
|
||||
}
|
||||
recentFacts = append(recentFacts, m)
|
||||
}
|
||||
out["recent_facts"] = recentFacts // 最近事实的 {id, summary, from_intent, confidence?},详情/证据用 node_detail(id)
|
||||
// the original task (root) so the planner always has it, not just the
|
||||
// decomposed goals.
|
||||
if description, goal, err := t.ts.Root(); err == nil {
|
||||
out["task"] = map[string]any{"description": description, "goal": goal}
|
||||
}
|
||||
// Direct source tasks are a live, read-only blackboard view. Keep their
|
||||
// summaries in a separate field so their intents never enter this task's
|
||||
// frontier or get mistaken for locally claimable work.
|
||||
out["related_tasks"] = t.relatedTaskOverviews()
|
||||
// coverage:粗略的资产测试覆盖度参考——范围(task_scope)内的资产里,被 fact 碰过的
|
||||
// 占比 + by_type(按类型的 总数/已测)。要看未测的具体资产由 agent 按需调 list_untested_assets 自行判断。仅任务上下文有。
|
||||
if t.as != nil && t.ts != nil && t.taskID > 0 {
|
||||
if cov, err := t.as.TaskCoverageWithSources(t.taskID); err == nil {
|
||||
m := map[string]any{
|
||||
"denominator": cov.Denominator,
|
||||
"tested": cov.Tested,
|
||||
"by_type": cov.ByType,
|
||||
"note": "coverage资产测试覆盖度(包括接口等各种相关资产),粗略估计、仅供参考:包含当前任务与直接关联任务的 scope、事实锚点;关联 scope 只读。容器型资产/大量枚举会让它偏低,勿据此认为已测完;可用 add_task_scope 增补本任务范围、list_untested_assets 看未测资产【通常不调用list_untested_assets,按照任务推进即可】;",
|
||||
}
|
||||
if cov.Denominator == 0 {
|
||||
m["pct"] = nil
|
||||
m["status"] = "范围未锚定"
|
||||
} else {
|
||||
m["pct"] = cov.Pct
|
||||
}
|
||||
// scope:当前测试范围的根资产(task_scope 原始行),让 agent 知道这个任务到底
|
||||
// 圈定了哪些目标(不是全部测试资产,而是范围边界本身)。
|
||||
if rows, err := t.as.ListTaskScopeWithSources(t.taskID); err == nil && len(rows) > 0 {
|
||||
scope := make([]map[string]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
e := map[string]any{"kind": r.Kind, "source": r.Source, "task_id": r.TaskID}
|
||||
if r.TaskID != t.taskID {
|
||||
inheritedMap(e, r.TaskID)
|
||||
}
|
||||
switch {
|
||||
case r.Domain != "":
|
||||
e["value"] = r.Domain
|
||||
case r.Net != "":
|
||||
e["value"] = r.Net
|
||||
case r.CompanyID != nil:
|
||||
e["company_id"] = *r.CompanyID
|
||||
}
|
||||
scope = append(scope, e)
|
||||
}
|
||||
m["scope"] = scope
|
||||
}
|
||||
if hosts, err := t.as.HostsByTaskWithSources(t.taskID); err == nil {
|
||||
const hostContextLimit = 500
|
||||
visible := hosts
|
||||
if len(visible) > hostContextLimit {
|
||||
visible = visible[:hostContextLimit]
|
||||
}
|
||||
m["hosts"] = visible
|
||||
m["host_count"] = len(hosts)
|
||||
if len(visible) < len(hosts) {
|
||||
m["hosts_truncated"] = true
|
||||
}
|
||||
}
|
||||
out["coverage"] = m
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func inheritedMap(m map[string]any, sourceTaskID int64) map[string]any {
|
||||
m["source_task_id"] = sourceTaskID
|
||||
m["inherited"] = true
|
||||
return m
|
||||
}
|
||||
|
||||
const (
|
||||
relatedOverviewTotalTextRunes = 48_000
|
||||
relatedOverviewMaxTextPerSource = 8_000
|
||||
relatedOverviewMaxGoalsPerSource = 8
|
||||
relatedOverviewMaxHintsPerSource = 6
|
||||
relatedOverviewMaxFactsPerSource = 12
|
||||
relatedOverviewMaxFindingsPerTask = 6
|
||||
relatedOverviewMaxIntentsPerTask = 8
|
||||
relatedOverviewMaxScopePerSource = 12
|
||||
)
|
||||
|
||||
// overviewTextBudget bounds inherited prompt text while preserving a fair slice
|
||||
// for every direct source. Full evidence remains available through the on-demand
|
||||
// read tools, so truncation here does not discard persisted blackboard data.
|
||||
type overviewTextBudget struct {
|
||||
remaining int
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func relatedOverviewBudgetForSources(sourceCount int) int {
|
||||
if sourceCount <= 0 {
|
||||
return 0
|
||||
}
|
||||
if sourceCount > db.MaxTaskSourceCount {
|
||||
sourceCount = db.MaxTaskSourceCount
|
||||
}
|
||||
perSource := relatedOverviewTotalTextRunes / sourceCount
|
||||
if perSource > relatedOverviewMaxTextPerSource {
|
||||
perSource = relatedOverviewMaxTextPerSource
|
||||
}
|
||||
return perSource
|
||||
}
|
||||
|
||||
func (b *overviewTextBudget) take(value any, fieldLimit int) string {
|
||||
var text string
|
||||
switch value := value.(type) {
|
||||
case string:
|
||||
text = strings.TrimSpace(value)
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
text = strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
if b.remaining <= 0 || fieldLimit <= 0 {
|
||||
b.truncated = true
|
||||
return ""
|
||||
}
|
||||
runes := []rune(text)
|
||||
limit := fieldLimit
|
||||
if limit > b.remaining {
|
||||
limit = b.remaining
|
||||
}
|
||||
if len(runes) > limit {
|
||||
b.truncated = true
|
||||
if limit == 1 {
|
||||
text = "…"
|
||||
} else {
|
||||
text = string(runes[:limit-1]) + "…"
|
||||
}
|
||||
runes = []rune(text)
|
||||
}
|
||||
b.remaining -= len(runes)
|
||||
return text
|
||||
}
|
||||
|
||||
func recentTerminalIntents(store *db.ExplorationStore, limit int) []*db.Node {
|
||||
if limit <= 0 {
|
||||
return []*db.Node{}
|
||||
}
|
||||
const batch = 300
|
||||
cursor := int64(0)
|
||||
out := make([]*db.Node, 0, limit)
|
||||
for len(out) < limit {
|
||||
page, more, err := store.ListByKindPage(db.KindIntent, cursor, batch)
|
||||
if err != nil || len(page) == 0 {
|
||||
break
|
||||
}
|
||||
for _, intent := range page {
|
||||
switch intent.State {
|
||||
case "done", "blocked", "exhausted", "stopped":
|
||||
out = append(out, intent)
|
||||
}
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
cursor = page[len(page)-1].ID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// relatedTaskOverviews distills persistent blackboard state from direct source
|
||||
// tasks. It intentionally reads each source's local store methods, never its own
|
||||
// related sources, so inheritance is one level only.
|
||||
func (t *ToolSet) relatedTaskOverviews() []map[string]any {
|
||||
sources, err := t.ts.DirectSourceStores()
|
||||
if err != nil {
|
||||
return []map[string]any{}
|
||||
}
|
||||
if len(sources) > db.MaxTaskSourceCount {
|
||||
sources = sources[:db.MaxTaskSourceCount]
|
||||
}
|
||||
perSourceTextBudget := relatedOverviewBudgetForSources(len(sources))
|
||||
out := make([]map[string]any, 0, len(sources))
|
||||
for _, source := range sources {
|
||||
ts := source.Store
|
||||
budget := overviewTextBudget{remaining: perSourceTextBudget}
|
||||
item := map[string]any{
|
||||
"source_task_id": source.Task.TaskID,
|
||||
"inherited": true,
|
||||
"task": map[string]any{
|
||||
"description": budget.take(source.Task.Description, 800),
|
||||
"goal": budget.take(source.Task.Goal, 800),
|
||||
"status": source.Task.Status,
|
||||
},
|
||||
}
|
||||
stats, statsErr := ts.Stats()
|
||||
|
||||
edges, _ := ts.Edges(5000)
|
||||
parentsOf := map[int64][]int64{}
|
||||
yieldsOf := map[int64][]int64{}
|
||||
factFrom := map[int64]int64{}
|
||||
for _, e := range edges {
|
||||
switch e.Rel {
|
||||
case db.RelDerivedFrom, db.RelSpawns: // upstream: derived_from (fact/finding/intent→intent) or spawns (origin fact→goal, legacy begin→intent)
|
||||
parentsOf[e.To] = append(parentsOf[e.To], e.From)
|
||||
case db.RelYields: // intent --yields--> fact/finding
|
||||
yieldsOf[e.From] = append(yieldsOf[e.From], e.To)
|
||||
factFrom[e.To] = e.From
|
||||
for _, edge := range edges {
|
||||
switch edge.Rel {
|
||||
case db.RelDerivedFrom, db.RelSpawns:
|
||||
parentsOf[edge.To] = append(parentsOf[edge.To], edge.From)
|
||||
case db.RelYields:
|
||||
yieldsOf[edge.From] = append(yieldsOf[edge.From], edge.To)
|
||||
factFrom[edge.To] = edge.From
|
||||
}
|
||||
}
|
||||
fr, _ := t.ts.Frontier(100)
|
||||
out["open_intents"] = compactIntents(fr, parentsOf, yieldsOf)
|
||||
all, _ := t.ts.ListByKind(db.KindIntent, 300)
|
||||
var running, recentDone []*db.Node
|
||||
for _, n := range all {
|
||||
switch n.State {
|
||||
case "running":
|
||||
running = append(running, n)
|
||||
case "done", "blocked", "exhausted":
|
||||
if len(recentDone) < 15 {
|
||||
recentDone = append(recentDone, n)
|
||||
}
|
||||
|
||||
goals, _ := ts.ListByKind(db.KindGoal, relatedOverviewMaxGoalsPerSource)
|
||||
goalSummary := make([]map[string]any, 0, len(goals))
|
||||
for _, goal := range goals {
|
||||
var payload map[string]any
|
||||
_ = json.Unmarshal(goal.Payload, &payload)
|
||||
goalSummary = append(goalSummary, inheritedMap(map[string]any{
|
||||
"id": goal.ID, "state": goal.State, "text": budget.take(payload["text"], 400),
|
||||
}, source.Task.TaskID))
|
||||
}
|
||||
item["goals"] = goalSummary
|
||||
|
||||
hints, _ := ts.ListByKind(db.KindHint, relatedOverviewMaxHintsPerSource)
|
||||
hintSummary := make([]map[string]any, 0, len(hints))
|
||||
for _, hint := range hints {
|
||||
var payload map[string]any
|
||||
_ = json.Unmarshal(hint.Payload, &payload)
|
||||
hintSummary = append(hintSummary, inheritedMap(map[string]any{
|
||||
"id": hint.ID, "state": hint.State, "text": budget.take(payload["text"], 400),
|
||||
}, source.Task.TaskID))
|
||||
}
|
||||
item["hints"] = hintSummary
|
||||
|
||||
facts, _ := ts.ListByKind(db.KindFact, relatedOverviewMaxFactsPerSource)
|
||||
findings, _ := ts.ListByKind(db.KindFinding, relatedOverviewMaxFindingsPerTask)
|
||||
intentNodes, _ := ts.ListByKind(db.KindIntent, 300)
|
||||
terminalIntent := make(map[int64]bool, len(intentNodes))
|
||||
for _, intent := range intentNodes {
|
||||
terminalIntent[intent.ID] = inheritedIntentSummaryState(intent.State)
|
||||
}
|
||||
item["facts"] = len(facts)
|
||||
item["findings"] = len(findings)
|
||||
if statsErr == nil {
|
||||
item["facts"] = stats[db.KindFact]
|
||||
item["findings"] = stats[db.KindFinding]
|
||||
if stats[db.KindGoal] > len(goals) || stats[db.KindHint] > len(hints) ||
|
||||
stats[db.KindFact] > len(facts) || stats[db.KindFinding] > len(findings) {
|
||||
budget.truncated = true
|
||||
}
|
||||
}
|
||||
out["running_intents"] = compactIntents(running, parentsOf, yieldsOf)
|
||||
out["recent_done_intents"] = compactIntents(recentDone, parentsOf, yieldsOf)
|
||||
out["frontier_open"] = len(fr)
|
||||
// findings (confirmed vulns) and facts (worker exploration results) are
|
||||
// now distinct node kinds. recent_facts surfaces fact summaries (esp.
|
||||
// negative results) so the planner sees them in one call; full content
|
||||
// via node_detail(id).
|
||||
vulnNodes, _ := t.ts.ListByKind(db.KindFinding, 1000)
|
||||
factNodes, _ := t.ts.ListByKind(db.KindFact, 1000) // newest first
|
||||
out["findings"] = len(vulnNodes) // 确认漏洞数(目标判定看它)
|
||||
out["facts"] = len(factNodes) // 探索事实/结论数(含否定结论)
|
||||
recentFacts := make([]map[string]any, 0, 20)
|
||||
for _, n := range factNodes {
|
||||
if len(recentFacts) >= 20 {
|
||||
break
|
||||
recentFindings := make([]map[string]any, 0, len(findings))
|
||||
for _, finding := range findings {
|
||||
entry := inheritedMap(compactFinding(finding), source.Task.TaskID)
|
||||
entry["summary"] = budget.take(entry["summary"], 400)
|
||||
recentFindings = append(recentFindings, entry)
|
||||
}
|
||||
item["recent_findings"] = recentFindings
|
||||
recentFacts := make([]map[string]any, 0, len(facts))
|
||||
for _, fact := range facts {
|
||||
m := inheritedMap(compactNode(fact), source.Task.TaskID)
|
||||
m["summary"] = budget.take(m["summary"], 400)
|
||||
if from := factFrom[fact.ID]; from > 0 && terminalIntent[from] {
|
||||
m["from_intent"] = from
|
||||
}
|
||||
m := compactNode(n)
|
||||
if from := factFrom[n.ID]; from > 0 {
|
||||
m["from_intent"] = from // 本事实由哪个意图产生
|
||||
}
|
||||
// confidence 带进概览:让规划者一眼看出哪条结论只是 inferred(尤其否定结论
|
||||
// 别当铁案);evidence 较长,留给 node_detail(id)。
|
||||
var fp map[string]any
|
||||
if json.Unmarshal(n.Payload, &fp) == nil {
|
||||
if c, ok := fp["confidence"].(string); ok && c != "" {
|
||||
m["confidence"] = c
|
||||
var payload map[string]any
|
||||
if json.Unmarshal(fact.Payload, &payload) == nil {
|
||||
if confidence, ok := payload["confidence"].(string); ok && confidence != "" {
|
||||
m["confidence"] = confidence
|
||||
}
|
||||
}
|
||||
recentFacts = append(recentFacts, m)
|
||||
}
|
||||
out["recent_facts"] = recentFacts // 最近事实的 {id, summary, from_intent, confidence?},详情/证据用 node_detail(id)
|
||||
// the original task (root) so the planner always has it, not just the
|
||||
// decomposed goals.
|
||||
if description, goal, err := t.ts.Root(); err == nil {
|
||||
out["task"] = map[string]any{"description": description, "goal": goal}
|
||||
item["recent_facts"] = recentFacts
|
||||
|
||||
recentDone := recentTerminalIntents(ts, relatedOverviewMaxIntentsPerTask)
|
||||
for _, intent := range recentDone {
|
||||
intent.Inherited = true
|
||||
intent.SourceTaskID = source.Task.TaskID
|
||||
}
|
||||
// coverage:粗略的资产测试覆盖度参考——范围(task_scope)内的资产里,被 fact 碰过的
|
||||
// 占比 + by_type(按类型的 总数/已测)。要看未测的具体资产由 agent 按需调 list_untested_assets 自行判断。仅任务上下文有。
|
||||
if t.as != nil && t.ts != nil && t.taskID > 0 {
|
||||
if cov, err := t.as.TaskCoverage(t.taskID, t.ts.ID()); err == nil {
|
||||
m := map[string]any{
|
||||
"denominator": cov.Denominator,
|
||||
"tested": cov.Tested,
|
||||
"by_type": cov.ByType,
|
||||
"note": "coverage资产测试覆盖度(包括接口等各种相关资产),粗略估计、仅供参考:容器型资产/大量枚举会让它偏低,勿据此认为已测完;scope 是当前任务测试范围的根资产(root_domain/subdomain/ip/cidr/company 原始行),即覆盖度分母的边界、本任务授权/圈定的目标本身;可用 add_task_scope 增补范围、list_untested_assets 看未测的具体资产【通常不调用list_untested_assets,按照任务推进即可】;",
|
||||
intentResults := compactIntents(recentDone, parentsOf, yieldsOf)
|
||||
for i, intent := range recentDone {
|
||||
intentResults[i]["summary"] = budget.take(intentResults[i]["summary"], 400)
|
||||
acts, _, err := ts.ActivityPageForTerminalIntent(intent.ID, 0, 20)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var resultSummary, textFallback string
|
||||
for _, activity := range acts {
|
||||
switch activity.Kind {
|
||||
case "result":
|
||||
resultSummary = activity.Summary
|
||||
case "text":
|
||||
textFallback = activity.Summary
|
||||
}
|
||||
if cov.ScopeRows == 0 {
|
||||
m["pct"] = nil
|
||||
m["status"] = "范围未锚定"
|
||||
} else {
|
||||
m["pct"] = cov.Pct
|
||||
}
|
||||
// scope:当前测试范围的根资产(task_scope 原始行),让 agent 知道这个任务到底
|
||||
// 圈定了哪些目标(不是全部测试资产,而是范围边界本身)。
|
||||
if rows, err := t.as.ListTaskScope(t.taskID); err == nil && len(rows) > 0 {
|
||||
scope := make([]map[string]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
e := map[string]any{"kind": r.Kind, "source": r.Source}
|
||||
switch {
|
||||
case r.Domain != "":
|
||||
e["value"] = r.Domain
|
||||
case r.Net != "":
|
||||
e["value"] = r.Net
|
||||
case r.CompanyID != nil:
|
||||
e["company_id"] = *r.CompanyID
|
||||
}
|
||||
scope = append(scope, e)
|
||||
}
|
||||
m["scope"] = scope
|
||||
}
|
||||
out["coverage"] = m
|
||||
}
|
||||
if resultSummary == "" {
|
||||
resultSummary = textFallback
|
||||
}
|
||||
if resultSummary != "" {
|
||||
intentResults[i]["result_summary"] = budget.take(resultSummary, 800)
|
||||
}
|
||||
}
|
||||
return out
|
||||
item["recent_intent_results"] = intentResults
|
||||
if statsErr == nil {
|
||||
item["node_stats"] = stats
|
||||
}
|
||||
|
||||
if t.as != nil {
|
||||
if scopeRows, err := t.as.ListTaskScope(source.Task.TaskID); err == nil && len(scopeRows) > 0 {
|
||||
scopeCount := len(scopeRows)
|
||||
if len(scopeRows) > relatedOverviewMaxScopePerSource {
|
||||
scopeRows = scopeRows[:relatedOverviewMaxScopePerSource]
|
||||
budget.truncated = true
|
||||
}
|
||||
scope := make([]map[string]any, 0, len(scopeRows))
|
||||
for _, row := range scopeRows {
|
||||
entry := map[string]any{"kind": row.Kind, "source": budget.take(row.Source, 300)}
|
||||
switch {
|
||||
case row.Domain != "":
|
||||
entry["value"] = budget.take(row.Domain, 400)
|
||||
case row.Net != "":
|
||||
entry["value"] = budget.take(row.Net, 400)
|
||||
case row.CompanyID != nil:
|
||||
entry["company_id"] = *row.CompanyID
|
||||
}
|
||||
scope = append(scope, entry)
|
||||
}
|
||||
item["asset_scope"] = scope
|
||||
item["asset_scope_count"] = scopeCount
|
||||
}
|
||||
if coverage, err := t.as.TaskCoverage(source.Task.TaskID, source.Task.ExplorationID); err == nil {
|
||||
item["asset_coverage"] = map[string]any{
|
||||
"denominator": coverage.Denominator,
|
||||
"tested": coverage.Tested,
|
||||
"pct": coverage.Pct,
|
||||
"by_type": coverage.ByType,
|
||||
}
|
||||
}
|
||||
}
|
||||
if budget.truncated {
|
||||
item["summary_truncated"] = true
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func inheritedIntentSummaryState(state string) bool {
|
||||
switch state {
|
||||
case "done", "blocked", "exhausted", "stopped":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,7 +709,11 @@ func (t *ToolSet) graphOverviewData() map[string]any {
|
||||
func compactNode(n *db.Node) map[string]any {
|
||||
var p map[string]any
|
||||
_ = json.Unmarshal(n.Payload, &p)
|
||||
return map[string]any{"id": n.ID, "state": n.State, "summary": p["summary"]}
|
||||
m := map[string]any{"id": n.ID, "state": n.State, "summary": p["summary"]}
|
||||
if n.Inherited {
|
||||
inheritedMap(m, n.SourceTaskID)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// compactFinding is compactNode plus the vuln-specific vulnclass/severity.
|
||||
@@ -407,6 +721,9 @@ func compactFinding(n *db.Node) map[string]any {
|
||||
var p map[string]any
|
||||
_ = json.Unmarshal(n.Payload, &p)
|
||||
m := map[string]any{"id": n.ID, "state": n.State, "summary": p["summary"]}
|
||||
if n.Inherited {
|
||||
inheritedMap(m, n.SourceTaskID)
|
||||
}
|
||||
if vc, ok := p["vulnclass"]; ok && vc != nil && vc != "" {
|
||||
m["vulnclass"] = vc
|
||||
}
|
||||
@@ -417,16 +734,23 @@ func compactFinding(n *db.Node) map[string]any {
|
||||
}
|
||||
|
||||
func (t *ToolSet) listFindings() actool.CoreTool {
|
||||
return readTool("list_findings", "列【确认漏洞】(紧凑:id+task_id+intent_id+vulnclass+severity+摘要+状态)。task_id=该漏洞所属任务, intent_id=产生它的意图。这里只有漏洞,不含普通探索事实(那是 list_facts)。详情用 node_detail(id)。",
|
||||
return readTool("list_findings", "列本任务及直接关联任务的【确认漏洞】(紧凑:id+task_id+intent_id+vulnclass+severity+摘要+状态)。关联任务条目带 source_task_id/inherited=true 且只读。这里只含漏洞;普通探索事实用 list_facts,详情用 node_detail(id)。",
|
||||
obj(map[string]any{}),
|
||||
func(context.Context, json.RawMessage) (actool.Result, error) {
|
||||
f, _ := t.ts.ListByKind(db.KindFinding, 500)
|
||||
intentOf, _ := t.ts.FindingIntents() // finding id -> 产生它的 intent id
|
||||
taskID := t.ts.ID() // exploration id = 任务 id(本 store 内所有 finding 同属)
|
||||
f, _ := t.ts.ListByKindWithSources(db.KindFinding, 500)
|
||||
intentOf, _ := t.ts.FindingIntentsWithSources() // finding id -> 产生它的 intent id
|
||||
taskID := t.taskID
|
||||
if taskID <= 0 {
|
||||
taskID, _ = t.ts.TaskID()
|
||||
}
|
||||
out := make([]map[string]any, 0, len(f))
|
||||
for _, n := range f {
|
||||
m := compactFinding(n)
|
||||
m["task_id"] = taskID
|
||||
if n.Inherited {
|
||||
m["task_id"] = n.SourceTaskID
|
||||
} else {
|
||||
m["task_id"] = taskID
|
||||
}
|
||||
if iid, ok := intentOf[n.ID]; ok {
|
||||
m["intent_id"] = iid
|
||||
}
|
||||
@@ -437,10 +761,10 @@ func (t *ToolSet) listFindings() actool.CoreTool {
|
||||
}
|
||||
|
||||
func (t *ToolSet) listFacts() actool.CoreTool {
|
||||
return readTool("list_facts", "列【探索事实/结论】(紧凑:id+摘要+状态),即 worker 按意图探出的结论(含'端口关闭/不可注入'等否定结论)。详情用 node_detail(id)。漏洞看 list_findings。",
|
||||
return readTool("list_facts", "列本任务及直接关联任务的【探索事实/结论】(紧凑:id+摘要+状态)。关联任务条目带 source_task_id/inherited=true 且只读。详情用 node_detail(id),漏洞看 list_findings。",
|
||||
obj(map[string]any{}),
|
||||
func(context.Context, json.RawMessage) (actool.Result, error) {
|
||||
f, _ := t.ts.ListByKind(db.KindFact, 500)
|
||||
f, _ := t.ts.ListByKindWithSources(db.KindFact, 500)
|
||||
out := make([]map[string]any, 0, len(f))
|
||||
for _, n := range f {
|
||||
out = append(out, compactNode(n))
|
||||
@@ -450,7 +774,7 @@ func (t *ToolSet) listFacts() actool.CoreTool {
|
||||
}
|
||||
|
||||
func (t *ToolSet) nodeDetail() actool.CoreTool {
|
||||
return readTool("node_detail", "按 id 取某个【探索图节点】的完整内容(事实/发现/意图/目标:摘要 + 详情/证据/PoC)。仅限探索图节点 id(来自 list_facts / list_findings / graph_overview 的 recent_facts 返回的 id)。查【资产】请用 list_assets / asset_neighbors——资产 id 不是探索节点 id,不要传进来。",
|
||||
return readTool("node_detail", "按 id 取本任务或直接关联任务的【探索图节点】完整内容。继承节点带 source_task_id/inherited=true 且只读。仅限 list_facts/list_findings/graph_overview 返回的探索节点 id;资产请用 list_assets/asset_neighbors。",
|
||||
obj(map[string]any{"id": idp("探索图节点 id(非资产 id)")}, "id"),
|
||||
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
var a struct {
|
||||
@@ -461,7 +785,7 @@ func (t *ToolSet) nodeDetail() actool.CoreTool {
|
||||
if id <= 0 {
|
||||
return actool.Errorf("id 必填"), nil
|
||||
}
|
||||
n, err := t.ts.GetNode(id)
|
||||
n, err := t.ts.GetNodeWithSources(id)
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
@@ -493,9 +817,9 @@ func (t *ToolSet) addOneIntent(it intentItem) (int64, error) {
|
||||
// 先校验锚点(建节点前,避免坏锚点留下孤儿意图)。
|
||||
parents := pidList(it.ParentIDs)
|
||||
for _, pidv := range parents {
|
||||
n, err := t.ts.GetNode(pidv)
|
||||
n, err := t.ts.GetNodeWithSources(pidv)
|
||||
if err != nil || n == nil {
|
||||
return 0, fmt.Errorf("parent_id %d 不存在:parent_ids 必须是已存在的【事实(fact)/发现(finding)】节点 id;顶层全新方向请留空 parent_ids", pidv)
|
||||
return 0, fmt.Errorf("parent_id %d 不存在于本任务或直接关联任务:parent_ids 必须是已存在的【事实(fact)/发现(finding)】节点 id;顶层全新方向请留空 parent_ids", pidv)
|
||||
}
|
||||
if n.Kind != db.KindFact && n.Kind != db.KindFinding {
|
||||
return 0, fmt.Errorf("parent_id %d 是 %q 节点,不能作为意图锚点:意图只能锚在已确认的【事实(fact)/发现(finding)】上,不能挂在意图/目标/提示上;顶层全新方向请留空 parent_ids", pidv, n.Kind)
|
||||
@@ -604,6 +928,14 @@ func (t *ToolSet) proveGoal() actool.CoreTool {
|
||||
if goal == 0 || ev == 0 {
|
||||
return actool.Errorf("goal_id 和 evidence_id 必填"), nil
|
||||
}
|
||||
goalNode, err := t.ts.GetNode(goal)
|
||||
if err != nil || goalNode == nil || goalNode.Kind != db.KindGoal {
|
||||
return actool.Errorf("goal_id 必须是本任务的目标节点(关联任务目标只读)"), nil
|
||||
}
|
||||
evidenceNode, err := t.ts.GetNodeWithSources(ev)
|
||||
if err != nil || evidenceNode == nil || (evidenceNode.Kind != db.KindFact && evidenceNode.Kind != db.KindFinding) {
|
||||
return actool.Errorf("evidence_id 必须是本任务或直接关联任务的事实/漏洞节点"), nil
|
||||
}
|
||||
_ = t.ts.Link(ev, db.RelProves, goal)
|
||||
_ = t.ts.SetNodeState(goal, "met")
|
||||
// 每标记一个目标 met,就检查本任务是否【所有目标】都已 met;若是,自动判定
|
||||
@@ -668,12 +1000,19 @@ func (t *ToolSet) addFinding() actool.CoreTool {
|
||||
}
|
||||
var id int64
|
||||
if t.ts != nil {
|
||||
intent := pid(a.IntentID)
|
||||
if intent > 0 {
|
||||
node, err := t.ts.GetNode(intent)
|
||||
if err != nil || node == nil || node.Kind != db.KindIntent {
|
||||
return actool.Errorf("intent_id 必须是本任务的意图(关联任务意图只读)"), nil
|
||||
}
|
||||
}
|
||||
var err error
|
||||
id, err = t.ts.AddNode(db.KindFinding, payload, 9, "confirmed", t.worker, anchors)
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
if intent := pid(a.IntentID); intent > 0 {
|
||||
if intent > 0 {
|
||||
_ = t.ts.Link(intent, db.RelYields, id) // chain: intent -> finding
|
||||
}
|
||||
_, _ = t.ts.AddStandaloneFinding(t.taskID, id, a.VulnClass, a.Name, a.Severity, a.Summary, a.Evidence, t.worker, anchors)
|
||||
@@ -728,15 +1067,21 @@ func (t *ToolSet) recordOneFact(it factItem, defaultIntent int64) (int64, error)
|
||||
if c := strings.TrimSpace(it.Confidence); c != "" {
|
||||
payload["confidence"] = c
|
||||
}
|
||||
intent := pid(it.IntentID)
|
||||
if intent <= 0 {
|
||||
intent = defaultIntent
|
||||
}
|
||||
if intent > 0 {
|
||||
node, err := t.ts.GetNode(intent)
|
||||
if err != nil || node == nil || node.Kind != db.KindIntent {
|
||||
return 0, fmt.Errorf("intent_id 必须是本任务的意图(关联任务意图只读)")
|
||||
}
|
||||
}
|
||||
// a fact is its OWN node kind (distinct from a vuln finding).
|
||||
id, err := t.ts.AddNode(db.KindFact, payload, 5, "confirmed", t.worker, pidList(it.AssetIDs))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
intent := pid(it.IntentID)
|
||||
if intent <= 0 {
|
||||
intent = defaultIntent
|
||||
}
|
||||
if intent > 0 {
|
||||
_ = t.ts.Link(intent, db.RelYields, id) // chain: intent -> fact
|
||||
}
|
||||
@@ -984,6 +1329,10 @@ func (t *ToolSet) killWorkTool() actool.CoreTool {
|
||||
if id <= 0 {
|
||||
return actool.Errorf("intent_id 必填"), nil
|
||||
}
|
||||
node, err := t.ts.GetNode(id)
|
||||
if err != nil || node == nil || node.Kind != db.KindIntent {
|
||||
return actool.Errorf("intent_id 必须是本任务的意图(关联任务意图只读)"), nil
|
||||
}
|
||||
if err := t.killWork(id); err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
@@ -1014,6 +1363,10 @@ func (t *ToolSet) steerWorkTool() actool.CoreTool {
|
||||
if id <= 0 {
|
||||
return actool.Errorf("intent_id 必填"), nil
|
||||
}
|
||||
node, err := t.ts.GetNode(id)
|
||||
if err != nil || node == nil || node.Kind != db.KindIntent {
|
||||
return actool.Errorf("intent_id 必须是本任务的意图(关联任务意图只读)"), nil
|
||||
}
|
||||
if strings.TrimSpace(a.Message) == "" {
|
||||
return actool.Errorf("message 必填"), nil
|
||||
}
|
||||
@@ -1026,7 +1379,7 @@ func (t *ToolSet) steerWorkTool() actool.CoreTool {
|
||||
|
||||
// getWorkerOutput returns a work's final (or截至中止时的) conclusion text by intent id.
|
||||
func (t *ToolSet) getWorkerOutput() actool.CoreTool {
|
||||
return readTool("get_worker_output", "取某条意图(work)的最终输出结论。正常结束返回其总结;被终止(stopped)/异常的 work 返回其截至中止时的最后输出(terminated=true)。",
|
||||
return readTool("get_worker_output", "取本任务或直接关联任务某条意图(work)的最终输出结论。关联任务结果带 source_task_id/inherited=true 且只读。正常结束返回其总结;被终止(stopped)/异常的 work 返回其截至中止时的最后输出(terminated=true)。",
|
||||
obj(map[string]any{"intent_id": idp("意图 id(= work 句柄)")}, "intent_id"),
|
||||
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
var a struct {
|
||||
@@ -1037,7 +1390,14 @@ func (t *ToolSet) getWorkerOutput() actool.CoreTool {
|
||||
if id <= 0 {
|
||||
return actool.Errorf("intent_id 必填"), nil
|
||||
}
|
||||
acts, _, err := t.ts.ActivityList(&id, 0, 1000)
|
||||
intentNode, err := t.ts.GetNodeWithSources(id)
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
if intentNode == nil || intentNode.Kind != db.KindIntent {
|
||||
return actool.Errorf("intent_id 不属于本任务或其直接关联任务"), nil
|
||||
}
|
||||
acts, _, err := t.ts.ActivityListWithSources(id, 0, 1000)
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
@@ -1056,16 +1416,25 @@ func (t *ToolSet) getWorkerOutput() actool.CoreTool {
|
||||
pick, terminated = fallback, true
|
||||
}
|
||||
if pick == nil {
|
||||
if intentNode.Inherited {
|
||||
return jsonResult(inheritedMap(map[string]any{
|
||||
"intent_id": id, "final_text": "(该 work 尚无任何输出)", "terminated": true,
|
||||
}, intentNode.SourceTaskID))
|
||||
}
|
||||
return actool.Text("(该 work 尚无任何输出)"), nil
|
||||
}
|
||||
detail, _ := t.ts.ActivityDetail(pick.ID)
|
||||
detail, _ := t.ts.ActivityDetailWithSources(pick.ID)
|
||||
if detail == "" {
|
||||
detail = pick.Summary
|
||||
}
|
||||
return jsonResult(map[string]any{
|
||||
result := map[string]any{
|
||||
"intent_id": id, "worker_name": pick.Worker, "final_text": detail,
|
||||
"summary": pick.Summary, "is_error": pick.IsError, "terminated": terminated,
|
||||
})
|
||||
}
|
||||
if intentNode.Inherited {
|
||||
inheritedMap(result, intentNode.SourceTaskID)
|
||||
}
|
||||
return jsonResult(result)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1075,10 +1444,14 @@ func (t *ToolSet) getWorkerOutput() actool.CoreTool {
|
||||
func traceSteps(acts []db.Activity) []map[string]any {
|
||||
steps := make([]map[string]any, 0, len(acts))
|
||||
for i := range acts {
|
||||
steps = append(steps, map[string]any{
|
||||
step := map[string]any{
|
||||
"step_id": acts[i].ID, "kind": acts[i].Kind, "tool": acts[i].Tool,
|
||||
"is_error": acts[i].IsError, "summary": firstLine(acts[i].Summary, 100),
|
||||
})
|
||||
}
|
||||
if acts[i].Inherited {
|
||||
inheritedMap(step, acts[i].SourceTaskID)
|
||||
}
|
||||
steps = append(steps, step)
|
||||
}
|
||||
return steps
|
||||
}
|
||||
@@ -1092,7 +1465,7 @@ func (t *ToolSet) getWorkerTrace() actool.CoreTool {
|
||||
"① 只传 intent_id → 返回该 work 每一步的摘要流(summary≤100字,含 step_id;只是动作轮廓,不含完整输出);\n"+
|
||||
"② intent_id + q → 只返回命中关键字的步骤摘要(在摘要和完整输出里都搜;仍只给 summary,要看内容用③);\n"+
|
||||
"③ intent_id + step_ids(最多5个)→ 返回这些步骤的完整内容(detail)。\n"+
|
||||
"典型流程:先①/②定位可疑步骤的 step_id,再用③取其完整输出。不含思考(thinking)步骤。",
|
||||
"典型流程:先①/②定位可疑步骤的 step_id,再用③取其完整输出。不含思考(thinking)步骤。支持直接关联任务的历史 trace;其结果带 source_task_id/inherited=true 且只读。",
|
||||
obj(map[string]any{
|
||||
"intent_id": idp("意图 id(= work 句柄)"),
|
||||
"q": str("关键字:只返回摘要/完整输出命中它的步骤(可选;与 step_ids 互斥)"),
|
||||
@@ -1111,6 +1484,13 @@ func (t *ToolSet) getWorkerTrace() actool.CoreTool {
|
||||
if id <= 0 {
|
||||
return actool.Errorf("intent_id 必填"), nil
|
||||
}
|
||||
intentNode, nodeErr := t.ts.GetNodeWithSources(id)
|
||||
if nodeErr != nil {
|
||||
return actool.Errorf(nodeErr.Error()), nil
|
||||
}
|
||||
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.
|
||||
if len(a.StepIDs) > 0 {
|
||||
var ids []int64
|
||||
@@ -1122,31 +1502,47 @@ func (t *ToolSet) getWorkerTrace() actool.CoreTool {
|
||||
if len(ids) > 5 {
|
||||
return actool.Errorf("step_ids 一次最多 5 个(先用不带 step_ids 的调用定位,再分批取)"), nil
|
||||
}
|
||||
acts, err := t.ts.ActivityByIDs(ids)
|
||||
acts, err := t.ts.ActivityByIDsWithSources(ids)
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
steps := make([]map[string]any, 0, len(acts))
|
||||
for i := range acts {
|
||||
steps = append(steps, map[string]any{
|
||||
if acts[i].NodeID == nil || *acts[i].NodeID != id || acts[i].Inherited != intentNode.Inherited ||
|
||||
(acts[i].Inherited && acts[i].SourceTaskID != intentNode.SourceTaskID) {
|
||||
continue
|
||||
}
|
||||
step := map[string]any{
|
||||
"step_id": acts[i].ID, "kind": acts[i].Kind, "tool": acts[i].Tool,
|
||||
"is_error": acts[i].IsError, "detail": acts[i].Detail,
|
||||
})
|
||||
}
|
||||
if acts[i].Inherited {
|
||||
inheritedMap(step, acts[i].SourceTaskID)
|
||||
}
|
||||
steps = append(steps, step)
|
||||
}
|
||||
return jsonResult(map[string]any{"intent_id": id, "steps": steps})
|
||||
result := map[string]any{"intent_id": id, "steps": steps}
|
||||
if intentNode.Inherited {
|
||||
inheritedMap(result, intentNode.SourceTaskID)
|
||||
}
|
||||
return jsonResult(result)
|
||||
}
|
||||
// ①/② summary stream, optionally keyword-filtered; 100-char summaries.
|
||||
var acts []db.Activity
|
||||
var err error
|
||||
if strings.TrimSpace(a.Q) != "" {
|
||||
acts, err = t.ts.ActivityTraceSearch(&id, a.Q, a.Limit)
|
||||
acts, err = t.ts.ActivityTraceSearchWithSources(id, a.Q, a.Limit)
|
||||
} else {
|
||||
acts, err = t.ts.ActivityTrace(id, a.Limit)
|
||||
acts, err = t.ts.ActivityTraceWithSources(id, a.Limit)
|
||||
}
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
return jsonResult(map[string]any{"intent_id": id, "steps": traceSteps(acts)})
|
||||
result := map[string]any{"intent_id": id, "steps": traceSteps(acts)}
|
||||
if intentNode.Inherited {
|
||||
inheritedMap(result, intentNode.SourceTaskID)
|
||||
}
|
||||
return jsonResult(result)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1172,7 +1568,7 @@ func (t *ToolSet) searchAllWorkerTraces() actool.CoreTool {
|
||||
return actool.Errorf("q 必填"), nil
|
||||
}
|
||||
// 排除调用者自身这条意图的步骤(worker 的自有 trace 已在其上下文里)。
|
||||
acts, err := t.ts.ActivityTraceSearchExcluding(t.ownerNode, a.Q, a.Limit)
|
||||
acts, err := t.ts.ActivityTraceSearchAllWithSources(t.ownerNode, a.Q, a.Limit)
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
@@ -1182,11 +1578,15 @@ func (t *ToolSet) searchAllWorkerTraces() actool.CoreTool {
|
||||
if acts[i].NodeID != nil {
|
||||
intent = *acts[i].NodeID
|
||||
}
|
||||
hits = append(hits, map[string]any{
|
||||
hit := map[string]any{
|
||||
"intent_id": intent, "step_id": acts[i].ID, "worker": acts[i].Worker,
|
||||
"kind": acts[i].Kind, "tool": acts[i].Tool, "is_error": acts[i].IsError,
|
||||
"summary": firstLine(acts[i].Summary, 100),
|
||||
})
|
||||
}
|
||||
if acts[i].Inherited {
|
||||
inheritedMap(hit, acts[i].SourceTaskID)
|
||||
}
|
||||
hits = append(hits, hit)
|
||||
}
|
||||
return jsonResult(map[string]any{"query": a.Q, "hits": hits})
|
||||
})
|
||||
@@ -1217,13 +1617,16 @@ func (t *ToolSet) listWorkerTraces() actool.CoreTool {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
all, err := t.ts.ListByKind(db.KindIntent, 500)
|
||||
all, err := t.ts.ListByKindWithSources(db.KindIntent, 500)
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
q := strings.ToLower(strings.TrimSpace(a.Q))
|
||||
out := make([]map[string]any, 0, limit)
|
||||
for _, n := range all {
|
||||
if n.Inherited && n.State == "running" {
|
||||
continue
|
||||
}
|
||||
switch n.State {
|
||||
case "running", "done", "exhausted", "blocked", "stopped": // has run → has a process
|
||||
default:
|
||||
@@ -1235,7 +1638,11 @@ func (t *ToolSet) listWorkerTraces() actool.CoreTool {
|
||||
if q != "" && !strings.Contains(strings.ToLower(summary), q) {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{"intent_id": n.ID, "summary": summary, "state": n.State})
|
||||
item := map[string]any{"intent_id": n.ID, "summary": summary, "state": n.State}
|
||||
if n.Inherited {
|
||||
inheritedMap(item, n.SourceTaskID)
|
||||
}
|
||||
out = append(out, item)
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -398,13 +398,12 @@ func (t *ToolSet) addTaskScope() actool.CoreTool {
|
||||
)
|
||||
}
|
||||
|
||||
// listUntestedAssets lets the plan agent pull the CURRENT TASK's in-scope, not-yet-
|
||||
// tested assets on demand (filter by type, paginated) — so it can decide what to
|
||||
// test next itself, instead of coverage pushing a backlog list into every prompt.
|
||||
// listUntestedAssets lets the plan agent pull the current + directly inherited
|
||||
// scope's not-yet-tested assets on demand (filter by type, paginated).
|
||||
func (t *ToolSet) listUntestedAssets() actool.CoreTool {
|
||||
return readTool(
|
||||
"list_untested_assets",
|
||||
"查询【本任务】范围内、还没被测过的资产(供你自己判断要不要补测,不代替你决策)。\n"+
|
||||
"查询【本任务及直接关联任务】范围内、还没被事实锚点覆盖的资产(关联范围只读,供你自己判断要不要补测,不代替你决策)。\n"+
|
||||
"可选按资产类型过滤:root_domain/subdomain/service/app/endpoint/ip。\n"+
|
||||
"分页:page 从 1 起、page_size 默认 10。返回 {assets:[{id,type,label}], total, page, page_size}。仅任务上下文可用。",
|
||||
obj(map[string]any{
|
||||
@@ -432,7 +431,7 @@ func (t *ToolSet) listUntestedAssets() actool.CoreTool {
|
||||
a.PageSize = 10
|
||||
}
|
||||
offset := (a.Page - 1) * a.PageSize
|
||||
assets, total, err := t.as.ListUntestedAssets(t.taskID, t.ts.ID(), strings.TrimSpace(a.Type), a.PageSize, offset)
|
||||
assets, total, err := t.as.ListUntestedAssetsWithSources(t.taskID, strings.TrimSpace(a.Type), a.PageSize, offset)
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
func TestOverviewTextBudgetIsFairAndUTF8Safe(t *testing.T) {
|
||||
if got := relatedOverviewBudgetForSources(1); got != relatedOverviewMaxTextPerSource {
|
||||
t.Fatalf("single source budget=%d want=%d", got, relatedOverviewMaxTextPerSource)
|
||||
}
|
||||
perSource := relatedOverviewBudgetForSources(db.MaxTaskSourceCount)
|
||||
if perSource*db.MaxTaskSourceCount > relatedOverviewTotalTextRunes {
|
||||
t.Fatalf("aggregate budget exceeded: per_source=%d", perSource)
|
||||
}
|
||||
|
||||
budget := overviewTextBudget{remaining: 5}
|
||||
got := budget.take(strings.Repeat("中", 10), 20)
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatalf("budget truncation produced invalid UTF-8: %q", got)
|
||||
}
|
||||
if utf8.RuneCountInString(got) != 5 || !budget.truncated || budget.remaining != 0 {
|
||||
t.Fatalf("unexpected truncation: got=%q budget=%+v", got, budget)
|
||||
}
|
||||
if tail := budget.take("more", 20); tail != "" {
|
||||
t.Fatalf("exhausted budget returned more text: %q", tail)
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -49,6 +49,7 @@ type Worker struct {
|
||||
mem *memory.Store // cross-engagement tradecraft memory (G4)
|
||||
tx *transcript.Store // raw LLM conversation persistence (nil = off)
|
||||
window int // context window in tokens (for compaction)
|
||||
windowFn func() int // optional dynamic task-chain minimum
|
||||
maxTurns int // max agent turns per run (0 = unlimited)
|
||||
// runTimeout is the wall-clock budget for the main exploration of one intent
|
||||
// (0 = unlimited). When it fires, the run is cut and a settlement round is
|
||||
@@ -84,6 +85,15 @@ func NewWorker(prov llm.Provider, model, workDir string, tx *transcript.Store, w
|
||||
return &Worker{prov: prov, model: model, workDir: workDir, tx: tx, window: window, maxTurns: maxTurns, extraTools: extra}
|
||||
}
|
||||
|
||||
func (w *Worker) SetCompactionWindowResolver(fn func() int) { w.windowFn = fn }
|
||||
|
||||
func (w *Worker) compactionWindow() int {
|
||||
if w.windowFn != nil {
|
||||
return w.windowFn()
|
||||
}
|
||||
return w.window
|
||||
}
|
||||
|
||||
// SetProxy configures the recording proxy address that workers route target
|
||||
// traffic through, plus the CA cert path WebFetch trusts to verify HTTPS through
|
||||
// that MITM proxy. Empty addr disables the hint.
|
||||
@@ -306,8 +316,8 @@ func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db.
|
||||
// large tool output spills to cmd-output/ with a head + pointer (SDK tool.Capture);
|
||||
// full output preserved on disk. 截断上限用 SDK 默认(30000 字符)。
|
||||
ToolOutputDir: cmdOutDir(runDir),
|
||||
Compaction: compactionConfig(w.window), // long tool-heavy runs stay within the window
|
||||
Todos: actool.NewTodoStore(), // 会话级临时待办(TodoWrite),纯规划用,退出即丢
|
||||
Compaction: compactionConfig(w.compactionWindow()), // long tool-heavy runs stay within the window
|
||||
Todos: actool.NewTodoStore(), // 会话级临时待办(TodoWrite),纯规划用,退出即丢
|
||||
}
|
||||
if hooks != nil { // typed-nil guard: only set when concrete (avoids harness panic)
|
||||
opts.Hooks = hooks
|
||||
|
||||
Reference in new issue
Block a user