feat(agent): 诊断 run 取消/终止原因,取代笼统的中断提示

- 新增 AbortCause(cancelcause.go):各取消点用 WithCancelCause/WithTimeoutCause
  附上原因(暂停/删除/kill_work/目标达成/drain 兜底/聊天停止/进程关闭/硬超时…),
  captureRun 用 context.Cause 还原出到底是谁、为何取消。
- 新增 terminalreason.go:把终端状态转成可读的一行摘要 + 多行详情(取消原因、
  终止 reason、轮数、卡住的工具),替换原来固定那句猜测式提示。
- engine/worker/planner/capture 及 conversations/orchestration/server 接入;
  cmd/artex/main.go、前端 llm/main-content 相应调整。
- 配套测试 terminalreason_test.go / engine_cancelcause_test.go。
This commit is contained in:
Autumn-27 committed 2026-08-19 09:20:10 -04:00
1 parent 8e9b126972
commit a6bd18050c
16 files changed
+624 -60

No files matched your search

+20 -21
View File
@@ -3,6 +3,8 @@ package agent
import (
"context"
"strings"
"time"
"unicode/utf8"
"github.com/Autumn-27/norma/agentcore"
"github.com/Autumn-27/norma/harness"
@@ -64,13 +66,18 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
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
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 { // ctx cancelled = stopped by the engine, not a 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()
}
rec(db.Activity{Kind: "result", IsError: true, Summary: "执行出错: " + err.Error(), Detail: err.Error()})
@@ -84,6 +91,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 +100,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:
@@ -120,25 +129,13 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
tkind = ""
}
flush() // flush any trailing thinking / non-final text
sum := ev.Terminal.Text
sum, detail := ev.Terminal.Text, 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 = 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 {
@@ -166,13 +163,15 @@ func blocksText(blocks []llm.ContentBlock) 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 "恢�…".
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
}