@vietor/agent-core 0.7.6 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -108,6 +108,7 @@ const session = await createSession({
108
108
  | `sessionId` | `string` | `randomUUID()` | Unique session identifier. |
109
109
  | `maxTurns` | `number` | `50` | Maximum agent turns (LLM calls with tool calls) per prompt before the run errors out. |
110
110
  | `stallThreshold` | `number` | `3` | Stall tolerance: consecutive identical tool-call sets, or consecutive text-only responses while todos are incomplete, before the run is treated as stalled. |
111
+ | `maxParallelToolCalls` | `number` | `10` | Maximum number of tool calls executed concurrently in one turn. |
111
112
 
112
113
  The auto-compaction threshold is not configurable — it's derived internally as 75% of `llm.maxInputTokens` and exposed via `session.contextLimit`.
113
114
 
@@ -150,8 +151,41 @@ const session = await createSession({ systemPrompt, llm });
150
151
  | `exportState(): SessionState` | Return the full session state (`{ messages, todos }`) for the host to persist; `export()` returns only messages. |
151
152
  | `compact(): Promise<RunStatus>` | Ask the LLM to summarize the conversation so far, replacing history with a single summary message. Runs through the run loop — streams the summary and can be aborted via `abort()`. |
152
153
  | `abort(): void` | Abort the current prompt or compact, cancel pending tool calls, and dismiss unanswered user questions. |
153
- | `submitAnswer(id: string, answer: string): void` | Supply an answer to a pending user question (from the built-in AskUser tool). |
154
- | `pendingQuestion: Extract<TimelineEvent, { type: "question" }> \| undefined` | *(getter)* The most recent unanswered question, or `undefined` if none are pending. |
154
+ | `submitAnswer(id: string, answers: AskAnswer[]): void` | Supply answers to a pending user question group (from the built-in AskUser tool), one entry per question; multi-select answers are `string[]`, skipped ones `""`. |
155
+ | `pendingQuestion: Extract<TimelineEvent, { type: "question" }> \| undefined` | *(getter)* The most recent unanswered question group, or `undefined` if none are pending. |
156
+
157
+ ### Asking the user
158
+
159
+ The built-in **AskUser** tool takes 1-4 questions in one call; each question has an optional `header` (≤ 12 chars), 2-4 `options` with optional `description`s, and an optional `multiSelect` flag:
160
+
161
+ ```ts
162
+ {
163
+ questions: [
164
+ { header: "Deploy", question: "Which environment?", options: [{ label: "prod" }, { label: "staging" }], multiSelect: false },
165
+ { question: "Notify on?", options: [{ label: "email" }, { label: "slack" }], multiSelect: true },
166
+ ],
167
+ }
168
+ ```
169
+
170
+ The session emits one `question` timeline entry (also delivered via `onEvent`) — each item is an `AskedQuestion` (an `AskQuestion` plus an `answer` field, `null` while pending) — and pauses the run until answered:
171
+
172
+ ```ts
173
+ { type: "question", id: "q1", questions: [{ question: "Which environment?", options: [...], multiSelect: false, answer: null }, ...] }
174
+ ```
175
+
176
+ The host renders the questions and supplies one answer per question via `submitAnswer` — `string` for single-select, `string[]` for multi-select, `""` to skip:
177
+
178
+ ```ts
179
+ session.submitAnswer("q1", ["prod", ["email", "slack"]]);
180
+ ```
181
+
182
+ The tool result fed back to the LLM is JSON keyed by question text; multi-select values are arrays of selected labels, skipped questions `""`:
183
+
184
+ ```json
185
+ { "Which environment?": "prod", "Notify on?": ["email", "slack"] }
186
+ ```
187
+
188
+ `abort()` (or `dispose()`) resolves any pending question group with all-`""` answers and stops the run.
155
189
 
156
190
  ### Events
157
191
 
@@ -178,7 +212,7 @@ type TimelineEvent =
178
212
  | { type: "retry"; attempt: number; max: number; reason: string }
179
213
  | { type: "error"; text: string }
180
214
  | { type: "interrupted" }
181
- | { type: "question"; id: string; text: string; options: string[]; answer: string | null }
215
+ | { type: "question"; id: string; questions: AskedQuestion[] }
182
216
  | { type: "notice"; text: string };
183
217
  ```
184
218
 
@@ -207,7 +241,7 @@ type StreamEvent =
207
241
  | `retry` | The LLM client retries after a transient API error. | ✓ |
208
242
  | `error` | An error occurred. | ✓ |
209
243
  | `interrupted` | The current run was aborted. | ✓ |
210
- | `question` | The AskUser tool poses a question. | ✓ |
244
+ | `question` | The AskUser tool poses a group of 1-4 questions. | ✓ |
211
245
  | `notice` | `session.addNotice()` is called, or the run auto-compacts context. | ✓ |
212
246
  | `run_metrics` | Run metrics change: at run start, every second, and at run end (`running: false`). | — |
213
247
 
@@ -349,7 +383,7 @@ Also returned by `session.compact()` (`"ok"` on success, `"aborted"` if aborted,
349
383
  |---|---|
350
384
  | `result: string \| null` (`tool`) | `null` while the tool is running; the result text once `tool_end` arrives, or `"aborted"` if the run was interrupted. |
351
385
  | `isError?: boolean` / `resultSummary?: string` (`tool`) | Set when the tool ended with an error / a condensed summary of the result. |
352
- | `answer: string \| null` (`question`) | `null` until the user answers (via `submitAnswer` or `abort`). |
386
+ | `questions: AskedQuestion[]` (`question`) | The question group; each item is an `AskQuestion` (`header?`, `question`, `options: {label, description?}[]`, `multiSelect`) with an `answer` field: `string` for single-select, `string[]` for multi-select, `""` when skipped; `null` until answered (via `submitAnswer` or `abort`). |
353
387
 
354
388
  ### `SessionMessage`
355
389
 
@@ -493,7 +527,7 @@ Interactive tools are **off by default** and registered only when explicitly ena
493
527
 
494
528
  | Tool | Description |
495
529
  |---|---|
496
- | **AskUser** | Ask the user a question and wait for the answer. |
530
+ | **AskUser** | Ask the user 1-4 questions in one call (each with 2-4 options and optional multi-select) and wait for the answers. |
497
531
  | **TodoWrite** | Track multi-step task progress; the agent must complete every task before its final reply. |
498
532
  | **Skill** | Invoke a skill by name; loads its instructions into context. Registered automatically whenever `skills` are provided. |
499
533
  | **SubAgent** | Run a nested sub-agent: read-only "explore" investigation or "plan" implementation planning. Sub-agents are equipped with the session's read-only tools (FileRead/Glob/Grep/WebFetch, plus any custom tools marked `readOnly`). |
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export type { RunStatus } from "./runtime/agent.js";
3
3
  export type { SessionMessage } from "./runtime/session-messages.js";
4
4
  export { INITIAL_RUN_METRICS, type SessionEvent, type TimelineEvent, type RunMetrics } from "./runtime/events.js";
5
5
  export type { Tool, ToolContext, ToolSchema, Todo, TodoStatus, TextResult } from "./tools/types.js";
6
+ export type { AskOption, AskQuestion, AskAnswer, AskedQuestion } from "./tools/ask-user.js";
6
7
  export { toolError } from "./tools/types.js";
7
8
  export type { BuiltinToolsOptions } from "./tools/registry.js";
8
9
  export type { Skill } from "./skills/types.js";
@@ -14,6 +14,7 @@ export interface AgentOptions {
14
14
  getTodos: () => readonly Todo[];
15
15
  stallThreshold: number;
16
16
  maxTurns: number;
17
+ maxParallelToolCalls: number;
17
18
  contextLimit: number;
18
19
  resolveSkill?: (name: string) => Skill | undefined;
19
20
  onCompact?: () => void;
@@ -27,6 +28,7 @@ export declare class Agent {
27
28
  private getTodos;
28
29
  private stallThreshold;
29
30
  private maxTurns;
31
+ private maxParallelToolCalls;
30
32
  readonly contextLimit: number;
31
33
  private todoSnapshot;
32
34
  private resolveSkill?;
@@ -1,5 +1,5 @@
1
1
  import { isAbortError, mapWithConcurrency, withAbort } from "../util/async.js";
2
- import { MAX_PARALLEL_TOOL_CALLS, NOT_EXECUTED_PREFIX, SKILL_TOOL_NAME } from "../util/constants.js";
2
+ import { NOT_EXECUTED_PREFIX, SKILL_TOOL_NAME } from "../util/constants.js";
3
3
  import { summarizeText, toErrorMessage } from "../util/text.js";
4
4
  import { parseToolArgs, toText } from "../llm/messages.js";
5
5
  import { SessionMessages } from "./session-messages.js";
@@ -14,6 +14,7 @@ export class Agent {
14
14
  getTodos;
15
15
  stallThreshold;
16
16
  maxTurns;
17
+ maxParallelToolCalls;
17
18
  contextLimit;
18
19
  todoSnapshot = [];
19
20
  resolveSkill;
@@ -30,6 +31,7 @@ export class Agent {
30
31
  this.getTodos = opts.getTodos;
31
32
  this.stallThreshold = opts.stallThreshold;
32
33
  this.maxTurns = opts.maxTurns;
34
+ this.maxParallelToolCalls = opts.maxParallelToolCalls;
33
35
  this.contextLimit = opts.contextLimit;
34
36
  this.resolveSkill = opts.resolveSkill;
35
37
  this.onCompact = opts.onCompact;
@@ -241,7 +243,7 @@ export class Agent {
241
243
  }
242
244
  }
243
245
  async runToolCalls(calls, onEvent, signal) {
244
- const results = await mapWithConcurrency(calls, MAX_PARALLEL_TOOL_CALLS, (call) => this.executeToolCall(call, onEvent, signal), signal);
246
+ const results = await mapWithConcurrency(calls, this.maxParallelToolCalls, (call) => this.executeToolCall(call, onEvent, signal), signal);
245
247
  return signal?.aborted ? null : results;
246
248
  }
247
249
  async executeToolCall(call, onEvent, signal) {
@@ -1,3 +1,4 @@
1
+ import type { AskedQuestion } from "../tools/ask-user.js";
1
2
  export interface RunMetrics {
2
3
  running: boolean;
3
4
  elapsed: number;
@@ -38,9 +39,7 @@ export type TimelineEvent = {
38
39
  } | {
39
40
  type: "question";
40
41
  id: string;
41
- text: string;
42
- options: string[];
43
- answer: string | null;
42
+ questions: AskedQuestion[];
44
43
  } | {
45
44
  type: "notice";
46
45
  text: string;
@@ -4,6 +4,7 @@ import type { MCPServerConfig, MCPServerInfo } from "../mcp/types.js";
4
4
  import type { Skill } from "../skills/types.js";
5
5
  import { type BuiltinToolsOptions, type ToolRegistry } from "../tools/registry.js";
6
6
  import type { Todo, Tool } from "../tools/types.js";
7
+ import type { AskAnswer } from "../tools/ask-user.js";
7
8
  import { type SessionEvent, type TimelineEvent } from "./events.js";
8
9
  import type { MCPClientInfo } from "../mcp/types.js";
9
10
  import { type RunStatus } from "./agent.js";
@@ -20,6 +21,7 @@ export interface SessionOptions {
20
21
  sessionId?: string;
21
22
  maxTurns?: number;
22
23
  stallThreshold?: number;
24
+ maxParallelToolCalls?: number;
23
25
  }
24
26
  export interface SessionDeps extends Omit<SessionOptions, "llm" | "tools" | "mcpServers"> {
25
27
  llm: LLMClient;
@@ -96,7 +98,7 @@ export declare class Session {
96
98
  importState(state: SessionState): void;
97
99
  compact(): Promise<RunStatus>;
98
100
  abort(): void;
99
- submitAnswer(id: string, answer: string): void;
101
+ submitAnswer(id: string, answers: AskAnswer[]): void;
100
102
  prompt(text: string): Promise<PromptResult>;
101
103
  private ask;
102
104
  }
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { isAbortError } from "../util/async.js";
3
3
  import { toErrorMessage } from "../util/text.js";
4
- import { DEFAULT_MAX_TURNS, DEFAULT_STALL_THRESHOLD } from "../util/constants.js";
4
+ import { DEFAULT_MAX_PARALLEL_TOOL_CALLS, DEFAULT_MAX_TURNS, DEFAULT_STALL_THRESHOLD } from "../util/constants.js";
5
5
  import { registerBuiltinTools } from "../tools/registry.js";
6
6
  import { INITIAL_RUN_METRICS } from "./events.js";
7
7
  import { Agent } from "./agent.js";
@@ -77,17 +77,17 @@ class QuestionQueue {
77
77
  });
78
78
  return { id, promise };
79
79
  }
80
- submit(id, answer) {
80
+ submit(id, answers) {
81
81
  const resolve = this.resolvers.get(id);
82
82
  if (resolve) {
83
83
  this.resolvers.delete(id);
84
- resolve(answer);
84
+ resolve(answers);
85
85
  }
86
86
  }
87
- resolveAll(answer) {
87
+ resolveAll() {
88
88
  const ids = [...this.resolvers.keys()];
89
89
  for (const id of ids) {
90
- this.submit(id, answer);
90
+ this.submit(id, []);
91
91
  }
92
92
  return ids;
93
93
  }
@@ -200,7 +200,7 @@ export class Session {
200
200
  for (const s of deps.skills ?? [])
201
201
  this.skillsMap.set(s.name, s);
202
202
  registerBuiltinTools(this.tools, deps.builtInTools, {
203
- ask: (q, o) => this.ask(q, o),
203
+ ask: (questions) => this.ask(questions),
204
204
  setTodos: (t) => this.todoStore.set(t),
205
205
  resolveSkill: deps.skills?.length ? this.resolveSkill : undefined,
206
206
  subAgent: {
@@ -210,6 +210,7 @@ export class Session {
210
210
  cwd: this.cwd,
211
211
  maxTurns: deps.maxTurns ?? DEFAULT_MAX_TURNS,
212
212
  stallThreshold: deps.stallThreshold ?? DEFAULT_STALL_THRESHOLD,
213
+ maxParallelToolCalls: deps.maxParallelToolCalls ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS,
213
214
  contextLimit: deps.contextLimit,
214
215
  onUsage: (cacheInputTokens, missInputTokens, outputTokens) => this.agent.addUsage(cacheInputTokens, missInputTokens, outputTokens),
215
216
  })(systemPrompt, task, signal),
@@ -224,6 +225,7 @@ export class Session {
224
225
  getTodos: () => this.todoStore.all,
225
226
  stallThreshold: deps.stallThreshold ?? DEFAULT_STALL_THRESHOLD,
226
227
  maxTurns: deps.maxTurns ?? DEFAULT_MAX_TURNS,
228
+ maxParallelToolCalls: deps.maxParallelToolCalls ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS,
227
229
  contextLimit: deps.contextLimit,
228
230
  resolveSkill: this.resolveSkill,
229
231
  onCompact: () => {
@@ -356,21 +358,20 @@ export class Session {
356
358
  }
357
359
  abort() {
358
360
  this.abortController?.abort();
359
- for (const id of this.questionQueue.resolveAll("")) {
360
- this.timelineStore.setAnswer(id, "");
361
- }
361
+ for (const id of this.questionQueue.resolveAll())
362
+ this.submitAnswer(id, []);
362
363
  }
363
- submitAnswer(id, answer) {
364
- this.questionQueue.submit(id, answer);
365
- this.timelineStore.setAnswer(id, answer);
364
+ submitAnswer(id, answers) {
365
+ this.questionQueue.submit(id, answers);
366
+ this.timelineStore.setAnswers(id, answers);
366
367
  }
367
368
  async prompt(text) {
368
369
  this.rejectIfBusy();
369
370
  return this.start({ type: "user", text }, (signal) => this.agent.run(text, this.handleEvent, signal));
370
371
  }
371
- ask(text, options) {
372
+ ask(questions) {
372
373
  const { id, promise } = this.questionQueue.ask();
373
- this.emit({ type: "question", id, text, options, answer: null });
374
+ this.emit({ type: "question", id, questions: questions.map((q) => ({ ...q, answer: null })) });
374
375
  return promise;
375
376
  }
376
377
  }
@@ -8,6 +8,7 @@ export interface SubAgentRunOptions {
8
8
  cwd: string;
9
9
  maxTurns: number;
10
10
  stallThreshold: number;
11
+ maxParallelToolCalls: number;
11
12
  contextLimit: number;
12
13
  onUsage?: (cacheInputTokens: number, missInputTokens: number, outputTokens: number) => void;
13
14
  }
@@ -16,6 +16,7 @@ export function createSubAgentRunner(opts) {
16
16
  getTodos: () => [],
17
17
  stallThreshold: opts.stallThreshold,
18
18
  maxTurns: opts.maxTurns,
19
+ maxParallelToolCalls: opts.maxParallelToolCalls,
19
20
  contextLimit: opts.contextLimit,
20
21
  });
21
22
  const status = await subAgent.run(task, undefined, signal);
@@ -1,5 +1,6 @@
1
1
  import type { SessionMessage } from "./session-messages.js";
2
2
  import type { SessionEvent, TimelineEvent } from "./events.js";
3
+ import { type AskAnswer } from "../tools/ask-user.js";
3
4
  export declare class TimelineStore {
4
5
  private listeners;
5
6
  private entries;
@@ -10,7 +11,7 @@ export declare class TimelineStore {
10
11
  applyEvent(e: SessionEvent): void;
11
12
  private append;
12
13
  setResult(id: string, result: string, isError?: boolean, resultSummary?: string): void;
13
- setAnswer(id: string, answer: string): void;
14
+ setAnswers(id: string, answers: AskAnswer[]): void;
14
15
  get latestUnansweredQuestion(): Extract<TimelineEvent, {
15
16
  type: "question";
16
17
  }> | undefined;
@@ -1,4 +1,5 @@
1
1
  import { parseToolArgs, toText } from "../llm/messages.js";
2
+ import { parseQuestions } from "../tools/ask-user.js";
2
3
  import { Emitter } from "../util/emitter.js";
3
4
  export class TimelineStore {
4
5
  listeners = new Emitter();
@@ -19,10 +20,6 @@ export class TimelineStore {
19
20
  case "tool_end":
20
21
  this.setResult(e.id, e.result, e.isError, e.resultSummary);
21
22
  break;
22
- case "question":
23
- this.pendingQuestions.set(e.id, this.entries.length);
24
- this.append(e);
25
- break;
26
23
  case "assistant_delta":
27
24
  case "thinking_delta":
28
25
  case "thinking_cleared":
@@ -37,6 +34,9 @@ export class TimelineStore {
37
34
  if (entry.type === "tool" && entry.result === null) {
38
35
  this.pendingTools.set(entry.id, this.entries.length - 1);
39
36
  }
37
+ else if (entry.type === "question") {
38
+ this.pendingQuestions.set(entry.id, this.entries.length - 1);
39
+ }
40
40
  this.listeners.notify();
41
41
  }
42
42
  setResult(id, result, isError, resultSummary) {
@@ -50,21 +50,24 @@ export class TimelineStore {
50
50
  this.entries[idx] = { ...entry, result, isError, resultSummary };
51
51
  this.listeners.notify();
52
52
  }
53
- setAnswer(id, answer) {
53
+ setAnswers(id, answers) {
54
54
  const index = this.pendingQuestions.get(id);
55
55
  if (index === undefined)
56
56
  return;
57
57
  this.pendingQuestions.delete(id);
58
58
  const entry = this.entries[index];
59
- if (entry.type !== "question" || entry.answer !== null)
59
+ if (entry.type !== "question" || entry.questions.some((q) => q.answer !== null))
60
60
  return;
61
- this.entries[index] = { ...entry, answer };
61
+ this.entries[index] = {
62
+ ...entry,
63
+ questions: entry.questions.map((q, i) => ({ ...q, answer: answers[i] ?? "" })),
64
+ };
62
65
  this.listeners.notify();
63
66
  }
64
67
  get latestUnansweredQuestion() {
65
68
  for (let i = this.entries.length - 1; i >= 0; i--) {
66
69
  const e = this.entries[i];
67
- if (e.type === "question" && e.answer === null)
70
+ if (e.type === "question" && e.questions.some((q) => q.answer === null))
68
71
  return e;
69
72
  }
70
73
  return undefined;
@@ -138,6 +141,24 @@ export function toTimelineEntries(messages, summarizeArgs) {
138
141
  else {
139
142
  entries.push(entry);
140
143
  }
144
+ if (tc.function.name === "AskUser" && parsed.ok && result) {
145
+ const { questions } = parseQuestions(parsed.args);
146
+ if (questions.length > 0) {
147
+ let answerMap = {};
148
+ try {
149
+ const parsedResult = JSON.parse(result.content);
150
+ if (typeof parsedResult === "object" && parsedResult !== null) {
151
+ answerMap = parsedResult;
152
+ }
153
+ }
154
+ catch { }
155
+ entries.push({
156
+ type: "question",
157
+ id: tc.id,
158
+ questions: questions.map((q) => ({ ...q, answer: answerMap[q.question] ?? "" })),
159
+ });
160
+ }
161
+ }
141
162
  }
142
163
  }
143
164
  }
@@ -1,3 +1,21 @@
1
1
  import type { Tool } from "./types.js";
2
- export declare const ASK_USER_GUIDANCE = "- When a decision belongs to the user, call AskUser and wait for the answer rather than listing options in prose. Ask when there are multiple reasonable approaches, an irreversible or consequential action, or the request is ambiguous. When you have enough information to proceed, act without asking.";
3
- export declare function createAskUserTool(ask: (question: string, options: string[]) => Promise<string>): Tool;
2
+ export declare const ASK_USER_GUIDANCE = "- When a decision belongs to the user, call AskUser and wait for the answer rather than listing options in prose. Ask when there are multiple reasonable approaches, an irreversible or consequential action, or the request is ambiguous. When you have enough information to proceed, act without asking. Batch related questions into a single AskUser call (up to 4 questions, 2-4 options each).";
3
+ export interface AskOption {
4
+ label: string;
5
+ description?: string;
6
+ }
7
+ export interface AskQuestion {
8
+ header?: string;
9
+ question: string;
10
+ options: AskOption[];
11
+ multiSelect: boolean;
12
+ }
13
+ export type AskAnswer = string | string[];
14
+ export type AskedQuestion = AskQuestion & {
15
+ answer: AskAnswer | null;
16
+ };
17
+ export declare function parseQuestions(args: Record<string, unknown>): {
18
+ questions: AskQuestion[];
19
+ error?: string;
20
+ };
21
+ export declare function createAskUserTool(ask: (questions: AskQuestion[]) => Promise<AskAnswer[]>): Tool;
@@ -1,6 +1,45 @@
1
1
  import { toolError } from "./types.js";
2
- export const ASK_USER_GUIDANCE = "- When a decision belongs to the user, call AskUser and wait for the answer rather than listing options in prose. Ask when there are multiple reasonable approaches, an irreversible or consequential action, or the request is ambiguous. When you have enough information to proceed, act without asking.";
3
- const DESCRIPTION = "Ask the user a question and wait for the answer. Provide at least one option. Returns the answer as text.";
2
+ export const ASK_USER_GUIDANCE = "- When a decision belongs to the user, call AskUser and wait for the answer rather than listing options in prose. Ask when there are multiple reasonable approaches, an irreversible or consequential action, or the request is ambiguous. When you have enough information to proceed, act without asking. Batch related questions into a single AskUser call (up to 4 questions, 2-4 options each).";
3
+ const MAX_QUESTIONS = 4;
4
+ const MAX_OPTIONS = 4;
5
+ const MAX_HEADER_LENGTH = 12;
6
+ const DESCRIPTION = "Ask the user 1-4 questions and wait for the answers. Each question has an optional header (at most 12 chars), 2-4 options with optional descriptions, and an optional multiSelect flag. Returns JSON keyed by question text; multi-select answers are arrays of selected labels; skipped questions return an empty string. Do not add an 'Other' option — the user can always type a custom answer.";
7
+ export function parseQuestions(args) {
8
+ const raw = args.questions;
9
+ if (!Array.isArray(raw) || raw.length === 0) {
10
+ return { questions: [], error: `"questions" must be an array of 1-${MAX_QUESTIONS} question objects, got ${typeof raw}` };
11
+ }
12
+ if (raw.length > MAX_QUESTIONS) {
13
+ return { questions: [], error: `"questions" must contain at most ${MAX_QUESTIONS} questions, got ${raw.length}` };
14
+ }
15
+ const questions = [];
16
+ const seen = new Set();
17
+ for (const q of raw) {
18
+ if (!q)
19
+ return { questions: [], error: "each question must be an object" };
20
+ const question = typeof q.question === "string" ? q.question.trim() : "";
21
+ if (!question)
22
+ return { questions: [], error: "each question needs non-empty text" };
23
+ if (seen.has(question))
24
+ return { questions: [], error: "questions must be unique" };
25
+ seen.add(question);
26
+ const options = [];
27
+ if (!Array.isArray(q.options) || q.options.length < 2 || q.options.length > MAX_OPTIONS) {
28
+ return { questions: [], error: `each question needs 2-${MAX_OPTIONS} options` };
29
+ }
30
+ for (const o of q.options) {
31
+ if (!o)
32
+ return { questions: [], error: "each option must be an object" };
33
+ const label = typeof o.label === "string" ? o.label.trim() : "";
34
+ if (!label)
35
+ return { questions: [], error: "each option needs a non-empty label" };
36
+ options.push({ label, description: typeof o.description === "string" && o.description ? o.description : undefined });
37
+ }
38
+ const header = typeof q.header === "string" && q.header ? q.header.slice(0, MAX_HEADER_LENGTH) : undefined;
39
+ questions.push({ header, question, options, multiSelect: q.multiSelect === true });
40
+ }
41
+ return { questions };
42
+ }
4
43
  export function createAskUserTool(ask) {
5
44
  return {
6
45
  name: "AskUser",
@@ -8,19 +47,54 @@ export function createAskUserTool(ask) {
8
47
  parameters: {
9
48
  type: "object",
10
49
  properties: {
11
- question: { type: "string", description: "The question to ask the user." },
12
- options: { type: "array", items: { type: "string" }, minItems: 1, description: "List of choices; at least one required." },
50
+ questions: {
51
+ type: "array",
52
+ description: "1-4 questions to ask the user, answered together.",
53
+ minItems: 1,
54
+ maxItems: MAX_QUESTIONS,
55
+ items: {
56
+ type: "object",
57
+ properties: {
58
+ header: { type: "string", maxLength: MAX_HEADER_LENGTH, description: "Short label for the question, shown as a chip." },
59
+ question: { type: "string", description: "The question text." },
60
+ options: {
61
+ type: "array",
62
+ minItems: 2,
63
+ maxItems: MAX_OPTIONS,
64
+ description: "2-4 mutually exclusive choices.",
65
+ items: {
66
+ type: "object",
67
+ properties: {
68
+ label: { type: "string", description: "The choice label." },
69
+ description: { type: "string", description: "Optional detail shown under the label." },
70
+ },
71
+ required: ["label"],
72
+ },
73
+ },
74
+ multiSelect: { type: "boolean", description: "Whether the user may pick more than one option." },
75
+ },
76
+ required: ["question", "options"],
77
+ },
78
+ },
13
79
  },
14
- required: ["question", "options"],
80
+ required: ["questions"],
81
+ },
82
+ summarizeArgs(args) {
83
+ const { questions, error } = parseQuestions(args);
84
+ if (error)
85
+ return "invalid";
86
+ return `${questions.length} question${questions.length === 1 ? "" : "s"}`;
15
87
  },
16
88
  async execute(args, _ctx) {
17
- const question = args.question;
18
- const options = Array.isArray(args.options) ? args.options : [];
19
- if (!options.length) {
20
- return toolError("options must contain at least one choice");
21
- }
22
- return { content: await ask(question, options) };
89
+ const { questions, error } = parseQuestions(args);
90
+ if (error)
91
+ return toolError(error);
92
+ const answers = await ask(questions);
93
+ const result = {};
94
+ questions.forEach((q, i) => {
95
+ result[q.question] = answers[i] ?? "";
96
+ });
97
+ return { content: JSON.stringify(result) };
23
98
  },
24
- argSummaryKeys: ["question"],
25
99
  };
26
100
  }
@@ -1,4 +1,5 @@
1
1
  import type { Tool, ToolContext, ToolSchema, Todo } from "./types.js";
2
+ import { type AskAnswer, type AskQuestion } from "./ask-user.js";
2
3
  import { type SubAgentToolDeps } from "./sub-agent.js";
3
4
  import type { Skill } from "../skills/types.js";
4
5
  import type { TextResult } from "./types.js";
@@ -22,7 +23,7 @@ export interface BuiltinToolsOptions {
22
23
  subAgent?: boolean;
23
24
  }
24
25
  export interface BuiltinToolsDeps {
25
- ask: (question: string, options: string[]) => Promise<string>;
26
+ ask: (questions: AskQuestion[]) => Promise<AskAnswer[]>;
26
27
  setTodos: (todos: Todo[]) => void;
27
28
  resolveSkill?: (name: string) => Skill | undefined;
28
29
  subAgent: SubAgentToolDeps;
@@ -14,7 +14,7 @@ export declare const DEFAULT_STALL_THRESHOLD = 3;
14
14
  export declare const DEFAULT_MAX_TURNS = 50;
15
15
  export declare const DEFAULT_FILE_READ_LIMIT = 2000;
16
16
  export declare const DEFAULT_GREP_LIMIT = 200;
17
- export declare const MAX_PARALLEL_TOOL_CALLS = 8;
17
+ export declare const DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10;
18
18
  export declare const SKILL_TOOL_NAME: "Skill";
19
19
  export declare const LLM_MAX_RETRIES = 3;
20
20
  export declare const WEB_FETCH_RETRIES = 2;
@@ -14,7 +14,7 @@ export const DEFAULT_STALL_THRESHOLD = 3;
14
14
  export const DEFAULT_MAX_TURNS = 50;
15
15
  export const DEFAULT_FILE_READ_LIMIT = 2000;
16
16
  export const DEFAULT_GREP_LIMIT = 200;
17
- export const MAX_PARALLEL_TOOL_CALLS = 8;
17
+ export const DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10;
18
18
  export const SKILL_TOOL_NAME = "Skill";
19
19
  export const LLM_MAX_RETRIES = 3;
20
20
  export const WEB_FETCH_RETRIES = 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vietor/agent-core",
3
- "version": "0.7.6",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",