@vietor/agent-core 0.7.5 → 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 +40 -6
- package/dist/index.d.ts +1 -0
- package/dist/runtime/agent.d.ts +2 -0
- package/dist/runtime/agent.js +4 -2
- package/dist/runtime/events.d.ts +2 -3
- package/dist/runtime/session.d.ts +3 -1
- package/dist/runtime/session.js +15 -14
- package/dist/runtime/sub-agent-runner.d.ts +1 -0
- package/dist/runtime/sub-agent-runner.js +1 -0
- package/dist/runtime/timeline.d.ts +2 -1
- package/dist/runtime/timeline.js +29 -8
- package/dist/tools/ask-user.d.ts +20 -2
- package/dist/tools/ask-user.js +86 -12
- package/dist/tools/glob.js +3 -3
- package/dist/tools/grep.js +4 -4
- package/dist/tools/registry.d.ts +2 -1
- package/dist/tools/sub-agent.js +14 -2
- package/dist/util/async.js +1 -1
- package/dist/util/constants.d.ts +1 -1
- package/dist/util/constants.js +1 -1
- package/dist/util/file.d.ts +4 -1
- package/dist/util/file.js +7 -3
- package/package.json +1 -1
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,
|
|
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;
|
|
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
|
|
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
|
-
| `
|
|
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
|
|
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";
|
package/dist/runtime/agent.d.ts
CHANGED
|
@@ -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?;
|
package/dist/runtime/agent.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isAbortError, mapWithConcurrency, withAbort } from "../util/async.js";
|
|
2
|
-
import {
|
|
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,
|
|
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) {
|
package/dist/runtime/events.d.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
101
|
+
submitAnswer(id: string, answers: AskAnswer[]): void;
|
|
100
102
|
prompt(text: string): Promise<PromptResult>;
|
|
101
103
|
private ask;
|
|
102
104
|
}
|
package/dist/runtime/session.js
CHANGED
|
@@ -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,
|
|
80
|
+
submit(id, answers) {
|
|
81
81
|
const resolve = this.resolvers.get(id);
|
|
82
82
|
if (resolve) {
|
|
83
83
|
this.resolvers.delete(id);
|
|
84
|
-
resolve(
|
|
84
|
+
resolve(answers);
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
-
resolveAll(
|
|
87
|
+
resolveAll() {
|
|
88
88
|
const ids = [...this.resolvers.keys()];
|
|
89
89
|
for (const id of ids) {
|
|
90
|
-
this.submit(id,
|
|
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: (
|
|
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.
|
|
361
|
-
}
|
|
361
|
+
for (const id of this.questionQueue.resolveAll())
|
|
362
|
+
this.submitAnswer(id, []);
|
|
362
363
|
}
|
|
363
|
-
submitAnswer(id,
|
|
364
|
-
this.questionQueue.submit(id,
|
|
365
|
-
this.timelineStore.
|
|
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(
|
|
372
|
+
ask(questions) {
|
|
372
373
|
const { id, promise } = this.questionQueue.ask();
|
|
373
|
-
this.emit({ type: "question", id,
|
|
374
|
+
this.emit({ type: "question", id, questions: questions.map((q) => ({ ...q, answer: null })) });
|
|
374
375
|
return promise;
|
|
375
376
|
}
|
|
376
377
|
}
|
|
@@ -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
|
-
|
|
14
|
+
setAnswers(id: string, answers: AskAnswer[]): void;
|
|
14
15
|
get latestUnansweredQuestion(): Extract<TimelineEvent, {
|
|
15
16
|
type: "question";
|
|
16
17
|
}> | undefined;
|
package/dist/runtime/timeline.js
CHANGED
|
@@ -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
|
-
|
|
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] = {
|
|
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
|
}
|
package/dist/tools/ask-user.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/tools/ask-user.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
12
|
-
|
|
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: ["
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
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
|
}
|
package/dist/tools/glob.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { formatRipgrepOutput, ripgrepResultSummary, runRipgrepLines } from "../util/ripgrep.js";
|
|
2
2
|
import { NO_MATCHES } from "../util/constants.js";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveSearchPath } from "../util/file.js";
|
|
4
4
|
const DESCRIPTION = "List files under a directory, optionally filtered by a glob pattern (e.g. **/*.ts). Skips node_modules and .git.";
|
|
5
5
|
export const globTool = {
|
|
6
6
|
name: "Glob",
|
|
@@ -15,12 +15,12 @@ export const globTool = {
|
|
|
15
15
|
required: [],
|
|
16
16
|
},
|
|
17
17
|
async execute(args, ctx) {
|
|
18
|
-
const cwd =
|
|
18
|
+
const { cwd, target } = resolveSearchPath(args, ctx.cwd);
|
|
19
19
|
const rgArgs = ["--files"];
|
|
20
20
|
const pattern = args.pattern;
|
|
21
21
|
if (pattern)
|
|
22
22
|
rgArgs.push("-g", pattern);
|
|
23
|
-
rgArgs.push(
|
|
23
|
+
rgArgs.push(target);
|
|
24
24
|
const { lines, truncated } = await runRipgrepLines(rgArgs, cwd, ctx.signal);
|
|
25
25
|
return { content: formatRipgrepOutput(lines, truncated, NO_MATCHES) };
|
|
26
26
|
},
|
package/dist/tools/grep.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { formatRipgrepOutput, ripgrepResultSummary, runRipgrepLines } from "../util/ripgrep.js";
|
|
2
2
|
import { DEFAULT_GREP_LIMIT, NO_MATCHES } from "../util/constants.js";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveSearchPath } from "../util/file.js";
|
|
4
4
|
const DESCRIPTION = `Search file contents recursively for a regex pattern (RE2 syntax). Skips node_modules and .git. Returns path:line:content, capped at ${DEFAULT_GREP_LIMIT} lines. For large codebases, use output_mode=files_with_matches first, or narrow with glob/type, or raise head_limit.`;
|
|
5
5
|
export const grepTool = {
|
|
6
6
|
name: "Grep",
|
|
@@ -10,7 +10,7 @@ export const grepTool = {
|
|
|
10
10
|
type: "object",
|
|
11
11
|
properties: {
|
|
12
12
|
pattern: { type: "string" },
|
|
13
|
-
path: { type: "string", description: "
|
|
13
|
+
path: { type: "string", description: "file or directory, defaults to cwd" },
|
|
14
14
|
glob: { type: "string", description: "filter files, e.g. *.ts" },
|
|
15
15
|
type: { type: "string", description: "file type, e.g. ts, js, py" },
|
|
16
16
|
output_mode: { type: "string", enum: ["content", "files_with_matches", "count"], description: "defaults to content" },
|
|
@@ -25,7 +25,7 @@ export const grepTool = {
|
|
|
25
25
|
required: ["pattern"],
|
|
26
26
|
},
|
|
27
27
|
async execute(args, ctx) {
|
|
28
|
-
const cwd =
|
|
28
|
+
const { cwd, target } = resolveSearchPath(args, ctx.cwd);
|
|
29
29
|
const rgArgs = ["--line-number", "--with-filename", "--no-heading"];
|
|
30
30
|
if (args.ignore_case)
|
|
31
31
|
rgArgs.push("-i");
|
|
@@ -56,7 +56,7 @@ export const grepTool = {
|
|
|
56
56
|
rgArgs.push("-c");
|
|
57
57
|
else
|
|
58
58
|
rgArgs.push("-m", String(headLimit));
|
|
59
|
-
rgArgs.push("--", args.pattern,
|
|
59
|
+
rgArgs.push("--", args.pattern, target);
|
|
60
60
|
const { lines, truncated } = await runRipgrepLines(rgArgs, cwd, ctx.signal, headLimit);
|
|
61
61
|
return { content: formatRipgrepOutput(lines, truncated, NO_MATCHES) };
|
|
62
62
|
},
|
package/dist/tools/registry.d.ts
CHANGED
|
@@ -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: (
|
|
26
|
+
ask: (questions: AskQuestion[]) => Promise<AskAnswer[]>;
|
|
26
27
|
setTodos: (todos: Todo[]) => void;
|
|
27
28
|
resolveSkill?: (name: string) => Skill | undefined;
|
|
28
29
|
subAgent: SubAgentToolDeps;
|
package/dist/tools/sub-agent.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { NOT_EXECUTED_PREFIX } from "../util/constants.js";
|
|
2
|
+
import { summarizeText } from "../util/text.js";
|
|
2
3
|
import { toolError } from "./types.js";
|
|
4
|
+
const MAX_LABEL_LENGTH = 50;
|
|
3
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.';
|
|
4
6
|
const EXPLORE_PROMPT = [
|
|
5
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.",
|
|
@@ -51,11 +53,21 @@ export function createSubAgentTool(deps) {
|
|
|
51
53
|
enum: SUB_AGENT_DEFS.map((d) => d.type),
|
|
52
54
|
description: 'The sub-agent type to invoke: "explore" (read-only fan-out search) or "plan" (implementation plan).',
|
|
53
55
|
},
|
|
56
|
+
label: {
|
|
57
|
+
type: "string",
|
|
58
|
+
maxLength: MAX_LABEL_LENGTH,
|
|
59
|
+
description: `Short label (max ${MAX_LABEL_LENGTH} characters) for this sub-agent run, shown in the UI.`,
|
|
60
|
+
},
|
|
54
61
|
task: { type: "string", description: "The task or question for the sub-agent, as a self-contained description." },
|
|
55
62
|
},
|
|
56
63
|
required: ["type", "task"],
|
|
57
64
|
},
|
|
58
|
-
|
|
65
|
+
summarizeArgs: (args) => {
|
|
66
|
+
const type = args.type;
|
|
67
|
+
const label = typeof args.label === "string" ? summarizeText(args.label, MAX_LABEL_LENGTH) : "";
|
|
68
|
+
const def = SUB_AGENT_DEFS.find((d) => d.type === type);
|
|
69
|
+
return (def?.name || type) + (label ? ` ${label}` : "");
|
|
70
|
+
},
|
|
59
71
|
async execute(args, ctx) {
|
|
60
72
|
const type = args.type;
|
|
61
73
|
const task = (args.task ?? "").trim();
|
|
@@ -80,7 +92,7 @@ export function createSubAgentTool(deps) {
|
|
|
80
92
|
}
|
|
81
93
|
}
|
|
82
94
|
const suffix = stallReason ? ` ${stallReason}` : "";
|
|
83
|
-
return { content: `Sub-agent "${
|
|
95
|
+
return { content: `Sub-agent "${def.name}" ended with status ${status}.${suffix}\n\n${reply}`, isError: true };
|
|
84
96
|
},
|
|
85
97
|
};
|
|
86
98
|
}
|
package/dist/util/async.js
CHANGED
|
@@ -11,7 +11,7 @@ export function isAbortError(e) {
|
|
|
11
11
|
return name === "AbortError" || name === "APIUserAbortError";
|
|
12
12
|
}
|
|
13
13
|
export function backoffDelay(attempt) {
|
|
14
|
-
return
|
|
14
|
+
return Math.min(2000 * 2 ** attempt, 60_000);
|
|
15
15
|
}
|
|
16
16
|
export async function withRetry(fn, opts) {
|
|
17
17
|
for (let attempt = 0;; attempt++) {
|
package/dist/util/constants.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/util/constants.js
CHANGED
|
@@ -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
|
|
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/file.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
export declare function tryReadFileText(path: string): string | undefined;
|
|
2
2
|
export declare function resolveRequiredPath(args: Record<string, unknown>, cwd: string): string;
|
|
3
|
-
export declare function
|
|
3
|
+
export declare function resolveSearchPath(args: Record<string, unknown>, cwd: string): {
|
|
4
|
+
cwd: string;
|
|
5
|
+
target: string;
|
|
6
|
+
};
|
package/dist/util/file.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
export function tryReadFileText(path) {
|
|
4
4
|
if (existsSync(path)) {
|
|
@@ -14,6 +14,10 @@ export function resolveRequiredPath(args, cwd) {
|
|
|
14
14
|
throw new Error("path is required");
|
|
15
15
|
return resolve(cwd, path);
|
|
16
16
|
}
|
|
17
|
-
export function
|
|
18
|
-
|
|
17
|
+
export function resolveSearchPath(args, cwd) {
|
|
18
|
+
const path = resolve(cwd, args.path || "");
|
|
19
|
+
if (existsSync(path) && !statSync(path).isDirectory()) {
|
|
20
|
+
return { cwd, target: path };
|
|
21
|
+
}
|
|
22
|
+
return { cwd: path, target: "." };
|
|
19
23
|
}
|