Files
Agent-System-Backend/internal/runtime/llm_direct.go
T
weichen 5f62b3d226 Protect project context from stale history and forged receipts
Why: Revoked project material could remain in later model turns, and plugin Audit could impersonate a model-input manifest.

Evidence: SELF red tests reproduced both paths; updated project revocation, trusted receipt, migration, and entrypoint tests pass locally with go test ./... -count=1 and targeted race checks.

Impact: Rebuild direct model requests after scope changes, sanitize or block unproven generic history, omit legacy project injections, and persist manifests in a dedicated state-checked table.

Refs: TT-C02 TT-C03 TT-C04; stage5 W6; review P1 stale context and manifest provenance
2026-10-14 04:33:05 +00:00

1376 lines
52 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package runtime
import (
"context"
"crypto/sha256"
"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"
"agent-native/runtime/reminders"
agentsession "agent-native/runtime/session"
agenttools "agent-native/runtime/tools"
"agent-system-backend/internal/ctxasm"
"agent-system-backend/internal/projectcontext"
"agent-system-backend/internal/squadadapter"
"agent-system-backend/internal/wfcompile"
"agent-system-backend/pkg/extension"
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 map[string]*toolSlot // 并行执行下按内核事件 CallID 配对
toolMu sync.Mutex // 并行 onEvent 下 inflight/queued/finalParts 互斥
turnBody string
evidence []toolEvidence
todos core.TodoList
plan core.PlanSnapshot
closedToolTurn bool
checklistTouched bool
toolTurnsSince int
nudgedPlanless bool
factCheckRounds int // E3:事实核查修正轮计数(上限 factCheckMaxRounds)
factCheckPrev map[string]bool // E3:上一轮的未核实 token 集合(未变化则不再拦)
// E4(R4 升级规则):harness 侧触发的换路提醒——不依赖模型自觉发现
// 自己在原地打转。成败计数在 onEvent 终点统一维护(noteFailureOutcome),
// 达阈值注入一次换路指令;检索连续空结果同理(noteEvidenceCall)。
recentOutcomes []bool // E4(R4):最近 N 次工具成败(true=失败),滑动窗口
failWindowNoted bool // E4(R4):窗口判据已提醒(每 run 一次)
zeroHitSearches int
zeroHitNoted bool
// E4(结构化 source_id 账本):证据类工具的成功结果登记为带 ID 的
// 条目,工具输出尾注【src_N|工具】标记——数字溯源的「材料/来源」
// 从此有可指地址。
ledger []evidenceEntry
ledgerCount int
progress *gates.ProgressReminder
checks []core.CheckPart
servedModel string
switches []string
switchMu sync.Mutex
// 任务快慢双速(SR-B10 实路径):started 用注入时钟记 run 起点,
// nudged/replied/delegated 三个标志驱动「超时仍未反馈则提醒转双速」的一次性触发。
started time.Time
nudgedDualSpeedCount int
replied bool
delegatedBackground bool
materialChecked bool // 材料兜底只问一次(runCtx 侧另有幂等,双保险)
// 交付物确认链(I2):confirm_choice 发起过选择卡;用户答复后
// pendingDeliverable 非空——回合结束仍未委派则硬性续跑一轮。
confirmIssued bool
pendingDeliverable string
deliverableNudged bool
// 材料整理通知回合(历史含「材料整理」完成标记):结束仍无选择卡且未委派
// 时,硬性要求发起 confirm_choice(弱模型应急,一次性)。
materialNotice bool
materialNoticeNudged bool
}
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, started: l.now(), inflight: map[string]*toolSlot{}}
// 记忆端口(N3 WP5):runCtx 暴露 store 时接线(注入由循环按请求完成)。
if ms, ok := rc.(interface{ StoreMemory() memoryStore }); ok && !projectContextEnabled(rc) {
st.memoryStore = ms.StoreMemory()
}
if !projectContextEnabled(rc) {
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
// 材料整理通知回合识别(I2):历史含「材料整理」完成标记的系统提醒——
// 该回合负有发起交付物选择卡的职责,stop 钩子据此兜底。扫会话历史而非
// 组装后的 msgs:系统提示词块会撑大 msgs,窗口不稳定。
histMsgs := rc.History()
for i := len(histMsgs) - 1; i >= 0 && i >= len(histMsgs)-8; i-- {
if histMsgs[i].Role != core.RoleSystem {
continue
}
for _, p := range histMsgs[i].Parts {
if p.Type == core.PartText && strings.Contains(p.Text, materialNoticeMarker) {
st.materialNotice = true
break
}
}
if st.materialNotice {
break
}
}
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.runToolCtx(ctx, 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(ctx, res)
// E4:工作区读仅在 inputs/ 材料读时登记账本(isEvidenceRead;
// 自产文件的读结果不打标,防编辑循环被尾注污染——实测教训)。
if !res.IsError && (evidenceTools[name] || isEvidenceRead(name, args)) && strings.TrimSpace(res.Content) != "" {
if tag, ok := st.tagSource(name, res.Content); ok {
res.Content += "\n【" + tag + "】"
}
}
return res
}})
}
decls = filterPlanDecls(decls, permissionModeOf(rc))
}
user := agentllm.Message{Role: agentllm.RoleUser}
if len(msgs) > 0 {
user = msgs[len(msgs)-1]
}
var projectErr error
var projectManifest *extension.ProjectContextManifest
var projectBody string
var lastProjectScope string
var projectHistoryTainted bool
wrapped := func(ctx context.Context, model string, messages []agentllm.Message, _ []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
fail := func(err error) <-chan agentllm.Event {
out := make(chan agentllm.Event, 1)
out <- agentllm.Event{Kind: agentllm.KindError, Err: err}
close(out)
return out
}
if projectErr != nil {
return fail(projectErr)
}
var manifest *extension.ProjectContextManifest
var saveManifest func(context.Context, extension.ProjectContextManifest) error
if projectManifest != nil {
copy := *projectManifest
manifest = &copy
manifest.Model = model
scope := projectScopeKey(*manifest)
if lastProjectScope != "" && (scope != lastProjectScope || manifest.AuthorizationEpoch == "" || manifest.PublicationEpoch == "") {
projectHistoryTainted = true
}
lastProjectScope = scope
if projectHistoryTainted {
if len(msgs) == 0 || user.Role != agentllm.RoleUser || projectBody == "" {
return fail(fmt.Errorf("trusted project request seed unavailable after scope change"))
}
// The kernel's conversation still contains assistant/tool text from
// the previous authorization epoch. Rebuild this request solely from
// the current run's clean seed and freshly resolved project bundle.
messages = append([]agentllm.Message(nil), msgs[:len(msgs)-1]...)
messages = append(messages, user)
messages = reminders.Attach(messages, []string{projectBody})
}
if err := checkProjectInputBudget(rc, messages, decls); err != nil {
return fail(err)
}
wire, err := json.Marshal(messages)
if err != nil {
return fail(err)
}
manifest.InputSHA256 = fmt.Sprintf("%x", sha256.Sum256(wire))
if ledger, ok := rc.(interface {
ContextEvidenceInRequest([]agentllm.Message) []extension.ProjectContextManifestEntry
}); ok {
manifest.ReadEvidence = ledger.ContextEvidenceInRequest(messages)
}
attemptID, err := projectcontext.NewAttemptID()
if err != nil {
return fail(err)
}
manifest.AttemptID, manifest.Delivery = attemptID, "attempted"
if sink, ok := rc.(interface {
SaveContextManifest(context.Context, extension.ProjectContextManifest) error
}); ok {
saveManifest = sink.SaveContextManifest
if err := saveManifest(ctx, *manifest); err != nil {
return fail(err)
}
}
}
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, 1)
go func() {
terminalRecorded := false
record := func(status string) error {
if manifest == nil || saveManifest == nil {
return nil
}
manifest.Delivery = status
receiptCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return saveManifest(receiptCtx, *manifest)
}
defer func() {
if !terminalRecorded {
_ = record("unknown")
}
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
}
}
if ev.Kind == agentllm.KindMessageEnd || ev.Kind == agentllm.KindError {
status := "unknown"
if ev.Kind == agentllm.KindMessageEnd {
status = "responded"
}
if err := record(status); err != nil {
terminalRecorded = true
select {
case out <- agentllm.Event{Kind: agentllm.KindError, Err: err}:
case <-ctx.Done():
}
return
}
terminalRecorded = true
}
select {
case out <- ev:
case <-ctx.Done():
return
}
if terminalRecorded {
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 != "" && !projectContextEnabled(rc) {
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,
ToolExecution: toolExecMode(eff),
SerialTools: serialTools,
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)...)
projectErr, projectManifest, projectBody = nil, nil, ""
if refresh.ProjectContext != nil {
var body string
body, projectManifest, projectErr = refresh.ProjectContext()
if projectErr == nil && body != "" {
projectBody = body
out = append(out, body)
}
}
// 快慢双速入口前置(SR-B10 实路径):带附件的任务在第 0 回合就把
// 双速选项摆给模型——模型对开场指令的遵循远好于中途提醒(实测
// MiniMax 无视中途 system-reminder,但对工作方法开场指令照办)。
// 材料整理入口(附件即保证):系统已确定性启动时注入事实说明
// (职责改为快通道反馈+后续交付物),未预启动(文字+小材料)时
// 保留建议式提示,回合结束由 stop 钩子兜底保证。
if turn == 0 && st.eff.GetNumber("runtime.dualSpeedNudgeSeconds", dualSpeedNudgeDefaultSeconds) > 0 && len(rc.Attachments()) > 0 {
note := ""
if src, ok := rc.(interface{ MaterialNote() string }); ok {
note = strings.TrimSpace(src.MaterialNote())
}
if note == "" {
note = "本任务附带材料,可能属于重任务。若预计要产出正式文档或做深度处理,采用快慢双速:先在当前回复里给用户即时反馈(材料相关性判断、概要或执行计划),再用 delegate_task 的 background=true 把深度工作放到后台执行——收件箱会显示进度卡,完成后系统自动回投结果并汇报。简单任务按常规同步完成即可。"
}
out = append(out, note)
}
// 转录权威:系统段在播种时冻结,动态上下文(尾注/环境/新命中模块)
// 只在非播种 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(GuardProjectStream(rc, currentUserText(rc), 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(GuardProjectStream(rc, currentUserText(rc), 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(ctx context.Context, res agenttools.Result) {
call, ok := nruntime.ToolCallFromContext(ctx)
slot := st.slotFor("")
if ok {
slot = st.slotFor(call.ID)
}
if slot == nil {
return
}
summary := summarize(res.Content, nil)
if res.IsError {
summary = "error: " + summary
}
slot.summary = summary
slot.status = statusOf(nil)
if res.IsError {
slot.status = "error"
}
slot.verifyText = tailRunes(res.Content, 600)
}
// slotFor 按调用 ID 取在途槽位;ID 为空且恰有一个槽位时取之(兼容无注入
// 的调用路径)。返回的指针仅供本次工具执行内写回,不跨事件持有。
func (st *directRun) slotFor(callID string) *toolSlot {
st.toolMu.Lock()
defer st.toolMu.Unlock()
if callID != "" {
return st.inflight[callID]
}
if len(st.inflight) == 1 {
for _, slot := range st.inflight {
return slot
}
}
return nil
}
// 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.toolMu.Lock()
st.queued = append([]agentllm.ToolCall(nil), ev.Message.ToolCalls...)
st.toolMu.Unlock()
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
if !hasCalls {
// 面向用户的答复段落已出现:快通道成立,双速提醒不再需要。
st.replied = true
}
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, ok := st.takeQueued(ev.CallID)
if !ok {
return
}
st.toolMu.Lock()
st.inflight[call.ID] = &toolSlot{call: call, start: st.l.now()}
st.toolMu.Unlock()
_ = st.rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: call.ID, Name: call.Name, Arguments: call.Arguments})
case "tool_execution_end":
st.toolMu.Lock()
slot := st.takeInflight(ev.CallID)
if slot == nil {
st.toolMu.Unlock()
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.toolMu.Unlock()
st.noteFailureOutcome(slot.status)
_ = 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)})
}
}
func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntime.StopDecision {
// 材料整理兜底(附件即保证):带附件回合结束仍无后台任务且模型也没自行
// 委派(delegatedBackground 尊重模型判断)→ 系统确定性启动,续跑一轮让
// 模型告知用户。ensureMaterial 幂等 + materialChecked 一次性:不续跑循环。
// 实测动机:MiniMax 对入口提示无视、问一句澄清就收场,0 委派 0 卡片,
// 用户全程无反馈。
if !st.materialChecked && len(st.rc.Attachments()) > 0 && !st.delegatedBackground {
if ens, ok := st.rc.(interface{ EnsureMaterial() string }); ok {
st.materialChecked = true
if note := strings.TrimSpace(ens.EnsureMaterial()); note != "" {
return nruntime.StopDecision{Continue: true, Reason: materialBackstopReason}
}
}
}
// 交付物确认链(I2 混合兜底):用户已在选择卡拍板,但回合结束仍未委派
// 后台任务 → 硬性续跑一轮要求启动(一次性)。
if st.pendingDeliverable != "" && !st.delegatedBackground && !st.deliverableNudged {
st.deliverableNudged = true
return nruntime.StopDecision{Continue: true, Reason: deliverableBackstopReason}
}
// 材料通知回合应急(弱模型不发 confirm_choice):汇报了却没让用户选
// 交付物、也没委派任何后台 → 硬性要求发起选择卡(一次性)。
if st.materialNotice && !st.confirmIssued && !st.delegatedBackground && !st.materialNoticeNudged {
st.materialNoticeNudged = true
return nruntime.StopDecision{Continue: true, Reason: materialNoticeBackstopReason}
}
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 投影;
// P5 后 scorer 只拦 lint error 与 lint 未运行)。
if reasons := gates.CheckArtifacts(artifactRefsOf(latest), artifactLookupOf(st.rc), hardFactScorer()); len(reasons) > 0 {
return st.continueOrNotice(info, strings.Join(reasons, ";"))
}
// P5→E3:事实一致性核查——交付物里的百分比/日期/大额数字回溯本轮材料
// (附件解析文本 + 工具证据)。E3 收紧为两轮:第一轮照常给修正机会
// (改值/删除/加「目标/假设」标注——标注后经 MarkedAsEstimate 机械放行);
// 第二轮明确这是最后一次拦截并给出标注出路;两轮后不再拦截(记入检查行
// 如实呈现),续写上限(MaxVerifyContinuations)仍兜底防死循环。
if st.eff.GetBool("runtime.factCheck", true) && len(latest) > 0 && info.Continuations < gates.MaxVerifyContinuations {
pairs := st.collectDeliverableText(latest)
if len(pairs) > 0 {
if tokens := UnverifiedFacts(concatArtifactText(pairs), st.materialCorpus(), factExemptions()); len(tokens) > 0 {
if notice, cont := st.factCheckGate(tokens); cont {
// 与交付物门禁/续写上限分支对齐:检查未通过时,现场那段总结归入
// 过程时间线——修正轮之后的收尾才是最终答复(实测 run_b520bf2e3037
// 未降级导致好总结被前端切分折叠,用户只看到修正流水账)。
st.demoteLastText()
st.addVerifyNote("事实核查:" + notice)
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason("终轮事实核查发现以下内容在材料中找不到出处,请修正后重新生成/登记交付物,然后重新给出更新后的完整最终答复(先结论、再产出物与要点,可注明本次修正)。\n" + notice)}
}
}
}
}
verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, latest)
if err != nil {
// 验证器没跑成不再是用户可见正文(实测 Notice 会顶掉正式总结)。
// 同源跳过是能力说明,记通过态检查行;真失败记未通过态检查行,
// 过程时间线里都可见,最终答复只留模型自己的收尾。
if strings.Contains(err.Error(), "跳过同源验证") {
st.addCheck(true, verifierUnavailableNotice)
} else {
st.addVerifyNote(err.Error())
}
return nruntime.StopDecision{}
}
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)
// 达到续写上限同样收进检查行(未通过态):让用户看见检查确实没过,
// 而不是用一句系统提示替换掉模型的最终答复。
st.addCheck(false, verifyCapNotice)
return nruntime.StopDecision{}
}
func (st *directRun) runTool(name, args string) agenttools.Result {
return st.runToolCtx(context.Background(), name, args)
}
// runToolCtx 执行一个工具调用;ctx 携带内核注入的当前调用(并行执行下按
// CallID 定位在途槽位),无注入时回落唯一在途槽位(旧内核/测试直调)。
func (st *directRun) runToolCtx(ctx context.Context, name, args string) agenttools.Result {
call, _ := nruntime.ToolCallFromContext(ctx)
if call.ID == "" {
call = agentllm.ToolCall{Name: name, Arguments: args}
}
slot := st.slotFor(call.ID)
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 {
// 材料传递(R2):模型自派的后台任务同样带上材料指引——子代理
// 看不到父回合内联的附件全文,需指明检索通道。
task := a.Task
if len(st.rc.Attachments()) > 0 {
task += delegateMaterialsNote(st.rc.Attachments())
}
id, serr := sp.SpawnBackground(subagentProfileID(a.Agent), orDefault(a.Name, "子代理"), task, a.Hint)
if serr != nil {
out = "子代理失败:" + serr.Error()
} else {
st.delegatedBackground = true
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 {
// F1 修复:回传子代理结论全文(capSubagentResult 已做 32k 截断与
// 超长落盘指针),Summary 只是 160 字摘要——只回摘要会让父代理在
// 摘要的摘要上凭空补细节。
out = orDefault(res.Text, orDefault(res.Summary, "子代理完成"))
st.pinSubagentArtifacts(res.Summary + "\n" + res.Text)
}
case toolConfirmChoice:
var a struct {
Question string `json:"question"`
Options []struct {
ID, Label, Description, Impact string
Recommended bool
} `json:"options"`
FreeInput *bool `json:"free_input"`
TimeoutSec int `json:"timeout_sec"`
}
if uerr := json.Unmarshal([]byte(call.Arguments), &a); uerr != nil || strings.TrimSpace(a.Question) == "" || len(a.Options) < 2 {
out = "confirm_choice 参数无效:需要 question 与 2-4 个选项(id/label/description/impact)"
break
}
card := core.HITLCard{Kind: core.HITLKindChoice, Question: strings.TrimSpace(a.Question), AllowFreeInput: a.FreeInput == nil || *a.FreeInput}
for _, o := range a.Options {
choice := core.HITLChoice{ID: strings.TrimSpace(o.ID), Label: strings.TrimSpace(o.Label), Description: o.Description, Impact: o.Impact, Recommended: o.Recommended}
if choice.ID == "" {
choice.ID = choice.Label
}
if strings.TrimSpace(choice.Impact) == "" {
choice.Impact = "按选项说明执行"
}
card.Choices = append(card.Choices, choice)
}
if a.TimeoutSec > 0 {
secs := a.TimeoutSec
card.TimeoutSec = &secs
}
st.confirmIssued = true
resp, herr := st.rc.AskHITL(card)
if herr != nil {
out = "选择等待结束(未获得答复):" + herr.Error() + "。请在回复里列出可选项,请用户直接答复。"
break
}
if id := strings.TrimSpace(resp.ChoiceID); id != "" {
label := id
for _, c := range card.Choices {
if c.ID == id {
label = c.Label
}
}
st.pendingDeliverable = label
out = "用户已选择:" + label + "。立即用 delegate_task 的 background=true 启动对应后台任务(任务描述基于材料整理结果定制,指明输出文件名与必须覆盖的要点),启动后给用户一句确认反馈即可,不要等待完成。"
} else if free := strings.TrimSpace(resp.FreeText); free != "" {
st.pendingDeliverable = free
out = "用户自由输入:" + free + "。若这是正式交付物要求,立即用 delegate_task 的 background=true 启动对应后台任务(描述结合材料整理结果);若是澄清或追问,先如实答复,再按需推进。"
} else {
out = "用户未给出明确答复。请在回复里列出可选项,请用户直接答复。"
}
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 toolReadProjectEvidence:
var a struct {
Reference string `json:"reference"`
}
if e := json.Unmarshal([]byte(call.Arguments), &a); e != nil || strings.TrimSpace(a.Reference) == "" {
err = fmt.Errorf("project evidence requires a reference")
break
}
reader, ok := st.rc.(interface {
ProjectEvidence(context.Context, string) (extension.ProjectEvidence, error)
})
if !ok {
err = fmt.Errorf("project evidence unavailable")
break
}
var evidence extension.ProjectEvidence
evidence, err = reader.ProjectEvidence(ctx, a.Reference)
if err == nil {
if evidence.Reference.ID != a.Reference {
err = fmt.Errorf("project evidence reference mismatch")
} else {
out = evidence.Text
}
}
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
}
// E4:R4 空命中计数 + 证据账本登记(成功且非空的证据类工具输出
// 尾注【src_N|工具】来源标记——数字溯源从此有可指地址)。
st.noteEvidenceCall(call.Name, res.Status, out)
if res.Status == "ok" && evidenceTools[call.Name] && strings.TrimSpace(out) != "" {
if tag, ok := st.tagSource(call.Name, out); ok {
out += "\n【" + tag + "】"
}
}
if res.Artifact != nil && slot != nil {
slot.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 slot != nil {
slot.truncated = res.Truncated
}
}
summary := summarize(out, nil)
status := statusOf(err)
if err != nil {
summary = "error: " + err.Error()
out = summary
}
if slot != nil {
slot.summary = summary
slot.status = status
slot.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()
}
// ---- 并行工具执行的辅助(E1/F5)----
// serialTools 顺序敏感或带会话级副作用的工具:作为内核 SerialTools 传入——
// 并行执行下这些调用按模型声明顺序逐个执行(互斥且保序;只互斥不保序会让
// 流式卡 card_begin→append→commit 乱序,实测踩过)。卡片流四步有先后依赖,
// 计划/待办是整表更新,工作流与小队编排派生 run,执行原语与文件编辑共享会话
// 工作区。读检索类与 delegate_task/review_findings 并行。
var serialTools = map[string]bool{
toolPlan: true, toolTodos: true,
toolCard: true, toolCardBegin: true, toolCardAppend: true, toolCardCommit: true,
toolRunWorkflow: true, toolSquad: true, toolConfirmChoice: true,
"run_shell": true, "run_python": true, "register_artifact": true,
"edit": true, "multi_edit": true,
}
// toolExecMode 同回合多工具调用的执行模式:runtime.parallelTools(缺省开)
// 为 parallel(顺序敏感工具经 Config.SerialTools 由内核保序),关闭时空串
// 回落内核缺省 sequential。
func toolExecMode(eff core.EffectiveSettings) string {
if eff.GetBool("runtime.parallelTools", true) {
return nruntime.ExecParallel
}
return ""
}
// takeQueued 按内核事件 CallID 取出队列中对应调用(并行下 start 事件乱序到达);
// CallID 为空(旧内核/未知来源)时按原先进先出。调用方须持有 toolMu。
func (st *directRun) takeQueued(callID string) (agentllm.ToolCall, bool) {
if callID != "" {
for i, c := range st.queued {
if c.ID == callID {
st.queued = append(st.queued[:i], st.queued[i+1:]...)
return c, true
}
}
return agentllm.ToolCall{}, false
}
if len(st.queued) == 0 {
return agentllm.ToolCall{}, false
}
c := st.queued[0]
st.queued = st.queued[1:]
return c, true
}
// takeInflight 按事件 CallID 取出并删除在途槽位;CallID 为空且恰有一个在途
// 槽位时取之(兼容旧内核)。调用方须持有 toolMu。
func (st *directRun) takeInflight(callID string) *toolSlot {
if callID != "" {
slot := st.inflight[callID]
delete(st.inflight, callID)
return slot
}
if len(st.inflight) == 1 {
for _, slot := range st.inflight {
clear(st.inflight)
return slot
}
}
return nil
}