From cfcf95d33524d5313f2cd967c8632553f28662b2 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 1 Oct 2026 13:28:17 +0000 Subject: [PATCH] Run direct chat through the native turn loop Why: deliverable checks, truncation continuation, and tool budgets should live in one loop instead of a second copy inside LLMRunner. Evidence: go test ./... in backend passed. Existing runtime assertions were unchanged; new tests cover whole-batch tool-cap rejection with parts saved, and length continuation. Impact: the direct path enables repeat guard and length continuation. Squad still uses the one-shot deliverable predicate and does not set the new kernel switches. Refs: feature/agent-harness-p1 Co-authored-by: Cursor --- deps/agent-native | 2 +- internal/runtime/llm.go | 312 +----------------------- internal/runtime/llm_direct.go | 434 +++++++++++++++++++++++++++++++++ internal/runtime/llm_test.go | 90 +++++++ 4 files changed, 527 insertions(+), 311 deletions(-) create mode 100644 internal/runtime/llm_direct.go diff --git a/deps/agent-native b/deps/agent-native index 6b7d243..a34689c 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit 6b7d2432b3bc4f2bbdbde12c26d0c83f09450f47 +Subproject commit a34689c78fbddbfa34b343cd6f85cca36c4d2f9d diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index 28bb3e1..919aad0 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -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 { - // 工具轮的文本是过程解说(终态随过程折叠);终轮文本才是对用户的回复。 - // 部分模型把推理以 内联混进 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 子代理执行器:轻量模型单轮完成任务并返回结果。 diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go new file mode 100644 index 0000000..0f3c8de --- /dev/null +++ b/internal/runtime/llm_direct.go @@ -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() +} diff --git a/internal/runtime/llm_test.go b/internal/runtime/llm_test.go index b7e2980..06e70ca 100644 --- a/internal/runtime/llm_test.go +++ b/internal/runtime/llm_test.go @@ -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() +} -- 2.54.0