From 9bb17731d552e94c18bf2fcb1211c8e19e747f95 Mon Sep 17 00:00:00 2001 From: yingchaox <1020316234@qq.com> Date: Thu, 26 Feb 2026 22:07:47 +0800 Subject: [PATCH 1/2] refactor: enhance orchestrator and configuration management - Introduced a layered approach in the orchestrator's `RunTurn` process, separating concerns into distinct pipeline steps for better maintainability. - Added a new `approval_builder.go` to handle approval logic, improving the clarity and reusability of approval-related code. - Refactored configuration management to utilize default values from a dedicated `default_values.go` file, ensuring consistency and reducing hardcoded values. - Updated the initialization of project configuration to respect existing user settings and streamline the setup process. - Enhanced error handling and logging for tool execution and approval processes, improving user feedback and debugging capabilities. - Added tests for new approval logic and configuration handling to ensure robustness and reliability. --- ...47\350\241\214\345\276\252\347\216\257.md" | 37 ++++ ...75\344\270\216\350\246\206\347\233\226.md" | 126 ++++++----- internal/bootstrap/approval_builder.go | 120 +++++++++++ internal/bootstrap/bootstrap.go | 195 +---------------- internal/bootstrap/build_helpers.go | 114 ++++++++++ internal/config/config.go | 150 +------------ internal/config/default_values.go | 11 + internal/config/project_config.go | 151 +++++++++++++ internal/orchestrator/helpers.go | 3 +- internal/orchestrator/orchestrator.go | 10 +- internal/orchestrator/orchestrator_test.go | 32 +++ internal/orchestrator/turn.go | 166 +-------------- internal/orchestrator/turn_pipeline.go | 200 ++++++++++++++++++ internal/repl/loop.go | 3 +- 14 files changed, 767 insertions(+), 551 deletions(-) create mode 100644 internal/bootstrap/approval_builder.go create mode 100644 internal/bootstrap/build_helpers.go create mode 100644 internal/config/default_values.go create mode 100644 internal/config/project_config.go create mode 100644 internal/orchestrator/turn_pipeline.go diff --git "a/docs/technical/02-Orchestrator\346\211\247\350\241\214\345\276\252\347\216\257.md" "b/docs/technical/02-Orchestrator\346\211\247\350\241\214\345\276\252\347\216\257.md" index 981acd7..8ebacf4 100644 --- "a/docs/technical/02-Orchestrator\346\211\247\350\241\214\345\276\252\347\216\257.md" +++ "b/docs/technical/02-Orchestrator\346\211\247\350\241\214\345\276\252\347\216\257.md" @@ -22,6 +22,21 @@ 7. 若有工具调用:逐个执行,写入 tool 消息,再进入下一步。 8. 达到步数上限返回 `step limit reached`。 +### 3.1 推荐实现分层(重构目标) + +`RunTurn` 应只保留流程编排职责,具体逻辑下沉为 pipeline step: + +1. `prepareTurnState`:输入入栈、上下文刷新、回合状态初始化。 +2. `chatStep`:调用 provider(含流式输出控制)。 +3. `toolLoopStep`:工具调用循环(策略、审批、执行、回写)。 +4. `autoVerifyStep`:自动验证与重试注入。 +5. `persistStep`:session 落盘与收尾同步。 + +要求: + +- 每个 step 尽量可单测。 +- step 之间通过显式状态结构传递,不直接读写过多 orchestrator 字段。 + ## 4. 工具调用执行顺序 1. Agent 工具开关检查。 2. Policy 决策(`allow/ask/deny`)。 @@ -30,6 +45,11 @@ 5. 执行工具。 6. 结果写入 `tool` 消息并更新运行状态。 +补充约束: + +- 审批链路应支持“策略层 ask + 工具层 approval request”聚合为一次交互。 +- tool 执行失败要标准化写回(`{"ok":false,"error":"..."}`)并继续后续流程判定。 + ## 5. 模式行为矩阵 - `build` - 目标:交付改动。 @@ -111,6 +131,11 @@ - 若无可执行白名单命令:显式提示“未执行自动验证”。 - 可重试失败时注入修复提示继续回合;最多 `max_verify_attempts` 次。 +工程约束: + +- 自动验证开关与重试默认值应来源于配置层,orchestrator 不维护重复硬编码默认值。 +- 是否触发验证的判定逻辑与命令选择逻辑应解耦,分别可测试。 + ## 9. 复杂任务判定 按需求固定规则执行: 1. 显式多目标枚举命中即复杂。 @@ -122,8 +147,20 @@ - `/new`、`/resume` 会切换当前 session 上下文。 - 维护回合级 undo 快照栈(仅保留最近若干回合),供 `/undo` 精确回滚使用。 +## 10.1 错误与取消语义 + +- `context canceled/deadline exceeded`:优先返回上下文错误,不包装为业务错误。 +- provider/tool/approval 等非上下文错误:按链路分别包装,保留根因信息。 +- Esc 取消后不自动回滚副作用,保持“已执行即生效”。 + ## 11. 运行态取消(Esc) - REPL 在执行 `RunInput` 时提供可取消的 `context`;Esc 触发 `context cancel`。 - `RunTurn` 在模型调用、审批、tool 执行、自动验证链路中检测 `context canceled` 并立即终止,不继续后续自动化步骤。 - 审批等待场景中 Esc 语义为全局 Cancel(不是 `N`)。 - 取消不做回滚;已完成副作用保持,todo 状态维持最后一次持久化结果。 + +## 12. 兼容策略(重构期间) + +- 对外行为保持稳定:`RunInput`、`RunTurn`、`/` 与 `!` 命令契约不变。 +- 允许的小行为修复(例如错误文案更准确、边界值处理修复)需在变更说明中列出。 +- 任何行为变化必须配套回归测试(优先 orchestrator 层单测)。 diff --git "a/docs/technical/10-\351\205\215\347\275\256\345\212\240\350\275\275\344\270\216\350\246\206\347\233\226.md" "b/docs/technical/10-\351\205\215\347\275\256\345\212\240\350\275\275\344\270\216\350\246\206\347\233\226.md" index 066ec23..d8fc113 100644 --- "a/docs/technical/10-\351\205\215\347\275\256\345\212\240\350\275\275\344\270\216\350\246\206\347\233\226.md" +++ "b/docs/technical/10-\351\205\215\347\275\256\345\212\240\350\275\275\344\270\216\350\246\206\347\233\226.md" @@ -1,51 +1,59 @@ # 10. 配置加载与覆盖(目标态) -## 1. 首次启动初始化项目配置 +## 1. 设计目标 -当在某个工作目录下首次运行 agent 二进制时,如果当前目录下: +- 以 `config.Default()` 作为**默认值单一来源(SSOT)**。 +- `Load()` 仅负责“读取 + 合并 + 归一化 + 环境变量覆盖”。 +- 业务层(如 orchestrator/bootstrap)不再复制配置默认值常量。 -- 不存在 `./.coder/config.json`,且 -- 不存在 `./agent.config.json` +## 2. 首次启动初始化项目配置 -程序会自动: +当首次在项目目录运行时,若不存在 `./.coder/config.json`,程序会: -1. 创建 `./.coder/` 目录(如有需要)。 -2. 将内置默认配置(`config.Default()`)序列化为 JSON,写入 `./.coder/config.json`(缩进 2 空格)。 +1. 创建 `./.coder/`(若不存在)。 +2. 以 `config.Default()` 序列化输出模板到 `./.coder/config.json`。 -若目录无写权限或写入失败,仅在 stderr 打印警告,不会中断后续配置加载流程。 +若目录无写权限或写入失败,仅输出 warning,不阻断后续加载流程。 -## 2. 加载顺序 +## 3. 加载顺序 -`config.Load()` 实际流程: +`config.Load()` 的确定性顺序: -1. 内置默认配置(`config.Default()`)。 -2. 全局配置:`~/.coder/config.json`(若存在则合并)。 -3. 项目配置(按顺序自动发现并合并): - - 当前路径下的 `agent.config.json`; - - 当前路径下的 `.coder/config.json`。 -4. 归一化: - - 补默认值; - - 路径展开(如 `~`); - - 列表去重与去空等。 -5. 环境变量覆盖(如 `AGENT_BASE_URL`、`AGENT_MODEL` 等)。 -6. 二次归一化(保证覆盖后仍满足约束)。 +1. 内置默认配置:`config.Default()`。 +2. 全局配置:`~/.coder/config.json`(存在则 merge)。 +3. 项目配置:`./.coder/config.json`(存在则 merge)。 +4. `normalize`(补缺省、路径展开、去重与约束校验)。 +5. 环境变量覆盖(如 `AGENT_BASE_URL`、`AGENT_MODEL`)。 +6. 二次 `normalize`(确保 env 覆盖后仍满足约束)。 -## 3. JSONC 支持 -- 解析前去除行注释与块注释。 -- 使用标准 JSON 反序列化。 +## 4. JSONC 支持 -## 4. merge 规则 -- 标量字段:非空/有效值覆盖。 -- 列表字段:新列表整体覆盖旧列表。 -- map 字段:按配置块整体替换。 +- 读取后先移除行注释 `//` 与块注释 `/* ... */`。 +- 再使用标准 JSON 反序列化。 -## 5. normalize 规则 -- 补默认值:`max_steps`、`timeout`、`token_limit` 等。 -- 路径展开:`~` -> home,统一绝对路径。 -- 去重与去空:模型列表、技能路径、命令列表。 -- 空白字段清理。 +## 5. merge 责任边界 + +- `types`:声明配置结构,不包含 merge 行为。 +- `loader`:文件读取、JSONC 清洗、反序列化。 +- `merge`:字段级覆盖策略(标量、list、map 规则)。 +- `normalize`:约束收敛与路径归一化。 +- `env`:环境变量读取与覆盖。 + +merge 规则: + +- 标量:override 为非空/有效值时覆盖 base。 +- list:采用“整体替换”或“显式追加”策略,禁止隐式拼接。 +- map:按配置块语义处理(例如 `permission.bash` 为整体替换)。 + +## 6. normalize 规则 + +- 补默认值:来自 `config.Default()`。 +- 路径展开:`~` 展开为 home,统一绝对路径。 +- 去重与去空:模型列表、命令列表、路径列表。 +- 约束修正:如阈值上下界、最小重试次数等。 + +## 7. 环境变量覆盖 -## 6. 环境变量覆盖 - `AGENT_BASE_URL` - `AGENT_MODEL` - `AGENT_API_KEY`(可回退 `DASHSCOPE_API_KEY`) @@ -53,27 +61,35 @@ - `AGENT_MAX_STEPS` - `AGENT_CACHE_PATH` -非法值处理: -- 例如 `AGENT_MAX_STEPS<=0` 直接报错。 +错误处理: + +- 非法值立即返回错误(如 `AGENT_MAX_STEPS<=0`)。 + +## 8. 关键配置块 -## 7. 关键配置块 -- `provider`:私有模型地址、默认模型、超时、模型列表。 +- `provider`:模型地址/默认模型/超时/模型列表。 - `runtime`:workspace、最大步数、上下文上限。 -- `safety`:命令超时、输出截断。 -- `compaction`:压缩阈值与保留条数。 -- `workflow`:复杂任务 todo、自动验证开关与命令、模式轮转。 -- `workflow`:复杂任务 todo、自动验证开关与命令。 -- `permission`:工具权限与 bash pattern。 -- `agents`:agent 定义与默认 agent。 -- `skills`:skill 扫描路径(默认开启)。 -- `storage`:SQLite 路径。 - -## 8. 预设与运行规则 -- `/permissions` 预设:`build`、`plan`(与 `/mode` 联动)。 -- `/model ` 持久化仅写 `./.coder/config.json`。 -- 主题/字体不提供配置项:固定 `OpenCode Dark` + 终端默认等宽字体。 +- `safety`:命令超时、输出上限。 +- `compaction`:压缩开关、阈值、保留消息数。 +- `workflow`:todo 约束、自动验证、重试次数、验证命令。 +- `approval`:交互审批与自动放行策略。 +- `permission`:工具权限、bash 策略、allowlist。 +- `agent/agents`:模式与 agent profile 定义。 +- `skills`:skill 搜索路径。 +- `storage`:持久化目录与缓存策略。 +- `lsp`:语言服务配置。 +- `fetch`:抓取超时、大小限制、默认请求头。 + +## 9. 兼容与行为变更记录(重构要求) + +- 配置重构应保持外部字段兼容,不改 JSON key。 +- 若存在行为变化(如默认值修正),必须: + - 在 PR 描述列出“Before/After”; + - 在本文件追加兼容说明与迁移建议; + - 为变化点补充回归测试。 + +## 10. 运行规则 -## 9. 离线默认建议 -- `workflow.verify_commands` 配置白名单命令。 -- token 估算默认启发式,精确计数仅作可选增强。 -- 不配置 MCP 相关项。 +- `/permissions` 预设:`build`、`plan`(与 `/mode` 联动)。 +- `/model ` 持久化仅写入 `./.coder/config.json`。 +- 默认离线模式不要求 MCP 配置。 diff --git a/internal/bootstrap/approval_builder.go b/internal/bootstrap/approval_builder.go new file mode 100644 index 0000000..2a572da --- /dev/null +++ b/internal/bootstrap/approval_builder.go @@ -0,0 +1,120 @@ +package bootstrap + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "golang.org/x/term" + + "coder/internal/config" + "coder/internal/permission" + "coder/internal/tools" +) + +func buildApprovalFunc(cfg config.Config, policy *permission.Policy, workspaceRoot string) func(context.Context, tools.ApprovalRequest) (bool, error) { + return func(ctx context.Context, req tools.ApprovalRequest) (bool, error) { + isTTY := term.IsTerminal(int(os.Stdin.Fd())) + isBash := strings.EqualFold(strings.TrimSpace(req.Tool), "bash") + reason := strings.TrimSpace(req.Reason) + + // 非交互环境:为安全起见,继续拒绝执行,避免静默放行破坏性操作。 + if !isTTY { + return false, nil + } + + // 解析 bash 命令文本(仅在需要展示或加入 allowlist 时使用)。 + reader := bufio.NewReader(os.Stdin) + bashCommand := "" + if isBash && strings.TrimSpace(req.RawArgs) != "" { + var in struct { + Command string `json:"command"` + } + if err := json.Unmarshal([]byte(req.RawArgs), &in); err == nil { + bashCommand = strings.TrimSpace(in.Command) + } + } + + // 区分策略层 ask 与工具层危险命令风险审批。 + isPolicyAsk := strings.Contains(reason, "policy requires approval") + isDangerous := strings.Contains(reason, "dangerous") || + strings.Contains(reason, "overwrite") || + strings.Contains(reason, "substitution") || + strings.Contains(reason, "parse failed") || + strings.Contains(reason, "matches dangerous command policy") + + // 非交互模式配置:策略层 ask 可自动放行;危险命令仍需显式 y/n。 + if !cfg.Approval.Interactive && !isDangerous { + // 仅策略层 ask 走 auto_approve_ask;危险命令一律不在此路径放行。 + if cfg.Approval.AutoApproveAsk || isPolicyAsk { + return true, nil + } + } + + if prompter, ok := approvalPrompterFromContext(ctx); ok { + decision, err := prompter.PromptApproval(ctx, req, ApprovalPromptOptions{ + AllowAlways: !isDangerous, + BashCommand: bashCommand, + }) + if err != nil { + return false, err + } + switch decision { + case ApprovalDecisionAllowOnce: + return true, nil + case ApprovalDecisionAllowAlways: + if !isDangerous && isBash && bashCommand != "" { + name := config.NormalizeCommandName(bashCommand) + if name != "" && policy.AddToCommandAllowlist(name) { + _ = config.WriteCommandAllowlist(workspaceRoot, name) + } + } + return true, nil + default: + return false, nil + } + } + + // 交互式审批:策略层 ask 支持 y/n/always;危险命令仅 y/n。 + _, _ = fmt.Fprintln(os.Stdout) + _, _ = fmt.Fprintf(os.Stdout, "[approval required] tool=%s reason=%s\n", req.Tool, req.Reason) + if isBash && bashCommand != "" { + _, _ = fmt.Fprintf(os.Stdout, "[command] %s\n", bashCommand) + } + + if isDangerous { + // 危险命令风险审批:始终仅 y/n。 + _, _ = fmt.Fprint(os.Stdout, "允许执行?(y/N): ") + line, _ := reader.ReadString('\n') + ans := strings.ToLower(strings.TrimSpace(line)) + if ans != "y" && ans != "yes" { + return false, nil + } + return true, nil + } + + // 策略层 ask:支持 y/n/always。 + _, _ = fmt.Fprint(os.Stdout, "允许执行?(y/N/always): ") + line, _ := reader.ReadString('\n') + ans := strings.ToLower(strings.TrimSpace(line)) + switch ans { + case "y", "yes": + return true, nil + case "always", "a": + // 仅针对 bash 记录 allowlist;按命令名归一化。 + if isBash && bashCommand != "" { + name := config.NormalizeCommandName(bashCommand) + if name != "" && policy.AddToCommandAllowlist(name) { + // best-effort 持久化到项目配置;失败不影响本次放行。 + _ = config.WriteCommandAllowlist(workspaceRoot, name) + } + } + return true, nil + default: + return false, nil + } + } +} diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index c047247..a7d82e2 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -1,28 +1,20 @@ package bootstrap import ( - "bufio" "context" - "encoding/json" "fmt" - "os" "path/filepath" - "strings" - - "golang.org/x/term" "coder/internal/agent" "coder/internal/config" "coder/internal/contextmgr" "coder/internal/defaults" - "coder/internal/lsp" "coder/internal/orchestrator" "coder/internal/permission" "coder/internal/provider" "coder/internal/security" "coder/internal/skills" "coder/internal/storage" - "coder/internal/tools" ) // BuildResult 与 UI 无关的构建结果,供 main 构造 REPL @@ -41,12 +33,9 @@ type BuildResult struct { // Build 按文档顺序初始化并返回 BuildResult;调用方负责 defer result.Store.Close() // Build initializes in doc order and returns BuildResult; caller must defer result.Store.Close() func Build(cfg config.Config, workspaceRoot string) (*BuildResult, error) { - root := strings.TrimSpace(workspaceRoot) - if root == "" { - root = strings.TrimSpace(cfg.Runtime.WorkspaceRoot) - } - if root == "" { - return nil, fmt.Errorf("workspace root is empty") + root, err := resolveWorkspaceRoot(cfg, workspaceRoot) + if err != nil { + return nil, err } ws, err := security.NewWorkspace(root) @@ -71,28 +60,8 @@ func Build(cfg config.Config, workspaceRoot string) (*BuildResult, error) { } skills.MergeBuiltin(skillManager) - // Initialize LSP Manager - lspManager := lsp.NewManager(cfg.LSP, ws.Root()) - missingServers := lspManager.DetectServers() - if len(missingServers) > 0 { - fmt.Fprintln(os.Stderr, "[LSP] Some language servers are not installed:") - for _, info := range lspManager.GetMissingServers() { - fmt.Fprintf(os.Stderr, "[LSP] %s (%s): %s\n", info.Lang, info.Command, info.InstallHint) - } - fmt.Fprintln(os.Stderr, "[LSP] LSP tools will be disabled for these languages. Install the servers to enable LSP features.") - } - - // Initialize Git Manager - gitManager := tools.NewGitManager(ws) - if available, isRepo, version := gitManager.Check(); !available { - fmt.Fprintln(os.Stderr, "[Git] Git is not installed.") - fmt.Fprintln(os.Stderr, "[Git] Git tools will be disabled. Install git to enable git features.") - } else if !isRepo { - fmt.Fprintln(os.Stderr, "[Git] Current directory is not a git repository.") - fmt.Fprintln(os.Stderr, "[Git] Git tools will work in degraded mode. Initialize git to enable full features.") - } else { - fmt.Fprintf(os.Stderr, "[Git] Git detected: %s\n", version) - } + lspManager := initLSPManager(cfg, ws) + gitManager := initGitManager(ws) policy := permission.New(cfg.Permission) agentsCfg := config.MergeAgentConfig(cfg.Agent, cfg.Agents) @@ -123,159 +92,11 @@ func Build(cfg config.Config, workspaceRoot string) (*BuildResult, error) { } sessionIDRef := &sessionMeta.ID - taskTool := tools.NewTaskTool(nil) - skillTool := tools.NewSkillTool(skillManager, func(name string, action string) permission.Decision { - return policy.SkillVisibilityDecision(name) - }) - todoReadTool := tools.NewTodoReadTool(store, func() string { return *sessionIDRef }) - todoWriteTool := tools.NewTodoWriteTool(store, func() string { return *sessionIDRef }) - toolList := []tools.Tool{ - tools.NewReadTool(ws), - tools.NewWriteTool(ws), - tools.NewEditTool(ws), - tools.NewListTool(ws), - tools.NewGlobTool(ws), - tools.NewGrepTool(ws), - tools.NewPatchTool(ws), - tools.NewBashTool(ws.Root(), cfg.Safety.CommandTimeoutMS, cfg.Safety.OutputLimitBytes), - todoReadTool, - todoWriteTool, - skillTool, - taskTool, - tools.NewLSPDiagnosticsTool(lspManager), - tools.NewLSPDefinitionTool(lspManager), - tools.NewLSPHoverTool(lspManager), - tools.NewGitStatusTool(ws, gitManager), - tools.NewGitDiffTool(ws, gitManager), - tools.NewGitLogTool(ws, gitManager), - tools.NewGitAddTool(ws, gitManager), - tools.NewGitCommitTool(ws, gitManager), - tools.NewFetchTool(ws, tools.FetchConfig{ - TimeoutSec: cfg.Fetch.TimeoutMS / 1000, - MaxTextSizeKB: cfg.Fetch.MaxTextSizeKB, - MaxImageSizeMB: cfg.Fetch.MaxImageSizeMB, - SkipTLSVerify: cfg.Fetch.SkipTLSVerify, - DefaultHeaders: cfg.Fetch.DefaultHeaders, - }), - tools.NewPDFParserTool(ws), - tools.NewQuestionTool(), - } - registry := tools.NewRegistry(toolList...) - - approveFn := func(ctx context.Context, req tools.ApprovalRequest) (bool, error) { - isTTY := term.IsTerminal(int(os.Stdin.Fd())) - isBash := strings.EqualFold(strings.TrimSpace(req.Tool), "bash") - reason := strings.TrimSpace(req.Reason) - - // 非交互环境:为安全起见,继续拒绝执行,避免静默放行破坏性操作。 - if !isTTY { - return false, nil - } - - // 解析 bash 命令文本(仅在需要展示或加入 allowlist 时使用)。 - reader := bufio.NewReader(os.Stdin) - bashCommand := "" - if isBash && strings.TrimSpace(req.RawArgs) != "" { - var in struct { - Command string `json:"command"` - } - if err := json.Unmarshal([]byte(req.RawArgs), &in); err == nil { - bashCommand = strings.TrimSpace(in.Command) - } - } - - // 区分策略层 ask 与工具层危险命令风险审批。 - isPolicyAsk := strings.Contains(reason, "policy requires approval") - isDangerous := strings.Contains(reason, "dangerous") || - strings.Contains(reason, "overwrite") || - strings.Contains(reason, "substitution") || - strings.Contains(reason, "parse failed") || - strings.Contains(reason, "matches dangerous command policy") - - // 非交互模式配置:策略层 ask 可自动放行;危险命令仍需显式 y/n。 - if !cfg.Approval.Interactive && !isDangerous { - // 仅策略层 ask 走 auto_approve_ask;危险命令一律不在此路径放行。 - if cfg.Approval.AutoApproveAsk || isPolicyAsk { - return true, nil - } - } - - if prompter, ok := approvalPrompterFromContext(ctx); ok { - decision, err := prompter.PromptApproval(ctx, req, ApprovalPromptOptions{ - AllowAlways: !isDangerous, - BashCommand: bashCommand, - }) - if err != nil { - return false, err - } - switch decision { - case ApprovalDecisionAllowOnce: - return true, nil - case ApprovalDecisionAllowAlways: - if !isDangerous && isBash && bashCommand != "" { - name := config.NormalizeCommandName(bashCommand) - if name != "" { - if policy.AddToCommandAllowlist(name) { - _ = config.WriteCommandAllowlist(ws.Root(), name) - } - } - } - if isDangerous { - return true, nil - } - return true, nil - default: - return false, nil - } - } - - // 交互式审批:策略层 ask 支持 y/n/always;危险命令仅 y/n。 - _, _ = fmt.Fprintln(os.Stdout) - _, _ = fmt.Fprintf(os.Stdout, "[approval required] tool=%s reason=%s\n", req.Tool, req.Reason) - if isBash && bashCommand != "" { - _, _ = fmt.Fprintf(os.Stdout, "[command] %s\n", bashCommand) - } - - if isDangerous { - // 危险命令风险审批:始终仅 y/n。 - _, _ = fmt.Fprint(os.Stdout, "允许执行?(y/N): ") - line, _ := reader.ReadString('\n') - ans := strings.ToLower(strings.TrimSpace(line)) - if ans != "y" && ans != "yes" { - return false, nil - } - return true, nil - } - - // 策略层 ask:支持 y/n/always。 - _, _ = fmt.Fprint(os.Stdout, "允许执行?(y/N/always): ") - line, _ := reader.ReadString('\n') - ans := strings.ToLower(strings.TrimSpace(line)) - switch ans { - case "y", "yes": - return true, nil - case "always", "a": - // 仅针对 bash 记录 allowlist;按命令名归一化。 - if isBash && bashCommand != "" { - name := config.NormalizeCommandName(bashCommand) - if name != "" { - if policy.AddToCommandAllowlist(name) { - // best-effort 持久化到项目配置;失败不影响本次放行。 - _ = config.WriteCommandAllowlist(ws.Root(), name) - } - } - } - return true, nil - default: - return false, nil - } - } + registry, taskTool := buildToolRegistry(cfg, ws, store, sessionIDRef, skillManager, policy, lspManager, gitManager) + approveFn := buildApprovalFunc(cfg, policy, ws.Root()) toolNames := registry.Names() - skillNames := make([]string, 0, len(skillManager.List())) - for _, info := range skillManager.List() { - skillNames = append(skillNames, info.Name) - } + skillNames := collectSkillNames(skillManager) orch := orchestrator.New(providerClient, registry, orchestrator.Options{ MaxSteps: cfg.Runtime.MaxSteps, SystemPrompt: defaults.DefaultSystemPrompt, diff --git a/internal/bootstrap/build_helpers.go b/internal/bootstrap/build_helpers.go new file mode 100644 index 0000000..9d56d1b --- /dev/null +++ b/internal/bootstrap/build_helpers.go @@ -0,0 +1,114 @@ +package bootstrap + +import ( + "fmt" + "os" + "strings" + + "coder/internal/config" + "coder/internal/lsp" + "coder/internal/permission" + "coder/internal/security" + "coder/internal/skills" + "coder/internal/storage" + "coder/internal/tools" +) + +func resolveWorkspaceRoot(cfg config.Config, workspaceRoot string) (string, error) { + root := strings.TrimSpace(workspaceRoot) + if root == "" { + root = strings.TrimSpace(cfg.Runtime.WorkspaceRoot) + } + if root == "" { + return "", fmt.Errorf("workspace root is empty") + } + return root, nil +} + +func initLSPManager(cfg config.Config, ws *security.Workspace) *lsp.Manager { + lspManager := lsp.NewManager(cfg.LSP, ws.Root()) + if len(lspManager.DetectServers()) == 0 { + return lspManager + } + fmt.Fprintln(os.Stderr, "[LSP] Some language servers are not installed:") + for _, info := range lspManager.GetMissingServers() { + fmt.Fprintf(os.Stderr, "[LSP] %s (%s): %s\n", info.Lang, info.Command, info.InstallHint) + } + fmt.Fprintln(os.Stderr, "[LSP] LSP tools will be disabled for these languages. Install the servers to enable LSP features.") + return lspManager +} + +func initGitManager(ws *security.Workspace) *tools.GitManager { + gitManager := tools.NewGitManager(ws) + if available, isRepo, version := gitManager.Check(); !available { + fmt.Fprintln(os.Stderr, "[Git] Git is not installed.") + fmt.Fprintln(os.Stderr, "[Git] Git tools will be disabled. Install git to enable git features.") + } else if !isRepo { + fmt.Fprintln(os.Stderr, "[Git] Current directory is not a git repository.") + fmt.Fprintln(os.Stderr, "[Git] Git tools will work in degraded mode. Initialize git to enable full features.") + } else { + fmt.Fprintf(os.Stderr, "[Git] Git detected: %s\n", version) + } + return gitManager +} + +func buildToolRegistry( + cfg config.Config, + ws *security.Workspace, + store storage.Store, + sessionIDRef *string, + skillManager *skills.Manager, + policy *permission.Policy, + lspManager *lsp.Manager, + gitManager *tools.GitManager, +) (*tools.Registry, *tools.TaskTool) { + taskTool := tools.NewTaskTool(nil) + skillTool := tools.NewSkillTool(skillManager, func(name string, _ string) permission.Decision { + return policy.SkillVisibilityDecision(name) + }) + todoReadTool := tools.NewTodoReadTool(store, func() string { return *sessionIDRef }) + todoWriteTool := tools.NewTodoWriteTool(store, func() string { return *sessionIDRef }) + + toolList := []tools.Tool{ + tools.NewReadTool(ws), + tools.NewWriteTool(ws), + tools.NewEditTool(ws), + tools.NewListTool(ws), + tools.NewGlobTool(ws), + tools.NewGrepTool(ws), + tools.NewPatchTool(ws), + tools.NewBashTool(ws.Root(), cfg.Safety.CommandTimeoutMS, cfg.Safety.OutputLimitBytes), + todoReadTool, + todoWriteTool, + skillTool, + taskTool, + tools.NewLSPDiagnosticsTool(lspManager), + tools.NewLSPDefinitionTool(lspManager), + tools.NewLSPHoverTool(lspManager), + tools.NewGitStatusTool(ws, gitManager), + tools.NewGitDiffTool(ws, gitManager), + tools.NewGitLogTool(ws, gitManager), + tools.NewGitAddTool(ws, gitManager), + tools.NewGitCommitTool(ws, gitManager), + tools.NewFetchTool(ws, tools.FetchConfig{ + TimeoutSec: cfg.Fetch.TimeoutMS / 1000, + MaxTextSizeKB: cfg.Fetch.MaxTextSizeKB, + MaxImageSizeMB: cfg.Fetch.MaxImageSizeMB, + SkipTLSVerify: cfg.Fetch.SkipTLSVerify, + DefaultHeaders: cfg.Fetch.DefaultHeaders, + }), + tools.NewPDFParserTool(ws), + tools.NewQuestionTool(), + } + + return tools.NewRegistry(toolList...), taskTool +} + +func collectSkillNames(skillManager *skills.Manager) []string { + skillInfos := skillManager.List() + skillNames := make([]string, 0, len(skillInfos)) + for _, info := range skillInfos { + skillNames = append(skillNames, info.Name) + } + return skillNames +} diff --git a/internal/config/config.go b/internal/config/config.go index a43f682..c3be318 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -198,8 +198,8 @@ func Default() Config { TimeoutMS: 120000, }, Runtime: RuntimeConfig{ - MaxSteps: 128, - ContextTokenLimit: 24000, + MaxSteps: DefaultRuntimeMaxSteps, + ContextTokenLimit: DefaultRuntimeContextTokenLimit, }, Safety: SafetyConfig{ CommandTimeoutMS: 120000, @@ -208,8 +208,8 @@ func Default() Config { Compaction: CompactionConfig{ Auto: true, Prune: true, - Threshold: 0.8, - RecentMessages: 12, + Threshold: DefaultCompactionThreshold, + RecentMessages: DefaultCompactionRecentMessages, }, Approval: ApprovalConfig{ AutoApproveAsk: false, @@ -247,7 +247,7 @@ func Default() Config { Workflow: WorkflowConfig{ RequireTodoForComplex: true, AutoVerifyAfterEdit: true, - MaxVerifyAttempts: 2, + MaxVerifyAttempts: DefaultWorkflowMaxVerifyAttempts, VerifyCommands: nil, }, Agent: AgentConfig{Default: "build"}, @@ -918,143 +918,3 @@ func stripJSONComments(data []byte) []byte { return out.Bytes() } -// InitProjectConfigScaffold 在当前工作目录下初始化项目级配置模板(./.coder/config.json)。 -// InitProjectConfigScaffold initializes a project-level config scaffold (./.coder/config.json) in the current working directory. -func InitProjectConfigScaffold() error { - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("get current working directory: %w", err) - } - - dir := filepath.Join(cwd, ".coder") - path := filepath.Join(dir, "config.json") - - // 若项目已经有 ./.coder/config.json,则尊重用户现有配置。 - info, err := os.Stat(path) - if err == nil { - if info.IsDir() { - return fmt.Errorf("project config path is a directory: %s", path) - } - return nil - } - if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("stat project config: %w", err) - } - - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("mkdir .coder: %w", err) - } - - cfg := Default() - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return fmt.Errorf("marshal default config: %w", err) - } - - if err := os.WriteFile(path, data, 0o644); err != nil { - return fmt.Errorf("write project config: %w", err) - } - - return nil -} - -// WriteProviderModel 将 provider.model 写入项目配置(./.coder/config.json);目录不存在则创建 -// WriteProviderModel writes provider.model to project config (./.coder/config.json); creates dir if needed -func WriteProviderModel(projectDir, model string) error { - model = strings.TrimSpace(model) - if model == "" { - return errors.New("model is empty") - } - dir := filepath.Join(strings.TrimSpace(projectDir), ".coder") - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("mkdir .coder: %w", err) - } - path := filepath.Join(dir, "config.json") - var out map[string]any - data, err := os.ReadFile(path) - if err == nil { - if err := json.Unmarshal(data, &out); err != nil { - out = nil - } - } - if out == nil { - out = make(map[string]any) - } - providerMap, _ := out["provider"].(map[string]any) - if providerMap == nil { - providerMap = make(map[string]any) - } - providerMap["model"] = model - out["provider"] = providerMap - data, err = json.MarshalIndent(out, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, data, 0o644) -} - -// WriteCommandAllowlist 追加命令名到项目级 allowlist(permission.command_allowlist),目录不存在则创建。 -// WriteCommandAllowlist appends a command name to project-level permission.command_allowlist; creates .coder if needed. -func WriteCommandAllowlist(projectDir, commandName string) error { - name := NormalizeCommandName(commandName) - if name == "" { - return errors.New("command name is empty") - } - dir := filepath.Join(strings.TrimSpace(projectDir), ".coder") - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("mkdir .coder: %w", err) - } - path := filepath.Join(dir, "config.json") - var root map[string]any - data, err := os.ReadFile(path) - if err == nil { - if err := json.Unmarshal(data, &root); err != nil { - root = nil - } - } - if root == nil { - root = make(map[string]any) - } - permAny, ok := root["permission"] - var perm map[string]any - if ok { - if m, ok2 := permAny.(map[string]any); ok2 { - perm = m - } - } - if perm == nil { - perm = make(map[string]any) - } - existingAny, _ := perm["command_allowlist"].([]any) - seen := map[string]struct{}{} - names := make([]string, 0, len(existingAny)+1) - for _, v := range existingAny { - s, ok := v.(string) - if !ok { - continue - } - n := strings.ToLower(strings.TrimSpace(s)) - if n == "" { - continue - } - if _, ok := seen[n]; ok { - continue - } - seen[n] = struct{}{} - names = append(names, n) - } - if _, ok := seen[name]; !ok { - names = append(names, name) - } - outArr := make([]any, 0, len(names)) - for _, n := range names { - outArr = append(outArr, n) - } - perm["command_allowlist"] = outArr - root["permission"] = perm - data, err = json.MarshalIndent(root, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, data, 0o644) -} diff --git a/internal/config/default_values.go b/internal/config/default_values.go new file mode 100644 index 0000000..2414827 --- /dev/null +++ b/internal/config/default_values.go @@ -0,0 +1,11 @@ +package config + +const ( + DefaultRuntimeMaxSteps = 128 + DefaultRuntimeContextTokenLimit = 24000 + + DefaultCompactionThreshold = 0.8 + DefaultCompactionRecentMessages = 12 + + DefaultWorkflowMaxVerifyAttempts = 2 +) diff --git a/internal/config/project_config.go b/internal/config/project_config.go new file mode 100644 index 0000000..18ac3bc --- /dev/null +++ b/internal/config/project_config.go @@ -0,0 +1,151 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// InitProjectConfigScaffold 在当前工作目录下初始化项目级配置模板(./.coder/config.json)。 +// InitProjectConfigScaffold initializes a project-level config scaffold (./.coder/config.json) in the current working directory. +func InitProjectConfigScaffold() error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get current working directory: %w", err) + } + + dir := filepath.Join(cwd, ".coder") + path := filepath.Join(dir, "config.json") + + // 若项目已经有 ./.coder/config.json,则尊重用户现有配置。 + info, err := os.Stat(path) + if err == nil { + if info.IsDir() { + return fmt.Errorf("project config path is a directory: %s", path) + } + return nil + } + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("stat project config: %w", err) + } + + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir .coder: %w", err) + } + + cfg := Default() + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("marshal default config: %w", err) + } + + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("write project config: %w", err) + } + + return nil +} + +// WriteProviderModel 将 provider.model 写入项目配置(./.coder/config.json);目录不存在则创建 +// WriteProviderModel writes provider.model to project config (./.coder/config.json); creates dir if needed +func WriteProviderModel(projectDir, model string) error { + model = strings.TrimSpace(model) + if model == "" { + return errors.New("model is empty") + } + dir := filepath.Join(strings.TrimSpace(projectDir), ".coder") + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir .coder: %w", err) + } + path := filepath.Join(dir, "config.json") + var out map[string]any + data, err := os.ReadFile(path) + if err == nil { + if err := json.Unmarshal(data, &out); err != nil { + out = nil + } + } + if out == nil { + out = make(map[string]any) + } + providerMap, _ := out["provider"].(map[string]any) + if providerMap == nil { + providerMap = make(map[string]any) + } + providerMap["model"] = model + out["provider"] = providerMap + data, err = json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +// WriteCommandAllowlist 追加命令名到项目级 allowlist(permission.command_allowlist),目录不存在则创建。 +// WriteCommandAllowlist appends a command name to project-level permission.command_allowlist; creates .coder if needed. +func WriteCommandAllowlist(projectDir, commandName string) error { + name := NormalizeCommandName(commandName) + if name == "" { + return errors.New("command name is empty") + } + dir := filepath.Join(strings.TrimSpace(projectDir), ".coder") + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir .coder: %w", err) + } + path := filepath.Join(dir, "config.json") + var root map[string]any + data, err := os.ReadFile(path) + if err == nil { + if err := json.Unmarshal(data, &root); err != nil { + root = nil + } + } + if root == nil { + root = make(map[string]any) + } + permAny, ok := root["permission"] + var perm map[string]any + if ok { + if m, ok2 := permAny.(map[string]any); ok2 { + perm = m + } + } + if perm == nil { + perm = make(map[string]any) + } + existingAny, _ := perm["command_allowlist"].([]any) + seen := map[string]struct{}{} + names := make([]string, 0, len(existingAny)+1) + for _, v := range existingAny { + s, ok := v.(string) + if !ok { + continue + } + n := strings.ToLower(strings.TrimSpace(s)) + if n == "" { + continue + } + if _, ok := seen[n]; ok { + continue + } + seen[n] = struct{}{} + names = append(names, n) + } + if _, ok := seen[name]; !ok { + names = append(names, name) + } + outArr := make([]any, 0, len(names)) + for _, n := range names { + outArr = append(outArr, n) + } + perm["command_allowlist"] = outArr + root["permission"] = perm + data, err = json.MarshalIndent(root, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} diff --git a/internal/orchestrator/helpers.go b/internal/orchestrator/helpers.go index c85d4db..31c6c93 100644 --- a/internal/orchestrator/helpers.go +++ b/internal/orchestrator/helpers.go @@ -9,6 +9,7 @@ import ( "strings" "coder/internal/chat" + "coder/internal/config" ) func (o *Orchestrator) resolveMaxSteps() int { @@ -16,7 +17,7 @@ func (o *Orchestrator) resolveMaxSteps() int { return o.activeAgent.MaxSteps } if o.maxSteps <= 0 { - return 128 + return config.DefaultRuntimeMaxSteps } return o.maxSteps } diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index db12995..11737d9 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -50,20 +50,20 @@ type Orchestrator struct { func New(providerClient provider.Provider, registry *tools.Registry, opts Options) *Orchestrator { maxSteps := opts.MaxSteps if maxSteps <= 0 { - maxSteps = 128 + maxSteps = config.DefaultRuntimeMaxSteps } contextLimit := opts.ContextTokenLimit if contextLimit <= 0 { - contextLimit = 24000 + contextLimit = config.DefaultRuntimeContextTokenLimit } if opts.Compaction.Threshold <= 0 || opts.Compaction.Threshold >= 1 { - opts.Compaction.Threshold = 0.8 + opts.Compaction.Threshold = config.DefaultCompactionThreshold } if opts.Compaction.RecentMessages <= 0 { - opts.Compaction.RecentMessages = 12 + opts.Compaction.RecentMessages = config.DefaultCompactionRecentMessages } if opts.Workflow.MaxVerifyAttempts <= 0 { - opts.Workflow.MaxVerifyAttempts = 2 + opts.Workflow.MaxVerifyAttempts = config.DefaultWorkflowMaxVerifyAttempts } activeAgent := opts.ActiveAgent diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go index cf24e5f..5dffbd4 100644 --- a/internal/orchestrator/orchestrator_test.go +++ b/internal/orchestrator/orchestrator_test.go @@ -154,6 +154,38 @@ func TestRenderToolResultMultiline(t *testing.T) { } } +func TestJoinApprovalReasons(t *testing.T) { + tests := []struct { + name string + reasons []string + want string + }{ + { + name: "empty reasons", + reasons: nil, + want: "approval required", + }, + { + name: "trim and deduplicate", + reasons: []string{" policy requires approval ", "dangerous command", "policy requires approval"}, + want: "policy requires approval; dangerous command", + }, + { + name: "all blanks", + reasons: []string{" ", "\t"}, + want: "approval required", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := joinApprovalReasons(tc.reasons) + if got != tc.want { + t.Fatalf("joinApprovalReasons() = %q, want %q", got, tc.want) + } + }) + } +} + func TestRenderToolResultDiffColorized(t *testing.T) { t.Setenv("TERM", "xterm-256color") t.Setenv("NO_COLOR", "") diff --git a/internal/orchestrator/turn.go b/internal/orchestrator/turn.go index 090c54e..c676c99 100644 --- a/internal/orchestrator/turn.go +++ b/internal/orchestrator/turn.go @@ -8,9 +8,9 @@ import ( "strings" "coder/internal/chat" + "coder/internal/config" "coder/internal/contextmgr" "coder/internal/permission" - "coder/internal/tools" ) func (o *Orchestrator) RunTurn(ctx context.Context, userInput string, out io.Writer) (string, error) { @@ -107,166 +107,18 @@ func (o *Orchestrator) RunTurn(ctx context.Context, userInput string, out io.Wri } if len(resp.ToolCalls) == 0 { - if turnEditedCode && shouldAutoVerifyEditedPaths(editedPaths) && o.workflow.AutoVerifyAfterEdit && verifyAttempts < o.workflow.MaxVerifyAttempts && o.isToolAllowed("bash") && o.registry.Has("bash") { - command := o.pickVerifyCommand() - if command != "" { - verifyAttempts++ - passed, retryable, err := o.runAutoVerify(ctx, command, verifyAttempts, out) - if err == nil && !passed { - if retryable && verifyAttempts < o.workflow.MaxVerifyAttempts { - repairHint := fmt.Sprintf("Auto verification command `%s` failed. Please fix the issues, then continue and make verification pass.", command) - o.appendMessage(chat.Message{Role: "user", Content: repairHint}) - continue - } - if !retryable { - verifyWarn := fmt.Sprintf("Auto verification command `%s` failed due to environment/runtime issues. Continue with best-effort manual validation.", command) - o.appendMessage(chat.Message{Role: "assistant", Content: verifyWarn}) - _ = o.flushSessionToFile(ctx) - } - } - if err != nil { - if isContextCancellationErr(ctx, err) { - return "", contextErrOr(ctx, err) - } - verifyWarn := fmt.Sprintf("Auto verification could not complete (%v). Continue with best-effort manual validation.", err) - o.appendMessage(chat.Message{Role: "assistant", Content: verifyWarn}) - _ = o.flushSessionToFile(ctx) - } - } - } - o.refreshTodos(ctx) - return finalText, nil - } - - for _, call := range resp.ToolCalls { - if err := ctx.Err(); err != nil { + needsNextStep, err := o.handleNoToolCalls(ctx, out, turnEditedCode, editedPaths, &verifyAttempts) + if err != nil { return "", err } - startSummary := formatToolStart(call.Function.Name, call.Function.Arguments) - if out != nil { - renderToolStart(out, startSummary) - } - if o.onToolEvent != nil { - o.onToolEvent(call.Function.Name, startSummary, false) - } - if !o.isToolAllowed(call.Function.Name) { - reason := fmt.Sprintf("tool %s disabled by active agent %s", call.Function.Name, o.activeAgent.Name) - if out != nil { - renderToolBlocked(out, reason) - } - o.appendToolDenied(call, reason) - continue - } - args := json.RawMessage(call.Function.Arguments) - decision := permission.Result{Decision: permission.DecisionAllow} - if o.policy != nil { - decision = o.policy.Decide(call.Function.Name, args) - } - if decision.Decision == permission.DecisionDeny { - reason := strings.TrimSpace(decision.Reason) - if reason == "" { - reason = "blocked by policy" - } - if out != nil { - renderToolBlocked(out, summarizeForLog(reason)) - } - o.appendToolDenied(call, reason) - continue - } - - approvalReq, err := o.registry.ApprovalRequest(call.Function.Name, args) - if err != nil { - if out != nil { - renderToolError(out, summarizeForLog(err.Error())) - } - o.appendToolError(call, fmt.Errorf("approval check: %w", err)) + if needsNextStep { continue } - needsApproval := decision.Decision == permission.DecisionAsk || approvalReq != nil - if needsApproval { - reasons := make([]string, 0, 2) - if decision.Decision == permission.DecisionAsk { - if r := strings.TrimSpace(decision.Reason); r != "" { - reasons = append(reasons, r) - } - } - if approvalReq != nil { - if r := strings.TrimSpace(approvalReq.Reason); r != "" { - reasons = append(reasons, r) - } - } - approvalReason := joinApprovalReasons(reasons) - if o.onApproval == nil { - if out != nil { - renderToolBlocked(out, "approval callback unavailable") - } - o.appendToolDenied(call, "approval callback unavailable") - continue - } - allowed, err := o.onApproval(ctx, tools.ApprovalRequest{ - Tool: call.Function.Name, - Reason: approvalReason, - RawArgs: string(args), - }) - if err != nil { - if isContextCancellationErr(ctx, err) { - return "", contextErrOr(ctx, err) - } - return "", fmt.Errorf("approval callback: %w", err) - } - if !allowed { - if err := ctx.Err(); err != nil { - return "", err - } - if out != nil { - renderToolBlocked(out, summarizeForLog(approvalReason)) - } - o.appendToolDenied(call, approvalReason) - continue - } - } - if call.Function.Name == "write" || call.Function.Name == "edit" || call.Function.Name == "patch" { - undoRecorder.CaptureFromToolCall(call.Function.Name, args) - } + return finalText, nil + } - result, err := o.registry.Execute(ctx, call.Function.Name, args) - if err != nil { - if isContextCancellationErr(ctx, err) { - return "", contextErrOr(ctx, err) - } - if out != nil { - renderToolError(out, summarizeForLog(err.Error())) - } - o.appendToolError(call, err) - continue - } - resultSummary := summarizeToolResult(call.Function.Name, result) - if out != nil { - renderToolResult(out, resultSummary) - } - if o.onToolEvent != nil { - o.onToolEvent(call.Function.Name, resultSummary, true) - } - o.appendMessage(chat.Message{ - Role: "tool", - Name: call.Function.Name, - ToolCallID: call.ID, - Content: result, - }) - if call.Function.Name == "todoread" || call.Function.Name == "todowrite" { - if o.onTodoUpdate != nil { - items := todoItemsFromResult(result) - if items != nil { - o.onTodoUpdate(items) - } - } - } - if call.Function.Name == "write" || call.Function.Name == "edit" || call.Function.Name == "patch" { - turnEditedCode = true - if editedPath := editedPathFromToolCall(call.Function.Name, args); editedPath != "" { - editedPaths = append(editedPaths, editedPath) - } - } + if err := o.executeToolCalls(ctx, out, undoRecorder, resp.ToolCalls, &turnEditedCode, &editedPaths); err != nil { + return "", err } } if err := ctx.Err(); err != nil { @@ -387,7 +239,7 @@ func (o *Orchestrator) emitContextUpdate() { estimated := contextmgr.EstimateTokens(messages) limit := o.contextTokenLimit if limit <= 0 { - limit = 24000 + limit = config.DefaultRuntimeContextTokenLimit } percent := 0.0 if limit > 0 { diff --git a/internal/orchestrator/turn_pipeline.go b/internal/orchestrator/turn_pipeline.go new file mode 100644 index 0000000..d2ce9a6 --- /dev/null +++ b/internal/orchestrator/turn_pipeline.go @@ -0,0 +1,200 @@ +package orchestrator + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "coder/internal/chat" + "coder/internal/permission" + "coder/internal/tools" +) + +func (o *Orchestrator) handleNoToolCalls( + ctx context.Context, + out io.Writer, + turnEditedCode bool, + editedPaths []string, + verifyAttempts *int, +) (bool, error) { + if turnEditedCode && + shouldAutoVerifyEditedPaths(editedPaths) && + o.workflow.AutoVerifyAfterEdit && + *verifyAttempts < o.workflow.MaxVerifyAttempts && + o.isToolAllowed("bash") && + o.registry.Has("bash") { + command := o.pickVerifyCommand() + if command != "" { + *verifyAttempts++ + passed, retryable, err := o.runAutoVerify(ctx, command, *verifyAttempts, out) + if err == nil && !passed { + if retryable && *verifyAttempts < o.workflow.MaxVerifyAttempts { + repairHint := fmt.Sprintf("Auto verification command `%s` failed. Please fix the issues, then continue and make verification pass.", command) + o.appendMessage(chat.Message{Role: "user", Content: repairHint}) + return true, nil + } + if !retryable { + verifyWarn := fmt.Sprintf("Auto verification command `%s` failed due to environment/runtime issues. Continue with best-effort manual validation.", command) + o.appendMessage(chat.Message{Role: "assistant", Content: verifyWarn}) + _ = o.flushSessionToFile(ctx) + } + } + if err != nil { + if isContextCancellationErr(ctx, err) { + return false, contextErrOr(ctx, err) + } + verifyWarn := fmt.Sprintf("Auto verification could not complete (%v). Continue with best-effort manual validation.", err) + o.appendMessage(chat.Message{Role: "assistant", Content: verifyWarn}) + _ = o.flushSessionToFile(ctx) + } + } + } + + o.refreshTodos(ctx) + return false, nil +} + +func (o *Orchestrator) executeToolCalls( + ctx context.Context, + out io.Writer, + undoRecorder *turnUndoRecorder, + toolCalls []chat.ToolCall, + turnEditedCode *bool, + editedPaths *[]string, +) error { + for _, call := range toolCalls { + if err := ctx.Err(); err != nil { + return err + } + startSummary := formatToolStart(call.Function.Name, call.Function.Arguments) + if out != nil { + renderToolStart(out, startSummary) + } + if o.onToolEvent != nil { + o.onToolEvent(call.Function.Name, startSummary, false) + } + if !o.isToolAllowed(call.Function.Name) { + reason := fmt.Sprintf("tool %s disabled by active agent %s", call.Function.Name, o.activeAgent.Name) + if out != nil { + renderToolBlocked(out, reason) + } + o.appendToolDenied(call, reason) + continue + } + + args := json.RawMessage(call.Function.Arguments) + decision := permission.Result{Decision: permission.DecisionAllow} + if o.policy != nil { + decision = o.policy.Decide(call.Function.Name, args) + } + if decision.Decision == permission.DecisionDeny { + reason := strings.TrimSpace(decision.Reason) + if reason == "" { + reason = "blocked by policy" + } + if out != nil { + renderToolBlocked(out, summarizeForLog(reason)) + } + o.appendToolDenied(call, reason) + continue + } + + approvalReq, err := o.registry.ApprovalRequest(call.Function.Name, args) + if err != nil { + if out != nil { + renderToolError(out, summarizeForLog(err.Error())) + } + o.appendToolError(call, fmt.Errorf("approval check: %w", err)) + continue + } + needsApproval := decision.Decision == permission.DecisionAsk || approvalReq != nil + if needsApproval { + reasons := make([]string, 0, 2) + if decision.Decision == permission.DecisionAsk { + if r := strings.TrimSpace(decision.Reason); r != "" { + reasons = append(reasons, r) + } + } + if approvalReq != nil { + if r := strings.TrimSpace(approvalReq.Reason); r != "" { + reasons = append(reasons, r) + } + } + approvalReason := joinApprovalReasons(reasons) + if o.onApproval == nil { + if out != nil { + renderToolBlocked(out, "approval callback unavailable") + } + o.appendToolDenied(call, "approval callback unavailable") + continue + } + allowed, err := o.onApproval(ctx, tools.ApprovalRequest{ + Tool: call.Function.Name, + Reason: approvalReason, + RawArgs: string(args), + }) + if err != nil { + if isContextCancellationErr(ctx, err) { + return contextErrOr(ctx, err) + } + return fmt.Errorf("approval callback: %w", err) + } + if !allowed { + if err := ctx.Err(); err != nil { + return err + } + if out != nil { + renderToolBlocked(out, summarizeForLog(approvalReason)) + } + o.appendToolDenied(call, approvalReason) + continue + } + } + + if call.Function.Name == "write" || call.Function.Name == "edit" || call.Function.Name == "patch" { + undoRecorder.CaptureFromToolCall(call.Function.Name, args) + } + + result, err := o.registry.Execute(ctx, call.Function.Name, args) + if err != nil { + if isContextCancellationErr(ctx, err) { + return contextErrOr(ctx, err) + } + if out != nil { + renderToolError(out, summarizeForLog(err.Error())) + } + o.appendToolError(call, err) + continue + } + resultSummary := summarizeToolResult(call.Function.Name, result) + if out != nil { + renderToolResult(out, resultSummary) + } + if o.onToolEvent != nil { + o.onToolEvent(call.Function.Name, resultSummary, true) + } + o.appendMessage(chat.Message{ + Role: "tool", + Name: call.Function.Name, + ToolCallID: call.ID, + Content: result, + }) + if call.Function.Name == "todoread" || call.Function.Name == "todowrite" { + if o.onTodoUpdate != nil { + items := todoItemsFromResult(result) + if items != nil { + o.onTodoUpdate(items) + } + } + } + if call.Function.Name == "write" || call.Function.Name == "edit" || call.Function.Name == "patch" { + *turnEditedCode = true + if editedPath := editedPathFromToolCall(call.Function.Name, args); editedPath != "" { + *editedPaths = append(*editedPaths, editedPath) + } + } + } + return nil +} diff --git a/internal/repl/loop.go b/internal/repl/loop.go index 4a18938..77c8ec3 100644 --- a/internal/repl/loop.go +++ b/internal/repl/loop.go @@ -12,6 +12,7 @@ import ( "golang.org/x/term" "coder/internal/bootstrap" + "coder/internal/config" "coder/internal/orchestrator" "coder/internal/tools" ) @@ -158,7 +159,7 @@ func (loop *Loop) updatePromptState(orch *orchestrator.Orchestrator) { loop.tokens = stats.EstimatedTokens loop.limit = stats.ContextLimit if loop.limit <= 0 { - loop.limit = 24000 + loop.limit = config.DefaultRuntimeContextTokenLimit } } } From e277849cc51a33f8d0f899d2f0c03f68065c2f24 Mon Sep 17 00:00:00 2001 From: yingchaox <1020316234@qq.com> Date: Thu, 26 Feb 2026 23:01:14 +0800 Subject: [PATCH 2/2] chore: remove trailing newline in config.go --- internal/config/config.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index c3be318..9777b8d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -917,4 +917,3 @@ func stripJSONComments(data []byte) []byte { return out.Bytes() } -