@vietor/agent-core 0.7.6 → 0.8.1

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;
@@ -1,6 +1,6 @@
1
1
  import type { SubAgentRunResult } from "../runtime/sub-agent-runner.js";
2
2
  import type { Tool } from "./types.js";
3
- export declare const SUB_AGENT_GUIDANCE = "- Consider delegating to the SubAgent tool when the task matches an agent type, when you have independent work to run in parallel, or when answering would mean reading across several files \u2014 delegate and keep the conclusion, not the file dumps. type: \"explore\" \u2014 read-only search agent for broad fan-out searches (state the search breadth in the task); type: \"plan\" \u2014 software architect producing implementation plans. For a single-fact lookup where you already know the file, symbol, or value, search directly. Once you have delegated a search, do not also run it yourself \u2014 wait for the result. Issue at most 2 SubAgent calls per turn; multiple calls in the same turn run concurrently. Sub-agents are read-only and return only their final report, not intermediate steps \u2014 verify important results yourself. For large workloads with many independent items that would exceed the turn budget, split the items into chunks sized so each sub-agent can complete its chunk within its own loop budget, delegate one SubAgent per chunk, and run the remaining chunks in the following turns as results return. Instruct each sub-agent to report results per item in structured lines so you can consolidate.";
3
+ export declare const SUB_AGENT_GUIDANCE = "- Consider delegating to the SubAgent tool when the task matches an agent type, when you have independent work to run in parallel, or when answering would mean reading across several files \u2014 delegate and keep the conclusion, not the file dumps. The only valid values for type are \"explore\" \u2014 read-only search agent for broad fan-out searches (state the search breadth in the task) \u2014 and \"plan\" \u2014 software architect producing implementation plans; never use any other value. For a single-fact lookup where you already know the file, symbol, or value, search directly. Once you have delegated a search, do not also run it yourself \u2014 wait for the result. Issue at most 2 SubAgent calls per turn; multiple calls in the same turn run concurrently. Sub-agents are read-only and return only their final report, not intermediate steps \u2014 verify important results yourself. For large workloads with many independent items that would exceed the turn budget, split the items into chunks sized so each sub-agent can complete its chunk within its own loop budget, delegate one SubAgent per chunk, and run the remaining chunks in the following turns as results return. Instruct each sub-agent to report results per item in structured lines so you can consolidate.";
4
4
  export interface SubAgentToolDeps {
5
5
  runSubAgent: (systemPrompt: string, task: string, signal?: AbortSignal) => Promise<SubAgentRunResult>;
6
6
  }
@@ -2,7 +2,7 @@ import { NOT_EXECUTED_PREFIX } from "../util/constants.js";
2
2
  import { summarizeText } from "../util/text.js";
3
3
  import { toolError } from "./types.js";
4
4
  const MAX_LABEL_LENGTH = 50;
5
- export const SUB_AGENT_GUIDANCE = '- Consider delegating to the SubAgent tool when the task matches an agent type, when you have independent work to run in parallel, or when answering would mean reading across several files — delegate and keep the conclusion, not the file dumps. type: "explore" — read-only search agent for broad fan-out searches (state the search breadth in the task); type: "plan" — software architect producing implementation plans. For a single-fact lookup where you already know the file, symbol, or value, search directly. Once you have delegated a search, do not also run it yourself — wait for the result. Issue at most 2 SubAgent calls per turn; multiple calls in the same turn run concurrently. Sub-agents are read-only and return only their final report, not intermediate steps — verify important results yourself. For large workloads with many independent items that would exceed the turn budget, split the items into chunks sized so each sub-agent can complete its chunk within its own loop budget, delegate one SubAgent per chunk, and run the remaining chunks in the following turns as results return. Instruct each sub-agent to report results per item in structured lines so you can consolidate.';
5
+ export const SUB_AGENT_GUIDANCE = '- Consider delegating to the SubAgent tool when the task matches an agent type, when you have independent work to run in parallel, or when answering would mean reading across several files — delegate and keep the conclusion, not the file dumps. The only valid values for type are "explore" — read-only search agent for broad fan-out searches (state the search breadth in the task) and "plan" — software architect producing implementation plans; never use any other value. For a single-fact lookup where you already know the file, symbol, or value, search directly. Once you have delegated a search, do not also run it yourself — wait for the result. Issue at most 2 SubAgent calls per turn; multiple calls in the same turn run concurrently. Sub-agents are read-only and return only their final report, not intermediate steps — verify important results yourself. For large workloads with many independent items that would exceed the turn budget, split the items into chunks sized so each sub-agent can complete its chunk within its own loop budget, delegate one SubAgent per chunk, and run the remaining chunks in the following turns as results return. Instruct each sub-agent to report results per item in structured lines so you can consolidate.';
6
6
  const EXPLORE_PROMPT = [
7
7
  "You are the Explore sub-agent — a read-only search agent for broad fan-out searches. Use it when answering means sweeping many files, directories, or naming conventions and the parent needs only the conclusion, not the file dumps. You read excerpts rather than whole files, so you locate code — you do not review or audit it. You are read-only: you must not modify any files.",
8
8
  "Guidelines:",
@@ -40,7 +40,7 @@ const SUB_AGENT_DEFS = [
40
40
  systemPrompt: PLAN_PROMPT,
41
41
  },
42
42
  ];
43
- const TOOL_DESCRIPTION = 'Run a dedicated sub-agent in its own nested loop — the only result you receive is its final report as text, not intermediate steps. type: "explore" — a read-only search agent for broad fan-out searches of the codebase or web; specify a search breadth in the task ("medium" for moderate exploration, "very thorough" for multiple locations and naming conventions). type: "plan" — a software architect that reads the relevant code first, then returns a step-by-step implementation plan identifying critical files and architectural trade-offs. Sub-agents are read-only and cannot ask questions, use skills, or spawn further sub-agents.';
43
+ const TOOL_DESCRIPTION = 'Run a dedicated sub-agent in its own nested loop — the only result you receive is its final report as text, not intermediate steps. type: "explore" — a read-only search agent for broad fan-out searches of the codebase or web; specify a search breadth in the task ("medium" for moderate exploration, "very thorough" for multiple locations and naming conventions). type: "plan" — a software architect that reads the relevant code first, then returns a step-by-step implementation plan identifying critical files and architectural trade-offs. These are the only two valid type values — never pass any other string. Sub-agents are read-only and cannot ask questions, use skills, or spawn further sub-agents.';
44
44
  export function createSubAgentTool(deps) {
45
45
  return {
46
46
  name: "SubAgent",
@@ -51,7 +51,7 @@ export function createSubAgentTool(deps) {
51
51
  type: {
52
52
  type: "string",
53
53
  enum: SUB_AGENT_DEFS.map((d) => d.type),
54
- description: 'The sub-agent type to invoke: "explore" (read-only fan-out search) or "plan" (implementation plan).',
54
+ description: 'The sub-agent type to invoke — only "explore" (read-only fan-out search) or "plan" (implementation plan) is valid.',
55
55
  },
56
56
  label: {
57
57
  type: "string",
@@ -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/dist/util/net.js CHANGED
@@ -15,8 +15,11 @@ const undiciFetch = fetch;
15
15
  export async function netFetch(input, init) {
16
16
  if (!proxyInitialized)
17
17
  ensureProxy();
18
+ const headers = new Headers(init?.headers);
19
+ if (!headers.has("accept-encoding"))
20
+ headers.set("accept-encoding", "gzip, deflate");
18
21
  if (!proxyAgent)
19
- return undiciFetch(input, init);
22
+ return undiciFetch(input, { ...init, headers });
20
23
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
21
24
  if (noProxyList) {
22
25
  const hostname = new URL(url).hostname;
@@ -26,7 +29,7 @@ export async function netFetch(input, init) {
26
29
  return hostname === p || hostname.endsWith("." + p);
27
30
  });
28
31
  if (shouldBypass)
29
- return undiciFetch(input, init);
32
+ return undiciFetch(input, { ...init, headers });
30
33
  }
31
- return undiciFetch(input, { ...init, dispatcher: proxyAgent });
34
+ return undiciFetch(input, { ...init, headers, dispatcher: proxyAgent });
32
35
  }
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.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",