diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..ae33f3dc --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,29 @@ +# ACECode + +ACECode is a local coding agent with a TUI and a desktop/web GUI, sharing one agent loop and one tool set across ends. + +## Language + +**AskUserQuestion**: +A cross-end tool that asks the user multiple-choice questions mid-task. TUI (overlay) and GUI (browser modal) are only different transports; the tool logic and its limits are shared and single-sourced. +_Avoid_: question tool, 提问工具 (ambiguous with unrelated prompts) + +**Question budget**: +The maximum number of questions one AskUserQuestion call may contain. Configured by `ask.max_questions`; shared by all ends. +_Avoid_: 问题上限 (vague, could mean the text length limit) + +**Option budget**: +The maximum number of selectable options a single question may carry. Configured by `ask.max_options`; shared by all ends. +_Avoid_: 选项个数限制 (vague), 选项上限 (collides with option text limits) + +**Option floor**: +The minimum number of options a question must have. Fixed at 2; not configurable. +_Avoid_: 最少选项 (unclear whether user-facing or validation-facing) + +**Over-limit request**: +A model request whose question count or option count exceeds the current configured budget. Always rejected with a hard error naming the dynamic limit; never silently truncated. +_Avoid_: 超量 (unclear), truncation (that is the rejected alternative, not the term) + +**Out-of-range config**: +A config value outside the legal range of a budget key. Clamped to the nearest boundary with a warning; the app still starts. +_Avoid_: 非法配置 (implies rejection) diff --git a/docs/adr/0001-configurable-ask-option-limit.md b/docs/adr/0001-configurable-ask-option-limit.md new file mode 100644 index 00000000..acb1054b --- /dev/null +++ b/docs/adr/0001-configurable-ask-option-limit.md @@ -0,0 +1,20 @@ +# Configurable AskUserQuestion option count limit + +**Status**: accepted + +AskUserQuestion's per-question option count was hardcoded to `kMinOptions=2` / `kMaxOptions=4` in the shared tool code. We decided to make the upper bound configurable via `ask.max_options` (legal range 4–8, default 6, clamped with a warning when out of range), while the lower bound stays fixed at 2. The tool schema (`minItems`/`maxItems`) and the validation error message are built from the configured limit, so they stay in sync automatically. When the model submits more options than the configured budget, the call is rejected with a hard error naming the current limit. + +The existing `ask.max_questions` (question budget) established the config pattern — same section, same clamp-and-warn behavior — and this change extends that pattern rather than introducing a new mechanism. + +**Considered Options** + +- **Make the lower bound configurable too** — rejected: narrower change surface; nothing has needed a floor other than 2. +- **Silently truncate over-limit options** — rejected: masks model errors and makes the conversation transcript diverge from what the user actually saw. +- **Reject the config file on out-of-range values** — rejected: `ask.max_questions` already clamps with a warning; failing startup for a cosmetic knob is worse. + +**Consequences** + +- Default 6 is a deliberate behavior change from the previous fixed 4: models may now submit up to 6 options without any configuration. Set `ask.max_options: 4` to restore the old cap. +- Config `ask.max_options` is read once at startup, like `max_questions`; changing it requires a restart. +- Error messages and tool description previously hardcoded "2-4" must be derived from the configured value. +- TUI and GUI render option lists dynamically (`options.size()`), so no layout work is needed for up to 8 options. diff --git a/docs/help/configuration.html b/docs/help/configuration.html index 9434d5ea..52ad447e 100644 --- a/docs/help/configuration.html +++ b/docs/help/configuration.html @@ -35,7 +35,7 @@

配置文件与生效范围

优先通过界面修改对应设置;需要手动编辑时,先确认数据目录、字段范围和保存方式。

-
本页内容
+
本页内容

配置保存在哪里

个人安装的主配置是 ~/.acecode/config.json,Windows 对应 %USERPROFILE%\.acecode\config.json。模型预设、默认模型以及网络、技能、MCP 等全局选项保存在这套用户配置中。

Windows 服务模式使用 %PROGRAMDATA%\acecode,与个人安装的数据目录分开。连接远端后台时,配置属于远端用户或服务身份;编辑本机文件不会自动修改远端配置。

配置层适合保存的内容
全局配置服务商连接、默认值、扩展连接与运行选项。
工作目录覆盖例如 TUI 用 /model --cwd 保存的项目模型选择。
项目文件项目规则、项目技能和项目 Hooks。
当前任务任务选择的模型、权限与对话上下文。
@@ -48,11 +48,17 @@ "question_selection_feedback_ms": 200 } }

这两个字段会在读取配置时限制在有效范围内。超出范围的整数会自动限制到边界,并记录警告;非整数值会被忽略并继续使用默认值。省略字段时使用默认值,配置保存采用稀疏写入,不会强制写出默认值。

+

AskUserQuestion 跨端配置

TUI 与 Web/Desktop 使用同一个 AskUserQuestion 工具,题目数量和单个问题的选项数量上限由 ask 对象统一控制,对所有运行端生效。以下字段位于配置文件的 ask 对象中:

字段默认值有效范围说明
max_questions101–50单次 AskUserQuestion 调用允许的题目数量。
max_options64–8单个问题允许的选项数量上限。默认 6,可在 4 到 8 之间调整。

例如:

{
+  "ask": {
+    "max_questions": 10,
+    "max_options": 6
+  }
+}

超出有效范围的整数会自动限制到边界,并记录警告;非整数值会被忽略并继续使用默认值。省略字段时使用默认值,配置保存采用稀疏写入,不会强制写出默认值。

手动编辑与错误恢复

  1. 先备份当前有效配置,使用支持 UTF-8 的编辑器打开。
  2. 只修改目标字段,保持正确的 JSON 类型,不加入注释或尾随逗号。
  3. 重新加载相关功能,或按该功能要求重启。
  4. 检查界面实际值和一次小操作,确认修改已生效。

当前版本会保存有效配置快照。配置损坏且存在有效快照时,会备份错误文件并尝试自动恢复;Web/Desktop 会显示一次配置已自动回滚提示。没有可用快照时仍会报告配置错误。按提示查看备份位置并修复目标字段,备份可能含密钥,不要直接公开。

- +
diff --git a/openspec/changes/add-configurable-ask-option-limit/.openspec.yaml b/openspec/changes/add-configurable-ask-option-limit/.openspec.yaml new file mode 100644 index 00000000..96db9a43 --- /dev/null +++ b/openspec/changes/add-configurable-ask-option-limit/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-15 diff --git a/openspec/changes/add-configurable-ask-option-limit/design.md b/openspec/changes/add-configurable-ask-option-limit/design.md new file mode 100644 index 00000000..17fa684d --- /dev/null +++ b/openspec/changes/add-configurable-ask-option-limit/design.md @@ -0,0 +1,26 @@ +## Context + +See proposal.md. The option budget lives in `src/tool/ask_user_question_tool.cpp` as anonymous-namespace constants (`kMinOptions=2`, `kMaxOptions=4`) used by validation, the error message, and the tool-definition schema. The config layer already has the exact pattern we need: `AskConfig.max_questions` (default 10, clamped to [1,50] with a warning on load) flows into `create_ask_user_question_tool_async(max_questions)` at three registration sites. + +## Goals / Non-Goals + +**Goals:** Make the per-question option upper bound configurable (4–8, default 6); keep every limit the model sees (schema, error text) in sync with the configured value; preserve the fixed lower bound of 2; keep behavior identical for existing configs. + +**Non-Goals:** Configuring the lower bound; changing over-limit behavior (still a hard error); adding UI to edit the setting (the existing `ask.max_questions` is file-config-only and stays that way); touching TUI/Web rendering. + +## Decisions + +- Add `AskConfig.max_options` (default 6), parse it from the `ask` object as an integer, clamp to [4,8] with a warning, and mirror it in config dump/validation like `max_questions`. +- Thread the option budget through the tool factories: `validate_ask_user_question_args(args, err, max_questions, max_options)` and `build_ask_user_question_def(max_questions, max_options)`. Effective bounds are clamped once at the boundary of the tool layer so callers cannot bypass the legal range. +- Keep `kMinOptions=2` fixed. Replace the `kMaxOptions=4` constant with a default (`kDefaultAskMaxOptions=6`) plus the legal range constants (`kMinAskMaxOptions=4`, `kMaxAskMaxOptions=8`); the effective max used in validation and schema comes from the config. +- Over-limit requests keep failing with a hard error whose message names the current dynamic upper bound, and the schema `maxItems` reflects the same value, so the model is told the real limit before it can fail. + +## Risks / Trade-offs + +- Default 6 is a deliberate behavior change from the previous fixed 4: with no configuration, models may now submit up to 6 options. Restoring the old cap requires `ask.max_options: 4`. Recorded in ADR-0001. +- A config file written by a newer build (e.g. `max_options: 8`) read by an older build would be ignored by the older build's schema. Acceptable: the old build still enforces 4, which is a subset of what the newer config allows; this matches how unknown config keys already behave. +- The clamp range [4,8] is enforced in two layers (config load and tool factory). This is intentional defense in depth, mirroring the existing `max_questions` treatment; the two layers agree by construction. + +## Migration Plan + +No storage migration. New default applies on next startup for configs that omit the key. Users wanting the old cap set `ask.max_options: 4`. Deliver the change as a normal scoped commit on master. diff --git a/openspec/changes/add-configurable-ask-option-limit/proposal.md b/openspec/changes/add-configurable-ask-option-limit/proposal.md new file mode 100644 index 00000000..05edfc14 --- /dev/null +++ b/openspec/changes/add-configurable-ask-option-limit/proposal.md @@ -0,0 +1,24 @@ +## Why + +AskUserQuestion's per-question option count is hardcoded to 2–4 (`kMinOptions`/`kMaxOptions` in the shared tool code). Users cannot widen the choice set without editing source, and the limit is invisible in configuration. The user wants the upper bound to be configurable between 4 and 8. + +## What Changes + +- Add `ask.max_options` to the config: default 6, legal range 4–8, out-of-range values clamped to the nearest boundary with a warning (same pattern as `ask.max_questions`). +- Make the AskUserQuestion tool take the option budget from configuration: validation, error messages, and the tool schema (`minItems`/`maxItems`) are all derived from the configured value. +- Keep the lower bound fixed at 2. +- Update the config tests, the tool tests, and the official configuration documentation. + +## Capabilities + +### New Capabilities + +- `configurable-ask-option-limit`: Let `ask.max_options` control the maximum number of options a single AskUserQuestion question may carry. + +### Modified Capabilities + +None. + +## Impact + +The shared `AskConfig` struct and its JSON parsing/dumping, `validate_ask_user_question_args` and `build_ask_user_question_def`, the three tool-registration call sites (TUI main, headless runner, daemon worker), config and tool unit tests, and `docs/help/configuration.html`. No TUI/Web rendering changes are needed: both render option lists dynamically from `options.size()`. diff --git a/openspec/changes/add-configurable-ask-option-limit/specs/configurable-ask-option-limit/spec.md b/openspec/changes/add-configurable-ask-option-limit/specs/configurable-ask-option-limit/spec.md new file mode 100644 index 00000000..ef0cc696 --- /dev/null +++ b/openspec/changes/add-configurable-ask-option-limit/specs/configurable-ask-option-limit/spec.md @@ -0,0 +1,57 @@ +## Purpose + +Make the maximum number of options a single AskUserQuestion question may carry configurable, while keeping every model-visible limit derived from the configured value. + +## ADDED Requirements + +### Requirement: Option budget is configurable + +The system SHALL expose `ask.max_options` in the configuration with a default of 6. The legal range SHALL be 4 through 8 inclusive. All run ends (TUI, GUI, headless) SHALL enforce the same configured budget. + +#### Scenario: Default budget +- **WHEN** no `ask.max_options` is present in the configuration +- **THEN** a question with 6 options is accepted and a question with 7 options is rejected + +#### Scenario: Widen the budget +- **WHEN** `ask.max_options` is set to 8 +- **THEN** a question with 8 options is accepted and a question with 9 options is rejected + +#### Scenario: Narrow the budget +- **WHEN** `ask.max_options` is set to 4 +- **THEN** a question with 4 options is accepted and a question with 5 options is rejected + +### Requirement: Out-of-range configuration is clamped + +A configured `ask.max_options` outside 4–8 SHALL be clamped to the nearest boundary with a warning, and the application SHALL still start. + +#### Scenario: Clamp above the range +- **WHEN** `ask.max_options` is set to 10 +- **THEN** the effective budget is 8 and a warning is recorded + +#### Scenario: Clamp below the range +- **WHEN** `ask.max_options` is set to 3 +- **THEN** the effective budget is 4 and a warning is recorded + +### Requirement: Over-limit requests are rejected with the dynamic limit + +When a model submits more options than the current budget, the tool SHALL reject the call with a hard error naming the current upper bound. The tool schema (`maxItems`) SHALL reflect the same configured value so the model sees the real limit in advance. + +#### Scenario: Rejection message names the limit +- **WHEN** the budget is 6 and a question carries 7 options +- **THEN** the error states the limit as 6 and the call fails + +#### Scenario: Schema follows the configuration +- **WHEN** `ask.max_options` is set to 8 +- **THEN** the tool definition's options schema advertises a maximum of 8 items + +### Requirement: Lower bound stays fixed + +The minimum number of options per question SHALL remain 2 regardless of configuration. + +#### Scenario: Two options still valid +- **WHEN** a question carries 2 options and the budget is 6 +- **THEN** the question is accepted + +#### Scenario: One option still invalid +- **WHEN** a question carries 1 option and the budget is 6 +- **THEN** the question is rejected diff --git a/openspec/changes/add-configurable-ask-option-limit/tasks.md b/openspec/changes/add-configurable-ask-option-limit/tasks.md new file mode 100644 index 00000000..3067356e --- /dev/null +++ b/openspec/changes/add-configurable-ask-option-limit/tasks.md @@ -0,0 +1,29 @@ +## 1. Configuration + +- [ ] 1.1 Add `int max_options = 6` to `AskConfig` in `src/config/config.hpp`, with a comment mirroring `max_questions`. +- [ ] 1.2 Parse `ask.max_options` in `src/config/config.cpp` load path: integer-only, clamp to [4,8] with a warning on out-of-range values. +- [ ] 1.3 Add `max_options` to config validation (out-of-range check) and to the config JSON dump so the value persists and round-trips. + +## 2. Tool layer + +- [ ] 2.1 In `src/tool/ask_user_question_tool.hpp/.cpp`, replace the fixed `kMaxOptions=4` with `kDefaultAskMaxOptions=6` plus range constants `kMinAskMaxOptions=4` / `kMaxAskMaxOptions=8`; keep `kMinOptions=2` fixed. +- [ ] 2.2 Thread `max_options` through `validate_ask_user_question_args` and `build_ask_user_question_def`; clamp at the tool boundary; derive the error message ("between 2 and N") and the schema `maxItems` from the effective value. +- [ ] 2.3 Keep the overloads used by independent callers working with defaults (6). + +## 3. Registration sites + +- [ ] 3.1 Pass `cfg.ask.max_options` at `src/main.cpp` (TUI), `src/headless/headless_runner.cpp`, and `src/daemon/worker.cpp`. + +## 4. Tests + +- [ ] 4.1 `tests/config/config_ask_test.cpp`: default 6; parse of 4/8; clamp of 3→4 and 10→8 with warning. +- [ ] 4.2 `tests/tool/ask_user_question_tool_test.cpp`: dynamic acceptance/rejection at the configured bound; 8 accepts / 9 rejects; rejection message names the current limit; schema `maxItems` follows configuration; lower bound 2 still enforced. + +## 5. Documentation + +- [ ] 5.1 `docs/help/configuration.html`: add an "AskUserQuestion 跨端配置" section documenting `ask.max_questions` and `ask.max_options` (defaults, ranges, notes). + +## 6. Verification + +- [ ] 6.1 Build the C++ targets and run the focused config and tool test suites; fix any failures. +- [ ] 6.2 Write `verification.md` with the exact commands and results; run `git diff --check`. diff --git a/openspec/changes/add-configurable-ask-option-limit/verification.md b/openspec/changes/add-configurable-ask-option-limit/verification.md new file mode 100644 index 00000000..2997ae0c --- /dev/null +++ b/openspec/changes/add-configurable-ask-option-limit/verification.md @@ -0,0 +1,53 @@ +## Verification + +### Build + +Fresh Linux x64 build (vcpkg manifest mode, triplet x64-linux, tests feature): + +``` +cmake -S . -B build/linux-x64-release -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_TOOLCHAIN_FILE=$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake \ + -DVCPKG_TARGET_TRIPLET=x64-linux \ + -DVCPKG_OVERLAY_PORTS=ports \ + -DVCPKG_MANIFEST_FEATURES=tests \ + -DBUILD_TESTING=ON +cmake --build build/linux-x64-release --target acecode_unit_tests -j$(nproc) +``` + +Result: configure OK, `acecode_unit_tests` linked successfully. Two environment hurdles had to be cleared first (both independent of this change): the repo's ftxui overlay port builds from the `external/ftxui` git submodule (`git submodule update --init --recursive`), and the pinned vcpkg baseline commit must be fetched into a shallow vcpkg clone. + +### Focused test results + +`./tests/acecode_unit_tests --gtest_filter='Config*:*Ask*:*ask*:Headless*:-AgentLoopGoal.*:GoalCommand.*'` + +``` +[==========] 448 tests from 97 test suites ran. (10018 ms total) +[ PASSED ] 447 tests. +[ SKIPPED ] HeadlessJsonlProcess.RequiresWindowsPipeHarness (Windows-only) +``` + +All ConfigAsk* and AskUserQuestion* tests pass, including the new ones: + +- `ConfigAskDefaults.StructAndAppConfigUseDefaults` — max_questions=10, max_options=6 +- `ConfigAskLoader.AcceptsSupportedBoundaryValues` — max_options 4/6/8 +- `ConfigAskLoader.ClampsValuesOutsideSupportedRange` — max_options 3→4, 9/100→8 +- `ConfigAskLoader.InvalidTypesAndSectionKeepDefault` — non-integer max_options ignored +- `ConfigAskSave.NonDefaultValueIsPersistedAndRoundTrips` — max_options=8 persists and reloads +- `ConfigAskValidation.RejectsManuallyConstructedOutOfRangeValues` — max_options=9 rejected by validate_config +- `AskUserQuestionValidateTest.DefaultOptionLimitIsSix` — 6 accepted, 7 rejected with "between 2 and 6" +- `AskUserQuestionValidateTest.CustomOptionLimitIsApplied` — 8 accepted, 9 rejected with "between 2 and 8" +- `AskUserQuestionValidateTest.OptionLimitIsDefensivelyClamped` — 9→8, 3→4 +- `AskUserQuestionValidateTest.OptionFloorStaysAtTwo` — 2 accepted, 1 rejected +- `AskUserQuestionSchemaTest.SchemaFollowsConfiguredOptionLimit` — schema maxItems/description follow config (6 default, 8 custom, 9 clamped to 8) +- `AskUserQuestionExecutionTest.ConfiguredOptionLimitRejectsBeforeOpeningChannel` — over-limit rejected before the ask channel opens +- `AskUserQuestionValidateTest.OptionsLengthOutOfRangeRejected` — updated to the new default (7 rejected) + +### Pre-existing environment issue (not caused by this change) + +The full unit-test binary crashes in teardown of project-state harnesses (`AgentLoopGoal.*`, `AgentLoopTurnSteering.*`, `GoalCommand.*`) with `std::filesystem::filesystem_error: cannot remove: Directory not empty [/home/user/.acecode/projects/]`. The SessionManager's background writer can recreate files while the harness destructor runs `remove_all`. Evidence this predates the change: `~/.acecode/projects` contains leftover session artifacts timestamped 2026-09-14 17:56 (the day before this change), produced by the same teardown failure. These suites were excluded from the focused run above; they are unrelated to AskUserQuestion configuration. + +### Other checks + +- `git diff --check`: clean (no whitespace errors). +- No remaining references to the removed `kMaxOptions` constant or hardcoded "2-4" strings outside the historical ADR text. diff --git a/src/config/config.cpp b/src/config/config.cpp index c9f952c8..69a7f9bc 100644 --- a/src/config/config.cpp +++ b/src/config/config.cpp @@ -541,6 +541,10 @@ std::vector validate_config(const AppConfig& cfg) { errors.push_back("ask.max_questions out of range (1-50): " + std::to_string(cfg.ask.max_questions)); } + if (cfg.ask.max_options < 4 || cfg.ask.max_options > 8) { + errors.push_back("ask.max_options out of range (4-8): " + + std::to_string(cfg.ask.max_options)); + } if (cfg.openai.stream_timeout_ms <= 0) { errors.push_back("openai.stream_timeout_ms must be > 0"); } @@ -1388,6 +1392,23 @@ static AppConfig load_config_from_path_once( cfg.ask.max_questions = normalized; } } + // AskUserQuestion 选项数量上限。不存在时保持默认上限 6。 + // 非整数忽略,整数统一钳制到 [4,8]。 + if (aj.contains("max_options")) { + const auto& value = aj["max_options"]; + if (!value.is_number_integer()) { + LOG_WARN("[config] ask.max_options must be an integer; ignoring"); + } else { + const int normalized = clamp_config_integer(value, 4, 8); + if (value != normalized) { + LOG_WARN("[config] ask.max_options=" + + value.dump() + + " is outside [4, 8]; clamping to " + + std::to_string(normalized)); + } + cfg.ask.max_options = normalized; + } + } } } @@ -2403,6 +2424,8 @@ nlohmann::json build_config_json(const AppConfig& cfg) { nlohmann::json askj = nlohmann::json::object(); if (cfg.ask.max_questions != ask_d.max_questions) askj["max_questions"] = cfg.ask.max_questions; + if (cfg.ask.max_options != ask_d.max_options) + askj["max_options"] = cfg.ask.max_options; if (!askj.empty()) j["ask"] = askj; TuiConfig tui_d; diff --git a/src/config/config.hpp b/src/config/config.hpp index 96f00c9e..a58cfb1b 100644 --- a/src/config/config.hpp +++ b/src/config/config.hpp @@ -254,6 +254,9 @@ struct AskConfig { // 单次 AskUserQuestion 调用允许的题目数量;所有运行端共享。 // load_config clamp 到 [1, 50],默认 10。 int max_questions = 10; + // 单个问题允许的选项数量上限;所有运行端共享。 + // load_config clamp 到 [4, 8],默认 6。 + int max_options = 6; }; struct TuiConfig { @@ -336,7 +339,7 @@ struct LspServerOverride { nlohmann::json initialization; // initializationOptions 原样透传 }; -// LSP 集成总配置。enabled=false 时:lsp 工具不注册、编辑后不注入诊断、 +// LSP 集成总配置。enabled=false 时:lsp 工具不注册、不注入诊断、 // 不 spawn 任何 server 进程 —— 行为与引入 LSP 前完全一致。 struct LspConfig { bool enabled = true; diff --git a/src/daemon/worker.cpp b/src/daemon/worker.cpp index 317264d3..66515ea6 100644 --- a/src/daemon/worker.cpp +++ b/src/daemon/worker.cpp @@ -516,7 +516,7 @@ int run_worker(const WorkerOptions& opts, const AppConfig& cfg) { // daemon 用 async 版本(走 ToolContext::ask_user_questions → AskUserQuestionPrompter // → WS question_request)。TUI 工厂版需要 TuiState/ScreenInteractive,这里没有。 tools.register_tool(acecode::create_ask_user_question_tool_async( - cfg_mut.ask.max_questions)); + cfg_mut.ask.max_questions, cfg_mut.ask.max_options)); // skills_list / skill_view 让 LLM 按需加载 SKILL.md(配合 expand-webui-skill-commands // 的轻量提示策略 — daemon expander 不再 inject SKILL.md body,LLM 看到提示后用 diff --git a/src/headless/headless_runner.cpp b/src/headless/headless_runner.cpp index 90845b90..d8ecd687 100644 --- a/src/headless/headless_runner.cpp +++ b/src/headless/headless_runner.cpp @@ -199,7 +199,8 @@ void register_headless_tools( const std::shared_ptr& thread_deps, const std::shared_ptr& workspace_deps) { register_session_builtin_tools(tools, cfg); - tools.register_tool(create_ask_user_question_tool_async(cfg.ask.max_questions)); + tools.register_tool(create_ask_user_question_tool_async( + cfg.ask.max_questions, cfg.ask.max_options)); if (skill_registry && cfg.skills.allowed && !cfg.skills.allowed->empty()) { tools.register_tool(create_skills_list_tool(*skill_registry, &cfg)); @@ -345,7 +346,7 @@ int run_print_mode(const HeadlessCliOptions& opts) { } } - // Skill / MCP 先按“当前配置真实可用”做精确名称预检。两者都在本地 + // Skill / MCP 先按"当前配置真实可用"做精确名称预检。两者都在本地 // cfg 副本上编译成 allowlist,因此未知/全局 disabled 名称能在启动网络 // 子系统和 provider 之前以 usage error 失败。 { diff --git a/src/tool/ask_user_question_tool.cpp b/src/tool/ask_user_question_tool.cpp index d77c7604..4af42c8b 100644 --- a/src/tool/ask_user_question_tool.cpp +++ b/src/tool/ask_user_question_tool.cpp @@ -43,7 +43,7 @@ std::size_t utf8_codepoint_count(const std::string& s) { constexpr int kMaxHeaderChars = 12; constexpr int kMinOptions = 2; -constexpr int kMaxOptions = 4; +// kDefaultAskMaxOptions / kMinAskMaxOptions / kMaxAskMaxOptions 见头文件。 bool label_has_recommended_suffix(const std::string& label) { return ask_option_label_has_recommended_suffix(label); @@ -84,6 +84,13 @@ std::optional> validate_ask_user_question_args( std::optional> validate_ask_user_question_args( const std::string& arguments_json, std::string& err, int max_questions) { + return validate_ask_user_question_args( + arguments_json, err, max_questions, kDefaultAskMaxOptions); +} + +std::optional> validate_ask_user_question_args( + const std::string& arguments_json, std::string& err, int max_questions, + int max_options) { err.clear(); if (arguments_json.empty()) { err = "[Error] AskUserQuestion requires arguments."; @@ -100,6 +107,8 @@ std::optional> validate_ask_user_question_args( const int effective_max_questions = std::clamp( max_questions, kMinAskQuestions, kMaxAskQuestions); + const int effective_max_options = std::clamp( + max_options, kMinAskMaxOptions, kMaxAskMaxOptions); if (!root.is_object() || !root.contains("questions") || !root["questions"].is_array()) { err = "[Error] `questions` must be an array (length 1-" + std::to_string(effective_max_questions) + ")."; @@ -154,13 +163,16 @@ std::optional> validate_ask_user_question_args( if (!q.contains("options") || !q["options"].is_array()) { err = "[Error] questions[" + std::to_string(qi) + - "].options must be an array (length 2-4)."; + "].options must be an array (length 2-" + + std::to_string(effective_max_options) + ")."; return std::nullopt; } const auto& opts = q["options"]; - if (opts.size() < kMinOptions || opts.size() > kMaxOptions) { + if (opts.size() < kMinOptions || + opts.size() > static_cast(effective_max_options)) { err = "[Error] questions[" + std::to_string(qi) + - "].options length must be between 2 and 4 (got " + + "].options length must be between 2 and " + + std::to_string(effective_max_options) + " (got " + std::to_string(opts.size()) + ")."; return std::nullopt; } @@ -434,9 +446,11 @@ namespace { // 构造 daemon 工厂会用到的同一份 ToolDef。复用 create_ask_user_question_tool // 那段拼装太长 —— 把 def 抽出来共享。 -ToolDef build_ask_user_question_def(int max_questions) { +ToolDef build_ask_user_question_def(int max_questions, int max_options) { const int effective_max_questions = std::clamp( max_questions, kMinAskQuestions, kMaxAskQuestions); + const int effective_max_options = std::clamp( + max_options, kMinAskMaxOptions, kMaxAskMaxOptions); ToolDef def; def.name = "AskUserQuestion"; def.description = kToolDescription; @@ -480,10 +494,11 @@ ToolDef build_ask_user_question_def(int max_questions) { {"options", { {"type", "array"}, {"minItems", kMinOptions}, - {"maxItems", kMaxOptions}, + {"maxItems", effective_max_options}, {"items", option_schema}, {"description", - "2-4 mutually exclusive choices. Do NOT include an 'Other' option — " + "2-" + std::to_string(effective_max_options) + + " mutually exclusive choices. Do NOT include an 'Other' option — " "the UI appends one automatically."} }}, {"multiSelect", { @@ -580,17 +595,27 @@ parse_async_response(const nlohmann::json& resp_json, } // namespace ToolImpl create_ask_user_question_tool_async() { - return create_ask_user_question_tool_async(kDefaultAskMaxQuestions); + return create_ask_user_question_tool_async( + kDefaultAskMaxQuestions, kDefaultAskMaxOptions); } ToolImpl create_ask_user_question_tool_async(int max_questions) { + return create_ask_user_question_tool_async( + max_questions, kDefaultAskMaxOptions); +} + +ToolImpl create_ask_user_question_tool_async(int max_questions, int max_options) { const int effective_max_questions = std::clamp( max_questions, kMinAskQuestions, kMaxAskQuestions); - auto execute = [effective_max_questions](const std::string& arguments_json, - const ToolContext& ctx) -> ToolResult { + const int effective_max_options = std::clamp( + max_options, kMinAskMaxOptions, kMaxAskMaxOptions); + auto execute = [effective_max_questions, effective_max_options]( + const std::string& arguments_json, + const ToolContext& ctx) -> ToolResult { std::string err; auto parsed = validate_ask_user_question_args( - arguments_json, err, effective_max_questions); + arguments_json, err, effective_max_questions, + effective_max_options); if (!parsed.has_value()) { return ToolResult{err, false}; } @@ -672,7 +697,8 @@ ToolImpl create_ask_user_question_tool_async(int max_questions) { }; ToolImpl impl; - impl.definition = build_ask_user_question_def(effective_max_questions); + impl.definition = build_ask_user_question_def( + effective_max_questions, effective_max_options); impl.execute = execute; impl.is_read_only = true; impl.source = ToolSource::Builtin; diff --git a/src/tool/ask_user_question_tool.hpp b/src/tool/ask_user_question_tool.hpp index bc2c28fd..8a5bca09 100644 --- a/src/tool/ask_user_question_tool.hpp +++ b/src/tool/ask_user_question_tool.hpp @@ -19,6 +19,12 @@ inline constexpr int kDefaultAskMaxQuestions = 10; inline constexpr int kMinAskQuestions = 1; inline constexpr int kMaxAskQuestions = 50; +// 选项数量上限:可配置,默认 6,合法范围 [4, 8]。 +// 与 AskConfig.max_options 的默认值与钳制范围保持一致。 +inline constexpr int kDefaultAskMaxOptions = 6; +inline constexpr int kMinAskMaxOptions = 4; +inline constexpr int kMaxAskMaxOptions = 8; + // 解析 + 校验 `AskUserQuestion` 工具的 JSON 参数。成功时返回解析出来的 // question 列表,失败时返回 std::nullopt 并把错误消息写入 `err` // (以 "questions" / "options" / "unique" / "labels" / "header" 等关键词 @@ -31,6 +37,12 @@ std::optional> validate_ask_user_question_args( std::optional> validate_ask_user_question_args( const std::string& arguments_json, std::string& err, int max_questions); +// 同时指定题目上限与选项数量上限;选项上限应来自已校验的 +// AppConfig::ask.max_options,钳制到 [4,8]。 +std::optional> validate_ask_user_question_args( + const std::string& arguments_json, std::string& err, int max_questions, + int max_options); + // 拼接最终的 ToolResult 输出字符串。question_order 保留模型给问题的原始顺序, // answers 的 value 对于 multi-select 是调用方已经用 ", " 拼好的单一字符串。 // 返回形如 `User has answered your questions: "Q1"="A1", "Q2"="A2"` 的单行。 @@ -96,5 +108,6 @@ ToolResult make_timeout_adopted_ask_result( // 直接报错(该会话没接提问通道 = AskUserQuestion 不可用)。 ToolImpl create_ask_user_question_tool_async(); ToolImpl create_ask_user_question_tool_async(int max_questions); +ToolImpl create_ask_user_question_tool_async(int max_questions, int max_options); } // namespace acecode diff --git a/tests/config/config_ask_test.cpp b/tests/config/config_ask_test.cpp index c084eb7e..263654bb 100644 --- a/tests/config/config_ask_test.cpp +++ b/tests/config/config_ask_test.cpp @@ -41,12 +41,14 @@ void remove_file(const std::filesystem::path& path) { } // namespace -TEST(ConfigAskDefaults, StructAndAppConfigUseTenQuestions) { +TEST(ConfigAskDefaults, StructAndAppConfigUseDefaults) { acecode::AskConfig ask; EXPECT_EQ(ask.max_questions, 10); + EXPECT_EQ(ask.max_options, 6); acecode::AppConfig cfg; EXPECT_EQ(cfg.ask.max_questions, 10); + EXPECT_EQ(cfg.ask.max_options, 6); } TEST(ConfigAskLoader, MissingValueKeepsDefault) { @@ -55,6 +57,7 @@ TEST(ConfigAskLoader, MissingValueKeepsDefault) { const auto cfg = acecode::load_config_from_path(path.string()); EXPECT_EQ(cfg.ask.max_questions, 10); + EXPECT_EQ(cfg.ask.max_options, 6); remove_file(path); } @@ -67,6 +70,14 @@ TEST(ConfigAskLoader, AcceptsSupportedBoundaryValues) { EXPECT_EQ(cfg.ask.max_questions, value) << "value=" << value; remove_file(path); } + for (const int value : {4, 6, 8}) { + const auto path = temp_config_path("valid-options"); + write_json(path, {{"ask", {{"max_options", value}}}}); + + const auto cfg = acecode::load_config_from_path(path.string()); + EXPECT_EQ(cfg.ask.max_options, value) << "value=" << value; + remove_file(path); + } } TEST(ConfigAskLoader, ClampsValuesOutsideSupportedRange) { @@ -81,6 +92,16 @@ TEST(ConfigAskLoader, ClampsValuesOutsideSupportedRange) { << "configured=" << test_case.configured; remove_file(path); } + const Case option_cases[] = {{3, 4}, {2, 4}, {9, 8}, {100, 8}}; + for (const Case test_case : option_cases) { + const auto path = temp_config_path("clamp-options"); + write_json(path, {{"ask", {{"max_options", test_case.configured}}}}); + + const auto cfg = acecode::load_config_from_path(path.string()); + EXPECT_EQ(cfg.ask.max_options, test_case.expected) + << "configured=" << test_case.configured; + remove_file(path); + } } TEST(ConfigAskLoader, ClampsWideIntegersBeforeNarrowing) { @@ -106,6 +127,9 @@ TEST(ConfigAskLoader, InvalidTypesAndSectionKeepDefault) { for (const auto& ask_value : { nlohmann::json{{"max_questions", "10"}}, nlohmann::json{{"max_questions", nullptr}}, + nlohmann::json{{"max_options", "8"}}, + nlohmann::json{{"max_options", nullptr}}, + nlohmann::json{{"max_options", 8.5}}, nlohmann::json::array({10}), nlohmann::json("10")}) { const auto path = temp_config_path("invalid"); @@ -113,6 +137,7 @@ TEST(ConfigAskLoader, InvalidTypesAndSectionKeepDefault) { const auto cfg = acecode::load_config_from_path(path.string()); EXPECT_EQ(cfg.ask.max_questions, 10); + EXPECT_EQ(cfg.ask.max_options, 6); remove_file(path); } } @@ -130,15 +155,18 @@ TEST(ConfigAskSave, NonDefaultValueIsPersistedAndRoundTrips) { const auto path = temp_config_path("non-default-save"); acecode::AppConfig cfg; cfg.ask.max_questions = 12; + cfg.ask.max_options = 8; acecode::save_config(cfg, path.string()); const auto json = read_json(path); ASSERT_TRUE(json.contains("ask")); ASSERT_TRUE(json["ask"].is_object()); EXPECT_EQ(json["ask"]["max_questions"], 12); + EXPECT_EQ(json["ask"]["max_options"], 8); const auto loaded = acecode::load_config_from_path(path.string()); EXPECT_EQ(loaded.ask.max_questions, 12); + EXPECT_EQ(loaded.ask.max_options, 8); remove_file(path); } @@ -148,4 +176,10 @@ TEST(ConfigAskValidation, RejectsManuallyConstructedOutOfRangeValues) { auto errors = acecode::validate_config(cfg); ASSERT_FALSE(errors.empty()); EXPECT_NE(errors.front().find("ask.max_questions"), std::string::npos); + + acecode::AppConfig cfg2; + cfg2.ask.max_options = 9; + auto errors2 = acecode::validate_config(cfg2); + ASSERT_FALSE(errors2.empty()); + EXPECT_NE(errors2.front().find("ask.max_options"), std::string::npos); } diff --git a/tests/tool/ask_user_question_tool_test.cpp b/tests/tool/ask_user_question_tool_test.cpp index ef84673e..171f43f9 100644 --- a/tests/tool/ask_user_question_tool_test.cpp +++ b/tests/tool/ask_user_question_tool_test.cpp @@ -75,6 +75,22 @@ std::string questions_json(std::size_t count) { return nlohmann::json{{"questions", std::move(questions)}}.dump(); } +// 单题、可指定选项数量;label/description 用必填字段填充。 +std::string single_question_with_options(std::size_t option_count) { + nlohmann::json options = nlohmann::json::array(); + for (std::size_t i = 0; i < option_count; ++i) { + options.push_back({ + {"label", "Option " + std::to_string(i)}, + {"description", "description " + std::to_string(i)}, + }); + } + return nlohmann::json{{"questions", nlohmann::json::array({ + {{"question", "Which option?"}, + {"header", "Pick"}, + {"options", std::move(options)}} + })}}.dump(); +} + } // namespace // 场景:合法最小输入(1 题 2 选项,均含必填字段)应通过校验,并把 @@ -140,6 +156,77 @@ TEST(AskUserQuestionValidateTest, CustomLimitIsDefensivelyClamped) { EXPECT_TRUE(two.has_value()) << err; } +// 场景:默认选项上限为 6 —— 6 个选项通过,7 个被拒,错误文案带当前上限。 +TEST(AskUserQuestionValidateTest, DefaultOptionLimitIsSix) { + std::string err; + auto six = validate_ask_user_question_args(single_question_with_options(6), err); + ASSERT_TRUE(six.has_value()) << err; + ASSERT_EQ((*six)[0].options.size(), 6u); + + err.clear(); + auto seven = validate_ask_user_question_args(single_question_with_options(7), err); + EXPECT_FALSE(seven.has_value()); + EXPECT_NE(err.find("between 2 and 6"), std::string::npos) << err; + EXPECT_NE(err.find("got 7"), std::string::npos) << err; +} + +// 场景:自定义选项上限 8 时,8 个通过、9 个被拒,错误文案带当前上限。 +TEST(AskUserQuestionValidateTest, CustomOptionLimitIsApplied) { + std::string err; + auto eight = validate_ask_user_question_args( + single_question_with_options(8), err, + acecode::kDefaultAskMaxQuestions, 8); + ASSERT_TRUE(eight.has_value()) << err; + ASSERT_EQ((*eight)[0].options.size(), 8u); + + err.clear(); + auto nine = validate_ask_user_question_args( + single_question_with_options(9), err, + acecode::kDefaultAskMaxQuestions, 8); + EXPECT_FALSE(nine.has_value()); + EXPECT_NE(err.find("between 2 and 8"), std::string::npos) << err; + EXPECT_NE(err.find("got 9"), std::string::npos) << err; +} + +// 场景:选项上限被防御性钳制到 [4,8] —— 9 当 8 用,3 当 4 用。 +TEST(AskUserQuestionValidateTest, OptionLimitIsDefensivelyClamped) { + std::string err; + auto eight = validate_ask_user_question_args( + single_question_with_options(8), err, + acecode::kDefaultAskMaxQuestions, 9); + EXPECT_TRUE(eight.has_value()) << err; + + err.clear(); + auto nine = validate_ask_user_question_args( + single_question_with_options(9), err, + acecode::kDefaultAskMaxQuestions, 9); + EXPECT_FALSE(nine.has_value()); + + err.clear(); + auto four = validate_ask_user_question_args( + single_question_with_options(4), err, + acecode::kDefaultAskMaxQuestions, 3); + EXPECT_TRUE(four.has_value()) << err; + + err.clear(); + auto five = validate_ask_user_question_args( + single_question_with_options(5), err, + acecode::kDefaultAskMaxQuestions, 3); + EXPECT_FALSE(five.has_value()); +} + +// 场景:下限固定为 2,不受配置影响。 +TEST(AskUserQuestionValidateTest, OptionFloorStaysAtTwo) { + std::string err; + auto two = validate_ask_user_question_args(single_question_with_options(2), err); + ASSERT_TRUE(two.has_value()) << err; + + err.clear(); + auto one = validate_ask_user_question_args(single_question_with_options(1), err); + EXPECT_FALSE(one.has_value()); + EXPECT_NE(err.find("between 2 and"), std::string::npos) << err; +} + TEST(AskUserQuestionSchemaTest, SchemaUsesConfiguredQuestionLimit) { const auto default_tool = acecode::create_ask_user_question_tool_async(); EXPECT_EQ(default_tool.definition.parameters["properties"]["questions"]["maxItems"], 10); @@ -151,6 +238,28 @@ TEST(AskUserQuestionSchemaTest, SchemaUsesConfiguredQuestionLimit) { std::string::npos); } +TEST(AskUserQuestionSchemaTest, SchemaFollowsConfiguredOptionLimit) { + const auto default_tool = acecode::create_ask_user_question_tool_async(); + const auto& default_options = + default_tool.definition.parameters["properties"]["questions"]["items"]["properties"]["options"]; + EXPECT_EQ(default_options["minItems"], 2); + EXPECT_EQ(default_options["maxItems"], 6); + EXPECT_NE(default_options["description"].get().find("2-6 mutually"), + std::string::npos); + + const auto custom_tool = acecode::create_ask_user_question_tool_async(10, 8); + const auto& custom_options = + custom_tool.definition.parameters["properties"]["questions"]["items"]["properties"]["options"]; + EXPECT_EQ(custom_options["maxItems"], 8); + EXPECT_NE(custom_options["description"].get().find("2-8 mutually"), + std::string::npos); + + const auto clamped_tool = acecode::create_ask_user_question_tool_async(10, 9); + const auto& clamped_options = + clamped_tool.definition.parameters["properties"]["questions"]["items"]["properties"]["options"]; + EXPECT_EQ(clamped_options["maxItems"], 8); +} + TEST(AskUserQuestionExecutionTest, ConfiguredLimitRejectsBeforeOpeningChannel) { const auto tool = acecode::create_ask_user_question_tool_async(3); acecode::ToolContext ctx; @@ -168,7 +277,23 @@ TEST(AskUserQuestionExecutionTest, ConfiguredLimitRejectsBeforeOpeningChannel) { EXPECT_NE(result.output.find("Split the questions"), std::string::npos); } -// 场景:某题 options 长度越界(1 或 5)应被拒,错误信息里包含 "options"。 +TEST(AskUserQuestionExecutionTest, ConfiguredOptionLimitRejectsBeforeOpeningChannel) { + const auto tool = acecode::create_ask_user_question_tool_async(10, 6); + acecode::ToolContext ctx; + bool channel_called = false; + ctx.ask_user_questions = [&](const nlohmann::json&) { + channel_called = true; + return nlohmann::json{{"cancelled", false}}; + }; + + const auto result = tool.execute(single_question_with_options(7), ctx); + EXPECT_FALSE(result.success); + EXPECT_FALSE(channel_called); + EXPECT_NE(result.output.find("between 2 and 6"), std::string::npos); + EXPECT_NE(result.output.find("got 7"), std::string::npos); +} + +// 场景:某题 options 长度越界(1 或 7)应被拒,错误信息里包含 "options"。 TEST(AskUserQuestionValidateTest, OptionsLengthOutOfRangeRejected) { std::string err; auto too_few = validate_ask_user_question_args( @@ -188,7 +313,9 @@ TEST(AskUserQuestionValidateTest, OptionsLengthOutOfRangeRejected) { {"label":"2","description":""}, {"label":"3","description":""}, {"label":"4","description":""}, - {"label":"5","description":""} + {"label":"5","description":""}, + {"label":"6","description":""}, + {"label":"7","description":""} ] }]})", err); EXPECT_FALSE(too_many.has_value());