From cd74434b4f0f1226042e261737e7ca21ca0807ca Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 2 Oct 2026 02:17:02 +0000 Subject: [PATCH] Stream tool progress and drop hard agent limits Why: Tool calls stayed invisible until execution finished, and turn, tool, timeout, and output caps stopped long tasks early. Evidence: go test ./internal/runtime/ ./internal/tools/ ./internal/prompts/ ./internal/squadadapter/ ./internal/runmgr/ ./internal/app/ ./internal/settings/ Impact: tool.call.delta and tool.output reach the client; default profile, subagents, and squad no longer cap turns or tool calls; exec timeout defaults to 600s with no hard ceiling. A profile or subagent maxTurns above 0 still applies. Refs: feature/stream-display-overhaul Co-authored-by: Cursor --- deps/agent-native | 2 +- deps/agent-system-core | 2 +- internal/app/app.go | 6 +- internal/profiles/profiles.go | 5 +- internal/prompts/library/agent-sub-explore.md | 1 - internal/prompts/library/agent-sub-general.md | 1 - internal/prompts/library/agent-sub-plan.md | 1 - .../prompts/library/agent-sub-research.md | 1 - internal/prompts/library/agent-sub-review.md | 1 - internal/prompts/library/agent-sub-verify.md | 1 - .../library/agent-sub-workflow-plan.md | 1 - internal/prompts/library/agent-sub-writer.md | 1 - internal/prompts/parse.go | 6 +- internal/prompts/prompts_test.go | 13 ++-- internal/prompts/service.go | 4 +- internal/runmgr/events.go | 2 +- internal/runmgr/runmgr.go | 23 ++++--- internal/runtime/llm.go | 57 ++++++++++++---- internal/runtime/llm_direct.go | 40 +++++++---- internal/runtime/llm_test.go | 53 +++++++++++++++ internal/runtime/squad_runner.go | 11 +-- internal/runtime/sub_loop.go | 33 +++++---- internal/runtime/sub_loop_test.go | 16 ++--- internal/runtime/verify.go | 2 +- internal/runtime/workflow.go | 2 +- internal/settings/settings.go | 2 + internal/squadadapter/adapter.go | 3 +- internal/squadadapter/entry.go | 6 +- internal/tools/exec.go | 3 +- internal/tools/exec_interpreter.go | 67 +++++++++++++++---- internal/tools/exec_interpreter_test.go | 16 +++++ 31 files changed, 263 insertions(+), 119 deletions(-) diff --git a/deps/agent-native b/deps/agent-native index a34689c..c6d867c 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit a34689c78fbddbfa34b343cd6f85cca36c4d2f9d +Subproject commit c6d867cde7d87696feaf3073f097ca15b0145273 diff --git a/deps/agent-system-core b/deps/agent-system-core index f93f473..f08a4a9 160000 --- a/deps/agent-system-core +++ b/deps/agent-system-core @@ -1 +1 @@ -Subproject commit f93f473a0ea898785921e3c4fb16114549fd2783 +Subproject commit f08a4a90c63525401671231e262fd6c45e9c1bff diff --git a/internal/app/app.go b/internal/app/app.go index 11daac3..a1abf66 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -149,10 +149,10 @@ func Run(opt Options) error { return squad.AgentNativeConfig{}, false } cfg := squad.AgentNativeConfig{ - Client: &agentllm.Client{BaseURL: route.Base, APIKey: route.Key}, + Client: &agentllm.Client{BaseURL: route.Base, APIKey: route.Key, HTTPClient: &http.Client{}}, DefaultModel: route.Model, - MaxTurns: 12, - Timeout: 10 * time.Minute, + MaxTurns: 0, + Timeout: 0, Subagent: squadadapter.SubagentFactory(rc), } if keys := splitKeyPool(effNow.GetString("tools.tavily.apiKeys", "")); len(keys) > 0 { diff --git a/internal/profiles/profiles.go b/internal/profiles/profiles.go index 99275b2..f05e17b 100644 --- a/internal/profiles/profiles.go +++ b/internal/profiles/profiles.go @@ -19,8 +19,6 @@ const DefaultProfileID = "default" const defaultSystemPrompt = "" func DefaultProfile() core.AgentProfile { - maxTools := 40 - maxTurns := 16 return core.AgentProfile{ ID: DefaultProfileID, Name: "通用助手", @@ -32,7 +30,8 @@ func DefaultProfile() core.AgentProfile { Tools: core.ToolPolicy{Allow: []string{"*"}, Confirm: []string{"http."}}, Context: core.ContextPolicy{MaxHistoryMessages: 40, MaxToolResultChars: 20000}, HITL: core.HITLPolicy{AskOnAmbiguity: true}, - Budget: core.RunBudget{MaxToolCalls: &maxTools, MaxTurns: &maxTurns}, + // 空预算 = 不限制轮次、工具调用数和输出 token。用户自建画像可以自行设上限。 + Budget: core.RunBudget{}, } } diff --git a/internal/prompts/library/agent-sub-explore.md b/internal/prompts/library/agent-sub-explore.md index fd1ed77..cf99b92 100644 --- a/internal/prompts/library/agent-sub-explore.md +++ b/internal/prompts/library/agent-sub-explore.md @@ -6,7 +6,6 @@ title: 探索子代理(只读) description: 只读检索专家——定位文件/内容/事实,不做任何修改 tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir model: lightweight -maxTurns: 8 --- 你是只读探索子代理,职责是高效且彻底地定位目标信息。 diff --git a/internal/prompts/library/agent-sub-general.md b/internal/prompts/library/agent-sub-general.md index d62a373..ad31376 100644 --- a/internal/prompts/library/agent-sub-general.md +++ b/internal/prompts/library/agent-sub-general.md @@ -6,7 +6,6 @@ title: 通用子代理 description: 独立完成子任务并返回结构化结果(默认子代理提示词) tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, write_artifact, quality_review model: lightweight -maxTurns: 12 --- 你是被派生的子代理,负责独立完成一个子任务。 diff --git a/internal/prompts/library/agent-sub-plan.md b/internal/prompts/library/agent-sub-plan.md index 91fdddb..b9f54ce 100644 --- a/internal/prompts/library/agent-sub-plan.md +++ b/internal/prompts/library/agent-sub-plan.md @@ -6,7 +6,6 @@ title: 规划子代理(只出计划) description: 分析并产出可执行的分步实现计划,不执行实现 tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir model: lightweight -maxTurns: 6 --- 你是规划子代理:负责分析任务并产出详细、可执行的实现计划。你是规划者,不是实现者——不修改任何对象,只输出计划。 diff --git a/internal/prompts/library/agent-sub-research.md b/internal/prompts/library/agent-sub-research.md index 465332b..c909d7d 100644 --- a/internal/prompts/library/agent-sub-research.md +++ b/internal/prompts/library/agent-sub-research.md @@ -6,7 +6,6 @@ title: 调研子代理 description: 围绕问题多源调研,输出带依据的结论 tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir model: lightweight -maxTurns: 10 --- 你是调研子代理,围绕给定问题收集信息并给出结论。 diff --git a/internal/prompts/library/agent-sub-review.md b/internal/prompts/library/agent-sub-review.md index b569537..8c7fa73 100644 --- a/internal/prompts/library/agent-sub-review.md +++ b/internal/prompts/library/agent-sub-review.md @@ -6,7 +6,6 @@ title: 评审子代理(只读找问题) description: 只读审查材料,输出 JSON 问题清单,不修改任何内容 tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, quality_review model: lightweight -maxTurns: 8 --- 你是只读评审子代理。只找问题,不修改材料,也不下最终通过结论。 diff --git a/internal/prompts/library/agent-sub-verify.md b/internal/prompts/library/agent-sub-verify.md index f64a945..fb88230 100644 --- a/internal/prompts/library/agent-sub-verify.md +++ b/internal/prompts/library/agent-sub-verify.md @@ -6,7 +6,6 @@ title: 验证子代理(对抗审查) description: 对交付物做对抗性验证,必须输出明确裁决 tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, quality_review model: lightweight -maxTurns: 8 --- 你是验证子代理,对指定交付物做对抗性验证:假设它有问题,努力找出问题。 diff --git a/internal/prompts/library/agent-sub-workflow-plan.md b/internal/prompts/library/agent-sub-workflow-plan.md index c7b7cd9..726ea53 100644 --- a/internal/prompts/library/agent-sub-workflow-plan.md +++ b/internal/prompts/library/agent-sub-workflow-plan.md @@ -6,7 +6,6 @@ title: 工作流规划子代理 description: 把目标写成可校验的工作流 JSON,不执行步骤 tools: knowledge_search, time_now model: lightweight -maxTurns: 4 --- 你是工作流规划子代理。用户给你的是待规划的目标,不是要你现在完成的任务。不要调用工具,不要回答目标本身。只输出一个 JSON 对象,不要解释,不要写脚本。 diff --git a/internal/prompts/library/agent-sub-writer.md b/internal/prompts/library/agent-sub-writer.md index 99fb3c1..5eecda1 100644 --- a/internal/prompts/library/agent-sub-writer.md +++ b/internal/prompts/library/agent-sub-writer.md @@ -6,7 +6,6 @@ title: 撰稿子代理 description: 把要点与调研结论整理为完整成文的交付文档 tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, write_artifact, quality_review, run_python, register_artifact model: chat -maxTurns: 12 --- 你是撰稿子代理,负责把前序要点与调研结论整理成完整的交付文档。 diff --git a/internal/prompts/parse.go b/internal/prompts/parse.go index 4542838..aebe2e8 100644 --- a/internal/prompts/parse.go +++ b/internal/prompts/parse.go @@ -133,15 +133,13 @@ type SubagentSpec struct { MaxTurns int } -// specFromModule 由模块字段派生 profile。未写的 model / maxTurns 用默认值。 +// specFromModule 由模块字段派生 profile。未写 model 时用 lightweight; +// maxTurns 为 0 表示不限制轮次。 func specFromModule(m core.PromptModule) SubagentSpec { spec := SubagentSpec{Tools: m.Tools, Model: m.Model, MaxTurns: m.MaxTurns} if spec.Model == "" { spec.Model = "lightweight" } - if spec.MaxTurns <= 0 { - spec.MaxTurns = 8 - } return spec } diff --git a/internal/prompts/prompts_test.go b/internal/prompts/prompts_test.go index e5d7db7..3733b14 100644 --- a/internal/prompts/prompts_test.go +++ b/internal/prompts/prompts_test.go @@ -360,12 +360,12 @@ func TestBuiltinSubagentSpecs(t *testing.T) { tool string ban string }{ - "agent.sub.explore": {"lightweight", 8, "knowledge_search", "run_python"}, - "agent.sub.research": {"lightweight", 10, "knowledge_search", "write_artifact"}, - "agent.sub.plan": {"lightweight", 6, "knowledge_search", "register_artifact"}, - "agent.sub.verify": {"lightweight", 8, "quality_review", "run_python"}, - "agent.sub.general": {"lightweight", 12, "write_artifact", "run_python"}, - "agent.sub.writer": {"chat", 12, "run_python", "delegate_task"}, + "agent.sub.explore": {"lightweight", 0, "knowledge_search", "run_python"}, + "agent.sub.research": {"lightweight", 0, "knowledge_search", "write_artifact"}, + "agent.sub.plan": {"lightweight", 0, "knowledge_search", "register_artifact"}, + "agent.sub.verify": {"lightweight", 0, "quality_review", "run_python"}, + "agent.sub.general": {"lightweight", 0, "write_artifact", "run_python"}, + "agent.sub.writer": {"chat", 0, "run_python", "delegate_task"}, } for id, w := range want { spec, ok := svc.SubagentSpec("", id) @@ -437,7 +437,6 @@ func TestSubagentOverrideRejected(t *testing.T) { cases := []core.PromptModule{ {ID: "task.summarize", Layer: core.LayerTask, Title: "总结", Body: "正文", Tools: []string{"time_now"}}, {ID: "agent.sub.explore", Layer: core.LayerAgent, Title: "探索", Body: "正文", Model: "huge"}, - {ID: "agent.sub.explore", Layer: core.LayerAgent, Title: "探索", Body: "正文", MaxTurns: 31}, {ID: "agent.sub.explore", Layer: core.LayerAgent, Title: "探索", Body: "正文", MaxTurns: -1}, {ID: "agent.sub.explore", Layer: core.LayerAgent, Title: "探索", Body: "正文", Tools: []string{"delegate_task"}}, {ID: "agent.sub.explore", Layer: core.LayerAgent, Title: "探索", Body: "正文", Tools: []string{"squad_orchestrate"}}, diff --git a/internal/prompts/service.go b/internal/prompts/service.go index abbeeae..948e9aa 100644 --- a/internal/prompts/service.go +++ b/internal/prompts/service.go @@ -198,8 +198,8 @@ func validateSubagentOverride(m core.PromptModule) error { if m.Model != "" && m.Model != "lightweight" && m.Model != "chat" { return fmt.Errorf("model 只能是 lightweight 或 chat") } - if m.MaxTurns < 0 || m.MaxTurns > 30 { - return fmt.Errorf("maxTurns 必须在 0 到 30 之间") + if m.MaxTurns < 0 { + return fmt.Errorf("maxTurns 不能为负;0 表示不限制") } for _, name := range m.Tools { if _, bad := dispatchTools[name]; bad { diff --git a/internal/runmgr/events.go b/internal/runmgr/events.go index e4bc2af..9e41162 100644 --- a/internal/runmgr/events.go +++ b/internal/runmgr/events.go @@ -73,7 +73,7 @@ func (eb *eventBuffer) stop() { // isDelta 标记高频可缓冲事件。 func isDelta(typ core.StreamEventType) bool { switch typ { - case core.EvMessageDelta, core.EvThinkingDelta, core.EvAgentProgress: + case core.EvMessageDelta, core.EvThinkingDelta, core.EvAgentProgress, core.EvToolCallDelta, core.EvToolOutput: return true } return false diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index f4cdaeb..34e35f5 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -591,7 +591,7 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"} } } - tc := &toolCtx{rc: c} + tc := &toolCtx{rc: c, callID: call.CallID} res := c.mgr.registry.Execute(tc, call) // 工具产物回填(交付物卡片根因修复):注册表工具执行期经 rc.WriteArtifact // 写入的工件(write_artifact / fs_write_file / register_artifact / 未来执行 @@ -621,8 +621,9 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { // toolCtx 包装 runCtx 供工具执行;arts 记录本调用期间写入的工件, // ExecuteTool 据此回填 res.Artifact(对话内交付物卡片的来源)。 type toolCtx struct { - rc *runCtx - arts []core.Artifact + rc *runCtx + arts []core.Artifact + callID string } func (t *toolCtx) ProjectID() string { return t.rc.run.ProjectID } @@ -642,6 +643,14 @@ func (t *toolCtx) OwnerID() string { return t.rc.run.Owner func (t *toolCtx) WorkspaceRoot() string { return t.rc.mgr.workspaceRoot } func (t *toolCtx) SettingBool(key string, def bool) bool { return t.rc.eff.GetBool(key, def) } +// EmitToolOutput 把解释器执行中的输出增量发到会话事件流。 +func (t *toolCtx) EmitToolOutput(stream, text string) { + if t.callID == "" || text == "" { + return + } + _ = t.rc.Emit(core.EvToolOutput, core.ToolOutputPayload{CallID: t.callID, Stream: stream, Text: text}) +} + // ---- 跨会话读取门禁(提案 2026-09-30-cross-session-reading;fail-closed)---- // // canRead(A→B) = crossSession.read(项目闸门) AND A.allowCrossRead(出站,仅读他人时) @@ -888,6 +897,9 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage, m.mu.Lock() parts := append([]core.Part{}, rc.h.assistant...) m.mu.Unlock() + if errPart != nil { + parts = append(parts, core.Part{Type: core.PartError, Err: errPart}) + } if len(parts) > 0 { msg := core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: parts} if err := m.st.AppendMessage(&msg); err == nil { @@ -905,11 +917,6 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage, } } } - if errPart != nil { - m.st.AppendMessage(&core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartError, Err: errPart}}}) - } - } else if errPart != nil { - m.st.AppendMessage(&core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartError, Err: errPart}}}) } // 流式卡兜底(D45):run 终态时仍有未 commit 的流式卡——按 run 结果置 diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index fffdb65..927e0f9 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -3,10 +3,13 @@ package runtime import ( "encoding/json" "fmt" + "math" + "net/http" "regexp" "strings" "time" + nruntime "agent-native/runtime" agentllm "agent-native/runtime/llm" "agent-system-backend/internal/ctxasm" "agent-system-backend/internal/prompts" @@ -196,7 +199,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error { stream := l.Stream if stream == nil { - client := &agentllm.Client{BaseURL: route.Base, APIKey: route.Key} + client := openLLMClient(route.Base, route.Key) stream = client.Stream } @@ -279,22 +282,48 @@ func (l *LLMRunner) Execute(rc RunCtx) error { } } - maxTurns := 16 - if prof.Budget.MaxTurns != nil { - maxTurns = *prof.Budget.MaxTurns - } - maxTools := 40 - if prof.Budget.MaxToolCalls != nil { - maxTools = *prof.Budget.MaxToolCalls - } - // 输出预算:画像 budget.maxTokens 配置时下发(0 = 走服务端默认,不限制输出长度)。 - streamOpts := agentllm.StreamOptions{} - if prof.Budget.MaxTokens != nil && *prof.Budget.MaxTokens > 0 { - streamOpts.MaxTokens = *prof.Budget.MaxTokens - } + maxTurns, maxTools := loopCaps(prof.Budget) + streamOpts := agentllm.StreamOptions{MaxTokens: outputTokenCap(prof.Budget, eff, "models.chat.maxOutputTokens")} return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools) } +// loopCaps 把画像预算换成内核参数。轮次或工具数为 0 / 未设置表示不限制; +// 内核对 MaxTurns<=0 会回落到 12,所以不限制时传 MaxInt32。MaxToolCalls 的 0 才是不限制。 +func loopCaps(budget core.RunBudget) (maxTurns, maxTools int) { + maxTurns = math.MaxInt32 + if budget.MaxTurns != nil && *budget.MaxTurns > 0 { + maxTurns = *budget.MaxTurns + } + if budget.MaxToolCalls != nil && *budget.MaxToolCalls > 0 { + maxTools = *budget.MaxToolCalls + } + return maxTurns, maxTools +} + +func toolBudget(maxTools int) *nruntime.Budget { + if maxTools <= 0 { + return nil + } + return &nruntime.Budget{MaxToolCalls: maxTools} +} + +// outputTokenCap 优先用画像 budget.maxTokens,否则用设置项。两者都是 0 表示不下发,由服务端决定。 +func outputTokenCap(budget core.RunBudget, eff core.EffectiveSettings, key string) int { + if budget.MaxTokens != nil && *budget.MaxTokens > 0 { + return *budget.MaxTokens + } + n := int(eff.GetNumber(key, 0)) + if n > 0 { + return n + } + return 0 +} + +// openLLMClient 不设 HTTP 客户端超时。单次生成可能很长,停止按钮通过 run 的 context 取消。 +func openLLMClient(base, key string) *agentllm.Client { + return &agentllm.Client{BaseURL: base, APIKey: key, HTTPClient: &http.Client{}} +} + // SubLLMRunner 子代理执行器:按内置 profile 跑一轮带工具的内核循环。 type SubLLMRunner struct { // Prompts 提示词库(nil = 使用内联回退文案与只读回退 spec)。 diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index 660aca5..f1d7fd9 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -118,6 +118,9 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin go func() { defer close(out) for ev := range in { + if ev.Kind == agentllm.KindToolCallDelta { + st.noteToolDelta(ev) + } if ev.Kind == agentllm.KindMessageEnd { if ev.Message.Role == "" { ev.Message.Role = agentllm.RoleAssistant @@ -137,17 +140,18 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin } res, err := nruntime.Run(rc.Context(), nruntime.Config{ - Model: model, - History: history, - Tools: reg, - Stream: wrapped, - MaxTurns: maxTurns, - Budget: &nruntime.Budget{MaxToolCalls: maxTools}, - RepeatGuard: true, - ContinueOnLength: true, - OnEvent: st.onEvent, - Stop: st.stop, - Reminders: st.reminders, + Model: model, + History: history, + Tools: reg, + Stream: wrapped, + MaxTurns: maxTurns, + Budget: toolBudget(maxTools), + RepeatGuard: true, + ContinueOnLength: true, + MaxLengthContinuations: -1, + OnEvent: st.onEvent, + Stop: st.stop, + Reminders: st.reminders, UnknownTool: func(call agentllm.ToolCall) agenttools.Result { return st.runTool(call.Name, call.Arguments) }, @@ -177,6 +181,18 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin return nil } +// noteToolDelta 把模型正在编写的工具参数立刻转成会话事件。 +// 执行开始前的这段时间原先没有任何工具事件,界面只剩光标。 +func (st *directRun) noteToolDelta(ev agentllm.Event) { + if !st.acting { + _ = st.rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseActing}) + st.acting = true + } + _ = st.rc.Emit(core.EvToolCallDelta, core.ToolCallDeltaPayload{ + Index: ev.ToolIndex, CallID: ev.ToolID, Name: ev.ToolName, Delta: ev.ArgumentsDelta, + }) +} + func (st *directRun) onEvent(ev nruntime.Event) { switch ev.Type { case "turn_start": @@ -262,7 +278,7 @@ func (st *directRun) onEvent(ev nruntime.Event) { st.deliverables = append(st.deliverables, slot.artifact) } st.evidence = append(st.evidence, toolEvidence{Name: slot.call.Name, Status: slot.status, Summary: slot.summary}) - _ = st.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: slot.call.ID, Status: slot.status, Summary: slot.summary, Truncated: slot.truncated}) + _ = st.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: slot.call.ID, Status: slot.status, Summary: slot.summary, Truncated: slot.truncated, DurationMs: durationMs(slot.start, end)}) st.inflight = nil } } diff --git a/internal/runtime/llm_test.go b/internal/runtime/llm_test.go index f8cc642..55a841a 100644 --- a/internal/runtime/llm_test.go +++ b/internal/runtime/llm_test.go @@ -987,6 +987,59 @@ func TestLLMRunnerContinuesWhenTruncated(t *testing.T) { } } +func TestToolCallDeltaBeforeExecution(t *testing.T) { + call := toolCall("echo_tool", `{"script":"print(1)"}`) + turns := [][]agentllm.Event{ + { + {Kind: agentllm.KindTextDelta, Text: "我来跑一下。"}, + {Kind: agentllm.KindToolCallDelta, ToolIndex: 0, ToolID: call.ID, ToolName: call.Name, ArgumentsDelta: `{"script":`}, + {Kind: agentllm.KindToolCallDelta, ToolIndex: 0, ArgumentsDelta: `"print(1)"}`}, + {Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{call}}}, + }, + textDeltaTurn("跑完了。"), + } + rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}} + r := &LLMRunner{Stream: scriptStream(turns, nil)} + if err := r.Execute(rc); err != nil { + t.Fatal(err) + } + var order []core.StreamEventType + for _, ev := range rc.events { + switch ev.typ { + case core.EvToolCallDelta, core.EvToolCall, core.EvToolResult: + order = append(order, ev.typ) + } + } + want := []core.StreamEventType{core.EvToolCallDelta, core.EvToolCallDelta, core.EvToolCall, core.EvToolResult} + if len(order) != len(want) { + t.Fatalf("order = %v", order) + } + for i := range want { + if order[i] != want[i] { + t.Fatalf("order = %v", order) + } + } + var delta core.ToolCallDeltaPayload + for _, ev := range rc.events { + if ev.typ == core.EvToolCallDelta { + delta = ev.payload.(core.ToolCallDeltaPayload) + break + } + } + if delta.Name != call.Name || delta.Index != 0 || delta.Delta == "" { + t.Fatalf("first delta = %+v", delta) + } + var result core.ToolResultPayload + for _, ev := range rc.events { + if ev.typ == core.EvToolResult { + result = ev.payload.(core.ToolResultPayload) + } + } + if result.CallID != call.ID || result.DurationMs < 0 { + t.Fatalf("result = %+v", result) + } +} + func transcriptOf(msgs []agentllm.Message) string { var b strings.Builder for _, m := range msgs { diff --git a/internal/runtime/squad_runner.go b/internal/runtime/squad_runner.go index 18e257a..d0e5b04 100644 --- a/internal/runtime/squad_runner.go +++ b/internal/runtime/squad_runner.go @@ -29,14 +29,7 @@ func (s *SquadRunner) Execute(rc RunCtx) error { return rc.Context().Err() } prof := rc.Profile() - maxTurns := 12 - if prof.Budget.MaxTurns != nil && *prof.Budget.MaxTurns > 0 { - maxTurns = *prof.Budget.MaxTurns - } - maxTools := 0 - if prof.Budget.MaxToolCalls != nil && *prof.Budget.MaxToolCalls > 0 { - maxTools = *prof.Budget.MaxToolCalls - } + maxTurns, maxTools := loopCaps(prof.Budget) produced := &artifactList{} var streamed strings.Builder var toolParts []core.Part @@ -143,7 +136,7 @@ func newModelRouter(rc RunCtx) modelRouter { if route.Model == "" || route.Base == "" { return modelRouter{} } - client := &agentllm.Client{BaseURL: route.Base, APIKey: route.Key} + client := openLLMClient(route.Base, route.Key) return modelRouter{stream: client.Stream, model: route.Model} } diff --git a/internal/runtime/sub_loop.go b/internal/runtime/sub_loop.go index 3558712..6d46b19 100644 --- a/internal/runtime/sub_loop.go +++ b/internal/runtime/sub_loop.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "math" "strings" "time" @@ -18,13 +19,10 @@ const subagentFallback = "你是被派生的子代理。独立完成给定子任 const defaultSubagentID = "agent.sub.general" -const subagentToolCap = 20 - func fallbackSubagentSpec() prompts.SubagentSpec { return prompts.SubagentSpec{ - Tools: []string{"knowledge_search", "web_search", "tavily_search", "time_now", "list_conversations", "read_conversation", "fs_read_file", "fs_list_dir"}, - Model: "lightweight", - MaxTurns: 8, + Tools: []string{"knowledge_search", "web_search", "tavily_search", "time_now", "list_conversations", "read_conversation", "fs_read_file", "fs_list_dir"}, + Model: "lightweight", } } @@ -43,8 +41,9 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error { spec = got } } - if spec.MaxTurns <= 0 { - spec.MaxTurns = 8 + maxTurns := spec.MaxTurns + if maxTurns <= 0 { + maxTurns = math.MaxInt32 } route := ResolveLightweightModel(rc.Profile(), rc.Settings()) if spec.Model == "chat" { @@ -55,7 +54,7 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error { if route.Model == "" || route.Base == "" { return fmt.Errorf("llm-subagent: 模型未配置(当前解析:model=%q base=%q)", route.Model, route.Base) } - client := &agentllm.Client{BaseURL: route.Base, APIKey: route.Key} + client := openLLMClient(route.Base, route.Key) stream = client.Stream } decls := subagentDecls(rc, spec) @@ -93,15 +92,15 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error { _ = rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: "执行子任务……", Phase: core.PhaseActing}) user := agentllm.Message{Role: agentllm.RoleUser, Content: subagentUserText(rc)} res, err := nruntime.Run(rc.Context(), nruntime.Config{ - Model: route.Model, - SystemPrompt: l.subagentPrompt(rc, id), - Tools: reg, - Stream: wrapped, - MaxTurns: spec.MaxTurns, - Budget: &nruntime.Budget{MaxToolCalls: subagentToolCap}, - RepeatGuard: true, - ContinueOnLength: true, - OnEvent: st.onEvent, + Model: route.Model, + SystemPrompt: l.subagentPrompt(rc, id), + Tools: reg, + Stream: wrapped, + MaxTurns: maxTurns, + RepeatGuard: true, + ContinueOnLength: true, + MaxLengthContinuations: -1, + OnEvent: st.onEvent, }, user) if len(st.parts) == 0 { st.parts = append(st.parts, core.Part{Type: core.PartText, Text: "(子代理未返回内容)"}) diff --git a/internal/runtime/sub_loop_test.go b/internal/runtime/sub_loop_test.go index d7d2a34..c002c44 100644 --- a/internal/runtime/sub_loop_test.go +++ b/internal/runtime/sub_loop_test.go @@ -139,11 +139,12 @@ func TestSubagentWriterUsesChatModel(t *testing.T) { } } -func TestSubagentMaxTurns(t *testing.T) { - turns := make([][]agentllm.Event, 7) - for i := range turns { +func TestSubagentMaxTurnsUnbounded(t *testing.T) { + turns := make([][]agentllm.Event, 8) + for i := 0; i < 7; i++ { turns[i] = textDeltaTurn("", toolCall("knowledge_search", `{}`)) } + turns[7] = textDeltaTurn("计划写完了。") calls := 0 base := scriptStream(turns, nil) rc := &subRC{ @@ -159,12 +160,11 @@ func TestSubagentMaxTurns(t *testing.T) { return base(ctx, model, msgs, tools, opts) }, } - err := r.Execute(rc) - if err == nil || !strings.Contains(err.Error(), "max turns") { - t.Fatalf("err = %v", err) + if err := r.Execute(rc); err != nil { + t.Fatal(err) } - if calls != 6 { - t.Fatalf("stream calls = %d", calls) + if calls != 8 { + t.Fatalf("stream calls = %d, want past the old cap of 6", calls) } } diff --git a/internal/runtime/verify.go b/internal/runtime/verify.go index 4e7bb00..a3af8de 100644 --- a/internal/runtime/verify.go +++ b/internal/runtime/verify.go @@ -80,7 +80,7 @@ func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, if route.Model == "" || route.Base == "" { return verifyVerdict{}, fmt.Errorf("验证器调用失败") } - client := &agentllm.Client{BaseURL: route.Base, APIKey: route.Key} + client := openLLMClient(route.Base, route.Key) stream = client.Stream } model := route.Model diff --git a/internal/runtime/workflow.go b/internal/runtime/workflow.go index 1a3bde3..a9b0592 100644 --- a/internal/runtime/workflow.go +++ b/internal/runtime/workflow.go @@ -358,7 +358,7 @@ func (w WorkflowRunner) judgeStep(rc RunCtx, goal, criteria, output string) veri return verifyVerdict{Passed: false, Reason: "验证器不可用", NextAction: "检查轻量模型配置"} } model = route.Model - client := &agentllm.Client{BaseURL: route.Base, APIKey: route.Key} + client := openLLMClient(route.Base, route.Key) stream = client.Stream } prompt := fmt.Sprintf("你是验收员。目标:%s\n验收标准:%s\n这一步的产出:\n%s\n标准里要求出现的句子,产出里必须逐字包含,否则 passed 必须为 false,nextAction 写出要补上的原句。只输出一个 JSON 对象,字段为 passed、reason、nextAction。", goal, criteria, output) diff --git a/internal/settings/settings.go b/internal/settings/settings.go index f189221..a7f2415 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -64,9 +64,11 @@ func Schema() []core.SettingDesc { {Key: "models.chat.baseUrl", Group: "models", Label: "主模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "OpenAI 兼容端点,如 https://api.openai.com/v1"}, {Key: "models.chat.apiKey", Group: "models", Label: "主模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot}, {Key: "models.chat.model", Group: "models", Label: "主模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun}, + {Key: "models.chat.maxOutputTokens", Group: "models", Label: "主模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"}, {Key: "models.lightweight.baseUrl", Group: "models", Label: "轻量模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "标题生成/澄清等轻任务;空则回落主模型"}, {Key: "models.lightweight.apiKey", Group: "models", Label: "轻量模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot}, {Key: "models.lightweight.model", Group: "models", Label: "轻量模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun}, + {Key: "models.lightweight.maxOutputTokens", Group: "models", Label: "轻量模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"}, {Key: "models.embedding.baseUrl", Group: "models", Label: "Embedding Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "RAG/记忆检索用(向量管线远期启用)"}, {Key: "models.embedding.apiKey", Group: "models", Label: "Embedding API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot}, {Key: "models.embedding.model", Group: "models", Label: "Embedding 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot}, diff --git a/internal/squadadapter/adapter.go b/internal/squadadapter/adapter.go index e471680..e696ac8 100644 --- a/internal/squadadapter/adapter.go +++ b/internal/squadadapter/adapter.go @@ -16,7 +16,6 @@ import ( "os" "path/filepath" "strings" - "time" "agent-native/exec" "agent-native/runtime/subagent" @@ -78,7 +77,7 @@ func Orchestrate(rc Ctx, dataDir, goalText string) (string, error) { Planner: squad.NewRulePlanner(), Backends: backends, DefaultBackend: "agent-system", - StepTimeout: 5 * time.Minute, + StepTimeout: -1, // 负数 = 不设单步截止(引擎只在 0 时回落到 10 分钟) HitlPolicy: squad.HitlPolicy{Mode: squad.HitlInteractive}, Checker: quality.Checker{}, RepairBudget: 2, diff --git a/internal/squadadapter/entry.go b/internal/squadadapter/entry.go index 0f167cb..b37915d 100644 --- a/internal/squadadapter/entry.go +++ b/internal/squadadapter/entry.go @@ -2,10 +2,10 @@ package squadadapter import ( "fmt" + "math" "os" "path/filepath" "strings" - "time" "agent-native/exec" agentllm "agent-native/runtime/llm" @@ -66,7 +66,7 @@ func RunEntry(rc Ctx, opt EntryOptions) (EntryResult, error) { cfg.MaxTurns = opt.MaxTurns } if cfg.MaxTurns <= 0 { - cfg.MaxTurns = 12 + cfg.MaxTurns = math.MaxInt32 } cfg.MaxToolCalls = opt.MaxToolCalls @@ -101,7 +101,7 @@ func RunEntry(rc Ctx, opt EntryOptions) (EntryResult, error) { Planner: squad.NewRulePlanner(), Backends: backends, DefaultBackend: "agent-native", - StepTimeout: 5 * time.Minute, + StepTimeout: -1, // 负数 = 不设单步截止(引擎只在 0 时回落到 10 分钟) HitlPolicy: opt.Hitl, Checker: checker, RepairBudget: repair, diff --git a/internal/tools/exec.go b/internal/tools/exec.go index 2d9248d..a27e3b6 100644 --- a/internal/tools/exec.go +++ b/internal/tools/exec.go @@ -20,8 +20,7 @@ import ( const ( execMaxOutput = 64 << 10 // 单次输出上限 64KB(头尾保留) - execDefaultTimeoutS = 60 - execMaxTimeoutS = 300 + execDefaultTimeoutS = 600 // 未指定 timeout_sec 时的默认超时;模型可自行设更长,没有硬上限 execMaxArtifactBytes = 10 << 20 // register_artifact 登记上限 10MB execScriptDir = ".agent" // 脚本落位(会话工作区内,可审计) execSetting = "runtime.exec" diff --git a/internal/tools/exec_interpreter.go b/internal/tools/exec_interpreter.go index 4de4138..84e6535 100644 --- a/internal/tools/exec_interpreter.go +++ b/internal/tools/exec_interpreter.go @@ -2,16 +2,19 @@ // // 语义:模型提交的脚本原样落盘到会话工作区 .agent/ 下(字节精确、执行后保留以便审计), // 再以该文件为唯一参数启动白名单解释器(bash / python3 的绝对路径)——模型输入不进入 -// 进程参数。工作目录锁定会话沙箱;超时取请求 context 与 timeout_sec(默认 60s、上限 300s) -// 的先到者,超时杀掉整个进程组;输出 64KB 头尾截断。非零退出、超时与取消作为文本反馈返回 +// 进程参数。工作目录锁定会话沙箱;超时取请求 context 与 timeout_sec(默认 600s,无硬上限) +// 的先到者,超时杀掉整个进程组;输出 64KB 头尾截断。执行中的输出经可选回调流出。 +// 非零退出、超时与取消作为文本反馈返回 // (模型据此自纠),仅启动失败算工具错误。 package tools import ( + "bytes" "context" "encoding/json" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -31,7 +34,7 @@ func RegisterInterpreterTools(r *Registry) error { Description: "在会话工作区目录内执行 bash 脚本:检查环境、安装依赖(pip install --user …)、处理文件等。脚本原样落盘执行;返回退出码与合并输出(stdout+stderr,超限头尾截断)。以当前用户身份运行。", Parameters: json.RawMessage(`{"type":"object","properties":{` + `"script":{"type":"string","description":"bash 脚本(可多行)"},` + - `"timeout_sec":{"type":"integer","description":"超时秒数(默认 60,上限 300)"}}` + + `"timeout_sec":{"type":"integer","description":"超时秒数(默认 600,可自行设更长)"}}` + `,"required":["script"]}`), }, execShell); err != nil { return err @@ -41,7 +44,7 @@ func RegisterInterpreterTools(r *Registry) error { Description: "执行 Python 脚本(python3,工作目录为会话工作区):生成文档/表格/演示稿/数据处理的首选路径。脚本原样落盘执行;stdout/stderr 合并返回,超限头尾截断。python-docx/openpyxl/python-pptx 缺失时可先 pip install --user 安装。交付物用 import housestyle(技能目录在环境变量 AGENT_SKILLS_DIR,house-style 已在 PYTHONPATH)。", Parameters: json.RawMessage(`{"type":"object","properties":{` + `"script":{"type":"string","description":"完整 Python 脚本(utf-8)"},` + - `"timeout_sec":{"type":"integer","description":"超时秒数(默认 60,上限 300)"}}` + + `"timeout_sec":{"type":"integer","description":"超时秒数(默认 600,可自行设更长)"}}` + `,"required":["script"]}`), }, execPython) } @@ -129,16 +132,13 @@ func resolveInterpreter(name string) (string, error) { // runInterpreter 启动白名单解释器执行落盘脚本(脚本路径为唯一参数)。 // 非零退出、超时与请求取消都是正常反馈,以文本返回;仅启动失败算错误。 -func runInterpreter(parent context.Context, interpreter, scriptPath, dir string, timeoutS int) (string, error) { +func runInterpreter(parent context.Context, interpreter, scriptPath, dir string, timeoutS int, sink func(stream, text string)) (string, error) { if parent == nil { parent = context.Background() } if timeoutS <= 0 { timeoutS = execDefaultTimeoutS } - if timeoutS > execMaxTimeoutS { - timeoutS = execMaxTimeoutS - } bin, err := resolveInterpreter(interpreter) if err != nil { return "", fmt.Errorf("%s: %w", interpreter, err) @@ -160,8 +160,39 @@ func runInterpreter(parent context.Context, interpreter, scriptPath, dir string, } return err } - out, err := cmd.CombinedOutput() - text := truncateOutput(out) + pr, pw := io.Pipe() + cmd.Stdout = pw + cmd.Stderr = pw + var buf bytes.Buffer + readDone := make(chan struct{}) + go func() { + defer close(readDone) + tmp := make([]byte, 4096) + for { + n, rerr := pr.Read(tmp) + if n > 0 { + if buf.Len() < 1<<20 { + buf.Write(tmp[:n]) + } + if sink != nil { + sink("stdout", string(tmp[:n])) + } + } + if rerr != nil { + return + } + } + }() + err = cmd.Start() + if err != nil { + _ = pw.Close() + <-readDone + return "", fmt.Errorf("%s: %w", interpreter, err) + } + err = cmd.Wait() + _ = pw.Close() + <-readDone + text := truncateOutput(buf.Bytes()) if errors.Is(ctx.Err(), context.DeadlineExceeded) { return fmt.Sprintf("exit=timeout(%ds)\n%s", timeoutS, text), nil } @@ -208,7 +239,7 @@ func execShell(rc ToolExecCtx, args json.RawMessage) (string, error) { if err != nil { return "", err } - return runInterpreter(execParentCtx(rc), "bash", p, sandbox, firstPos(a.TimeoutS, a.TimeoutSec)) + return runInterpreter(execParentCtx(rc), "bash", p, sandbox, firstPos(a.TimeoutS, a.TimeoutSec), toolOutputSink(rc)) } // ScriptCommand 准备一条以当前用户身份、工作目录为会话沙箱的 python3 命令。 @@ -264,7 +295,19 @@ func execPython(rc ToolExecCtx, args json.RawMessage) (string, error) { if err != nil { return "", err } - return runInterpreter(execParentCtx(rc), "python3", p, sandbox, firstPos(a.TimeoutS, a.TimeoutSec)) + return runInterpreter(execParentCtx(rc), "python3", p, sandbox, firstPos(a.TimeoutS, a.TimeoutSec), toolOutputSink(rc)) +} + +// toolOutputSink 在执行上下文实现了 EmitToolOutput 时,把 stdout 增量交出去。 +func toolOutputSink(rc ToolExecCtx) func(stream, text string) { + type emitter interface { + EmitToolOutput(stream, text string) + } + e, ok := rc.(emitter) + if !ok { + return nil + } + return e.EmitToolOutput } func firstPos(vals ...int) int { diff --git a/internal/tools/exec_interpreter_test.go b/internal/tools/exec_interpreter_test.go index 5f0946b..73f5025 100644 --- a/internal/tools/exec_interpreter_test.go +++ b/internal/tools/exec_interpreter_test.go @@ -18,6 +18,7 @@ type execFake struct { execOn bool settings map[string]bool ctx context.Context + output []string } func (c *execFake) ProjectID() string { return "p1" } @@ -30,6 +31,8 @@ func (c *execFake) Context() context.Context { } return context.Background() } +func (c *execFake) EmitToolOutput(stream, text string) { c.output = append(c.output, stream+":"+text) } + func (c *execFake) SettingBool(key string, def bool) bool { if c.settings != nil { if v, ok := c.settings[key]; ok { @@ -113,6 +116,19 @@ func TestRunShellEmptyAndGate(t *testing.T) { } } +func TestRunShellStreamsOutput(t *testing.T) { + r := newInterpRegistry(t) + rc := &execFake{root: t.TempDir(), owner: "o1", conv: "c1", execOn: true} + res := interpCall(t, r, rc, "run_shell", `{"script":"echo streamed-line"}`) + if res.Status != "ok" || !strings.Contains(res.Output, "streamed-line") { + t.Fatalf("result: %s %s", res.Status, res.Output) + } + joined := strings.Join(rc.output, "") + if !strings.Contains(joined, "stdout:") || !strings.Contains(joined, "streamed-line") { + t.Fatalf("streamed chunks: %q", rc.output) + } +} + func TestRunShellTimeoutKillsProcessGroup(t *testing.T) { r := newInterpRegistry(t) rc := &execFake{root: t.TempDir(), owner: "o1", conv: "c1", execOn: true}