feat(tasks): add runtime controls and inherited context

This commit is contained in:
pphua committed 2026-08-24 19:29:00 +08:00
1 parent 18926c129f
commit 4392b4fee1
50 files changed
+8291 -663

No files matched your search

+274
View File
@@ -0,0 +1,274 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"testing"
"time"
"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 TestGraphOverviewExpandsAssociatedCompanyScope(t *testing.T) {
d := testDB(t)
defer d.Close()
companies := d.Companies()
companyID, _, err := companies.UpsertCompany(fmt.Sprintf("overview-scope-%d", time.Now().UnixNano()), "")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = companies.DeleteCompany(companyID) })
domain := fmt.Sprintf("overview-scope-%d.invalid", companyID)
ip := fmt.Sprintf("2001:db8:%x::42", companyID%0xffff)
cidr := fmt.Sprintf("2001:db8:%x:1::/64", companyID%0xffff)
icp := fmt.Sprintf("京 ICP 备 %d 号", companyID)
keyword := fmt.Sprintf("Scope Company %d", companyID)
inputs := []db.ScopeInput{
{Kind: "domain", Value: domain},
{Kind: "ip", Value: ip},
{Kind: "cidr", Value: cidr},
{Kind: "icp", Value: icp},
{Kind: "keyword", Value: keyword},
}
added, skipped, invalid, scopeErrors := companies.AddScopeInputs(companyID, inputs, "task context test")
if added != len(inputs) || skipped != 0 || invalid != 0 || len(scopeErrors) != 0 {
t.Fatalf("add company scope: added=%d skipped=%d invalid=%d errors=%v", added, skipped, invalid, scopeErrors)
}
task, err := d.CreateTaskWithOptions("company context", "read configured scope", db.TaskCreateOptions{
CompanyIDs: []int64{companyID},
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
tools := NewToolSet(d.Exploration(task.ExplorationID), "planner")
tools.SetTaskID(task.ID)
tools.SetAssetStore(d.Assets(), companies)
overview := tools.graphOverviewData()
coverage, ok := overview["coverage"].(map[string]any)
if !ok {
t.Fatalf("coverage missing: %#v", overview["coverage"])
}
scopeRows, ok := coverage["scope"].([]map[string]any)
if !ok || len(scopeRows) != 1 {
t.Fatalf("task scope missing: %#v", coverage["scope"])
}
companyScope, ok := scopeRows[0]["company_scope"].([]map[string]any)
if !ok || len(companyScope) != len(inputs) {
t.Fatalf("company scope not expanded: %#v", scopeRows[0])
}
kinds := make(map[string]string, len(companyScope))
for _, rule := range companyScope {
kinds[fmt.Sprint(rule["kind"])] = fmt.Sprint(rule["value"])
}
for _, input := range inputs {
if kinds[input.Kind] != input.Value {
t.Errorf("scope %s=%q want %q", input.Kind, kinds[input.Kind], input.Value)
}
}
keywords, ok := scopeRows[0]["company_keywords"].([]string)
if !ok || len(keywords) != 1 || keywords[0] != keyword {
t.Fatalf("company keywords missing: %#v", scopeRows[0]["company_keywords"])
}
}
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])
}
recentFacts, ok := related[0]["recent_facts"].([]map[string]any)
foundSourceFact := false
for _, fact := range recentFacts {
if fact["id"] == sourceFact {
foundSourceFact = true
break
}
}
if !ok || !foundSourceFact {
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)
}
}
+90
View File
@@ -0,0 +1,90 @@
package agent
import (
"context"
"errors"
"fmt"
)
// 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
Short string
Text string
}
func (c *AbortCause) Error() string { return c.Text }
func cause(code, short, text string) *AbortCause {
return &AbortCause{Code: code, Short: short, Text: text}
}
// 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...)}
}
var (
// Task-level execution context.
AbortPausedByUser = cause("paused_by_user", "用户暂停了任务",
"用户通过任务控制接口(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 和主 Agent;本次运行结果不会再被使用")
AbortPausedOnReload = cause("paused_on_reload", "后端恢复了任务的暂停状态",
"后端启动时根据数据库中持久化的状态恢复了任务暂停。本次运行被取消;正常情况下恢复阶段没有正在运行的 Agent")
AbortGoalMet = cause("goal_met", "规划者判定任务目标已达成",
"规划者判定任务目标已达成并将任务置为 done,随后取消仍在运行的 Worker;这些意图会标记为 stopped,而不是失败")
AbortSettleDrainTimeout = cause("settle_drain_timeout", "任务超时收尾的等待时间已用尽",
"任务到达 timeout 后等待正在运行的 Worker 优雅收尾,但 90 秒 drain 宽限仍不足,因此执行硬取消;意图会标记为 exhausted,收尾阶段已经写入的事实和资产会保留")
// 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")
// 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 资源。这不是运行中断;若它出现在中断消息中,说明取消与收场事件发生了竞态")
// Process-level and per-run hard backstop.
AbortShutdown = cause("shutdown", "后端进程正在关闭",
"后端进程收到 SIGINT 或 SIGTERM,正在重启、更新或关闭。所有运行中的 Agent 会被取消;重启后残留的 running 意图会重置为 open 并重新执行")
AbortRunHardTimeout = cause("run_hard_timeout", "单次运行的硬超时兜底已触发",
"单次运行超过软墙钟预算及额外宽限,说明模型请求或某个工具长时间没有返回,导致正常的回合边界收尾无法执行。请重点检查中断前最后一个未返回的工具调用")
)
// 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
}
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
case errors.Is(c, context.Canceled):
return "canceled_no_cause", "取消方未附加具名原因",
"上游 context 被取消,但取消方没有通过 context.WithCancelCause 附加具名原因;请在 agent/cancelcause.go 登记原因并接入该取消点", true
default:
return "other", firstLine(c.Error(), 80), c.Error(), true
}
}
+33 -25
View File
@@ -3,11 +3,13 @@ package agent
import (
"context"
"strings"
"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
@@ -63,17 +65,20 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
tkind = kind
tbuf.WriteString(text)
}
lastTool := &runTrace{startedAt: time.Now()}
var lastUsage *llm.Usage
var finalText string
var rerr error
for ev, err := range s.Prompt(ctx, input) {
if err != nil {
flush()
if ctx.Err() != nil { // ctx cancelled = manual stop, not a failure
rec(db.Activity{Kind: "result", Summary: "(已手动停止本次运行)"})
if ctx.Err() != nil { // engine/user cancellation, not a provider failure
sum, detail := terminalText(ctx, &harness.Terminal{Reason: reason, Err: ctx.Err()}, lastTool)
rec(activityWithUsage(db.Activity{Kind: "result", Summary: firstLine(sum, 400), Detail: detail}, lastUsage))
return finalText, reason, ctx.Err()
}
rec(db.Activity{Kind: "result", IsError: true, Summary: "执行出错: " + err.Error(), Detail: err.Error()})
rec(activityWithUsage(db.Activity{Kind: "result", IsError: true, Summary: "执行出错: " + err.Error(), Detail: err.Error()}, lastUsage))
return finalText, reason, err
}
switch ev.Kind {
@@ -84,6 +89,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
flush()
toolNames[ev.ToolUse.ID] = ev.ToolUse.Name
in := string(ev.ToolUse.Input)
lastTool.start(ev.ToolUse.ID, ev.ToolUse.Name, in)
rec(db.Activity{Kind: "tool_use", Tool: ev.ToolUse.Name, ToolUseID: ev.ToolUse.ID,
Summary: ev.ToolUse.Name + " " + firstLine(in, 200), Detail: in})
case harness.KindToolResult:
@@ -92,6 +98,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
}
flush()
out := blocksText(ev.ToolResult.Content)
lastTool.done(ev.ToolResult.ToolUseID)
rec(db.Activity{Kind: "tool_result", Tool: toolNames[ev.ToolResult.ToolUseID], ToolUseID: ev.ToolResult.ToolUseID,
IsError: ev.ToolResult.IsError, Summary: firstLine(out, 200), Detail: out})
case harness.KindText:
@@ -105,6 +112,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
// buffered final-answer text must stay for the KindResult de-dup.
if ev.Usage != nil {
u := *ev.Usage
lastUsage = &u
rec(db.Activity{Kind: "usage",
InputTokens: &u.InputTokens, OutputTokens: &u.OutputTokens,
CacheReadTokens: &u.CacheReadTokens, CacheWriteTokens: &u.CacheWriteTokens})
@@ -120,25 +128,13 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
tkind = ""
}
flush() // flush any trailing thinking / non-final text
sum := ev.Terminal.Text
if sum == "" {
switch ev.Terminal.Reason {
case harness.ReasonAbortedTools, harness.ReasonAbortedStreaming:
// interruption (task paused / worker killed / backend restart),
// not a real completion — the intent is re-queued (open) or
// stopped, so the session icon reflects that, not this run.
sum = "(运行被中断:任务暂停 / 终止 / 重启所致;未完成)"
case harness.ReasonTimeout, harness.ReasonMaxTurns:
// budget hit → settlement ran (facts/assets were written back);
// only the trailing text summary is missing, not a failure.
sum = "(达运行预算上限,已收尾写回事实;本次无文字总结,终态: " + string(ev.Terminal.Reason) + ")"
default:
sum = "(无总结,终态: " + string(ev.Terminal.Reason) + ")"
}
sum, detail := ev.Terminal.Text, ev.Terminal.Text
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
rec(db.Activity{Kind: "result", IsError: ev.Terminal.Err != nil,
Summary: firstLine(sum, 400), Detail: sum,
Summary: firstLine(sum, 400), Detail: detail,
InputTokens: &u.InputTokens, OutputTokens: &u.OutputTokens,
CacheReadTokens: &u.CacheReadTokens, CacheWriteTokens: &u.CacheWriteTokens})
if ev.Terminal.Err != nil {
@@ -151,6 +147,18 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
return finalText, reason, rerr
}
func activityWithUsage(activity db.Activity, usage *llm.Usage) db.Activity {
if usage == nil {
return activity
}
u := *usage
activity.InputTokens = &u.InputTokens
activity.OutputTokens = &u.OutputTokens
activity.CacheReadTokens = &u.CacheReadTokens
activity.CacheWriteTokens = &u.CacheWriteTokens
return activity
}
// blocksText concatenates the text of a tool-result's content blocks.
func blocksText(blocks []llm.ContentBlock) string {
var b strings.Builder
@@ -165,14 +173,14 @@ func blocksText(blocks []llm.ContentBlock) string {
return b.String()
}
// firstLine returns a single-line, length-capped preview for the summary column.
// firstLine returns a single-line, rune-capped preview for the summary column.
func firstLine(s string, max int) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
if before, _, found := strings.Cut(s, "\n"); found {
s = before
}
if len(s) > max {
s = s[:max] + "…"
if utf8.RuneCountInString(s) > max {
s = string([]rune(s)[:max]) + "…"
}
return s
}
+1
View File
@@ -92,6 +92,7 @@ func (c *ChatAgent) Chat(ctx context.Context, agentKey, sessionID, message strin
// visible skills/MCP and lets the DB tools table filter/override. DefaultTools
// have no tools-table rows, so they always pass through.
base := actool.DefaultTools()
ctx = WithRunInfo(ctx, RunInfo{SessionID: sessionID})
tools, def, cleanup := AugmentTools(ctx, agentKey, base)
defer cleanup()
+18 -3
View File
@@ -7,6 +7,7 @@ import (
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/agentcore"
"github.com/Autumn-27/norma/llm"
acperm "github.com/Autumn-27/norma/permission"
actool "github.com/Autumn-27/norma/tool"
)
@@ -74,6 +75,11 @@ type GoalSpec struct {
// no provider is configured or the call yields nothing — the caller then falls
// back to a rule-based split so goal nodes always exist.
//
// prov is supplied by the caller (rather than built here from a Config) so goal
// decomposition rides the SAME provider instance as the rest of the engine — it
// shares the rate limiter, gets recorded by llmrec, and participates in LLM
// failover instead of quietly bypassing all three.
//
// desc is the task's free-text description (背景:靶标范围/flag 数量/交战说明等).
// It is fed alongside the goal so the decomposer no longer splits blind — the
// prompt still forbids inventing anything the two texts don't state.
@@ -88,9 +94,18 @@ type GoalSpec struct {
// straight into it (the same managed tool the main agent uses to add goals at
// 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, c Config, dataDir, goalText, desc string, as *db.AssetStore, ts *db.ExplorationStore, taskID int64, emit func(db.Activity)) []GoalSpec {
prov, err := c.NewProvider()
if err != nil {
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
}
// worker="goals" tags the goal nodes' provenance; ts/taskID let set_goals link
+13 -11
View File
@@ -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)
@@ -33,6 +34,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 }
@@ -77,13 +87,6 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t
tsx.SetAssetStore(as, as.Companies())
}
tsx.SetTaskID(taskID)
<<<<<<< Updated upstream
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()...)
=======
tsx.SetCoverageEnabled(as == nil || as.CoverageEnabled(taskID))
tsx.SetNotify(notify) // add_hint wakes this task's planner (debounced)
tsx.SetResumeTask(resume) // set_goals 新增目标 → 把已完成/暂停的任务拉回 running
@@ -93,7 +96,6 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t
// 资产覆盖度功能关闭时剔除 add_task_scope/list_untested_assets(不入 prompt)。
base := append(tsx.DropCoverageTools(tsx.MainAgentTools()), actool.DefaultTools()...)
ctx = WithRunInfo(ctx, RunInfo{TaskID: taskID, ExplorationID: explorationID(ts)})
>>>>>>> Stashed changes
tools, def, cleanup := AugmentTools(ctx, "mainagent", base)
tools = tsx.StripCoverageParams(tools) // 覆盖度关闭时隐藏 insert_assets 的 related 入参
defer cleanup()
@@ -121,9 +123,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),
}
+12 -13
View File
@@ -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)
@@ -45,6 +46,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 }
@@ -103,12 +113,6 @@ 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:
<<<<<<< Updated upstream
// "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 = 摘要).
@@ -118,7 +122,6 @@ func renderPlannerTodos(items []actool.Todo) string {
// "goal_edited" — the human edited a goal from 总览的目标管理 (OldGoal→NewGoal 文本).
// "cancelled" — the human deleted intent IntentID (Detail = 删除原因). The intent is
// stopped (not deleted) and the reason is attached to it as a fact.
>>>>>>> Stashed changes
type TriggerEvent struct {
Kind string
IntentID int64
@@ -291,13 +294,9 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
tsx.SetOwnerNode(origin) // planner-side anchors default to the task root (origin fact)
}
// 领域工具 + 基础默认工具集(Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash)
<<<<<<< Updated upstream
base := append(tsx.PlannerTools(), actool.DefaultTools()...)
=======
// 资产覆盖度功能关闭时剔除 add_task_scope/list_untested_assets(不入 prompt)。
base := append(tsx.DropCoverageTools(tsx.PlannerTools()), actool.DefaultTools()...)
ctx = WithRunInfo(ctx, RunInfo{TaskID: taskID, ExplorationID: explorationID(ts)})
>>>>>>> Stashed changes
tools, def, cleanup := AugmentTools(ctx, "planner", base)
defer cleanup()
// 关键态势(刚完成的意图 + 预取的完整图)改放【本轮 user 输入】(见下方 input),system
@@ -348,7 +347,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、
@@ -374,7 +373,7 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
runCtx := ctx
if maxDur > 0 {
var cancel context.CancelFunc
runCtx, cancel = context.WithTimeout(ctx, maxDur+settleHardGrace)
runCtx, cancel = context.WithTimeoutCause(ctx, maxDur+settleHardGrace, AbortRunHardTimeout)
defer cancel()
}
_, _, err = captureRun(runCtx, opts, input,
+47
View File
@@ -0,0 +1,47 @@
package agent
import (
"context"
"github.com/Autumn-27/artex/db"
)
// RunInfo identifies WHICH run a tool call belongs to. Tool assembly only receives
// (ctx, agentKey) — the task/exploration ids live in the caller's arguments, not the
// ctx — so anything wired at assembly time (currently the Skill ledger in
// server/assembly.go) has no way to attribute a call to a task. Each run attaches
// its own RunInfo before calling AugmentTools; the wiring closure reads it once and
// captures it, so per-run attribution stays correct without threading parameters
// through the tool layer. Same pattern as TaskClock (see taskclock.go).
//
// Zero value = attribution unknown; every consumer must treat it as optional.
type RunInfo struct {
TaskID int64 // task registry id; 0 for non-task runs (chat sessions)
ExplorationID int64 // exploration id; 0 when unknown
IntentID int64 // worker's intent node; 0 for planner/mainagent/chat
SessionID string // chat conversation id; empty for task runs
}
// explorationID reads a store's exploration id, tolerating a nil store (planner and
// worker runs can be driven without one in tests).
func explorationID(ts *db.ExplorationStore) int64 {
if ts == nil {
return 0
}
return ts.ID()
}
type runInfoKey struct{}
// WithRunInfo attaches run attribution to ctx.
func WithRunInfo(ctx context.Context, ri RunInfo) context.Context {
return context.WithValue(ctx, runInfoKey{}, ri)
}
// RunInfoFrom reads the RunInfo (zero value if none attached).
func RunInfoFrom(ctx context.Context) RunInfo {
if v, ok := ctx.Value(runInfoKey{}).(RunInfo); ok {
return v
}
return RunInfo{}
}
+161
View File
@@ -0,0 +1,161 @@
package agent
import (
"context"
"fmt"
"strings"
"time"
"github.com/Autumn-27/norma/harness"
)
// 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
name string
input string
at time.Time
pending bool
}
func (t *runTrace) start(id, name, input string) {
t.id, t.name, t.input, t.at, t.pending = id, name, input, time.Now(), true
}
func (t *runTrace) done(id string) {
if id == t.id {
t.pending = false
}
}
var reasonHint = map[harness.TerminalReason]string{
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 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
}
var sum string
if aborted {
_, short, _, ok := AbortReason(ctx)
if !ok {
short = "未能取得取消原因"
}
stage := "执行过程中"
switch reason {
case harness.ReasonAbortedStreaming:
stage = "模型输出阶段"
case harness.ReasonAbortedTools:
stage = "工具执行阶段"
}
sum = "(运行被中断:" + short + ";停在" + stage + progressSuffix(term, tr) + ",未完成)"
} else if reason == harness.ReasonMaxTurns || reason == harness.ReasonTimeout {
sum = "(达到运行预算上限(" + string(reason) + "),已收尾写回事实" + progressSuffix(term, tr) + ";本次无文字总结)"
} else {
hint := terminalReasonHint(reason)
sum = "(无文字总结,终态 " + terminalReasonLabel(reason) + ":" + firstLine(hint, 80) + ")"
}
var b strings.Builder
b.WriteString(sum)
b.WriteString("\n\n")
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 附加具名原因\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)
}
if !tr.startedAt.IsZero() {
fmt.Fprintf(&b, "- **本次运行耗时**: %s\n", roundDur(time.Since(tr.startedAt)))
}
if u := term.Usage; u.InputTokens+u.OutputTokens+u.CacheReadTokens+u.CacheWriteTokens > 0 {
fmt.Fprintf(&b, "- **累计 token**: 输入 %d / 输出 %d / 缓存读 %d / 缓存写 %d\n",
u.InputTokens, u.OutputTokens, u.CacheReadTokens, u.CacheWriteTokens)
}
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 {
fmt.Fprintf(&b, "- **中断前最后一个工具**: `%s`(已正常返回)\n", tr.name)
}
return sum, b.String()
}
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 {
parts = append(parts, fmt.Sprintf("%d 轮", term.Turns))
}
if !tr.startedAt.IsZero() {
parts = append(parts, roundDur(time.Since(tr.startedAt)))
}
if len(parts) == 0 {
return ""
}
return ",已运行 " + strings.Join(parts, " / ")
}
func roundDur(d time.Duration) string {
switch {
case d < time.Minute:
return d.Round(100 * time.Millisecond).String()
case d < time.Hour:
return d.Round(time.Second).String()
default:
return d.Round(time.Minute).String()
}
}
+169
View File
@@ -0,0 +1,169 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/Autumn-27/norma/harness"
"github.com/Autumn-27/norma/llm"
)
func TestReasonHintCoversEveryTerminalReason(t *testing.T) {
all := []harness.TerminalReason{
harness.ReasonCompleted, harness.ReasonBlockingLimit, harness.ReasonImageError,
harness.ReasonModelError, harness.ReasonAbortedStreaming, harness.ReasonAbortedTools,
harness.ReasonPromptTooLong, harness.ReasonStopHookPrevented, harness.ReasonHookStopped,
harness.ReasonMaxTurns, harness.ReasonTimeout,
}
for _, reason := range all {
if strings.TrimSpace(reasonHint[reason]) == "" {
t.Errorf("terminal reason %q has no explanation", reason)
}
}
}
func TestAbortCausePropagatesThroughRunContextChain(t *testing.T) {
type key struct{}
execCtx, cancelExec := context.WithCancelCause(context.Background())
workCtx, cancelWork := context.WithCancelCause(execCtx)
defer cancelWork(nil)
valued := context.WithValue(workCtx, key{}, "task-1")
runCtx, cancelRun := context.WithTimeoutCause(valued, time.Hour, AbortRunHardTimeout)
defer cancelRun()
cancelExec(AbortPausedByUser)
code, _, text, ok := AbortReason(runCtx)
if !ok || code != "paused_by_user" {
t.Fatalf("code=%q ok=%v, want paused_by_user", code, ok)
}
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.Fatal("live context must not report an abort reason")
}
plain, cancel := context.WithCancel(context.Background())
cancel()
if code, _, _, ok := AbortReason(plain); !ok || code != "canceled_no_cause" {
t.Fatalf("code=%q ok=%v, want canceled_no_cause", code, ok)
}
timed, cancelTimed := context.WithTimeout(context.Background(), time.Nanosecond)
defer cancelTimed()
<-timed.Done()
if code, _, _, _ := AbortReason(timed); code != "deadline_exceeded" {
t.Fatalf("code=%q, want deadline_exceeded", code)
}
}
func TestTerminalTextAbortedNamesCauseAndHangingTool(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
cancel(AbortKilledByPlanner)
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},
}
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("detail missing %q:\n%s", want, detail)
}
}
}
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)
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)
}
}
func TestTerminalTextNonAbortReasons(t *testing.T) {
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)
}
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)
}
}
func TestAbortCausesAreWellFormed(t *testing.T) {
all := []*AbortCause{
AbortPausedByUser, AbortPausedByOrchestrator, AbortTaskDeleted, AbortPausedOnReload,
AbortGoalMet, AbortSettleDrainTimeout, AbortKilledByPlanner, AbortWorkPausedByUser,
AbortWorkCancelledByUser, AbortWorkFinished, AbortPausedRaceGuard,
AbortChatStoppedByUser, AbortChatPausedWithTask, AbortChatTurnFinished,
AbortShutdown, AbortRunHardTimeout,
}
seen := map[string]bool{}
for _, abort := range all {
switch {
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[abort.Code] = true
}
}
func TestFirstLineCapsByRuneNotByte(t *testing.T) {
if got := firstLine(strings.Repeat("恢复", 10), 5); got != "恢复恢复恢…" {
t.Fatalf("firstLine split a Unicode character: %q", got)
}
if got := firstLine("头\n尾", 100); got != "头" {
t.Fatalf("firstLine did not stop at newline: %q", got)
}
}
+329 -148
View File
@@ -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.
@@ -355,34 +359,56 @@ 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)
}
}
<<<<<<< Updated upstream
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)
@@ -460,6 +486,8 @@ func (t *ToolSet) graphOverviewData() map[string]any {
e["value"] = r.Domain
case r.Net != "":
e["value"] = r.Net
case r.Value != "":
e["value"] = r.Value
case r.CompanyID != nil:
e["company_id"] = *r.CompanyID
if t.cs != nil {
@@ -654,107 +682,163 @@ func (t *ToolSet) relatedTaskOverviews() []map[string]any {
stats, statsErr := ts.Stats()
edges, _ := ts.Edges(5000)
>>>>>>> Stashed changes
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.Value != "":
entry["value"] = budget.take(row.Value, 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
}
}
@@ -763,7 +847,11 @@ func (t *ToolSet) relatedTaskOverviews() []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.
@@ -771,6 +859,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
}
@@ -781,16 +872,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
}
@@ -801,10 +899,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))
@@ -814,7 +912,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 {
@@ -825,7 +923,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
}
@@ -857,9 +955,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)
@@ -968,6 +1066,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;若是,自动判定
@@ -1032,12 +1138,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)
@@ -1092,15 +1205,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
}
@@ -1422,6 +1541,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
}
@@ -1452,6 +1575,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
}
@@ -1464,7 +1591,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 {
@@ -1475,7 +1602,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
}
@@ -1494,16 +1628,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)
})
}
@@ -1513,10 +1656,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
}
@@ -1530,7 +1677,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 互斥)"),
@@ -1549,6 +1696,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
@@ -1560,31 +1714,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)
})
}
@@ -1610,7 +1780,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
}
@@ -1620,11 +1790,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})
})
@@ -1655,13 +1829,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:
@@ -1673,7 +1850,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
}
+28 -16
View File
@@ -3,6 +3,7 @@ package agent
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
@@ -284,6 +285,18 @@ func (t *ToolSet) insertAssets() actool.CoreTool {
results = append(results, result{Index: i, ID: id, Type: typ})
t.writes.Assets++
t.anchorOwner(id)
if taskID > 0 {
var sourceNodeID *int64
if t.ownerNode > 0 {
nodeID := t.ownerNode
sourceNodeID = &nodeID
}
summary := "Agent 通过 insert_assets 登记"
if t.ownerNode > 0 {
summary = fmt.Sprintf("Worker 意图 #%d 通过 insert_assets 登记", t.ownerNode)
}
_ = t.as.SetTaskAssetSource(taskID, id, "agent", summary, sourceNodeID)
}
// 自动入测试范围(source='auto'):只对 worker 顶层显式插入的这一项,按其
// 类型加保守范围;side-effect 派生的资产不经此处,故范围不盲目扩大。taskID=0 时无操作。
// 资产覆盖度功能关闭时不再累积测试范围(分母);related=false(与当前任务无关)
@@ -310,14 +323,14 @@ func (t *ToolSet) insertAssets() actool.CoreTool {
func (t *ToolSet) addCompanyScope() actool.CoreTool {
return writeTool(
"add_company_scope",
"把根域名/IP/IP段(CIDR)加入某公司的【资产范围】——加入后系统自动认领所有命中的资产(现有+未来)。\n"+
"把域名/IP/CIDR/ICP备案/企业关键词加入某公司的【资产范围】——域名、网络和ICP会自动认领命中的资产,关键词只提供给Agent作为范围提示。\n"+
"公司名唯一:company 不存在则新建,已存在则复用(只把范围并进去)。\n"+
"scope 一行一条,支持:根域名 / 单个 IP / CIDR 网段(如 1.2.3.0/24)。\n"+
"scope 一行一条,系统自动识别:根域名 / URL / 单个 IP / CIDR 网段 / ICP备案 / 企业关键词。\n"+
"务必给 reason 说明归属依据(whois/证书/ASN 等)。\n"+
"护栏:拒绝裸 TLD 与过宽网段(IPv4<=/16、IPv6<=/32),非法行会被跳过并在 errors 返回。",
"护栏:拒绝裸 TLD 与过宽网段(IPv4前缀需为/16-/32、IPv6前缀需为/32-/128),非法行会被跳过并在 errors 返回。",
obj(map[string]any{
"company": str("公司名(不存在则新建、存在则复用;名称唯一)"),
"scope": str("资产范围,一行一条:根域名 / IP / CIDR"),
"scope": str("资产范围,一行一条:域名 / URL / IP / CIDR / ICP备案 / 企业关键词"),
"reason": str("归属依据(证据/来源),务必填写"),
"logo": str("公司图标 URL(可选;仅新建公司时生效)"),
}, "company", "scope"),
@@ -365,14 +378,14 @@ func (t *ToolSet) addTaskScope() actool.CoreTool {
return writeTool(
"add_task_scope",
"把测试范围加入【本任务】——这是资产测试覆盖度的分母,也是本任务的授权边界。\n"+
"kind 支持:company(整个公司名下资产) / root_domain(整个根域,含所有子域) / subdomain(单个精确子域) / ip / cidr。\n"+
"kind 支持:company(整个公司名下资产) / root_domain(整个根域,含所有子域) / subdomain(单个精确子域) / ip / cidr / icp / keyword。\n"+
"说明:worker 逐个碰到的主机会被系统【自动】加进范围(精确子域);本工具用于【主动扩大】——把整个根域/整个公司纳入,或补充指定某子域/IP。\n"+
"value:company 传公司名或 id(公司须已存在);root_domain/subdomain 传域名;ip/cidr 传 IP 或网段。\n"+
"value:company 传公司名或 id(公司须已存在);root_domain/subdomain 传域名;ip/cidr 传 IP 或网段;icp/keyword 传备案号或企业关键词。\n"+
"务必给 reason 说明依据(可审计)。多条用 entries 数组。",
obj(map[string]any{
"entries": map[string]any{"type": "array", "description": "批量:[{kind, value}]。kind∈company/root_domain/subdomain/ip/cidr;value=公司名或id / 域名 / IP / CIDR。", "items": map[string]any{"type": "object"}},
"kind": str("[单条] company / root_domain / subdomain / ip / cidr"),
"value": str("[单条] 公司名或id / 域名 / IP / CIDR"),
"entries": map[string]any{"type": "array", "description": "批量:[{kind, value}]。kind∈company/root_domain/subdomain/ip/cidr/icp/keyword。", "items": map[string]any{"type": "object"}},
"kind": str("[单条] company / root_domain / subdomain / ip / cidr / icp / keyword"),
"value": str("[单条] 公司名或id / 域名 / IP / CIDR / ICP / 关键词"),
"reason": str("加入依据(用于审计),务必填写"),
}),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
@@ -399,12 +412,12 @@ func (t *ToolSet) addTaskScope() actool.CoreTool {
var added []map[string]any
errs := map[string]string{}
for i, e := range items {
ts, err := t.as.AddAgentScope(t.taskID, strings.TrimSpace(e.Kind), e.Value, a.Reason)
ts, err := t.as.AddAgentScope(t.taskID, strings.TrimSpace(e.Kind), e.Value, a.Reason, "agent")
if err != nil {
errs[strconv.Itoa(i)] = err.Error()
continue
}
added = append(added, map[string]any{"kind": ts.Kind, "domain": ts.Domain, "net": ts.Net, "company_id": ts.CompanyID})
added = append(added, map[string]any{"kind": ts.Kind, "domain": ts.Domain, "net": ts.Net, "value": ts.Value, "company_id": ts.CompanyID})
}
out := map[string]any{"added": added}
if len(errs) > 0 {
@@ -415,13 +428,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{
@@ -449,7 +461,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
}
+31
View File
@@ -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)
}
}
+26 -3
View File
@@ -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
@@ -95,6 +96,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.
@@ -268,6 +278,7 @@ func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db.
// phase, Bash is hidden via Settlement.DisabledTools (no local gating needed).
base := append(tsx.WorkerTools(), w.extraTools...)
base = append(base, actool.DefaultTools()...)
ctx = WithRunInfo(ctx, RunInfo{TaskID: taskID, ExplorationID: explorationID(ts), IntentID: intent.ID})
tools, def, cleanup := AugmentTools(ctx, "worker", base)
tools = tsx.StripCoverageParams(tools) // 覆盖度关闭时隐藏 insert_assets 的 related 入参
defer cleanup()
@@ -324,8 +335,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
@@ -371,6 +382,18 @@ func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db.
s := agentcore.NewSession(opts)
defer s.Close() // release the session's background-task manager (temp dir + processes)
// Resume prior conversation if this intent was paused/blocked/exhausted and is
// being re-run. The transcript ID is deterministic per intent, so if a prior
// session exists the worker continues from where it left off instead of
// restarting from scratch.
if w.tx != nil {
_ = s.Resume(opts.SessionID)
if len(s.Messages()) > 0 {
seedUnlockFromHistory(s.Messages(), def.UnlockSkill)
input = "继续执行。"
}
}
// Budgets + settlement are owned by the SDK (MaxTurns/MaxDuration + Settlement):
// on hit it runs a wrap-up turn and finishes with ReasonMaxTurns/ReasonTimeout.
// We only add a HARD ctx backstop past the soft wall-clock budget, for the rare
@@ -382,7 +405,7 @@ func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db.
runCtx := ctx
if maxDur > 0 {
var runCancel context.CancelFunc
runCtx, runCancel = context.WithTimeout(ctx, maxDur+settleHardGrace)
runCtx, runCancel = context.WithTimeoutCause(ctx, maxDur+settleHardGrace, AbortRunHardTimeout)
defer runCancel()
}
_, reason, err := captureRunSession(runCtx, s, input, emitWrap)