Merge pull request 'Run direct chat through the native turn loop' (#43) from feature/agent-harness-p1 into main

This commit was merged in pull request #43.
This commit is contained in:
2026-10-01 13:41:41 +00:00
4 changed files with 527 additions and 311 deletions
+2 -310
View File
@@ -10,13 +10,11 @@ import (
agentllm "agent-native/runtime/llm"
"agent-system-backend/internal/ctxasm"
"agent-system-backend/internal/prompts"
"agent-system-backend/internal/squadadapter"
core "agent-system-core"
)
// LLMRunner 主对话执行器:复用 agent-native 的 OpenAI 兼容流式客户端(不自研接入层)。
// 多轮循环:流式 → 工具调用(画像策略)→ 回灌结果 → 直至无工具调用或预算耗尽。
// 内置三个编排型工具(不经注册表):delegate_task(子代理)、update_plan、update_todos。
// 回合循环交给 runtime.Run;这里负责模型路由、提示词、上下文组装和工具声明。
type LLMRunner struct {
// Prompts 提示词库(nil = 回退画像 SystemPrompt 单段,兼容测试与骨架)。
Prompts *prompts.Service
@@ -267,19 +265,14 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
Parameters: json.RawMessage(`{"type":"object","properties":{}}`),
})
}
var toolDescs []core.ToolDesc
for _, t := range toolList(rc) {
if prof.Tools.ToolAllowed(t.Name) {
td := t
td.Description = l.toolDesc(prof.Owner, td.Name, td.Description)
toolDescs = append(toolDescs, td)
decls = append(decls, agentllm.ToolDecl{Name: td.Name, Description: td.Description, Parameters: td.Parameters})
}
}
var finalParts []core.Part
explicitCard := false // 本 run 已显式提交产物卡(submit_card/card_commit;门面卡避让,D47)
usageTotal := core.Usage{}
maxTurns := 16
if prof.Budget.MaxTurns != nil {
maxTurns = *prof.Budget.MaxTurns
@@ -293,308 +286,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
if prof.Budget.MaxTokens != nil && *prof.Budget.MaxTokens > 0 {
streamOpts.MaxTokens = *prof.Budget.MaxTokens
}
defer func() {
if sink, ok := rc.(interface{ AddUsage(core.Usage) }); ok {
sink.AddUsage(usageTotal)
}
}()
toolCallsMade := 0
thinkingIdx, textIdx := 0, 0
deliverableGuards := 0
// 交付物引用(对话内直接给出):工具轮产生的工件不进过程折叠区,
// 收集后作为内容部件钉在消息尾部(最终正文之后)——回复完给文件卡。
var deliverables []*core.ArtifactRef
for turn := 0; turn < maxTurns; turn++ {
select {
case <-rc.Context().Done():
return rc.Context().Err()
default:
}
var turnThinking, turnText strings.Builder
var calls []agentllm.ToolCall
var usage agentllm.Usage
var thinkStart, thinkEnd, textStart, textEnd time.Time
ev := stream(rc.Context(), route.Model, msgs, decls, streamOpts)
phaseEmitted := false
for e := range ev {
switch e.Kind {
case agentllm.KindTextDelta:
if !phaseEmitted {
rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseResponding})
phaseEmitted = true
}
now := l.now()
if textStart.IsZero() {
textStart = now
}
textEnd = now
turnText.WriteString(e.Text)
rc.Emit(core.EvMessageDelta, core.MessageDeltaPayload{Index: textIdx, Text: e.Text})
case agentllm.KindReasoningDelta:
now := l.now()
if thinkStart.IsZero() {
thinkStart = now
}
thinkEnd = now
turnThinking.WriteString(e.ReasoningDelta)
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: thinkingIdx, Text: e.ReasoningDelta})
case agentllm.KindToolCallDelta:
// 累积由 message_end 的 ToolCalls 提供;此处忽略增量。
case agentllm.KindMessageEnd:
calls = e.Message.ToolCalls
usage = e.Usage
case agentllm.KindError:
return fmt.Errorf("llm stream: %s (retryable=%v)", e.ErrorMessage, e.Retryable)
}
}
usageTotal.PromptTokens += usage.PromptTokens
usageTotal.CompletionTokens += usage.CompletionTokens
usageTotal.TotalTokens += usage.TotalTokens
// 记录本轮部件。
if turnThinking.Len() > 0 {
finalParts = append(finalParts, core.Part{Type: core.PartThinking, Text: turnThinking.String(), Process: true, DurationMs: durationMs(thinkStart, thinkEnd)})
thinkingIdx++
}
turnBody := ""
textPartIdx := -1
if turnText.Len() > 0 {
// 工具轮的文本是过程解说(终态随过程折叠);终轮文本才是对用户的回复。
// 部分模型把推理以 <think> 内联混进 content 流:折离为思考部件(过程),
// 保证「最终输出 = 干净的对用户回复」契约。
body, think := splitThinkBlock(turnText.String())
if think != "" {
inlineDur := durationMs(thinkStart, thinkEnd)
if inlineDur == 0 {
inlineDur = durationMs(textStart, textEnd)
}
finalParts = append(finalParts, core.Part{Type: core.PartThinking, Text: think, Process: true, DurationMs: inlineDur})
thinkingIdx++
}
if body != "" {
textPartIdx = len(finalParts)
finalParts = append(finalParts, core.Part{Type: core.PartText, Text: body, Process: len(calls) > 0})
turnBody = body
textIdx++
}
}
if len(calls) == 0 {
// 谎称交付:没产生工件却报文件已生成、引用了不存在的工件 ID,
// 或在正文里模仿工具调用。形状与日后的 Stop 门禁一致:block + reason。
// 纠正最多两次;回灌占位而不是伪造正文。用尽后不再请求模型,改附系统提示。
if block, _ := deliverableGate(turnBody, deliverables, rc); block {
if textPartIdx >= 0 {
finalParts[textPartIdx].Process = true
}
if deliverableGuards < maxDeliverableGuards {
deliverableGuards++
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleAssistant, Content: discardedClaimNote})
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleUser, Content: deliverableGuardNote})
continue
}
finalParts = append(finalParts, core.Part{Type: core.PartText, Text: deliverableFallbackNotice})
}
// 终轮:无工具调用。
if len(finalParts) == 0 {
finalParts = append(finalParts, core.Part{Type: core.PartText, Text: "(模型未返回内容)"})
}
// 交付物卡片钉尾(内容部件,不折叠):live 投影(live.ts)与落库同构。
for _, ref := range deliverables {
finalParts = append(finalParts, core.Part{Type: core.PartArtifactRef, Artifact: ref})
}
rc.SetAssistantParts(finalParts)
return nil
}
// 工具轮。
rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseActing})
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleAssistant, Content: turnText.String(), ToolCalls: calls, StopReason: agentllm.StopToolCalls})
for _, call := range calls {
toolCallsMade++
if toolCallsMade > maxTools {
return fmt.Errorf("budget: 工具调用超过上限 %d(profile.budget.maxToolCalls)", maxTools)
}
rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: call.ID, Name: call.Name, Arguments: call.Arguments})
callStart := l.now()
var out string
var err error
var artifactRef *core.ArtifactRef
switch call.Name {
case toolSquad:
if !eff.GetBool("runtime.squad", true) {
out = "小队编排已被设置禁用(runtime.squad)"
break
}
var a struct{ Goal string }
json.Unmarshal([]byte(call.Arguments), &a)
dataDir := eff.GetString("storage.dataDir", "./data")
summary, serr := squadadapter.Orchestrate(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 := 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 err := json.Unmarshal([]byte(call.Arguments), &p); err == nil {
rc.UpdatePlan(p)
out = "计划已更新"
} else {
out = "计划参数错误: " + err.Error()
}
case toolTodos:
var t core.TodoList
if err := json.Unmarshal([]byte(call.Arguments), &t); err == nil {
rc.UpdateTodos(t)
out = "待办已更新"
} else {
out = "待办参数错误: " + err.Error()
}
case toolCard:
if !cardflow {
// 未声明却被幻觉调用:拒绝执行(不产卡),明确告知模型该能力未启用。
out = "submit_card 未启用(设置 ui.cardflow 关闭);内容直接写在对话回复里"
break
}
var a struct{ Title, Summary, Body, Scope string }
if err := json.Unmarshal([]byte(call.Arguments), &a); err != nil || strings.TrimSpace(a.Title) == "" || strings.TrimSpace(a.Body) == "" {
out = "submit_card 参数错误(title/body 必填): " + err.Error()
break
}
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(rc.Run().ProjectID) // 项目级卡(D48):收进项目收件箱
}
card, cerr := rc.SubmitCard(newCard)
if cerr != nil {
out = "submit_card 失败: " + cerr.Error()
break
}
finalParts = append(finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
explicitCard = true
where := "对话"
if a.Scope == "project" {
where = "项目"
}
out = fmt.Sprintf("已作为卡片发出到%s收件箱(%s,card:%s)。对话中保留简短结论即可,不要重复卡片全文。", where, a.Title, card.ID)
case toolCardBegin:
if !cardflow {
out = "card_begin 未启用(设置 ui.cardflow 关闭);内容直接写在对话回复里"
break
}
var a struct{ Title, Summary, Scope string }
if err := json.Unmarshal([]byte(call.Arguments), &a); err != nil || strings.TrimSpace(a.Title) == "" {
out = "card_begin 参数错误(title 必填): " + err.Error()
break
}
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(rc.Run().ProjectID) // 项目级卡(D48)
}
card, cerr := rc.BeginStreamCard(newCard)
if cerr != nil {
out = "card_begin 失败: " + cerr.Error()
break
}
out = fmt.Sprintf("流式卡已开始(%s,card:%s)。用 card_append 逐段追加正文,写完调用 card_commit 收尾。", a.Title, card.ID)
case toolCardAppend:
if !cardflow {
out = "card_append 未启用(设置 ui.cardflow 关闭);内容直接写在对话回复里"
break
}
var a struct{ Text string }
if err := json.Unmarshal([]byte(call.Arguments), &a); err != nil {
out = "card_append 参数错误: " + err.Error()
break
}
if aerr := rc.AppendStreamCard(a.Text); aerr != nil {
out = "card_append 失败: " + aerr.Error()
break
}
out = "已追加"
case toolCardCommit:
if !cardflow {
out = "card_commit 未启用(设置 ui.cardflow 关闭);内容直接写在对话回复里"
break
}
card, cerr := rc.CommitStreamCard()
if cerr != nil {
out = "card_commit 失败: " + cerr.Error()
break
}
finalParts = append(finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
explicitCard = true
out = fmt.Sprintf("已作为卡片发出(%s,card:%s)。对话中保留简短结论即可,不要重复卡片全文。", card.Title, card.ID)
default:
res := 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 {
artifactRef = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
// 工具大结果门面卡(D47):截断超限的结果在收件箱留一张可发现
// 的门面(摘要 + 来源 + doc-ref),正文权威仍在工件(不复制字节)。
// 本 run 已显式提交产物卡时避让(防双卡)。
if cardflow && res.Truncated && !explicitCard {
fc, ferr := rc.SubmitCard(artifactFacadeCard(call.Name, *res.Artifact, summarize(out, nil)))
if ferr == nil {
finalParts = append(finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: fc.ID}})
}
}
}
rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: call.ID, Status: res.Status, Summary: summarize(out, err), Truncated: res.Truncated})
}
if call.Name == toolDelegate || call.Name == toolPlan || call.Name == toolTodos || call.Name == toolSquad || call.Name == toolCard ||
call.Name == toolCardBegin || call.Name == toolCardAppend || call.Name == toolCardCommit {
rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: call.ID, Status: "ok", Summary: summarize(out, err)})
}
summary := summarize(out, nil)
if err != nil {
summary = "error: " + err.Error()
out = summary
}
finalParts = append(finalParts,
core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: call.ID, Name: call.Name, Arguments: call.Arguments, Duration: durationMs(callStart, l.now())}},
core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: call.ID, Status: statusOf(err), Summary: summary}},
)
if artifactRef != nil {
deliverables = append(deliverables, artifactRef)
}
// 回灌工具结果(截断保护)。
if len(out) > 16000 {
out = out[:16000] + "…(超长截断)"
}
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleTool, ToolCallID: call.ID, Name: call.Name, Content: out, IsError: err != nil})
}
}
return fmt.Errorf("budget: 轮次超过上限 %d(profile.budget.maxTurns)", maxTurns)
return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools)
}
// SubLLMRunner 子代理执行器:轻量模型单轮完成任务并返回结果。
+434
View File
@@ -0,0 +1,434 @@
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
}
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}
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,
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 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.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 {
block, _ := deliverableGate(st.turnBody, st.deliverables, st.rc)
if !block {
return nruntime.StopDecision{}
}
for i := len(st.finalParts) - 1; i >= 0; i-- {
if st.finalParts[i].Type == core.PartText {
st.finalParts[i].Process = true
break
}
}
if info.Continuations < maxDeliverableGuards {
return nruntime.StopDecision{Continue: true, Replacement: discardedClaimNote, Reason: deliverableGuardNote}
}
return nruntime.StopDecision{Notice: deliverableFallbackNotice}
}
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)
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)
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()
}
+90
View File
@@ -30,6 +30,7 @@ type fakeRunCtx struct {
toolDecls []agentllm.ToolDecl
execArtifact *core.Artifact // ExecuteTool 返回的工件(非 nil 时走工件路径)
execTruncated bool // ExecuteTool 返回的截断标记(门面卡触发)
toolRuns int // ExecuteTool 被调用的次数
execArtifactContent []byte // Artifact(id) 回填内容(ctxasm D46 断言用)
}
@@ -61,6 +62,7 @@ func (f *fakeRunCtx) WriteArtifact(name, kind, mime string, content []byte) (cor
}
func (f *fakeRunCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { return nil, nil }
func (f *fakeRunCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
f.toolRuns++
res := core.ToolCallResult{Status: "ok", Output: "done", Truncated: f.execTruncated}
if f.execArtifact != nil {
res.Artifact = f.execArtifact
@@ -876,3 +878,91 @@ func TestLLMRunnerExistingArtifactCitationNotGuarded(t *testing.T) {
t.Fatalf("citation should stay the final reply, got %+v", texts)
}
}
// TestLLMRunnerToolCapKeepsParts 验证:超限的那一批工具在执行前被整批拒绝,
// 已经完成的工具轮部件仍然落库。内核按整批计数,不再执行批内第 N+1 个调用。
func TestLLMRunnerToolCapKeepsParts(t *testing.T) {
one := 1
turns := [][]agentllm.Event{
{
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "先做一件。"},
agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{
{ID: "c1", Name: "make_report", Arguments: "{}"},
}}},
},
{
agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{
{ID: "c2", Name: "make_report", Arguments: "{}"},
{ID: "c3", Name: "make_report", Arguments: "{}"},
}}},
},
}
r := &LLMRunner{Stream: scriptStream(turns, nil)}
rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{
Model: core.ModelRoute{Chat: "test-model"},
Budget: core.RunBudget{MaxToolCalls: &one},
}}
err := r.Execute(rc)
if err == nil || !strings.Contains(err.Error(), "工具调用超过上限") {
t.Fatalf("err = %v", err)
}
if rc.toolRuns != 1 {
t.Fatalf("tool runs = %d, want 1 (the over-cap batch is rejected whole)", rc.toolRuns)
}
var calls int
for _, p := range rc.assistant {
if p.Type == core.PartToolCall {
calls++
}
}
if calls != 1 {
t.Fatalf("saved tool-call parts = %d, want the completed turn only", calls)
}
}
// TestLLMRunnerContinuesWhenTruncated 验证:finish_reason=length 且没有工具调用时续写。
func TestLLMRunnerContinuesWhenTruncated(t *testing.T) {
turns := [][]agentllm.Event{
{
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "写到一半"},
agentllm.Event{Kind: agentllm.KindMessageEnd, FinishReason: "length", Message: agentllm.Message{Content: "写到一半"}},
},
{
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "后半写完了。"},
agentllm.Event{Kind: agentllm.KindMessageEnd, FinishReason: "stop", Message: agentllm.Message{Content: "后半写完了。"}},
},
}
var calls int
var second string
base := scriptStream(turns, nil)
r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
calls++
if calls == 2 {
second = transcriptOf(msgs)
}
return base(ctx, model, msgs, tools, opts)
}}
rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
if calls != 2 {
t.Fatalf("stream calls = %d, want 2", calls)
}
if !strings.Contains(second, "上一段回复因为达到长度上限被截断") {
t.Fatalf("continuation prompt missing: %s", second)
}
texts := textParts(rc.assistant)
if len(texts) != 2 || texts[0].Text != "写到一半" || texts[1].Text != "后半写完了。" || texts[1].Process {
t.Fatalf("texts = %+v", texts)
}
}
func transcriptOf(msgs []agentllm.Message) string {
var b strings.Builder
for _, m := range msgs {
b.WriteString(m.Content)
b.WriteByte('\n')
}
return b.String()
}