From 1010707cbc0a45f45a400f7125793a0cecd2c791 Mon Sep 17 00:00:00 2001 From: 2penheimer <2603237065@qq.com> Date: Tue, 22 Sep 2026 21:04:32 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=B8=A2=E6=8E=89=E7=A9=BA=E5=8A=A9?= =?UTF-8?q?=E6=89=8B=E6=B6=88=E6=81=AF=EF=BC=8C=E5=B9=B6=E6=8A=8A=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E8=BE=93=E5=87=BA=E9=A2=84=E7=AE=97=E6=8F=90=E5=88=B0?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 空正文且没有 tool_calls 的助手消息会被网关拒绝;思考和正文共用的输出上限原先只有 8192,长任务会在正文出现前被截断。 Co-authored-by: Cursor --- .env.example | 18 ++++ docs/architecture.md | 2 +- server/cmd/server/main.go | 35 +++++--- server/internal/agent/explore.go | 1 + server/internal/config/config.go | 113 +++++++++++++++---------- server/internal/config/config_test.go | 18 ++++ server/pkg/agent/content.go | 7 ++ server/pkg/agent/defaults.go | 10 +-- server/pkg/agent/engine.go | 31 ++++--- server/pkg/agent/engine_test.go | 29 ++++++- server/pkg/agent/explore.go | 31 ++++--- server/pkg/agent/openai.go | 9 ++ server/pkg/agent/openai_live_test.go | 116 ++++++++++++++++++++++++++ server/pkg/agent/openai_test.go | 29 +++++++ server/pkg/agent/review.go | 2 +- 15 files changed, 364 insertions(+), 87 deletions(-) create mode 100644 server/pkg/agent/openai_live_test.go diff --git a/.env.example b/.env.example index 3f43b5b..fc039d2 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,24 @@ LLM_API_KEY= # OpenAI 兼容 Base URL,例如 https://api.openai.com/v1(可空) LLM_BASE_URL= +# 思考开关:enabled | disabled。空则沿用模型默认(DeepSeek 当前默认开启) +# LLM_THINKING= + +# 单次回复输出 token 上限,思考和正文共用(默认 65536) +# LLM_MAX_OUTPUT_TOKENS=65536 + +# 单次 Run 输入 token 上限,含上下文(默认 256000) +# LLM_MAX_INPUT_TOKENS=256000 + +# 单次 Run 最多模型轮数(默认 32) +# LLM_MAX_TURNS=32 + +# 单次 Run 最多工具调用次数(默认 64) +# LLM_MAX_TOOL_CALLS=64 + +# 单次 Run 墙钟上限(默认 20m) +# LLM_MAX_WALL_TIME=20m + # 本地 Git 仓库根。未设则用进程 cwd(从 server/ 启动时 cwd 不是仓根)。 # 撤回会改磁盘。本地试用先跑 scripts/git-sandbox.sh,再把这里指到 tmp/git-sandbox。 # 要操作本仓时显式写成仓根。pnpm dev:api 在沙箱存在时默认用沙箱。 diff --git a/docs/architecture.md b/docs/architecture.md index 9371702..a432efd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -371,7 +371,7 @@ Worker ## 配置 -`LLM_PROVIDER`(`openai` | `fake`,默认 `fake`)、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`。工具出站审批用 `EVALUATOR_PROVIDER` / `EVALUATOR_MODEL`(及可选 Key/BaseURL);explore 用 `SUBAGENT_PROVIDER` / `SUBAGENT_MODEL`。空则回落主模型。`GIT_REPO` 指向本地仓库根,未设则用进程 cwd(不向上找 `.git`)。未设 `DB_DSN` 时 SQLite 写仓根 `data/codedock.db`,不写 `server/`。`PLUGIN_DIR` 指向插件根目录,未设则不拉插件进程;`PLUGIN_RPC_TIMEOUT` 默认 `10s`。`CODEX_BIN` 为本机 Codex CLI(默认 `codex`)。Handler 创建 Run 时写入 `RunConfigSnapshot`(含 `EvaluatorModel` / `SubagentModel`),后续 Turn 只读快照。收尾验证跑工作区 `.cursor/verify.yaml`,以及本会话绑定计划里非 `manual` 的 `verify_cmd`。取消已标 `cancel_requested` 后不得再落 `completed`;`verifying` 上的取消立即终态。 +`LLM_PROVIDER`(`openai` | `fake`,默认 `fake`)、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`、`LLM_THINKING`(`enabled` | `disabled`,空则沿用模型默认)。Run 预算:`LLM_MAX_INPUT_TOKENS`(默认 256000)、`LLM_MAX_OUTPUT_TOKENS`(默认 65536,思考和正文共用)、`LLM_MAX_TURNS`(默认 32)、`LLM_MAX_TOOL_CALLS`(默认 64)、`LLM_MAX_WALL_TIME`(默认 20m)。工具出站审批用 `EVALUATOR_PROVIDER` / `EVALUATOR_MODEL`(及可选 Key/BaseURL);explore 用 `SUBAGENT_PROVIDER` / `SUBAGENT_MODEL`。空则回落主模型。`GIT_REPO` 指向本地仓库根,未设则用进程 cwd(不向上找 `.git`)。未设 `DB_DSN` 时 SQLite 写仓根 `data/codedock.db`,不写 `server/`。`PLUGIN_DIR` 指向插件根目录,未设则不拉插件进程;`PLUGIN_RPC_TIMEOUT` 默认 `10s`。`CODEX_BIN` 为本机 Codex CLI(默认 `codex`)。Handler 创建 Run 时写入 `RunConfigSnapshot`(含 `EvaluatorModel` / `SubagentModel`),后续 Turn 只读快照。收尾验证跑工作区 `.cursor/verify.yaml`,以及本会话绑定计划里非 `manual` 的 `verify_cmd`。取消已标 `cancel_requested` 后不得再落 `completed`;`verifying` 上的取消立即终态。 HTTP 出站领域对象使用 snake_case JSON。Router 只对本地回环 Origin 放行 CORS,便于本机 Web 直连 `:8080`。Web 用 `NEXT_PUBLIC_API_BASE`(默认 `http://localhost:8080`)和 `NEXT_PUBLIC_USER_ID`(默认 `local`)。 diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index d5bf5a8..e41c7a4 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -95,8 +95,14 @@ func main() { runtime.Start(ctx) defaults := pkgagent.DefaultRunConfig(pkgagent.WorkAgent, model) - defaults.EvaluatorModel = sideModel(cfg.EvaluatorProvider, cfg.EvaluatorModel, cfg.EvaluatorAPIKey, cfg.EvaluatorBaseURL, model) - defaults.SubagentModel = sideModel(cfg.SubagentProvider, cfg.SubagentModel, cfg.SubagentAPIKey, cfg.SubagentBaseURL, defaults.EvaluatorModel) + defaults.Limits.MaxInputTokens = cfg.LLMMaxInputTokens + defaults.Limits.MaxOutputTokens = cfg.LLMMaxOutputTokens + defaults.Limits.MaxTurns = cfg.LLMMaxTurns + defaults.Limits.MaxToolCalls = cfg.LLMMaxToolCalls + defaults.Limits.MaxWallTime = cfg.LLMMaxWallTime + log.Info("llm limits", "max_input_tokens", cfg.LLMMaxInputTokens, "max_output_tokens", cfg.LLMMaxOutputTokens, "max_turns", cfg.LLMMaxTurns, "max_tool_calls", cfg.LLMMaxToolCalls, "max_wall_time", cfg.LLMMaxWallTime.String(), "thinking", cfg.LLMThinking) + defaults.EvaluatorModel = sideModel(cfg.EvaluatorProvider, cfg.EvaluatorModel, cfg.EvaluatorAPIKey, cfg.EvaluatorBaseURL, cfg.LLMThinking, model) + defaults.SubagentModel = sideModel(cfg.SubagentProvider, cfg.SubagentModel, cfg.SubagentAPIKey, cfg.SubagentBaseURL, cfg.LLMThinking, defaults.EvaluatorModel) api := handler.New(client, queries, runtime, bus, defaults, cfg, logger.NewLogger("handler")) codexRT := intcodex.New(intcodex.Options{Bin: cfg.CodexBin}) defer func() { _ = codexRT.Close() }() @@ -144,13 +150,13 @@ func main() { } } -// modelOptions 把 LLM API Key 与 BaseURL 编进 ModelConfig.Options。 +// modelOptions 把 LLM API Key、BaseURL 和思考开关编进 ModelConfig.Options。 func modelOptions(cfg config.Config) json.RawMessage { - return encodeModelOptions(cfg.LLMAPIKey, cfg.LLMBaseURL) + return encodeModelOptions(cfg.LLMAPIKey, cfg.LLMBaseURL, cfg.LLMThinking) } // sideModel 组装复审或子代理模型;未配置时回落 fallback。 -func sideModel(provider, name, apiKey, baseURL string, fallback pkgagent.ModelConfig) pkgagent.ModelConfig { +func sideModel(provider, name, apiKey, baseURL, thinking string, fallback pkgagent.ModelConfig) pkgagent.ModelConfig { out := fallback if strings.TrimSpace(provider) != "" { out.Provider = provider @@ -158,7 +164,10 @@ func sideModel(provider, name, apiKey, baseURL string, fallback pkgagent.ModelCo if strings.TrimSpace(name) != "" { out.Model = name } - if strings.TrimSpace(apiKey) != "" || strings.TrimSpace(baseURL) != "" { + if strings.TrimSpace(thinking) == "" { + thinking = optionString(fallback.Options, "thinking") + } + if strings.TrimSpace(apiKey) != "" || strings.TrimSpace(baseURL) != "" || strings.TrimSpace(thinking) != "" { key := apiKey if key == "" { key = optionString(fallback.Options, "api_key") @@ -167,17 +176,21 @@ func sideModel(provider, name, apiKey, baseURL string, fallback pkgagent.ModelCo if url == "" { url = optionString(fallback.Options, "base_url") } - out.Options = encodeModelOptions(key, url) + out.Options = encodeModelOptions(key, url, thinking) } return out } -// encodeModelOptions 把 Key 与 BaseURL 编成模型 Options。 -func encodeModelOptions(apiKey, baseURL string) json.RawMessage { - body, err := json.Marshal(map[string]string{ +// encodeModelOptions 把 Key、BaseURL 和思考开关编成模型 Options。 +func encodeModelOptions(apiKey, baseURL, thinking string) json.RawMessage { + fields := map[string]string{ "api_key": apiKey, "base_url": baseURL, - }) + } + if strings.TrimSpace(thinking) != "" { + fields["thinking"] = thinking + } + body, err := json.Marshal(fields) if err != nil { return json.RawMessage("{}") } diff --git a/server/internal/agent/explore.go b/server/internal/agent/explore.go index 0d8f1c3..1e3ad2d 100644 --- a/server/internal/agent/explore.go +++ b/server/internal/agent/explore.go @@ -26,6 +26,7 @@ func (r *Runtime) explore(ctx context.Context, input pkgagent.ExploreInput, work scope := pkgagent.ResolvePlanScope(state.ActivePlan, hist.Messages) req.ActivePlan = scope.ActivePlan req.MentionedPlans = scope.Mentioned + req.MaxOutputTokens = state.Config.Limits.MaxOutputTokens } } return pkgagent.Explore(ctx, req) diff --git a/server/internal/config/config.go b/server/internal/config/config.go index b100ab2..b6f6a95 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -10,55 +10,67 @@ import ( // Config 是进程启动时一次性读取的环境配置。 type Config struct { - HTTPAddr string // HTTP 监听地址 - LogLevel string - DBEngine string - DBDSN string - LLMProvider string - LLMModel string - LLMAPIKey string - LLMBaseURL string - GitRepo string // 默认仓库根;会话未指定工作目录时回落到这里,再否则 cwd - LLMConcurrency int // 进程内同时进行的模型调用上限;0 表示不限制 - ToolConcurrency int // 进程内同时执行的工具调用上限;0 表示不限制 - PluginDir string // 插件目录;空则不拉进程 - PluginRPCTimeout time.Duration // 单次插件 RPC 超时 - CodexBin string // 本机 Codex CLI;未安装时主服务仍可启动 - EvaluatorProvider string // 独立复审模型供应商;空则回落主模型 - EvaluatorModel string // 独立复审模型名 - EvaluatorAPIKey string // 复审模型 Key;空则回落 LLM_API_KEY - EvaluatorBaseURL string // 复审模型地址;空则回落 LLM_BASE_URL - SubagentProvider string // explore 子代理供应商;空则回落复审模型 - SubagentModel string // explore 子代理模型名 - SubagentAPIKey string // 子代理 Key;空则回落复审/主模型 - SubagentBaseURL string // 子代理地址;空则回落复审/主模型 + HTTPAddr string // HTTP 监听地址 + LogLevel string + DBEngine string + DBDSN string + LLMProvider string + LLMModel string + LLMAPIKey string + LLMBaseURL string + LLMThinking string // 思考开关:enabled | disabled;空则沿用模型默认 + LLMMaxInputTokens int64 // 单次 Run 的输入 token 上限,含上下文 + LLMMaxOutputTokens int64 // 单次模型回复的输出 token 上限,思考和正文共用 + LLMMaxTurns int // 单次 Run 最多调用模型的轮数 + LLMMaxToolCalls int // 单次 Run 最多执行的工具次数 + LLMMaxWallTime time.Duration // 单次 Run 的墙钟上限 + GitRepo string // 默认仓库根;会话未指定工作目录时回落到这里,再否则 cwd + LLMConcurrency int // 进程内同时进行的模型调用上限;0 表示不限制 + ToolConcurrency int // 进程内同时执行的工具调用上限;0 表示不限制 + PluginDir string // 插件目录;空则不拉进程 + PluginRPCTimeout time.Duration // 单次插件 RPC 超时 + CodexBin string // 本机 Codex CLI;未安装时主服务仍可启动 + EvaluatorProvider string // 独立复审模型供应商;空则回落主模型 + EvaluatorModel string // 独立复审模型名 + EvaluatorAPIKey string // 复审模型 Key;空则回落 LLM_API_KEY + EvaluatorBaseURL string // 复审模型地址;空则回落 LLM_BASE_URL + SubagentProvider string // explore 子代理供应商;空则回落复审模型 + SubagentModel string // explore 子代理模型名 + SubagentAPIKey string // 子代理 Key;空则回落复审/主模型 + SubagentBaseURL string // 子代理地址;空则回落复审/主模型 } // Load 从环境变量读取配置,未设置时使用默认值。 func Load() Config { return Config{ - HTTPAddr: env("HTTP_ADDR", ":8080"), - LogLevel: env("LOG_LEVEL", "debug"), - DBEngine: env("DB_ENGINE", "sqlite"), - DBDSN: env("DB_DSN", defaultSQLiteDSN()), - LLMProvider: env("LLM_PROVIDER", "fake"), - LLMModel: env("LLM_MODEL", "fake"), - LLMAPIKey: env("LLM_API_KEY", ""), - LLMBaseURL: env("LLM_BASE_URL", ""), - GitRepo: env("GIT_REPO", ""), - LLMConcurrency: envInt("LLM_CONCURRENCY", 4), - ToolConcurrency: envInt("TOOL_CONCURRENCY", 8), - PluginDir: env("PLUGIN_DIR", ""), - PluginRPCTimeout: envDuration("PLUGIN_RPC_TIMEOUT", 10*time.Second), - CodexBin: env("CODEX_BIN", "codex"), - EvaluatorProvider: env("EVALUATOR_PROVIDER", ""), - EvaluatorModel: env("EVALUATOR_MODEL", ""), - EvaluatorAPIKey: env("EVALUATOR_API_KEY", ""), - EvaluatorBaseURL: env("EVALUATOR_BASE_URL", ""), - SubagentProvider: env("SUBAGENT_PROVIDER", ""), - SubagentModel: env("SUBAGENT_MODEL", ""), - SubagentAPIKey: env("SUBAGENT_API_KEY", ""), - SubagentBaseURL: env("SUBAGENT_BASE_URL", ""), + HTTPAddr: env("HTTP_ADDR", ":8080"), + LogLevel: env("LOG_LEVEL", "debug"), + DBEngine: env("DB_ENGINE", "sqlite"), + DBDSN: env("DB_DSN", defaultSQLiteDSN()), + LLMProvider: env("LLM_PROVIDER", "fake"), + LLMModel: env("LLM_MODEL", "fake"), + LLMAPIKey: env("LLM_API_KEY", ""), + LLMBaseURL: env("LLM_BASE_URL", ""), + LLMThinking: env("LLM_THINKING", ""), + LLMMaxInputTokens: envInt64("LLM_MAX_INPUT_TOKENS", 256000), + LLMMaxOutputTokens: envInt64("LLM_MAX_OUTPUT_TOKENS", 65536), + LLMMaxTurns: envInt("LLM_MAX_TURNS", 32), + LLMMaxToolCalls: envInt("LLM_MAX_TOOL_CALLS", 64), + LLMMaxWallTime: envDuration("LLM_MAX_WALL_TIME", 20*time.Minute), + GitRepo: env("GIT_REPO", ""), + LLMConcurrency: envInt("LLM_CONCURRENCY", 4), + ToolConcurrency: envInt("TOOL_CONCURRENCY", 8), + PluginDir: env("PLUGIN_DIR", ""), + PluginRPCTimeout: envDuration("PLUGIN_RPC_TIMEOUT", 10*time.Second), + CodexBin: env("CODEX_BIN", "codex"), + EvaluatorProvider: env("EVALUATOR_PROVIDER", ""), + EvaluatorModel: env("EVALUATOR_MODEL", ""), + EvaluatorAPIKey: env("EVALUATOR_API_KEY", ""), + EvaluatorBaseURL: env("EVALUATOR_BASE_URL", ""), + SubagentProvider: env("SUBAGENT_PROVIDER", ""), + SubagentModel: env("SUBAGENT_MODEL", ""), + SubagentAPIKey: env("SUBAGENT_API_KEY", ""), + SubagentBaseURL: env("SUBAGENT_BASE_URL", ""), } } @@ -129,6 +141,19 @@ func env(key, fallback string) string { return value } +// envInt64 读 int64 环境变量,未设或解析失败时用 fallback。 +func envInt64(key string, fallback int64) int64 { + value := os.Getenv(key) + if value == "" { + return fallback + } + n, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return fallback + } + return n +} + // envInt 读整数环境变量,未设或解析失败时用 fallback。 func envInt(key string, fallback int) int { value := os.Getenv(key) diff --git a/server/internal/config/config_test.go b/server/internal/config/config_test.go index 8ceaafe..19cf1fc 100644 --- a/server/internal/config/config_test.go +++ b/server/internal/config/config_test.go @@ -18,6 +18,12 @@ func TestLoadDefaults(t *testing.T) { t.Setenv("LLM_MODEL", "") t.Setenv("LLM_API_KEY", "") t.Setenv("LLM_BASE_URL", "") + t.Setenv("LLM_THINKING", "") + t.Setenv("LLM_MAX_INPUT_TOKENS", "") + t.Setenv("LLM_MAX_OUTPUT_TOKENS", "") + t.Setenv("LLM_MAX_TURNS", "") + t.Setenv("LLM_MAX_TOOL_CALLS", "") + t.Setenv("LLM_MAX_WALL_TIME", "") t.Setenv("GIT_REPO", "") t.Setenv("PLUGIN_DIR", "") t.Setenv("PLUGIN_RPC_TIMEOUT", "") @@ -43,6 +49,9 @@ func TestLoadDefaults(t *testing.T) { if cfg.LLMModel != "fake" { t.Fatalf("LLMModel = %q, want fake", cfg.LLMModel) } + if cfg.LLMThinking != "" || cfg.LLMMaxInputTokens != 256000 || cfg.LLMMaxOutputTokens != 65536 || cfg.LLMMaxTurns != 32 || cfg.LLMMaxToolCalls != 64 || cfg.LLMMaxWallTime != 20*time.Minute { + t.Fatalf("llm limits = %+v", cfg) + } if cfg.GitRepo != "" { t.Fatalf("GitRepo = %q, want empty", cfg.GitRepo) } @@ -73,6 +82,12 @@ func TestLoadFromEnv(t *testing.T) { t.Setenv("LLM_MODEL", "gpt-4o") t.Setenv("LLM_API_KEY", "sk-test") t.Setenv("LLM_BASE_URL", "https://api.example.com/v1") + t.Setenv("LLM_THINKING", "disabled") + t.Setenv("LLM_MAX_INPUT_TOKENS", "1000") + t.Setenv("LLM_MAX_OUTPUT_TOKENS", "2000") + t.Setenv("LLM_MAX_TURNS", "3") + t.Setenv("LLM_MAX_TOOL_CALLS", "4") + t.Setenv("LLM_MAX_WALL_TIME", "1m") t.Setenv("GIT_REPO", "/tmp/repo") t.Setenv("PLUGIN_DIR", "/tmp/plugins") t.Setenv("PLUGIN_RPC_TIMEOUT", "2s") @@ -85,6 +100,9 @@ func TestLoadFromEnv(t *testing.T) { if cfg.LLMProvider != "openai" || cfg.LLMModel != "gpt-4o" || cfg.LLMAPIKey != "sk-test" || cfg.LLMBaseURL != "https://api.example.com/v1" { t.Fatalf("Load() LLM = %+v", cfg) } + if cfg.LLMThinking != "disabled" || cfg.LLMMaxInputTokens != 1000 || cfg.LLMMaxOutputTokens != 2000 || cfg.LLMMaxTurns != 3 || cfg.LLMMaxToolCalls != 4 || cfg.LLMMaxWallTime != time.Minute { + t.Fatalf("llm limits = %+v", cfg) + } if cfg.GitRepo != "/tmp/repo" { t.Fatalf("GitRepo = %q, want /tmp/repo", cfg.GitRepo) } diff --git a/server/pkg/agent/content.go b/server/pkg/agent/content.go index 15bb47a..dc60ba6 100644 --- a/server/pkg/agent/content.go +++ b/server/pkg/agent/content.go @@ -2,6 +2,7 @@ package agent import ( "encoding/json" + "strings" "codedock/pkg/agent/tool" ) @@ -42,6 +43,12 @@ func DecodeText(content json.RawMessage) string { return string(content) } +// assistantBlank 判断助手消息是否既无正文也无工具调用。 +// 空 content 且没有 tool_calls 的助手消息会被兼容网关拒绝。 +func assistantBlank(content json.RawMessage, calls []tool.Call) bool { + return strings.TrimSpace(DecodeText(content)) == "" && len(calls) == 0 +} + // EncodeToolResult 将工具结果编码为消息 Content。 func EncodeToolResult(callID string, output json.RawMessage) json.RawMessage { if len(output) == 0 { diff --git a/server/pkg/agent/defaults.go b/server/pkg/agent/defaults.go index 494d99f..124b44e 100644 --- a/server/pkg/agent/defaults.go +++ b/server/pkg/agent/defaults.go @@ -87,11 +87,11 @@ func DefaultRunConfig(mode WorkMode, model ModelConfig) RunConfigSnapshot { Tool: retry, }, Limits: RunLimits{ - MaxWallTime: 5 * time.Minute, - MaxTurns: 8, - MaxToolCalls: 16, - MaxInputTokens: 128000, - MaxOutputTokens: 8192, + MaxWallTime: 20 * time.Minute, + MaxTurns: 32, + MaxToolCalls: 64, + MaxInputTokens: 256000, + MaxOutputTokens: 65536, MaxParallelTools: 4, MaxVerifyRounds: 3, MaxEvaluateRounds: 2, diff --git a/server/pkg/agent/engine.go b/server/pkg/agent/engine.go index 848f57e..63f840f 100644 --- a/server/pkg/agent/engine.go +++ b/server/pkg/agent/engine.go @@ -119,7 +119,7 @@ func (e *Engine) Step(ctx context.Context, in StepInput) (StepResult, error) { // callLLM 占槽后压缩上下文、调模型,把流式增量写成 Fact,再产出 assistant 消息与下一步。 // 逻辑:校验取消 → 超回合则有副作用先验证否则收束 → CompactIfNeeded + Stream → 收齐文本/工具调用 → 有 Tool 则下一步 llm_result。 -// wrap-up 回合清空工具表;模型空正文时用改动和验证事实拼装。 +// wrap-up 回合清空工具表;模型空正文时用改动和验证事实拼装。正文和工具调用都空时不落助手消息。 func (e *Engine) callLLM(ctx context.Context, in StepInput, _ Instruction) (StepResult, error) { if err := ctx.Err(); err != nil { return e.finish(ctx, in, finishInstructions(RunCancelled, StopCancelled)[0]) @@ -241,18 +241,21 @@ func (e *Engine) callLLM(ctx context.Context, in StepInput, _ Instruction) (Step assistant.Content = EncodeText(composeWrapUpText(e, state, hist.Messages)) } } - if len(assistant.Content) == 0 { + blank := assistantBlank(assistant.Content, assistant.ToolCalls) + if !blank && len(assistant.Content) == 0 { assistant.Content = EncodeText("") } - _ = e.appendFact(ctx, state.RunID, Fact{ - Type: EventAssistantCompleted, - TurnID: state.TurnID, - Payload: MarshalPayload(AssistantCompletedPayload{ - MessageID: msgID, - Text: DecodeText(assistant.Content), - ToolCalls: result.ToolCalls, - }), - }) + if !blank { + _ = e.appendFact(ctx, state.RunID, Fact{ + Type: EventAssistantCompleted, + TurnID: state.TurnID, + Payload: MarshalPayload(AssistantCompletedPayload{ + MessageID: msgID, + Text: DecodeText(assistant.Content), + ToolCalls: result.ToolCalls, + }), + }) + } now := time.Now().UTC() if state.StartedAt == nil { @@ -269,9 +272,13 @@ func (e *Engine) callLLM(ctx context.Context, in StepInput, _ Instruction) (Step state.Checkpoint.Results = nil state.Checkpoint.Completed = nil } + var messages []Message + if !blank { + messages = []Message{assistant} + } return StepResult{ State: state, - Messages: []Message{assistant}, + Messages: messages, Next: &StepJob{ RunID: state.RunID, StepIndex: state.StepIndex + 1, diff --git a/server/pkg/agent/engine_test.go b/server/pkg/agent/engine_test.go index 399ee3e..6d6a967 100644 --- a/server/pkg/agent/engine_test.go +++ b/server/pkg/agent/engine_test.go @@ -834,9 +834,9 @@ func (failGate) Acquire(context.Context) error { return context.Canceled } // Release 空实现,满足 Gate 接口。 func (failGate) Release() {} -// TestEngineLLMGateAcquireCancelAndEmptyText 覆盖 LLM 占槽失败收束,以及空文本回复补 StepIndex。 +// TestEngineLLMGateAcquireCancelAndEmptyText 覆盖 LLM 占槽失败收束,以及空文本回复不落库。 func TestEngineLLMGateAcquireCancelAndEmptyText(t *testing.T) { - engine, _, _ := testEngine(t) + engine, facts, _ := testEngine(t) engine.SetGates(failGate{}, nil) got, err := engine.callLLM(context.Background(), StepInput{ State: AgentState{ @@ -870,6 +870,31 @@ func TestEngineLLMGateAcquireCancelAndEmptyText(t *testing.T) { if got.State.Status != RunRunningLLM || got.State.StepIndex != 1 { t.Fatalf("empty text %+v", got.State) } + if len(got.Messages) != 0 { + t.Fatalf("blank assistant persisted: %+v", got.Messages) + } + for _, fact := range facts.facts { + if fact.Type == EventAssistantCompleted { + t.Fatal("blank assistant should not complete") + } + } + + engine, _, _ = testEngine(t) + got, err = engine.callLLM(context.Background(), StepInput{ + State: AgentState{ + SessionID: "sess-1", + RunID: "run-1", + Config: DefaultYoloConfig(ModelConfig{Provider: "fake", Model: "fake", Options: mustRaw(FakeOptions{Turns: []FakeTurn{{ToolCalls: []FakeToolCall{{Name: "ping"}}}}})}), + }, + Job: StepJob{RunID: "run-1", StepIndex: 0, Phase: PhaseUserInput}, + History: fakeHistory("run-1", FakeOptions{Turns: []FakeTurn{{ToolCalls: []FakeToolCall{{Name: "ping"}}}}}), + }, Instruction{Type: InstructionCallLLM}) + if err != nil { + t.Fatal(err) + } + if len(got.Messages) != 1 || len(got.Messages[0].ToolCalls) != 1 || DecodeText(got.Messages[0].Content) != "" { + t.Fatalf("empty text with tools should persist: %+v", got.Messages) + } } func TestRetryHelpers(t *testing.T) { diff --git a/server/pkg/agent/explore.go b/server/pkg/agent/explore.go index 181f5fe..fc0bad0 100644 --- a/server/pkg/agent/explore.go +++ b/server/pkg/agent/explore.go @@ -15,7 +15,7 @@ const ( defaultExploreMaxTurns = 6 defaultExploreMaxTools = 12 defaultExploreTimeout = 60 * time.Second - defaultExploreOutputTokens = 2000 + defaultExploreOutputTokens = 16384 ) // ExploreInput 只读探索子任务的输入。 @@ -44,15 +44,24 @@ type ExploreOutput struct { // ExploreRequest 是底座小循环的完整入参。 type ExploreRequest struct { - Input ExploreInput - Model ModelConfig - Registry tool.Registry - WorkspaceRoot string - SessionID string - RunID string - BoundNames []string - ActivePlan string // 本会话已绑定的计划;探索时也不读其他计划 - MentionedPlans []string // 用户点名的计划 + Input ExploreInput + Model ModelConfig + Registry tool.Registry + WorkspaceRoot string + SessionID string + RunID string + BoundNames []string + ActivePlan string // 本会话已绑定的计划;探索时也不读其他计划 + MentionedPlans []string // 用户点名的计划 + MaxOutputTokens int64 // 单次子代理回复上限;0 用默认值。思考和正文共用这份预算 +} + +// exploreOutputTokens 取子代理单次回复上限,未指定时用默认可容纳思考的额度。 +func exploreOutputTokens(n int64) int64 { + if n <= 0 { + return defaultExploreOutputTokens + } + return n } // Explore 用便宜模型跑一个只读小循环,返回带引用的摘要。 @@ -97,7 +106,7 @@ func Explore(ctx context.Context, req ExploreRequest) (ExploreOutput, error) { SystemPrompt: exploreSystemPrompt(), Messages: messages, Tools: defs, - MaxOutputTokens: 1024, + MaxOutputTokens: exploreOutputTokens(req.MaxOutputTokens), } stream, err := Stream(ctx, chat) if err != nil { diff --git a/server/pkg/agent/openai.go b/server/pkg/agent/openai.go index 773999b..d900009 100644 --- a/server/pkg/agent/openai.go +++ b/server/pkg/agent/openai.go @@ -205,6 +205,7 @@ func developerWireRole(model ModelConfig) string { } // consumeOpenAI 解析 SSE 增量,拼出最终文本、工具调用和用量后关闭流。 +// 读完后若请求已取消,把空输出当成流失败,避免打断被记成一次成功的空回复。 func consumeOpenAI(ctx context.Context, chat Chat, body io.ReadCloser, stream *staticStream) { defer close(stream.done) defer close(stream.events) @@ -278,6 +279,10 @@ func consumeOpenAI(ctx context.Context, chat Chat, body io.ReadCloser, stream *s stream.err = err return } + if err := ctx.Err(); err != nil { + stream.err = err + return + } if usage.TotalTokens == 0 { usage.OutputTokens = CountTokens(text.String()) usage.TotalTokens = usage.OutputTokens + CountTokens(chat.SystemPrompt) @@ -305,12 +310,16 @@ func consumeOpenAI(ctx context.Context, chat Chat, body io.ReadCloser, stream *s // toOpenAIMessages 把系统提示、历史消息和工具结果映射成 OpenAI chat 消息。 // 模式规则紧跟底座 system:DeepSeek 等网关会丢掉插在对话历史后面的 system。 +// 已落库的空助手消息(无正文且无 tool_calls)在这里丢掉,避免下一轮被网关拒绝。 func toOpenAIMessages(chat Chat) []openaiChatMessage { var mode *openaiChatMessage rest := make([]openaiChatMessage, 0, len(chat.Messages)) for _, msg := range chat.Messages { switch msg.Role { case RoleAssistant: + if assistantBlank(msg.Content, msg.ToolCalls) { + continue + } item := openaiChatMessage{Role: "assistant", Content: DecodeText(msg.Content)} for _, call := range msg.ToolCalls { item.ToolCalls = append(item.ToolCalls, openaiToolCall{ diff --git a/server/pkg/agent/openai_live_test.go b/server/pkg/agent/openai_live_test.go new file mode 100644 index 0000000..0489686 --- /dev/null +++ b/server/pkg/agent/openai_live_test.go @@ -0,0 +1,116 @@ +package agent + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "codedock/internal/config" + "codedock/pkg/agent/tool" +) + +// liveModelFromDotEnv 用仓库 .env 里的真实供应商、模型和地址组装调用配置。 +func liveModelFromDotEnv(t *testing.T) (ModelConfig, config.Config) { + t.Helper() + if err := config.LoadDotEnv(); err != nil { + t.Fatalf("load .env: %v", err) + } + cfg := config.Load() + if strings.TrimSpace(cfg.LLMAPIKey) == "" || strings.EqualFold(cfg.LLMProvider, "fake") || strings.TrimSpace(cfg.LLMProvider) == "" { + t.Skip("live test needs LLM_API_KEY and a non-fake LLM_PROVIDER in .env") + } + if !strings.EqualFold(cfg.LLMProvider, "openai") { + t.Skipf("live wire test uses the openai-compatible client, provider=%s", cfg.LLMProvider) + } + opts, err := json.Marshal(map[string]string{ + "api_key": cfg.LLMAPIKey, + "base_url": cfg.LLMBaseURL, + }) + if err != nil { + t.Fatal(err) + } + return ModelConfig{Provider: cfg.LLMProvider, Model: cfg.LLMModel, Options: opts}, cfg +} + +// TestLiveBlankAssistantWire 用 .env 的真实网关确认:空助手消息会被拒绝,过滤后再发则能通过。 +func TestLiveBlankAssistantWire(t *testing.T) { + model, cfg := liveModelFromDotEnv(t) + history := []Message{ + {Role: RoleUser, Content: EncodeText("hi")}, + {Role: RoleAssistant, Content: EncodeText(""), ToolCalls: []tool.Call{{ID: "call_1", Name: "ping", Arguments: json.RawMessage(`{"path":"."}`)}}}, + {Role: RoleTool, Content: EncodeToolResult("call_1", json.RawMessage(`"ok"`))}, + {Role: RoleAssistant, Content: EncodeText("")}, + {Role: RoleUser, Content: EncodeText("Reply with the single word pong")}, + } + wired := toOpenAIMessages(Chat{Model: model, Messages: history}) + for _, msg := range wired { + if msg.Role == "assistant" && strings.TrimSpace(msg.Content) == "" && len(msg.ToolCalls) == 0 { + t.Fatalf("blank assistant still on the wire: %+v", wired) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + stream, err := Stream(ctx, Chat{Model: model, TurnID: "live-blank", Messages: history}) + if err != nil { + t.Fatal(PublicModelError(err)) + } + defer stream.Close() + got, err := stream.Result(ctx) + if err != nil { + t.Fatal(PublicModelError(err)) + } + text := strings.TrimSpace(DecodeText(got.Message.Content)) + if text == "" && len(got.ToolCalls) == 0 { + t.Fatal("model returned a blank assistant") + } + t.Logf("model=%s reply=%q", cfg.LLMModel, clipPublicError(text)) + + status, message := postRawAssistant(t, cfg, openaiChatRequest{ + Model: cfg.LLMModel, + Stream: true, + Messages: []openaiChatMessage{ + {Role: "user", Content: "hi"}, + {Role: "assistant"}, + {Role: "user", Content: "Reply with the single word pong"}, + }, + }) + if status < 400 || !strings.Contains(message, "Invalid assistant message") { + t.Fatalf("unfiltered blank assistant status=%d message=%s", status, message) + } +} + +// postRawAssistant 把未过滤的消息直接发给 .env 里的网关,用来对照空助手消息会被拒绝。 +func postRawAssistant(t *testing.T, cfg config.Config, payload openaiChatRequest) (int, string) { + t.Helper() + base := strings.TrimRight(cfg.LLMBaseURL, "/") + body, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/chat/completions", strings.NewReader(string(body))) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+cfg.LLMAPIKey) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return resp.StatusCode, PublicModelError(errString(string(raw))) +} + +// errString 包一层 error,供 PublicModelError 抽出网关消息。 +type errString string + +// Error 返回网关原文。 +func (e errString) Error() string { return string(e) } diff --git a/server/pkg/agent/openai_test.go b/server/pkg/agent/openai_test.go index ea81fd0..a5a2a7d 100644 --- a/server/pkg/agent/openai_test.go +++ b/server/pkg/agent/openai_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "strings" @@ -228,6 +229,34 @@ func TestStreamWithRetryThenOK(t *testing.T) { } } +// TestToOpenAIMessagesSkipsBlankAssistant 确认空助手消息不进网关,带 tool_calls 的空正文仍保留。 +func TestToOpenAIMessagesSkipsBlankAssistant(t *testing.T) { + msgs := toOpenAIMessages(Chat{ + Messages: []Message{ + {Role: RoleAssistant, Content: EncodeText("")}, + {Role: RoleAssistant, Content: EncodeText(" ")}, + {Role: RoleAssistant, Content: EncodeText(""), ToolCalls: []tool.Call{{ID: "c1", Name: "ping", Arguments: json.RawMessage(`{}`)}}}, + {Role: RoleUser, Content: EncodeText("go")}, + }, + }) + if len(msgs) != 2 || msgs[0].Role != "assistant" || msgs[0].Content != "" || len(msgs[0].ToolCalls) != 1 || msgs[1].Role != "user" { + t.Fatalf("%+v", msgs) + } +} + +// TestConsumeOpenAICancelAfterCleanEOF 确认请求已取消时,干净结束的空流不算成功回复。 +func TestConsumeOpenAICancelAfterCleanEOF(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + stream := &staticStream{events: make(chan ModelStreamEvent, 4), done: make(chan struct{})} + go consumeOpenAI(ctx, Chat{Model: ModelConfig{Model: "m"}}, io.NopCloser(strings.NewReader("")), stream) + for range stream.Events() { + } + if _, err := stream.Result(context.Background()); err == nil { + t.Fatal("cancelled empty stream should fail") + } +} + func stringsRepeat(s string, n int) string { out := make([]byte, 0, len(s)*n) for i := 0; i < n; i++ { diff --git a/server/pkg/agent/review.go b/server/pkg/agent/review.go index 3cd1352..2995dcd 100644 --- a/server/pkg/agent/review.go +++ b/server/pkg/agent/review.go @@ -70,7 +70,7 @@ func reviewOpenAI(ctx context.Context, model ModelConfig, calls []ApprovalToolCa Role: RoleUser, Content: EncodeText(reviewerUserText(calls)), }}, - MaxOutputTokens: 1024, + MaxOutputTokens: 8192, } reqBody := openaiChatRequest{ Model: model.Model,