ci / vet-and-test (pull_request) Successful in 38s
Why: User rulings 4 plus the P3 live evidence. The base library taught layout standards but not how to work: no planning discipline (plan tool used in 2 of 31 tool runs), no retry-escalation rule (same failing call retried verbatim), no check-API-first rule (phantom API calls), and nothing about edit granularity - the P3 after-run showed one turn of 27 single-line edits plus a self-review spiral (117 tools). The final-reply rewrite layer sees only 160-char summaries and fabricated chapters/promised unfinished work; with the transcript now authoritative the working model already has full evidence, so per ruling 4 the rewrite defaults off (kept as fallback when a work loop ends with no user-facing text at all, plus opt-in setting). Changes: new base.method module (plan-first for 3+ steps, switch approach after two failures, check API signatures before scripting, batch edits with multi_edit, persist to completion - never end on a promise, trace numbers/ dates/names back to source material or mark speculation, honest final report); tool-description overrides for read_file/edit_file/grep/glob/ run_python/run_shell (dedicated tools over cat/sed, build pipeline for Word); plan reminder now also nudges once when NO plan exists after several tool turns (ignorable, once per run); runtime.composeFinal default true->false with empty-body fallback. Evidence: SELF - TestPlanlessNudgeFiresOnce (fires once, never repeats), TestResolveSystemDefaultBase updated for the ninth base block; go vet + go test ./... pass; live before/after on the golden task set recorded on the phase04 task card per the policy clause. Impact: Behavior-surface change only; no contract or storage changes.
940 lines
33 KiB
Go
940 lines
33 KiB
Go
package runtime
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
nruntime "agent-native/runtime"
|
||
"agent-native/runtime/checkpoint"
|
||
"agent-native/runtime/filestate"
|
||
"agent-native/runtime/gates"
|
||
"agent-native/runtime/hooks"
|
||
agentllm "agent-native/runtime/llm"
|
||
agentsession "agent-native/runtime/session"
|
||
agenttools "agent-native/runtime/tools"
|
||
"agent-system-backend/internal/ctxasm"
|
||
"agent-system-backend/internal/squadadapter"
|
||
"agent-system-backend/internal/wfcompile"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// directRun 是直连路径在一次 runtime.Run 期间的部件与交付物状态。
|
||
// 工具声明仍用真实 schema 发给模型;注册表 schema 保持宽松,避免内核的
|
||
// 参数校验把今天能执行的空参数调用挡掉。
|
||
type directRun struct {
|
||
l *LLMRunner
|
||
rc RunCtx
|
||
eff core.EffectiveSettings
|
||
cardflow bool
|
||
memoryStore memoryStore
|
||
finalParts []core.Part
|
||
explicitCard bool
|
||
deliverables []*core.ArtifactRef
|
||
usageTotal core.Usage
|
||
|
||
thinkingIdx int
|
||
textIdx int
|
||
turnThink strings.Builder
|
||
turnText strings.Builder
|
||
thinkStart time.Time
|
||
thinkEnd time.Time
|
||
textStart time.Time
|
||
textEnd time.Time
|
||
responding bool
|
||
acting bool
|
||
queued []agentllm.ToolCall
|
||
inflight *toolSlot
|
||
turnBody string
|
||
|
||
evidence []toolEvidence
|
||
todos core.TodoList
|
||
plan core.PlanSnapshot
|
||
closedToolTurn bool
|
||
checklistTouched bool
|
||
toolTurnsSince int
|
||
nudgedPlanless bool
|
||
progress *gates.ProgressReminder
|
||
checks []core.CheckPart
|
||
servedModel string
|
||
switches []string
|
||
switchMu sync.Mutex
|
||
}
|
||
|
||
type toolEvidence struct {
|
||
Name string
|
||
Status string
|
||
Summary string
|
||
VerifyText string // 给验证器的尾部摘要,比界面上的摘要长
|
||
}
|
||
|
||
type toolSlot struct {
|
||
call agentllm.ToolCall
|
||
start time.Time
|
||
summary string
|
||
verifyText string
|
||
status string
|
||
truncated bool
|
||
artifact *core.ArtifactRef
|
||
}
|
||
|
||
type directTool struct {
|
||
name string
|
||
run func(context.Context, json.RawMessage) agenttools.Result
|
||
}
|
||
|
||
func (t directTool) Name() string { return t.name }
|
||
func (t directTool) Description() string { return t.name }
|
||
func (t directTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||
func (t directTool) Execute(ctx context.Context, args json.RawMessage) (agenttools.Result, error) {
|
||
return t.run(ctx, args), nil
|
||
}
|
||
|
||
func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model string, msgs []agentllm.Message, decls []agentllm.ToolDecl, streamOpts agentllm.StreamOptions, cardflow bool, eff core.EffectiveSettings, maxTurns, maxTools int, refresh turnRefresh) error {
|
||
st := &directRun{l: l, rc: rc, eff: eff, cardflow: cardflow}
|
||
// 记忆端口(N3 WP5):runCtx 暴露 store 时接线(注入由循环按请求完成)。
|
||
if ms, ok := rc.(interface{ StoreMemory() memoryStore }); ok {
|
||
st.memoryStore = ms.StoreMemory()
|
||
}
|
||
st.loadChecklist()
|
||
if msg, ok := lastAddressed(rc.History()); ok && msg.Role == core.RoleSystem {
|
||
var text string
|
||
for _, p := range msg.Parts {
|
||
if p.Type == core.PartText {
|
||
text += p.Text
|
||
}
|
||
}
|
||
st.pinSubagentArtifacts(text)
|
||
}
|
||
st.servedModel = model
|
||
stream = agentllm.WithFallback(stream, ChatFallbacks(eff), st.noteModelSwitch)
|
||
defer func() {
|
||
if sink, ok := rc.(interface{ AddUsage(core.Usage) }); ok {
|
||
st.switchMu.Lock()
|
||
st.usageTotal.Model = st.servedModel
|
||
st.switchMu.Unlock()
|
||
sink.AddUsage(st.usageTotal)
|
||
}
|
||
}()
|
||
|
||
reg := agenttools.NewRegistry("")
|
||
for _, d := range decls {
|
||
name := d.Name
|
||
reg.Register(directTool{name: name, run: func(ctx context.Context, args json.RawMessage) agenttools.Result {
|
||
return st.runTool(name, string(args))
|
||
}})
|
||
}
|
||
// 内核工作区工具接入直连路径(裁定 1:尽可能开放工具):行号分页 read、
|
||
// 精确 edit/multi_edit(先读后写)、grep/glob/list——沙箱=会话工作区。
|
||
// 产品侧同域 fs_* 仍按 runtime.fsTools 门控(隐藏),不与内核名冲突。
|
||
if sandbox := conversationSandbox(rc); sandbox != "" {
|
||
var cpLog *checkpoint.Log
|
||
var anchor func() string
|
||
if host, ok := rc.(interface {
|
||
OpenCheckpoints() (*checkpoint.Log, error)
|
||
CheckpointAnchor() string
|
||
}); ok {
|
||
if lg, err := host.OpenCheckpoints(); err == nil {
|
||
cpLog = lg
|
||
a := host.CheckpointAnchor()
|
||
anchor = func() string { return a }
|
||
}
|
||
}
|
||
ws := agenttools.WorkspaceToolsCheckpointed(sandbox, filestate.New(), cpLog, anchor)
|
||
for _, wd := range ws.Decls() {
|
||
name := wd.Name
|
||
decls = append(decls, agentllm.ToolDecl{Name: wd.Name, Description: wd.Description, Parameters: wd.Parameters})
|
||
wsReg := ws
|
||
reg.Register(directTool{name: name, run: func(ctx context.Context, args json.RawMessage) agenttools.Result {
|
||
res := wsReg.Execute(ctx, name, args)
|
||
st.noteKernelResult(res)
|
||
return res
|
||
}})
|
||
}
|
||
decls = filterPlanDecls(decls, permissionModeOf(rc))
|
||
}
|
||
|
||
user := agentllm.Message{Role: agentllm.RoleUser}
|
||
if len(msgs) > 0 {
|
||
user = msgs[len(msgs)-1]
|
||
}
|
||
wrapped := func(ctx context.Context, model string, messages []agentllm.Message, _ []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
|
||
if streamOpts.MaxTokens > 0 {
|
||
opts.MaxTokens = streamOpts.MaxTokens
|
||
}
|
||
if streamOpts.MessageShape != "" {
|
||
opts.MessageShape = streamOpts.MessageShape
|
||
}
|
||
in := stream(ctx, model, messages, decls, opts)
|
||
out := make(chan agentllm.Event)
|
||
go func() {
|
||
defer close(out)
|
||
for ev := range in {
|
||
if ev.Kind == agentllm.KindToolCallDelta {
|
||
st.noteToolDelta(ev)
|
||
}
|
||
if ev.Kind == agentllm.KindMessageEnd {
|
||
if ev.Message.Role == "" {
|
||
ev.Message.Role = agentllm.RoleAssistant
|
||
}
|
||
if len(ev.Message.ToolCalls) > 0 && ev.Message.StopReason == "" {
|
||
ev.Message.StopReason = agentllm.StopToolCalls
|
||
}
|
||
}
|
||
select {
|
||
case out <- ev:
|
||
case <-ctx.Done():
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
return out
|
||
}
|
||
|
||
window, reserve, buffer := contextWindow(st.eff)
|
||
// 运行内被清理的旧工具结果完整落盘(占位写路径,模型可 cat 回看)——
|
||
// 此前占位只写「见会话记录」,而模型没有读会话记录的工具。
|
||
var resultsDir string
|
||
sandbox := conversationSandbox(rc)
|
||
if sandbox != "" {
|
||
resultsDir = filepath.Join(sandbox, ".agent", "results")
|
||
}
|
||
// 会话转录为权威上下文(质量 P2-I2):内核 Session 承载跨轮历史——
|
||
// 完整工具结果/参数/附件原文随转录持久,不再由界面消息按 160 字摘要
|
||
// 重建。首轮(或重播种)用 ctxasm 整段组装做一次性种子;此后每轮只传
|
||
// 当前用户消息,系统段与动态环境经 Reminders 刷新。
|
||
// 结构分歧(排除消息 / 变体切换 / 重新生成)改变指纹 ⇒ 重播种转录,
|
||
// 保证转录与消息库一致(regenerate/exclude/候选选择的等价映射)。
|
||
// 打开失败降级为无 Session(旧行为:ctxasm 整段组装,不阻断对话)。
|
||
var sessStore *agentsession.Store
|
||
seedRun := true
|
||
if sandbox != "" {
|
||
transcriptPath := filepath.Join(sandbox, ".agentx", "transcript.jsonl")
|
||
rev := contextRevision(rc.History())
|
||
if opened, oerr := agentsession.Open(transcriptPath); oerr == nil {
|
||
meta := readSeedMeta(transcriptPath)
|
||
switch {
|
||
case opened.Stats().Lines == 0:
|
||
// 首次播种:本轮回整段组装作为一次性种子并登记指纹。
|
||
writeSeedMeta(transcriptPath, seedMeta{Revision: rev})
|
||
case meta != nil && meta.Revision != rev:
|
||
// 结构分歧(排除/变体切换/重新生成):丢弃旧转录,按当前消息库重建。
|
||
if rerr := resetTranscript(transcriptPath); rerr == nil {
|
||
if reopened, rerr2 := agentsession.Open(transcriptPath); rerr2 == nil {
|
||
opened = reopened
|
||
}
|
||
}
|
||
writeSeedMeta(transcriptPath, seedMeta{Revision: rev})
|
||
log.Printf("[runtime] transcript reseed: rev=%s", rev)
|
||
default:
|
||
// 正常续跑;或无指纹的既有转录(JournalOnly 时代/崩溃现场):
|
||
// 采纳为权威,补登记指纹,不破坏崩溃配对修复。
|
||
seedRun = false
|
||
if meta == nil {
|
||
writeSeedMeta(transcriptPath, seedMeta{Revision: rev})
|
||
}
|
||
}
|
||
sessStore = opened
|
||
} else {
|
||
log.Printf("[runtime] open session transcript: %v", oerr)
|
||
}
|
||
}
|
||
var history []agentllm.Message
|
||
if sessStore == nil || seedRun {
|
||
// 无转录权威:整段组装(旧行为);或播种轮:完整历史作一次性种子。
|
||
history = append(history, msgs[:len(msgs)-1]...)
|
||
}
|
||
// 转录接近窗口时先做结构化压缩(9 段模板),内核请求侧的微清理兜底。
|
||
if sessStore != nil && !seedRun {
|
||
st.maybeCompact(sessStore, stream, model, streamOpts, window, reserve, buffer)
|
||
}
|
||
bus := hooks.New()
|
||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||
if c.Event != hooks.Stop {
|
||
return hooks.Outcome{}
|
||
}
|
||
d := st.stop(agentllm.Message{Role: agentllm.RoleAssistant, Content: c.LastText}, nruntime.StopInfo{Continuations: c.Continuations, ToolCalls: c.ToolCalls})
|
||
if d.Continue {
|
||
return hooks.Outcome{Decision: hooks.Block, Reason: d.Reason, Replacement: d.Replacement, Notice: d.Notice}
|
||
}
|
||
return hooks.Outcome{Decision: hooks.Allow, Notice: d.Notice}
|
||
})
|
||
res, err := nruntime.Run(rc.Context(), nruntime.Config{
|
||
Model: model,
|
||
History: history,
|
||
Session: sessStore,
|
||
JournalOnly: sessStore == nil, // 有转录即为权威;无转录时不涉及
|
||
Tools: reg,
|
||
Stream: wrapped,
|
||
MaxTurns: maxTurns,
|
||
Budget: toolBudget(maxTools),
|
||
RepeatGuard: true,
|
||
ContinueOnLength: true,
|
||
MaxLengthContinuations: -1,
|
||
ContextWindow: window,
|
||
OutputReserve: reserve,
|
||
ContextBuffer: buffer,
|
||
ResultsDir: resultsDir,
|
||
OnEvent: st.onEvent,
|
||
Hooks: bus,
|
||
Reminders: func(turn int) []string {
|
||
var out []string
|
||
out = append(out, st.reminders(turn)...)
|
||
// 转录权威:系统段在播种时冻结,动态上下文(尾注/环境/新命中模块)
|
||
// 只在非播种 run 的首个请求注入一次——run 内反复重发没有信息增量。
|
||
if sessStore != nil && !seedRun && turn == 0 {
|
||
out = append(out, refresh.Notes...)
|
||
if refresh.EnvUpdate != "" {
|
||
out = append(out, refresh.EnvUpdate)
|
||
}
|
||
if sandbox != "" {
|
||
transcriptPath := filepath.Join(sandbox, ".agentx", "transcript.jsonl")
|
||
var ids []string
|
||
for _, b := range refresh.TaskBlocks {
|
||
ids = append(ids, b.ModuleID)
|
||
}
|
||
for _, id := range updateRemindedModules(transcriptPath, ids) {
|
||
for _, b := range refresh.TaskBlocks {
|
||
if b.ModuleID == id {
|
||
out = append(out, "本轮适用的补充指引("+b.ModuleID+"):\n"+b.Body)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
},
|
||
PermissionAsk: PermissionAskOf(rc),
|
||
Trace: l.Trace,
|
||
AgentName: agentNameOf(rc.Profile()),
|
||
Memory: st.memoryStore,
|
||
RetryAttempts: retryAttemptsOf(eff),
|
||
UnknownTool: func(call agentllm.ToolCall) agenttools.Result {
|
||
return st.runTool(call.Name, call.Arguments)
|
||
},
|
||
}, user)
|
||
composed := false
|
||
// composeFinal 默认关闭(裁定 4):转录权威后工作模型自带完整证据,重写层
|
||
// 只见 160 字摘要——实测会虚构章节、承诺未做之事。开启或异常兜底时才重写。
|
||
composeWanted := st.eff.GetBool("runtime.composeFinal", false)
|
||
if err == nil && res.Err == nil && len(st.evidence) > 0 && composeWanted {
|
||
composed = st.composeFinal(stream, model, res.Notice, streamOpts)
|
||
}
|
||
if err == nil && res.Err == nil && !composed && res.Notice == "" && strings.TrimSpace(st.turnBody) == "" && len(st.evidence) > 0 {
|
||
// 兜底:工作轮结束但没有任何给用户的正文(罕见),才用重写层补一段。
|
||
composed = st.composeFinal(stream, model, "", streamOpts)
|
||
}
|
||
if res.Notice != "" && !composed {
|
||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: res.Notice})
|
||
}
|
||
if err == nil && res.Err == nil && len(st.finalParts) == 0 {
|
||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: "(模型未返回内容)"})
|
||
}
|
||
for _, ref := range st.latestDeliverables() {
|
||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartArtifactRef, Artifact: ref})
|
||
}
|
||
st.switchMu.Lock()
|
||
notes := append([]string(nil), st.switches...)
|
||
st.switchMu.Unlock()
|
||
if len(notes) > 0 {
|
||
prefix := make([]core.Part, 0, len(notes))
|
||
for _, note := range notes {
|
||
prefix = append(prefix, core.Part{Type: core.PartText, Text: note, Process: true})
|
||
}
|
||
st.finalParts = append(prefix, st.finalParts...)
|
||
}
|
||
_ = rc.SetAssistantParts(st.finalParts)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if res.Err != nil {
|
||
if strings.Contains(res.Err.Error(), "tool calls") {
|
||
return fmt.Errorf("budget: 工具调用超过上限 %d(profile.budget.maxToolCalls)", maxTools)
|
||
}
|
||
if strings.Contains(res.Err.Error(), "max turns") {
|
||
return fmt.Errorf("budget: 轮次超过上限 %d(profile.budget.maxTurns)", maxTurns)
|
||
}
|
||
return res.Err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// noteKernelResult 镜像 runTool 的结果登记:内核工作区工具不经过产品执行路径,
|
||
// 摘要/状态/验证文本在这里补记,保证过程面板与终轮验证器看到同样的证据。
|
||
func (st *directRun) noteKernelResult(res agenttools.Result) {
|
||
if st.inflight == nil {
|
||
return
|
||
}
|
||
summary := summarize(res.Content, nil)
|
||
if res.IsError {
|
||
summary = "error: " + summary
|
||
}
|
||
st.inflight.summary = summary
|
||
st.inflight.status = statusOf(nil)
|
||
if res.IsError {
|
||
st.inflight.status = "error"
|
||
}
|
||
st.inflight.verifyText = tailRunes(res.Content, 600)
|
||
}
|
||
|
||
// noteToolDelta 把模型正在编写的工具参数立刻转成会话事件。
|
||
// 执行开始前的这段时间原先没有任何工具事件,界面只剩光标。
|
||
func (st *directRun) noteModelSwitch(from, to string) {
|
||
st.switchMu.Lock()
|
||
defer st.switchMu.Unlock()
|
||
st.servedModel = to
|
||
st.switches = append(st.switches, fmt.Sprintf("模型 %s 在输出前失败,已改用 %s", from, to))
|
||
}
|
||
|
||
func (st *directRun) noteToolDelta(ev agentllm.Event) {
|
||
if !st.acting {
|
||
_ = st.rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseActing})
|
||
st.acting = true
|
||
}
|
||
_ = st.rc.Emit(core.EvToolCallDelta, core.ToolCallDeltaPayload{
|
||
Index: ev.ToolIndex, CallID: ev.ToolID, Name: ev.ToolName, Delta: ev.ArgumentsDelta,
|
||
})
|
||
}
|
||
|
||
func (st *directRun) onEvent(ev nruntime.Event) {
|
||
switch ev.Type {
|
||
case "auto_retry_start":
|
||
// 重试可观测(N3 WP6):沿用 process part 形态。
|
||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: "模型在输出前失败,正在重试(" + ev.Text + ")", Process: true})
|
||
case "turn_start":
|
||
st.responding = false
|
||
st.acting = false
|
||
st.turnThink.Reset()
|
||
st.turnText.Reset()
|
||
st.thinkStart, st.thinkEnd = time.Time{}, time.Time{}
|
||
st.textStart, st.textEnd = time.Time{}, time.Time{}
|
||
st.turnBody = ""
|
||
case "text_delta":
|
||
if !st.responding {
|
||
_ = st.rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseResponding})
|
||
st.responding = true
|
||
}
|
||
now := st.l.now()
|
||
if st.textStart.IsZero() {
|
||
st.textStart = now
|
||
}
|
||
st.textEnd = now
|
||
st.turnText.WriteString(ev.Text)
|
||
_ = st.rc.Emit(core.EvMessageDelta, core.MessageDeltaPayload{Index: st.textIdx, Text: ev.Text})
|
||
case "reasoning_delta":
|
||
now := st.l.now()
|
||
if st.thinkStart.IsZero() {
|
||
st.thinkStart = now
|
||
}
|
||
st.thinkEnd = now
|
||
st.turnThink.WriteString(ev.Text)
|
||
_ = st.rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: st.thinkingIdx, Text: ev.Text})
|
||
case "message_end":
|
||
if ev.PromptTokens > 0 {
|
||
if src, ok := st.rc.(interface {
|
||
ContextObservation() *core.ContextObservation
|
||
}); ok && src.ContextObservation() != nil {
|
||
ctxasm.NotePromptUsage(src.ContextObservation().EstChars, ev.PromptTokens)
|
||
}
|
||
}
|
||
st.usageTotal.PromptTokens += ev.PromptTokens
|
||
st.usageTotal.CompletionTokens += ev.CompletionTokens
|
||
st.usageTotal.TotalTokens += ev.TotalTokens
|
||
// 计费拆分(N3 WP6):上一请求覆盖、缓存命中累计。
|
||
st.usageTotal.LastPromptTokens = ev.PromptTokens
|
||
st.usageTotal.LastCompletionTokens = ev.CompletionTokens
|
||
st.usageTotal.CacheReadTokens += ev.CacheReadTokens
|
||
st.usageTotal.CacheWriteTokens += ev.CacheWriteTokens
|
||
st.queued = append([]agentllm.ToolCall(nil), ev.Message.ToolCalls...)
|
||
hasCalls := len(ev.Message.ToolCalls) > 0
|
||
if st.turnThink.Len() > 0 {
|
||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartThinking, Text: st.turnThink.String(), Process: true, DurationMs: durationMs(st.thinkStart, st.thinkEnd)})
|
||
st.thinkingIdx++
|
||
}
|
||
if hasCalls {
|
||
st.closedToolTurn = true
|
||
}
|
||
if st.turnText.Len() > 0 {
|
||
body, think := splitThinkBlock(st.turnText.String())
|
||
if think != "" {
|
||
inlineDur := durationMs(st.thinkStart, st.thinkEnd)
|
||
if inlineDur == 0 {
|
||
inlineDur = durationMs(st.textStart, st.textEnd)
|
||
}
|
||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartThinking, Text: think, Process: true, DurationMs: inlineDur})
|
||
st.thinkingIdx++
|
||
}
|
||
if body != "" {
|
||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: body, Process: hasCalls})
|
||
st.turnBody = body
|
||
st.textIdx++
|
||
}
|
||
}
|
||
case "tool_execution_start":
|
||
if !st.acting {
|
||
_ = st.rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseActing})
|
||
st.acting = true
|
||
}
|
||
if len(st.queued) == 0 {
|
||
return
|
||
}
|
||
call := st.queued[0]
|
||
st.queued = st.queued[1:]
|
||
st.inflight = &toolSlot{call: call, start: st.l.now()}
|
||
_ = st.rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: call.ID, Name: call.Name, Arguments: call.Arguments})
|
||
case "tool_execution_end":
|
||
slot := st.inflight
|
||
if slot == nil {
|
||
return
|
||
}
|
||
end := st.l.now()
|
||
st.finalParts = append(st.finalParts,
|
||
core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: slot.call.ID, Name: slot.call.Name, Arguments: slot.call.Arguments, Duration: durationMs(slot.start, end)}},
|
||
core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: slot.call.ID, Status: slot.status, Summary: slot.summary}},
|
||
)
|
||
if slot.artifact != nil {
|
||
st.deliverables = append(st.deliverables, slot.artifact)
|
||
}
|
||
st.evidence = append(st.evidence, toolEvidence{Name: slot.call.Name, Status: slot.status, Summary: slot.summary, VerifyText: slot.verifyText})
|
||
_ = st.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: slot.call.ID, Status: slot.status, Summary: slot.summary, Truncated: slot.truncated, DurationMs: durationMs(slot.start, end)})
|
||
st.inflight = nil
|
||
}
|
||
}
|
||
|
||
func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntime.StopDecision {
|
||
if block, why := deliverableGateOf(st.turnBody, st.deliverables, st.rc); block {
|
||
st.demoteLastText()
|
||
if st.eff.GetBool("runtime.verify", false) {
|
||
st.addVerifyNote(why)
|
||
}
|
||
if info.Continuations < gates.MaxDeliverableGuards {
|
||
return nruntime.StopDecision{Continue: true, Replacement: discardedClaimNote, Reason: systemCheckReason(deliverableGuardNote)}
|
||
}
|
||
return nruntime.StopDecision{Notice: deliverableFallbackNotice}
|
||
}
|
||
if !st.eff.GetBool("runtime.verify", false) {
|
||
return nruntime.StopDecision{}
|
||
}
|
||
// 终轮检查只评每个文件的最新版本:修订产生新工件后,被替代的旧版本
|
||
// 已不是交付物,继续按旧版评分会让检查永远无法通过(实测死循环根因)。
|
||
latest := st.latestDeliverables()
|
||
// N1 WP5:工件存在/非空/质量检查走下沉的 CheckArtifacts(端口在 llm.go 投影)。
|
||
if reasons := gates.CheckArtifacts(artifactRefsOf(latest), artifactLookupOf(st.rc), qualityScorer()); len(reasons) > 0 {
|
||
return st.continueOrNotice(info, strings.Join(reasons, ";"))
|
||
}
|
||
verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, latest)
|
||
if err != nil {
|
||
st.addVerifyNote(err.Error())
|
||
return nruntime.StopDecision{Notice: verifierUnavailableNotice}
|
||
}
|
||
if !verdict.Passed {
|
||
reason := strings.TrimSpace(verdict.Reason)
|
||
if reason == "" {
|
||
reason = "未说明原因"
|
||
}
|
||
if next := strings.TrimSpace(verdict.NextAction); next != "" {
|
||
reason += "。下一步:" + next
|
||
}
|
||
return st.continueOrNotice(info, reason)
|
||
}
|
||
st.addCheck(true, strings.TrimSpace(verdict.Reason))
|
||
return nruntime.StopDecision{}
|
||
}
|
||
|
||
// demoteLastText 把最近一段还没标成过程的正文降级。已经是过程的解说不动。
|
||
func (st *directRun) demoteLastText() int {
|
||
for i := len(st.finalParts) - 1; i >= 0; i-- {
|
||
if st.finalParts[i].Type == core.PartText && !st.finalParts[i].Process {
|
||
st.finalParts[i].Process = true
|
||
return i
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
// latestDeliverables 同名工件只保留最后登记的版本,顺序不变。
|
||
// 修订(同名再登记)后旧版本不再是交付物:不进终轮检查、不重复出现在
|
||
// 回复的交付清单里。无名工件互不合并。
|
||
// 超长工具输出的截断转存(runmgr 的 tool-<调用>.txt,kind=data)不是交付物:
|
||
// 不进终轮检查、不占交付卡片(实测被评分器当 .txt 交付物误伤,检查连判负)。
|
||
func (st *directRun) latestDeliverables() []*core.ArtifactRef {
|
||
idx := map[string]int{}
|
||
var out []*core.ArtifactRef
|
||
for _, ref := range st.deliverables {
|
||
if ref == nil || strings.TrimSpace(ref.Name) == "" {
|
||
out = append(out, ref)
|
||
continue
|
||
}
|
||
if isToolDumpArtifact(ref) {
|
||
continue
|
||
}
|
||
if i, ok := idx[ref.Name]; ok {
|
||
out[i] = ref
|
||
continue
|
||
}
|
||
idx[ref.Name] = len(out)
|
||
out = append(out, ref)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// isToolDumpArtifact 识别「工具结果超长截断转工件」的落盘(上下文救济,非交付物)。
|
||
func isToolDumpArtifact(ref *core.ArtifactRef) bool {
|
||
return ref.Kind == "data" && strings.HasPrefix(ref.Name, "tool-")
|
||
}
|
||
|
||
func (st *directRun) addVerifyNote(reason string) {
|
||
st.addCheck(false, reason)
|
||
}
|
||
|
||
// addCheck 记下终轮检查并发出 run.check。有尚未降级的回复时,插在它前面,
|
||
// 这样检查行留在过程区,回复留在过程区外面。
|
||
func (st *directRun) addCheck(passed bool, reason string) {
|
||
part := core.Part{
|
||
Type: core.PartCheck,
|
||
Process: true,
|
||
Check: &core.CheckPart{Passed: passed, Reason: reason},
|
||
}
|
||
idx := -1
|
||
for i := len(st.finalParts) - 1; i >= 0; i-- {
|
||
if st.finalParts[i].Type == core.PartText && !st.finalParts[i].Process {
|
||
idx = i
|
||
break
|
||
}
|
||
}
|
||
if idx >= 0 {
|
||
st.finalParts = append(st.finalParts, core.Part{})
|
||
copy(st.finalParts[idx+1:], st.finalParts[idx:])
|
||
st.finalParts[idx] = part
|
||
} else {
|
||
st.finalParts = append(st.finalParts, part)
|
||
}
|
||
st.checks = append(st.checks, core.CheckPart{Passed: passed, Reason: reason})
|
||
_ = st.rc.Emit(core.EvRunCheck, core.RunCheckPayload{Passed: passed, Reason: reason})
|
||
}
|
||
|
||
func (st *directRun) continueOrNotice(info nruntime.StopInfo, reason string) nruntime.StopDecision {
|
||
if info.Continuations < gates.MaxVerifyContinuations {
|
||
st.demoteLastText()
|
||
st.addVerifyNote(reason)
|
||
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason(reason)}
|
||
}
|
||
st.addVerifyNote(reason)
|
||
return nruntime.StopDecision{Notice: verifyCapNotice}
|
||
}
|
||
|
||
func (st *directRun) runTool(name, args string) agenttools.Result {
|
||
call := agentllm.ToolCall{Name: name, Arguments: args}
|
||
if st.inflight != nil {
|
||
call = st.inflight.call
|
||
}
|
||
var out string
|
||
var err error
|
||
switch call.Name {
|
||
case toolSquad:
|
||
if !st.eff.GetBool("runtime.squad", true) {
|
||
out = "小队编排已被设置禁用(runtime.squad)"
|
||
break
|
||
}
|
||
var a struct{ Goal string }
|
||
_ = json.Unmarshal([]byte(call.Arguments), &a)
|
||
dataDir := st.eff.GetString("storage.dataDir", "./data")
|
||
summary, serr := squadadapter.Orchestrate(st.rc, dataDir, a.Goal)
|
||
if serr != nil {
|
||
out = "小队编排失败:" + serr.Error()
|
||
} else {
|
||
out = summary
|
||
}
|
||
case toolDelegate:
|
||
var a struct {
|
||
Agent, Name, Task, Hint string
|
||
Background bool
|
||
}
|
||
_ = json.Unmarshal([]byte(call.Arguments), &a)
|
||
if a.Background {
|
||
if sp, ok := st.rc.(interface {
|
||
SpawnBackground(agentID, name, task, hint string) (core.ID, error)
|
||
}); ok {
|
||
id, serr := sp.SpawnBackground(subagentProfileID(a.Agent), orDefault(a.Name, "子代理"), a.Task, a.Hint)
|
||
if serr != nil {
|
||
out = "子代理失败:" + serr.Error()
|
||
} else {
|
||
out = fmt.Sprintf("已在后台启动 %s,完成后会自动通知", id)
|
||
}
|
||
break
|
||
}
|
||
}
|
||
var res core.AgentResultPayload
|
||
var serr error
|
||
if sp, ok := st.rc.(interface {
|
||
SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error)
|
||
}); ok {
|
||
res, serr = sp.SpawnAgent(subagentProfileID(a.Agent), orDefault(a.Name, "子代理"), a.Task, a.Hint)
|
||
} else {
|
||
res, serr = st.rc.SpawnSubagent(orDefault(a.Name, "子代理"), a.Task, a.Hint)
|
||
}
|
||
if serr != nil {
|
||
out = "子代理失败:" + serr.Error()
|
||
} else {
|
||
out = orDefault(res.Summary, "子代理完成")
|
||
st.pinSubagentArtifacts(res.Summary + "\n" + res.Text)
|
||
}
|
||
case toolReview:
|
||
out = st.reviewFindings(call.Arguments)
|
||
case toolPlan:
|
||
var p core.PlanSnapshot
|
||
if uerr := json.Unmarshal([]byte(call.Arguments), &p); uerr == nil {
|
||
_ = st.rc.UpdatePlan(p)
|
||
st.plan = p
|
||
st.checklistTouched = true
|
||
out = "计划已更新"
|
||
} else {
|
||
out = "计划参数错误: " + uerr.Error()
|
||
}
|
||
case toolTodos:
|
||
var t core.TodoList
|
||
if uerr := json.Unmarshal([]byte(call.Arguments), &t); uerr == nil {
|
||
_ = st.rc.UpdateTodos(t)
|
||
st.todos = t
|
||
st.checklistTouched = true
|
||
out = "待办已更新"
|
||
} else {
|
||
out = "待办参数错误: " + uerr.Error()
|
||
}
|
||
case toolRunWorkflow:
|
||
out = st.runRegisteredWorkflow(call.Arguments)
|
||
case toolCard:
|
||
out = st.submitCard(call.Arguments)
|
||
case toolCardBegin:
|
||
out = st.beginCard(call.Arguments)
|
||
case toolCardAppend:
|
||
out = st.appendCard(call.Arguments)
|
||
case toolCardCommit:
|
||
out = st.commitCard()
|
||
default:
|
||
res := st.rc.ExecuteTool(core.ToolCallRequest{CallID: call.ID, Name: call.Name, Arguments: json.RawMessage(call.Arguments)})
|
||
if res.Status != "ok" {
|
||
err = fmt.Errorf("%s", res.Output)
|
||
} else {
|
||
out = res.Output
|
||
}
|
||
if res.Artifact != nil && st.inflight != nil {
|
||
st.inflight.artifact = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
|
||
if st.cardflow && res.Truncated && !st.explicitCard {
|
||
fc, ferr := st.rc.SubmitCard(artifactFacadeCard(call.Name, *res.Artifact, summarize(out, nil)))
|
||
if ferr == nil {
|
||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: fc.ID}})
|
||
}
|
||
}
|
||
}
|
||
if st.inflight != nil {
|
||
st.inflight.truncated = res.Truncated
|
||
}
|
||
}
|
||
|
||
summary := summarize(out, nil)
|
||
status := statusOf(err)
|
||
if err != nil {
|
||
summary = "error: " + err.Error()
|
||
out = summary
|
||
}
|
||
if st.inflight != nil {
|
||
st.inflight.summary = summary
|
||
st.inflight.status = status
|
||
st.inflight.verifyText = tailRunes(out, 600)
|
||
}
|
||
if r := []rune(out); len(r) > 16000 {
|
||
out = string(r[:16000]) + "…(超长截断)"
|
||
}
|
||
return agenttools.Result{Content: out, IsError: err != nil}
|
||
}
|
||
|
||
func (st *directRun) runRegisteredWorkflow(args string) string {
|
||
var a struct {
|
||
Workflow string `json:"workflow"`
|
||
Inputs map[string]string `json:"inputs"`
|
||
Goal string `json:"goal"`
|
||
Script string `json:"script"`
|
||
}
|
||
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||
return "run_workflow 参数不是合法 JSON"
|
||
}
|
||
n := 0
|
||
for _, s := range []string{a.Workflow, a.Goal, a.Script} {
|
||
if strings.TrimSpace(s) != "" {
|
||
n++
|
||
}
|
||
}
|
||
if n != 1 {
|
||
return "run_workflow 需要且只能指定 workflow、goal、script 之一"
|
||
}
|
||
if strings.TrimSpace(a.Goal) != "" {
|
||
return st.runPlannedWorkflow(strings.TrimSpace(a.Goal))
|
||
}
|
||
if strings.TrimSpace(a.Script) != "" {
|
||
return st.runScriptWorkflow(a.Script)
|
||
}
|
||
lookup, ok := st.rc.(interface {
|
||
WorkflowByID(id string) (core.WorkflowDef, bool)
|
||
})
|
||
if !ok {
|
||
return "当前运行不能查找工作流"
|
||
}
|
||
def, ok := lookup.WorkflowByID(a.Workflow)
|
||
if !ok {
|
||
return "未注册的工作流 " + a.Workflow
|
||
}
|
||
// Q3 WP2:注册工作流经编译图执行(P4 上限在编译入口强制)。
|
||
res, err := (CompiledWorkflowRunner{}).runLaunch(st.rc,
|
||
registeredWorkflowLaunch(st.rc, def, a.Inputs), wfcompile.RunOptions{})
|
||
st.pinSubagentArtifacts(res.Summary)
|
||
if err != nil {
|
||
if res.Summary != "" {
|
||
return res.Summary
|
||
}
|
||
return err.Error()
|
||
}
|
||
return res.Summary
|
||
}
|
||
|
||
func (st *directRun) runScriptWorkflow(body string) string {
|
||
// Q3 WP2:模型脚本包成单步工作流走编译图——runtime.workflowScripts
|
||
// 开关与执行前确认由 wfcompile 宿主处理器持有(P4/legacy 同款:模型脚本
|
||
// 只有脚本级确认门,不叠加计划确认)。
|
||
res, err := (CompiledWorkflowRunner{}).runLaunch(st.rc, modelScriptLaunch(body),
|
||
wfcompile.RunOptions{ModelScript: true})
|
||
st.pinSubagentArtifacts(res.Summary)
|
||
if err != nil {
|
||
if res.Summary != "" {
|
||
return res.Summary + "\n" + err.Error()
|
||
}
|
||
return err.Error()
|
||
}
|
||
return res.Summary
|
||
}
|
||
|
||
func (st *directRun) runPlannedWorkflow(goal string) string {
|
||
// Q3 WP2:规划分支——规划子代理产出 WorkflowDef,动态上限编译,
|
||
// 执行前强制计划确认(legacy planWorkflow 同款流)。
|
||
res, err := planCompiledWorkflow(st.rc, goal, CompiledWorkflowRunner{})
|
||
st.pinSubagentArtifacts(res.Summary)
|
||
if err != nil {
|
||
if res.Summary != "" {
|
||
return res.Summary
|
||
}
|
||
return err.Error()
|
||
}
|
||
return res.Summary
|
||
}
|
||
|
||
func (st *directRun) submitCard(args string) string {
|
||
if !st.cardflow {
|
||
return "submit_card 未启用(设置 ui.cardflow 关闭);内容直接写在对话回复里"
|
||
}
|
||
var a struct{ Title, Summary, Body, Scope string }
|
||
if err := json.Unmarshal([]byte(args), &a); err != nil || strings.TrimSpace(a.Title) == "" || strings.TrimSpace(a.Body) == "" {
|
||
return "submit_card 参数错误(title/body 必填): " + errString(err)
|
||
}
|
||
body := []core.UINode{{Type: "markdown", Markdown: a.Body}}
|
||
if strings.TrimSpace(a.Summary) != "" {
|
||
body = append(body, core.UINode{Type: "kv", KV: [][2]string{{"摘要", a.Summary}}})
|
||
}
|
||
newCard := core.ActivityCard{Kind: core.CardKindResult, Title: a.Title, Status: core.CardDone, Tone: "ok", Body: body}
|
||
if a.Scope == "project" {
|
||
newCard.ProjectID = core.ID(st.rc.Run().ProjectID)
|
||
}
|
||
card, cerr := st.rc.SubmitCard(newCard)
|
||
if cerr != nil {
|
||
return "submit_card 失败: " + cerr.Error()
|
||
}
|
||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
|
||
st.explicitCard = true
|
||
where := "对话"
|
||
if a.Scope == "project" {
|
||
where = "项目"
|
||
}
|
||
return fmt.Sprintf("已作为卡片发出到%s收件箱(%s,card:%s)。对话中保留简短结论即可,不要重复卡片全文。", where, a.Title, card.ID)
|
||
}
|
||
|
||
func (st *directRun) beginCard(args string) string {
|
||
if !st.cardflow {
|
||
return "card_begin 未启用(设置 ui.cardflow 关闭);内容直接写在对话回复里"
|
||
}
|
||
var a struct{ Title, Summary, Scope string }
|
||
if err := json.Unmarshal([]byte(args), &a); err != nil || strings.TrimSpace(a.Title) == "" {
|
||
return "card_begin 参数错误(title 必填): " + errString(err)
|
||
}
|
||
var body []core.UINode
|
||
if strings.TrimSpace(a.Summary) != "" {
|
||
body = append(body, core.UINode{Type: "kv", KV: [][2]string{{"摘要", a.Summary}}})
|
||
}
|
||
newCard := core.ActivityCard{Kind: core.CardKindResult, Title: a.Title, Status: core.CardActive, Tone: "ok", Body: body}
|
||
if a.Scope == "project" {
|
||
newCard.ProjectID = core.ID(st.rc.Run().ProjectID)
|
||
}
|
||
card, cerr := st.rc.BeginStreamCard(newCard)
|
||
if cerr != nil {
|
||
return "card_begin 失败: " + cerr.Error()
|
||
}
|
||
return fmt.Sprintf("流式卡已开始(%s,card:%s)。用 card_append 逐段追加正文,写完调用 card_commit 收尾。", a.Title, card.ID)
|
||
}
|
||
|
||
func (st *directRun) appendCard(args string) string {
|
||
if !st.cardflow {
|
||
return "card_append 未启用(设置 ui.cardflow 关闭);内容直接写在对话回复里"
|
||
}
|
||
var a struct{ Text string }
|
||
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||
return "card_append 参数错误: " + err.Error()
|
||
}
|
||
if aerr := st.rc.AppendStreamCard(a.Text); aerr != nil {
|
||
return "card_append 失败: " + aerr.Error()
|
||
}
|
||
return "已追加"
|
||
}
|
||
|
||
func (st *directRun) commitCard() string {
|
||
if !st.cardflow {
|
||
return "card_commit 未启用(设置 ui.cardflow 关闭);内容直接写在对话回复里"
|
||
}
|
||
card, cerr := st.rc.CommitStreamCard()
|
||
if cerr != nil {
|
||
return "card_commit 失败: " + cerr.Error()
|
||
}
|
||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
|
||
st.explicitCard = true
|
||
return fmt.Sprintf("已作为卡片发出(%s,card:%s)。对话中保留简短结论即可,不要重复卡片全文。", card.Title, card.ID)
|
||
}
|
||
|
||
func (st *directRun) pinSubagentArtifacts(text string) {
|
||
seen := map[string]bool{}
|
||
for _, ref := range st.deliverables {
|
||
if ref != nil {
|
||
seen[ref.ArtifactID] = true
|
||
}
|
||
}
|
||
for _, id := range gates.ArtifactIDPattern.FindAllString(text, -1) {
|
||
if seen[id] {
|
||
continue
|
||
}
|
||
art, _, ok := st.rc.Artifact(id)
|
||
if !ok {
|
||
continue
|
||
}
|
||
seen[id] = true
|
||
st.deliverables = append(st.deliverables, &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind})
|
||
}
|
||
}
|
||
|
||
func errString(err error) string {
|
||
if err == nil {
|
||
return ""
|
||
}
|
||
return err.Error()
|
||
}
|