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:
Vendored
+1
-1
Submodule deps/agent-native updated: 6b7d2432b3...a34689c78f
+2
-310
@@ -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 子代理执行器:轻量模型单轮完成任务并返回结果。
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user