-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathask_user_question_tool.cpp
More file actions
557 lines (500 loc) · 20.5 KB
/
Copy pathask_user_question_tool.cpp
File metadata and controls
557 lines (500 loc) · 20.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
#include "ask_user_question_tool.hpp"
#include "../headless/headless_mode.hpp"
#include "../session/session_manager.hpp"
#include "../utils/logger.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <sstream>
#include <atomic>
#include <chrono>
#include <cstddef>
#include <set>
#include <string>
namespace acecode {
namespace {
// 数一个 UTF-8 字符串的 codepoint 数 —— 12 字符上限必须按字符而不是字节算,
// 不然 "授权方式" (12 bytes in UTF-8, 4 chars) 会被误判越界。
std::size_t utf8_codepoint_count(const std::string& s) {
std::size_t count = 0;
const unsigned char* p = reinterpret_cast<const unsigned char*>(s.data());
std::size_t len = s.size();
for (std::size_t i = 0; i < len;) {
unsigned char c = p[i];
int seq = 1;
if ((c & 0x80) == 0x00) seq = 1;
else if ((c & 0xE0) == 0xC0) seq = 2;
else if ((c & 0xF0) == 0xE0) seq = 3;
else if ((c & 0xF8) == 0xF0) seq = 4;
else { i++; continue; }
i += seq;
count++;
}
return count;
}
constexpr int kMaxHeaderChars = 12;
constexpr int kMinQuestions = 1;
constexpr int kMaxQuestions = 4;
constexpr int kMinOptions = 2;
constexpr int kMaxOptions = 4;
// 工具 description —— 对齐 claudecodehaha `ASK_USER_QUESTION_TOOL_PROMPT`
// 原文,删除 ACECode 没有对应概念的 `Plan mode note:` 段。
constexpr const char* kToolDescription =
"Asks the user multiple choice questions to gather information, clarify "
"ambiguity, understand preferences, make decisions or offer them choices. "
"Use this tool when you need to ask the user questions during execution. "
"This allows you to:\n"
"1. Gather user preferences or requirements\n"
"2. Clarify ambiguous instructions\n"
"3. Get decisions on implementation choices as you work\n"
"4. Offer choices to the user about what direction to take.\n"
"\n"
"Usage notes:\n"
"- Users will always be able to select \"Other\" to provide custom text input\n"
"- Use multiSelect: true to allow multiple answers to be selected for a question\n"
"- If you recommend a specific option, make that the first option in the list "
"and add \"(Recommended)\" at the end of the label";
} // namespace
std::optional<std::vector<AskQuestion>> validate_ask_user_question_args(
const std::string& arguments_json, std::string& err) {
err.clear();
if (arguments_json.empty()) {
err = "[Error] AskUserQuestion requires arguments.";
return std::nullopt;
}
nlohmann::json root;
try {
root = nlohmann::json::parse(arguments_json);
} catch (const std::exception& e) {
err = std::string("[Error] Failed to parse arguments JSON: ") + e.what();
return std::nullopt;
}
if (!root.is_object() || !root.contains("questions") || !root["questions"].is_array()) {
err = "[Error] `questions` must be an array (length 1-4).";
return std::nullopt;
}
const auto& qs = root["questions"];
if (qs.size() < kMinQuestions || qs.size() > kMaxQuestions) {
err = "[Error] `questions` length must be between 1 and 4 (got " +
std::to_string(qs.size()) + ").";
return std::nullopt;
}
std::vector<AskQuestion> out;
out.reserve(qs.size());
std::set<std::string> seen_questions;
for (std::size_t qi = 0; qi < qs.size(); ++qi) {
const auto& q = qs[qi];
if (!q.is_object()) {
err = "[Error] questions[" + std::to_string(qi) + "] must be an object.";
return std::nullopt;
}
AskQuestion parsed;
parsed.question = q.value("question", std::string{});
parsed.header = q.value("header", std::string{});
parsed.multi_select = q.value("multiSelect", false);
if (parsed.question.empty()) {
err = "[Error] questions[" + std::to_string(qi) +
"].question must be a non-empty string.";
return std::nullopt;
}
if (parsed.header.empty()) {
err = "[Error] questions[" + std::to_string(qi) +
"].header must be a non-empty string.";
return std::nullopt;
}
if (utf8_codepoint_count(parsed.header) >
static_cast<std::size_t>(kMaxHeaderChars)) {
err = "[Error] questions[" + std::to_string(qi) +
"].header is too long (max 12 characters).";
return std::nullopt;
}
if (!seen_questions.insert(parsed.question).second) {
err = "[Error] Question texts must be unique across `questions`.";
return std::nullopt;
}
if (!q.contains("options") || !q["options"].is_array()) {
err = "[Error] questions[" + std::to_string(qi) +
"].options must be an array (length 2-4).";
return std::nullopt;
}
const auto& opts = q["options"];
if (opts.size() < kMinOptions || opts.size() > kMaxOptions) {
err = "[Error] questions[" + std::to_string(qi) +
"].options length must be between 2 and 4 (got " +
std::to_string(opts.size()) + ").";
return std::nullopt;
}
std::set<std::string> seen_labels;
for (std::size_t oi = 0; oi < opts.size(); ++oi) {
const auto& o = opts[oi];
if (!o.is_object()) {
err = "[Error] questions[" + std::to_string(qi) + "].options[" +
std::to_string(oi) + "] must be an object.";
return std::nullopt;
}
AskOption opt;
opt.label = o.value("label", std::string{});
opt.description = o.value("description", std::string{});
if (opt.label.empty()) {
err = "[Error] questions[" + std::to_string(qi) + "].options[" +
std::to_string(oi) + "].label must be non-empty.";
return std::nullopt;
}
if (!seen_labels.insert(opt.label).second) {
err = "[Error] Option labels must be unique within questions[" +
std::to_string(qi) + "].";
return std::nullopt;
}
// preview 字段如果存在,必须是字符串(类型错误早发现),但内容被忽略。
if (o.contains("preview") && !o["preview"].is_null() && !o["preview"].is_string()) {
err = "[Error] questions[" + std::to_string(qi) + "].options[" +
std::to_string(oi) + "].preview must be a string if present.";
return std::nullopt;
}
parsed.options.push_back(std::move(opt));
}
out.push_back(std::move(parsed));
}
return out;
}
std::string format_ask_answers(
const std::vector<std::string>& question_order,
const std::map<std::string, std::string>& answers) {
std::string out = "User has answered your questions: ";
bool first = true;
for (const auto& q : question_order) {
auto it = answers.find(q);
const std::string& a = (it == answers.end()) ? std::string{} : it->second;
if (!first) out += ", ";
out += "\"";
out += q;
out += "\"=\"";
out += a;
out += "\"";
first = false;
}
return out;
}
nlohmann::json build_ask_user_question_result_metadata(
const std::vector<std::string>& question_order,
const std::map<std::string, std::string>& answers) {
nlohmann::json items = nlohmann::json::array();
for (const auto& q : question_order) {
auto it = answers.find(q);
const std::string& a = (it == answers.end()) ? std::string{} : it->second;
items.push_back({
{"question", q},
{"answer", a},
});
}
return nlohmann::json{
{"ask_user_question_result", {
{"items", std::move(items)}
}}
};
}
std::string format_ask_user_question_result_display(
const nlohmann::json& metadata) {
if (!metadata.is_object()) return {};
auto result_it = metadata.find("ask_user_question_result");
if (result_it == metadata.end() || !result_it->is_object()) return {};
auto items_it = result_it->find("items");
if (items_it == result_it->end() || !items_it->is_array()) return {};
std::vector<std::pair<std::string, std::string>> items;
items.reserve(items_it->size());
for (const auto& item : *items_it) {
if (!item.is_object()) continue;
auto q_it = item.find("question");
auto a_it = item.find("answer");
if (q_it == item.end() || a_it == item.end() ||
!q_it->is_string() || !a_it->is_string()) {
continue;
}
items.emplace_back(q_it->get<std::string>(),
a_it->get<std::string>());
}
if (items.empty()) return {};
std::ostringstream out;
out << "已确认 " << items.size() << " 项";
for (size_t i = 0; i < items.size(); ++i) {
out << "\n";
if (i > 0) out << "---\n";
out << "Q " << items[i].first << "\n";
out << "A " << items[i].second;
}
return out.str();
}
ToolResult make_rejected_ask_result() {
ToolResult r;
r.output = "[Error] User declined to answer questions.";
r.success = false;
return r;
}
// Headless(-p / --print)模式的自动应答。success=true 防止模型当失败重问。
ToolResult make_headless_ask_result() {
ToolResult r;
r.success = true;
r.output =
"[Headless mode] The user cannot answer questions in print (-p) mode. "
"Do not wait and do not ask again. Decide autonomously: pick the "
"recommended option if one exists, otherwise the most reasonable "
"option, note the decision briefly in your response, and continue.";
return r;
}
ToolResult make_policy_denied_ask_result(const char* origin) {
ToolResult r;
r.success = true;
r.output =
"[Question policy: deny] Interactive questions are disabled for this "
"session. Do not wait and do not ask again. Decide autonomously: pick "
"the recommended option if one exists, otherwise the most reasonable "
"assumption, note the decision briefly in your response, and continue "
"with the task.";
r.metadata = nlohmann::json{
{"ask_user_question_auto", {
{"mode", "deny"},
{"origin", origin ? origin : "explicit"},
}}
};
return r;
}
ToolResult make_timeout_adopted_ask_result(
const std::vector<AskQuestion>& questions,
const std::vector<std::string>& question_order,
int timeout_seconds) {
std::map<std::string, std::string> answers;
for (const auto& q : questions) {
if (!q.options.empty()) answers[q.question] = q.options.front().label;
}
ToolResult r;
r.success = true;
r.output =
"[Question policy: timeout] The user did not answer within " +
std::to_string(timeout_seconds) +
" seconds. The first (recommended) option of each question was "
"adopted automatically — this is NOT an explicit user choice, so be "
"ready to adjust if the user corrects it later. " +
format_ask_answers(question_order, answers);
r.metadata = build_ask_user_question_result_metadata(question_order, answers);
r.metadata["ask_user_question_auto"] = {
{"mode", "timeout"},
{"seconds", timeout_seconds},
};
return r;
}
static bool goal_unattended(const ToolContext& ctx) {
return ctx.goal_unattended_active && ctx.goal_unattended_active();
}
// 解析生效策略:探针未注入(独立 ToolExecutor 调用)= Ask 维持旧行为。
static ResolvedQuestionPolicy effective_question_policy(const ToolContext& ctx) {
if (goal_unattended(ctx)) {
ResolvedQuestionPolicy policy;
policy.policy = QuestionPolicy::Timeout;
policy.timeout_seconds = kGoalQuestionTimeoutSeconds;
policy.origin = "goal";
return policy;
}
if (ctx.question_policy) return ctx.question_policy();
return ResolvedQuestionPolicy{};
}
// TUI 专用的工厂已删除。工具逻辑本来就只有一份(参数校验、策略
// 判定、结果拼装全是下面那些共享函数),两端不同的只是「怎么把问题送
// 到人面前」。那段 TUI overlay 传输现在住在 src/tui/tui_ask_channel.cpp,
// 经 `ToolContext::ask_user_questions` 注入 —— 与 daemon 同一个口子。
// 副作用:本 TU 不再引用 ftxui。
namespace {
// 构造 daemon 工厂会用到的同一份 ToolDef。复用 create_ask_user_question_tool
// 那段拼装太长 —— 把 def 抽出来共享。
ToolDef build_ask_user_question_def() {
ToolDef def;
def.name = "AskUserQuestion";
def.description = kToolDescription;
nlohmann::json option_schema = {
{"type", "object"},
{"required", nlohmann::json::array({"label", "description"})},
{"properties", {
{"label", {
{"type", "string"},
{"description",
"Short (1-5 word) label shown to the user as the selectable choice."}
}},
{"description", {
{"type", "string"},
{"description",
"Explanation of what this option means or what will happen if chosen."}
}},
{"preview", {
{"type", "string"},
{"description",
"Optional preview content. Accepted for SDK-schema parity."}
}}
}}
};
nlohmann::json question_schema = {
{"type", "object"},
{"required", nlohmann::json::array({"question", "header", "options"})},
{"properties", {
{"question", {
{"type", "string"},
{"description",
"The complete question. Should be clear, specific and end with '?'."}
}},
{"header", {
{"type", "string"},
{"description",
"Very short chip label (max 12 characters)."}
}},
{"options", {
{"type", "array"},
{"minItems", kMinOptions},
{"maxItems", kMaxOptions},
{"items", option_schema},
{"description",
"2-4 mutually exclusive choices. Do NOT include an 'Other' option — "
"the UI appends one automatically."}
}},
{"multiSelect", {
{"type", "boolean"},
{"default", false},
{"description", "Set true to allow the user to pick multiple options."}
}}
}}
};
def.parameters = {
{"type", "object"},
{"required", nlohmann::json::array({"questions"})},
{"properties", {
{"questions", {
{"type", "array"},
{"minItems", kMinQuestions},
{"maxItems", kMaxQuestions},
{"items", question_schema},
{"description",
"1-4 questions to ask the user. Question texts must be unique."}
}}
}}
};
return def;
}
// 把已 validate 的 question 列表转成 prompter 用的 questions_payload(给前端渲染)。
// 字段名与 design.md / spec.md 的 WS 协议对齐:每个 question 携带 id(用 question
// 文本作为 id,与 TUI 行为同步) / text / options[{label,value}] / multiSelect。
nlohmann::json questions_to_payload(const std::vector<AskQuestion>& qs) {
nlohmann::json arr = nlohmann::json::array();
for (const auto& q : qs) {
nlohmann::json options = nlohmann::json::array();
for (const auto& o : q.options) {
options.push_back({
{"label", o.label},
{"value", o.label}, // value=label,前端 v1 不区分两者
{"description", o.description},
});
}
arr.push_back({
{"id", q.question},
{"text", q.question},
{"header", q.header},
{"options", options},
{"multiSelect", q.multi_select},
});
}
return arr;
}
// 把 ctx.ask_user_questions 回来的 JSON 转成 std::map<question, answer_text>,
// 按 ", " 拼合 multiSelect。供 format_ask_answers 使用。
std::map<std::string, std::string>
parse_async_response(const nlohmann::json& resp_json) {
std::map<std::string, std::string> answers;
if (!resp_json.is_object()) return answers;
if (!resp_json.contains("answers") || !resp_json["answers"].is_array()) return answers;
for (const auto& a : resp_json["answers"]) {
if (!a.is_object()) continue;
std::string qid = a.value("question_id", std::string{});
if (qid.empty()) continue;
// selected 与 custom_text 都可能存在 —— 拼合
std::vector<std::string> parts;
if (a.contains("selected") && a["selected"].is_array()) {
for (const auto& s : a["selected"]) {
if (s.is_string()) parts.push_back(s.get<std::string>());
}
}
if (a.contains("custom_text") && a["custom_text"].is_string()) {
std::string ct = a["custom_text"].get<std::string>();
if (!ct.empty()) parts.push_back(ct);
}
std::string joined;
for (std::size_t i = 0; i < parts.size(); ++i) {
if (i) joined += ", ";
joined += parts[i];
}
answers[qid] = joined;
}
return answers;
}
} // namespace
ToolImpl create_ask_user_question_tool_async() {
auto execute = [](const std::string& arguments_json,
const ToolContext& ctx) -> ToolResult {
std::string err;
auto parsed = validate_ask_user_question_args(arguments_json, err);
if (!parsed.has_value()) {
return ToolResult{err, false};
}
// Headless(-p)进程:没有任何交互通道,自动应答(先于 goal 分支,
// 两者同时成立时文案取 headless —— 对模型的环境解释更准确)。
if (headless::active()) {
LOG_INFO("[AskUserQuestion] auto-answered (headless print mode)");
return make_headless_ask_result();
}
// 应答策略(add-ask-question-policy):deny 不发 question_request,
// 直接自动应答。timeout 的等待窗口由 AskUserQuestionPrompter 持有
// (session_registry 创建时注入),这里只消费响应里的 timed_out 标记。
const ResolvedQuestionPolicy policy = effective_question_policy(ctx);
if (policy.policy == QuestionPolicy::Deny) {
LOG_INFO(std::string("[AskUserQuestion] auto-answered (deny policy, ") +
policy.origin + ")");
return make_policy_denied_ask_result(policy.origin);
}
// ctx.ask_user_questions 为空 → daemon 没装 prompter,工具不可用。
// 直接拒绝 + 让 LLM 知道(避免无限挂起)。
if (!ctx.ask_user_questions) {
return ToolResult{
"[Error] AskUserQuestion is not supported by this session "
"(no UI channel connected).",
false};
}
// abort 已触发 = 不发问,直接 reject
if (ctx.abort_flag && ctx.abort_flag->load()) {
return make_rejected_ask_result();
}
std::vector<std::string> question_order;
question_order.reserve(parsed->size());
for (const auto& q : *parsed) question_order.push_back(q.question);
nlohmann::json payload = questions_to_payload(*parsed);
nlohmann::json resp = ctx.ask_user_questions(payload);
// timeout 策略到期:prompter 已发 question_closed(reason=timeout)
// 收掉前端 modal,这里合成自动采纳结果。
if (resp.value("timed_out", false)) {
LOG_INFO("[AskUserQuestion] timeout policy adopted first options after " +
std::to_string(policy.timeout_seconds) + "s");
return make_timeout_adopted_ask_result(*parsed, question_order,
policy.timeout_seconds);
}
bool cancelled = resp.value("cancelled", false);
if (cancelled) {
return make_rejected_ask_result();
}
auto answers = parse_async_response(resp);
ToolResult r;
r.success = true;
r.output = format_ask_answers(question_order, answers);
r.metadata = build_ask_user_question_result_metadata(question_order, answers);
return r;
};
ToolImpl impl;
impl.definition = build_ask_user_question_def();
impl.execute = execute;
impl.is_read_only = true;
impl.source = ToolSource::Builtin;
return impl;
}
} // namespace acecode