Why: Real transcripts (dev instance, 62 assistant turns) showed the artifact gate silently off and self-deadlocking. lintFile ran python3 -m housestyle.lint without the skills PYTHONPATH, so the import failed and nil was treated as "no issues" (same docx: 9 lint errors with PYTHONPATH, 0 without). stop() re-scored every artifact registered in the run, including superseded versions, so a fixed document could never pass and the composed reply reported the old verdict. ctxasm rendered history artifacts as "[产出工件 …]" - the exact pattern ClaimGate treats as a forged tool marker, so models copying the format were rejected. scoreReadable flagged table cells like "项目概述撰写" as column headings. Prompts and recipes said "python -m" (no python on PATH; exit 127) and housestyle.summarize was invoked as a module that does not exist. Evidence: SELF - new tests TestLintUnavailableFailsVisibleWhenSkillsRootSet, TestLintCommandEnvCarriesSkillsRoot, TestDocxHeadingMarkedAndTableCellNotFlagged, TestLatestDeliverablesKeepsLastPerName, TestRecipesUseRunnablePythonCommands, TestLibraryUsesRunnablePythonCommands; go test ./internal/... passes; manual: python3 -m housestyle.outline_cli reads a real artifact docx cleanly, lint without PYTHONPATH exits 1 with ModuleNotFoundError and is now surfaced as 自检未运行 when the skills root is configured. Impact: compose root injects quality.SetLintEnvRoot(<data>/skills). Final-turn checks score only the newest artifact per name; deliverable lists and reply cards follow the same dedupe. History artifact placeholders no longer collide with the forged-marker regex. docx text extraction marks Title/HeadingN styles so readability only judges real headings, with line numbers in the reason. Check reminders no longer invite the model to ignore failed checks. Oversized tool output is truncated by runes, not bytes. Refs: Dev task feature/agent-quality-phase01 (quality gap diagnosis 2026-10-09).
804 lines
27 KiB
Go
804 lines
27 KiB
Go
package runtime
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"log"
|
||
"path/filepath"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
nruntime "agent-native/runtime"
|
||
"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
|
||
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) 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))
|
||
}})
|
||
}
|
||
|
||
var history []agentllm.Message
|
||
user := agentllm.Message{Role: agentllm.RoleUser}
|
||
if len(msgs) > 0 {
|
||
user = msgs[len(msgs)-1]
|
||
history = append(history, 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)
|
||
// 会话转录(N3 WP2):产品会话 run 接入意图日志与恢复配对——
|
||
// JournalOnly 模式:journal 只作日志,上下文仍由 ctxasm 组装(产品管线
|
||
// 不冻结);恢复写入的配对摘要以系统消息注入。打开失败降级为无
|
||
// Session(旧行为,不阻断对话)。
|
||
var sessStore *agentsession.Store
|
||
if sandbox := conversationSandbox(rc); sandbox != "" {
|
||
if opened, oerr := agentsession.Open(filepath.Join(sandbox, ".agentx", "transcript.jsonl")); oerr == nil {
|
||
sessStore = opened
|
||
} else {
|
||
log.Printf("[runtime] open session transcript: %v", oerr)
|
||
}
|
||
}
|
||
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,
|
||
OnEvent: st.onEvent,
|
||
Hooks: bus,
|
||
Reminders: st.reminders,
|
||
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
|
||
if err == nil && res.Err == nil && len(st.evidence) > 0 && st.eff.GetBool("runtime.composeFinal", true) {
|
||
composed = st.composeFinal(stream, model, res.Notice, 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
|
||
}
|
||
|
||
// 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 同名工件只保留最后登记的版本,顺序不变。
|
||
// 修订(同名再登记)后旧版本不再是交付物:不进终轮检查、不重复出现在
|
||
// 回复的交付清单里。无名工件互不合并。
|
||
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 i, ok := idx[ref.Name]; ok {
|
||
out[i] = ref
|
||
continue
|
||
}
|
||
idx[ref.Name] = len(out)
|
||
out = append(out, ref)
|
||
}
|
||
return out
|
||
}
|
||
|
||
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()
|
||
}
|