Why: A direct run could stop on a missing file, a failed lint, or an unverified claim. The final turn now checks registered artifacts and asks a tool-less verifier, and nudges stale plans and todos. Evidence: go test ./... passed. New runtime tests cover missing and empty artifacts, docx lint errors, verifier reject-then-pass, unparseable and failed verifier calls, nil verifier tools, todo reminders, and the false-deliverable fixture. Impact: Direct runs with runtime.verify (default on for new runs) spend one extra lightweight call on every final turn. Setting it off keeps the previous stop behavior. Squad and SubLLMRunner are unchanged. The agent-native pin stays a34689c. Refs: feature/agent-harness-p2 Co-authored-by: Cursor <cursoragent@cursor.com>
504 lines
16 KiB
Go
504 lines
16 KiB
Go
package runtime
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
nruntime "agent-native/runtime"
|
||
agentllm "agent-native/runtime/llm"
|
||
agenttools "agent-native/runtime/tools"
|
||
"agent-system-backend/internal/squadadapter"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// directRun 是直连路径在一次 runtime.Run 期间的部件与交付物状态。
|
||
// 工具声明仍用真实 schema 发给模型;注册表 schema 保持宽松,避免内核的
|
||
// 参数校验把今天能执行的空参数调用挡掉。
|
||
type directRun struct {
|
||
l *LLMRunner
|
||
rc RunCtx
|
||
eff core.EffectiveSettings
|
||
cardflow bool
|
||
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
|
||
checklistReminders int
|
||
}
|
||
|
||
type toolEvidence struct {
|
||
Name string
|
||
Status string
|
||
Summary string
|
||
}
|
||
|
||
type toolSlot struct {
|
||
call agentllm.ToolCall
|
||
start time.Time
|
||
summary 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}
|
||
st.loadChecklist()
|
||
defer func() {
|
||
if sink, ok := rc.(interface{ AddUsage(core.Usage) }); ok {
|
||
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
|
||
}
|
||
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.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
|
||
}
|
||
|
||
res, err := nruntime.Run(rc.Context(), nruntime.Config{
|
||
Model: model,
|
||
History: history,
|
||
Tools: reg,
|
||
Stream: wrapped,
|
||
MaxTurns: maxTurns,
|
||
Budget: &nruntime.Budget{MaxToolCalls: maxTools},
|
||
RepeatGuard: true,
|
||
ContinueOnLength: true,
|
||
OnEvent: st.onEvent,
|
||
Stop: st.stop,
|
||
Reminders: st.reminders,
|
||
UnknownTool: func(call agentllm.ToolCall) agenttools.Result {
|
||
return st.runTool(call.Name, call.Arguments)
|
||
},
|
||
}, user)
|
||
if res.Notice != "" {
|
||
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.deliverables {
|
||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartArtifactRef, Artifact: ref})
|
||
}
|
||
_ = 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
|
||
}
|
||
|
||
func (st *directRun) onEvent(ev nruntime.Event) {
|
||
switch ev.Type {
|
||
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":
|
||
st.usageTotal.PromptTokens += ev.PromptTokens
|
||
st.usageTotal.CompletionTokens += ev.CompletionTokens
|
||
st.usageTotal.TotalTokens += ev.TotalTokens
|
||
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})
|
||
_ = st.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: slot.call.ID, Status: slot.status, Summary: slot.summary, Truncated: slot.truncated})
|
||
st.inflight = nil
|
||
}
|
||
}
|
||
|
||
func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntime.StopDecision {
|
||
if block, why := deliverableGate(st.turnBody, st.deliverables, st.rc); block {
|
||
st.demoteLastText()
|
||
if st.eff.GetBool("runtime.verify", false) {
|
||
st.addVerifyNote(why)
|
||
}
|
||
if info.Continuations < maxDeliverableGuards {
|
||
return nruntime.StopDecision{Continue: true, Replacement: discardedClaimNote, Reason: deliverableGuardNote}
|
||
}
|
||
return nruntime.StopDecision{Notice: deliverableFallbackNotice}
|
||
}
|
||
if !st.eff.GetBool("runtime.verify", false) {
|
||
return nruntime.StopDecision{}
|
||
}
|
||
if reasons := checkDeliverables(st.rc, st.deliverables); len(reasons) > 0 {
|
||
return st.continueOrNotice(info, strings.Join(reasons, ";"))
|
||
}
|
||
verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, st.deliverables)
|
||
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)
|
||
}
|
||
return nruntime.StopDecision{}
|
||
}
|
||
|
||
func (st *directRun) demoteLastText() {
|
||
for i := len(st.finalParts) - 1; i >= 0; i-- {
|
||
if st.finalParts[i].Type == core.PartText {
|
||
st.finalParts[i].Process = true
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func (st *directRun) addVerifyNote(reason string) {
|
||
st.finalParts = append(st.finalParts, core.Part{
|
||
Type: core.PartText,
|
||
Process: true,
|
||
Text: "(验证未通过:" + reason + ")",
|
||
})
|
||
}
|
||
|
||
func (st *directRun) continueOrNotice(info nruntime.StopInfo, reason string) nruntime.StopDecision {
|
||
if info.Continuations < maxVerifyContinuations {
|
||
st.demoteLastText()
|
||
st.addVerifyNote(reason)
|
||
return nruntime.StopDecision{Continue: true, Reason: 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{ Name, Task, Hint string }
|
||
_ = json.Unmarshal([]byte(call.Arguments), &a)
|
||
res, serr := st.rc.SpawnSubagent(orDefault(a.Name, "子代理"), a.Task, a.Hint)
|
||
if serr != nil {
|
||
out = "子代理失败:" + serr.Error()
|
||
} else {
|
||
out = orDefault(res.Summary, "子代理完成")
|
||
}
|
||
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 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
|
||
}
|
||
if len(out) > 16000 {
|
||
out = out[:16000] + "…(超长截断)"
|
||
}
|
||
return agenttools.Result{Content: out, IsError: err != nil}
|
||
}
|
||
|
||
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 errString(err error) string {
|
||
if err == nil {
|
||
return ""
|
||
}
|
||
return err.Error()
|
||
}
|