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.
173 lines
6.0 KiB
Go
173 lines
6.0 KiB
Go
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"
|
|
)
|
|
|
|
// captureRun drives one agent turn-to-completion over Session.Prompt and emits a
|
|
// coalesced ActivityRecord per execution step (tool_use / tool_result / text /
|
|
// thinking / result). It is shared by every LLM agent in the system (worker,
|
|
// planner, …) so their execution is visible instead of a black box — the old
|
|
// agentcore.Run discarded every event. The emitted records carry only
|
|
// Kind/Tool/ToolUseID/IsError/Summary/Detail; the caller's emit fills in
|
|
// IntentID/Worker. Returns the final assistant text + terminal error.
|
|
//
|
|
// KindText/KindThinking arrive as streaming deltas (one event per fragment); a
|
|
// contiguous run is coalesced into a single record so the trace shows whole
|
|
// messages, not dozens of fragments.
|
|
func captureRun(ctx context.Context, opts agentcore.Options, input string, emit func(db.Activity)) (string, harness.TerminalReason, error) {
|
|
s := agentcore.NewSession(opts)
|
|
defer s.Close() // release the session's background-task manager (temp dir + processes)
|
|
return captureRunSession(ctx, s, input, emit)
|
|
}
|
|
|
|
// captureRunSession is captureRun over an existing session, so a caller can run
|
|
// multiple prompts on the SAME conversation (e.g. a settlement round that reuses
|
|
// the worker's accumulated context after the main run hit max_turns).
|
|
func captureRunSession(ctx context.Context, s *agentcore.Session, input string, emit func(db.Activity)) (string, harness.TerminalReason, error) {
|
|
var reason harness.TerminalReason
|
|
rec := func(r db.Activity) {
|
|
if emit != nil {
|
|
emit(r)
|
|
}
|
|
}
|
|
toolNames := map[string]string{} // tool_use id -> name, to label results
|
|
|
|
var tbuf strings.Builder
|
|
var tkind string
|
|
flush := func() {
|
|
if tbuf.Len() == 0 {
|
|
return
|
|
}
|
|
s := strings.TrimSpace(tbuf.String())
|
|
k := tkind
|
|
tbuf.Reset()
|
|
tkind = ""
|
|
if s != "" {
|
|
rec(db.Activity{Kind: k, Summary: firstLine(s, 200), Detail: s})
|
|
}
|
|
}
|
|
addDelta := func(kind, text string) {
|
|
if text == "" {
|
|
return
|
|
}
|
|
if tkind != "" && tkind != kind {
|
|
flush()
|
|
}
|
|
tkind = kind
|
|
tbuf.WriteString(text)
|
|
}
|
|
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 { // 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()
|
|
}
|
|
rec(db.Activity{Kind: "result", IsError: true, Summary: "执行出错: " + err.Error(), Detail: err.Error()})
|
|
return finalText, reason, err
|
|
}
|
|
switch ev.Kind {
|
|
case harness.KindToolUse:
|
|
if ev.ToolUse == nil {
|
|
continue
|
|
}
|
|
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:
|
|
if ev.ToolResult == nil {
|
|
continue
|
|
}
|
|
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:
|
|
addDelta("text", ev.Text)
|
|
case harness.KindThinking:
|
|
addDelta("thinking", ev.Text)
|
|
case harness.KindUsage:
|
|
// live cumulative token usage (per model turn). Emitted as a non-rendered
|
|
// "usage" activity carrying only the token fields; the UI uses the latest
|
|
// one for a running session's live token count. Don't flush() here — the
|
|
// buffered final-answer text must stay for the KindResult de-dup.
|
|
if ev.Usage != nil {
|
|
u := *ev.Usage
|
|
rec(db.Activity{Kind: "usage",
|
|
InputTokens: &u.InputTokens, OutputTokens: &u.OutputTokens,
|
|
CacheReadTokens: &u.CacheReadTokens, CacheWriteTokens: &u.CacheWriteTokens})
|
|
}
|
|
case harness.KindResult:
|
|
if ev.Terminal != nil {
|
|
finalText = ev.Terminal.Text
|
|
reason = ev.Terminal.Reason
|
|
// the buffered tail text usually equals Terminal.Text (final answer);
|
|
// drop it to avoid a duplicate record, the result row carries it.
|
|
if tkind == "text" && strings.TrimSpace(tbuf.String()) == strings.TrimSpace(ev.Terminal.Text) {
|
|
tbuf.Reset()
|
|
tkind = ""
|
|
}
|
|
flush() // flush any trailing thinking / non-final text
|
|
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: detail,
|
|
InputTokens: &u.InputTokens, OutputTokens: &u.OutputTokens,
|
|
CacheReadTokens: &u.CacheReadTokens, CacheWriteTokens: &u.CacheWriteTokens})
|
|
if ev.Terminal.Err != nil {
|
|
rerr = ev.Terminal.Err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
flush() // safety: any unflushed text if the stream ended without KindResult
|
|
return finalText, reason, rerr
|
|
}
|
|
|
|
// blocksText concatenates the text of a tool-result's content blocks.
|
|
func blocksText(blocks []llm.ContentBlock) string {
|
|
var b strings.Builder
|
|
for _, bl := range blocks {
|
|
if bl.Type == llm.BlockText && bl.Text != "" {
|
|
if b.Len() > 0 {
|
|
b.WriteByte('\n')
|
|
}
|
|
b.WriteString(bl.Text)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// 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 {
|
|
s = before
|
|
}
|
|
if utf8.RuneCountInString(s) > max {
|
|
s = string([]rune(s)[:max]) + "…"
|
|
}
|
|
return s
|
|
}
|