@mikode13/harness 5.3.0 → 5.5.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.
Files changed (37) hide show
  1. package/README.md +36 -3
  2. package/dist/agent/domain/agent.d.ts +15 -3
  3. package/dist/agent/domain/agent.js.map +1 -1
  4. package/dist/agent/domain/question.d.ts +35 -0
  5. package/dist/agent/domain/question.js +35 -0
  6. package/dist/agent/domain/question.js.map +1 -0
  7. package/dist/engines/domain/model/askUser.d.ts +14 -0
  8. package/dist/engines/domain/model/askUser.js +107 -0
  9. package/dist/engines/domain/model/askUser.js.map +1 -0
  10. package/dist/engines/domain/model/llmAgent.d.ts +5 -0
  11. package/dist/engines/domain/model/llmAgent.js +55 -7
  12. package/dist/engines/domain/model/llmAgent.js.map +1 -1
  13. package/dist/engines/domain/model/stepContext.d.ts +1 -1
  14. package/dist/engines/domain/model/stepContext.js +8 -1
  15. package/dist/engines/domain/model/stepContext.js.map +1 -1
  16. package/dist/factory/infrastructure/agentLLMFactory.d.ts +17 -2
  17. package/dist/factory/infrastructure/agentLLMFactory.js +57 -27
  18. package/dist/factory/infrastructure/agentLLMFactory.js.map +1 -1
  19. package/dist/index.d.ts +2 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/llm/domain/llm.d.ts +2 -2
  22. package/dist/llm/domain/llm.js.map +1 -1
  23. package/dist/llm/domain/message.d.ts +17 -1
  24. package/dist/llm/domain/message.js +14 -1
  25. package/dist/llm/domain/message.js.map +1 -1
  26. package/dist/llm/infrastructure/claudeLLMClient.d.ts +5 -2
  27. package/dist/llm/infrastructure/claudeLLMClient.js +61 -5
  28. package/dist/llm/infrastructure/claudeLLMClient.js.map +1 -1
  29. package/dist/llm/infrastructure/openAILLMClient.d.ts +5 -2
  30. package/dist/llm/infrastructure/openAILLMClient.js +25 -2
  31. package/dist/llm/infrastructure/openAILLMClient.js.map +1 -1
  32. package/dist/retry/domain/model/retryingAgent.js +11 -2
  33. package/dist/retry/domain/model/retryingAgent.js.map +1 -1
  34. package/dist/tools/infrastructure/workspaceTools.d.ts +1 -0
  35. package/dist/tools/infrastructure/workspaceTools.js +32 -10
  36. package/dist/tools/infrastructure/workspaceTools.js.map +1 -1
  37. package/package.json +1 -1
package/README.md CHANGED
@@ -309,6 +309,24 @@ neither run nor remembered. Its `key` option decides what counts as the same cal
309
309
  such as the tool and its path. The memory lives as long as the approver you created,
310
310
  so create one per session, per user or per run.
311
311
 
312
+ A run given `ask` also lets the agent ask its user, through its own `askUser` tool,
313
+ what is theirs to decide: the shape of a public API, a new dependency, behaviour the
314
+ request does not ask for, or a choice with real trade-offs. The model sends one to
315
+ four questions, each with two to four options, and `ask` answers each one in order,
316
+ with the label of an option or with the user's own words:
317
+
318
+ ```ts
319
+ await agent.run('Add a cache to the client.', {
320
+ signal: controller.signal,
321
+ ask: async questions => Promise.all(questions.map(question => askYourUser(question))),
322
+ });
323
+ ```
324
+
325
+ Without `ask` the agent has no such tool, and neither does an agent given a tool of its own named `askUser`, which keeps it. Questions the model wrote wrong go back to
326
+ it to correct. An asker that throws, or does not answer each question with some text,
327
+ ends the run with an `UnrecoverableError`. The Agent SDK engines ignore `ask`, and
328
+ `createLLMOrchestrator` passes it to every role.
329
+
312
330
  ### Agents that change files
313
331
 
314
332
  `createFileTools` gives an agent the file tools of its provider, over the folders you
@@ -357,7 +375,7 @@ run in `AgentResponse.runId`. Progress reports each change as a unified `diff` o
357
375
  `completed` event.
358
376
 
359
377
  `createLLMOrchestrator()` takes the options of `createOrchestrator()` plus `workspace`,
360
- `maxSteps`, `commands` and `summarizeUndone`, and returns a promise. Every role runs on the
378
+ `maxSteps`, `compactAt`, `commands` and `summarizeUndone`, and returns a promise. Every role runs on the
361
379
  model APIs:
362
380
 
363
381
  - the planner and the reviewer read, with a `showChanges` tool that gives the current task's
@@ -377,6 +395,18 @@ createLLMOrchestrator({ workspace, maxSteps: { executor: 120 } });
377
395
  A run also stops sooner when five answers in a row ask for exactly the same tool calls,
378
396
  which means the model is stuck. `createLLMAgent` takes one number, 65 by default.
379
397
 
398
+ No conversation is compacted unless you ask. `compactAt` is the number of input tokens at
399
+ which the provider compacts it on its side, replacing the turns before that call with its own
400
+ summary, which only its model reads back; the conversation keeps every original turn. The
401
+ call that compacts can lose its cache, as it did on OpenAI, so set it well above what a task
402
+ needs. Like
403
+ `maxSteps`, it is one number for every role or per role, and `createLLMAgent` takes one. On
404
+ Anthropic it must be at least 50,000, and Claude Haiku cannot compact:
405
+
406
+ ```ts
407
+ createLLMOrchestrator({ workspace, compactAt: { executor: 200_000 } });
408
+ ```
409
+
380
410
  Without `workspace` it works on the current directory, read only. One run of the
381
411
  orchestrator is one run of the history, every round included.
382
412
 
@@ -523,12 +553,15 @@ that tool until the CLI exits, or no with an optional reason for the model. With
523
553
  piped input nobody can answer, so such a call is denied: a line written for a later
524
554
  question never approves one. Piped lines answer the prompt and the history commands in
525
555
  order. With `--llm`, the executor asks before a destructive change, such as one that would
526
- make `.gitignore` hide less.
556
+ make `.gitignore` hide less. With `--llm`, any role can also ask you a question: the CLI
557
+ numbers its options, and you answer with a number, several separated by commas where it
558
+ allows more than one, or in your own words. With piped input nobody answers, and the model
559
+ is told to decide and say what it assumed.
527
560
 
528
561
  With `--llm`, the agents can also [run commands](#running-the-projects-commands) you
529
562
  configure for the repository, in `~/.config/mikode-harness/config.json` (or under
530
563
  `$XDG_CONFIG_HOME`). The file maps each repository's real path to its `commands` and,
531
- optionally, its `maxSteps`. It lives outside every repository, so neither a cloned repository
564
+ optionally, its `maxSteps` and `compactAt`. It lives outside every repository, so neither a cloned repository
532
565
  nor the agent decides what runs, or for how long:
533
566
 
534
567
  ```json
@@ -1,5 +1,6 @@
1
1
  import type { Tokens } from '#src/shared/domain/tokens';
2
2
  import type { Approver } from './approval.ts';
3
+ import type { Asker } from './question.ts';
3
4
  /**
4
5
  * How a run ended when it did not fail. Every token a run spends travels with its end: here
5
6
  * when it finishes, in `RecoverableError.tokens` or `UnrecoverableError.tokens` when it fails.
@@ -23,8 +24,10 @@ export interface AgentResponse {
23
24
  }
24
25
  export type Callback = (item: ProgressEvent) => void;
25
26
  /**
26
- * What a run needs besides its prompt. RetryingAgent and InstructedAgent pass it on whole;
27
- * OrchestratorAgent copies its own enumerable fields and replaces only `onProgress` per role.
27
+ * What a run needs besides its prompt. InstructedAgent passes it on whole. OrchestratorAgent
28
+ * copies its own enumerable fields and replaces only `onProgress` per role; RetryingAgent does
29
+ * the same for a run with `ask`, replacing only `ask`. A field must therefore be an own
30
+ * enumerable one to reach every agent.
28
31
  */
29
32
  export interface RunOptions {
30
33
  /** Cancels the run, which then rejects with the signal's `AbortError`. */
@@ -37,6 +40,12 @@ export interface RunOptions {
37
40
  * it: their own permission systems decide.
38
41
  */
39
42
  approve?: Approver | undefined;
43
+ /**
44
+ * Answers what a model-backed agent asks the user, through its `askUser` tool, when a
45
+ * decision is the user's to make. Without it the agent has no such tool. The Agent SDK
46
+ * engines ignore it.
47
+ */
48
+ ask?: Asker | undefined;
40
49
  }
41
50
  /** The `onProgress` of a run that gave none. */
42
51
  export declare const ignoreProgress: Callback;
@@ -134,7 +143,10 @@ export interface StepContext {
134
143
  instructions: number;
135
144
  /** The tool definitions. */
136
145
  tools: number;
137
- /** The text of the user's prompts and the model's answers. */
146
+ /**
147
+ * The text of the user's prompts and the model's answers, and the provider's summary of the
148
+ * turns before it once it has compacted them: only what the call sent.
149
+ */
138
150
  messages: number;
139
151
  /** Reasoning the model produced earlier and is sent back: its provider's own signed or encrypted blocks. */
140
152
  reasoning: number;
@@ -1 +1 @@
1
- {"version":3,"file":"agent.js","sourceRoot":"","sources":["../../../src/agent/domain/agent.ts"],"names":[],"mappings":"AA4CA,gDAAgD;AAChD,MAAM,CAAC,MAAM,cAAc,GAAa,GAAG,EAAE,CAAC,SAAS,CAAC","sourcesContent":["import type { Tokens } from '#src/shared/domain/tokens';\nimport type { Approver } from './approval.ts';\n\n/**\n * How a run ended when it did not fail. Every token a run spends travels with its end: here\n * when it finishes, in `RecoverableError.tokens` or `UnrecoverableError.tokens` when it fails.\n */\nexport interface AgentResponse {\n\t/** The final text. Empty when the run produced none, for example a turn that only edited files. */\n\tresponse: string;\n\t/**\n\t * Missing when any call in the run completed without reporting usage, which is not the same\n\t * as zero: a partial sum would look complete.\n\t */\n\ttokens?: Tokens;\n\t/** Wall-clock seconds the consumer waited for the run, retries and every role included. */\n\tduration: number;\n\t/**\n\t * The run the workspace's history recorded, when this run wrote through the harness's write\n\t * tools. Absent for a run that wrote nothing, and from the Agent SDK engines, whose changes\n\t * the history does not record.\n\t */\n\trunId?: string;\n}\n\nexport type Callback = (item: ProgressEvent) => void;\n\n/**\n * What a run needs besides its prompt. RetryingAgent and InstructedAgent pass it on whole;\n * OrchestratorAgent copies its own enumerable fields and replaces only `onProgress` per role.\n */\nexport interface RunOptions {\n\t/** Cancels the run, which then rejects with the signal's `AbortError`. */\n\tsignal: AbortSignal;\n\t/** Receives the run's live activity. Without it, the run is silent. */\n\tonProgress?: Callback | undefined;\n\t/**\n\t * Asked before a model-backed agent runs a `destructive` tool call. Without it, such a call\n\t * is denied. An agent built with `autoApprove` never asks, and the Agent SDK engines ignore\n\t * it: their own permission systems decide.\n\t */\n\tapprove?: Approver | undefined;\n}\n\n/** The `onProgress` of a run that gave none. */\nexport const ignoreProgress: Callback = () => undefined;\n\n/**\n * Live activity during a run, for narration only: the result travels in `AgentResponse`.\n * New event types can arrive in a minor release, so render the types you know and ignore\n * the rest instead of switching over them exhaustively with a `never` check.\n */\nexport type ProgressEvent =\n\t| { type: 'command'; command: string; exitCode?: number; agent?: ProgressAgent }\n\t| { type: 'reasoning'; message: string; agent?: ProgressAgent }\n\t| { type: 'search'; query: string; agent?: ProgressAgent }\n\t| {\n\t\t\ttype: 'fileChange';\n\t\t\tchanges: { path: string; kind: 'add' | 'update' | 'delete' }[];\n\t\t\tagent?: ProgressAgent;\n\t }\n\t| { type: 'mcpTool'; server: string; tool: string; status: string; agent?: ProgressAgent }\n\t| { type: 'agentMessage'; message: string; agent?: ProgressAgent }\n\t/** `id` pairs a call's end with its start when one step calls the same tool more than once. */\n\t| {\n\t\t\ttype: 'tool';\n\t\t\tid: string;\n\t\t\tname: string;\n\t\t\tagent?: ProgressAgent;\n\t\t\t/** A short, tool-owned description of the call. Narration only. */\n\t\t\tsummary?: string;\n\t\t\t/** The exit code of a configured command that ended with an error. Narration only. */\n\t\t\texitCode?: number;\n\t\t\t/**\n\t\t\t * `in_progress` only once the call is about to run, after any approval. `denied` when\n\t\t\t * it never ran because no one allowed it, with no `in_progress` before it.\n\t\t\t */\n\t\t\tstatus: 'in_progress' | 'completed' | 'error' | 'denied';\n\t\t\t/**\n\t\t\t * On `completed`, for a call that changed a file through the harness's write tools: the\n\t\t\t * change as a unified diff, for the person watching. Narration only; the run's whole\n\t\t\t * record is the history's `changes`.\n\t\t\t */\n\t\t\tdiff?: string;\n\t }\n\t| { type: 'todoList'; items: { text: string; completed: boolean }[]; agent?: ProgressAgent }\n\t| { type: 'turnStarted'; agent?: ProgressAgent }\n\t| { type: 'turnEnded'; agent?: ProgressAgent }\n\t/**\n\t * One billed call to the model by an agent on the model APIs, including one the model\n\t * stopped early or the client turned into an error: what the call sent and what it cost.\n\t * Narration only: the run's total is `AgentResponse.tokens`, or the failure's `tokens`.\n\t */\n\t| {\n\t\t\ttype: 'step';\n\t\t\t/** 1 for the first call of the run. */\n\t\t\tstep: number;\n\t\t\t/** Missing when the provider did not report this call's usage. */\n\t\t\ttokens?: Tokens;\n\t\t\tcontext: StepContext;\n\t\t\tagent?: ProgressAgent;\n\t };\n\n/**\n * What one call to the model sent, in characters by where it came from: an estimate of size\n * for finding what fills a context, not a token count.\n */\nexport interface StepContext {\n\t/** The system prompt. */\n\tinstructions: number;\n\t/** The tool definitions. */\n\ttools: number;\n\t/** The text of the user's prompts and the model's answers. */\n\tmessages: number;\n\t/** Reasoning the model produced earlier and is sent back: its provider's own signed or encrypted blocks. */\n\treasoning: number;\n\t/** The input of every tool call so far. */\n\ttoolCalls: number;\n\t/** Every tool result so far, by tool, largest first. */\n\ttoolResults: { tool: string; characters: number; calls: number }[];\n\t/** The largest single results, each with its call's summary when the tool gives one. */\n\tlargestResults: { tool: string; characters: number; summary?: string }[];\n}\n\n/** A role in the fixed planner → executor → reviewer workflow. */\nexport type ProgressAgent = 'planner' | 'executor' | 'reviewer';\n\n/**\n * The contract every engine (CodexAgent, ClaudeAgent, future providers) and every\n * decorator (RetryingAgent, OrchestratorAgent) is built against.\n *\n * Implementers MUST only ever reject with `RecoverableError` or `UnrecoverableError`\n * (see src/shared/domain/errors.ts) — never a raw SDK error, a plain `Error`, or anything else leaked\n * unclassified. Every consumer of `Agent` (RetryingAgent's retry decision,\n * OrchestratorAgent's failure handling) `instanceof`-checks against those two types to\n * decide what to do next; a leaked, unclassified error bypasses that decision\n * entirely — it gets retried when it shouldn't be, or crashes a run that a retry\n * would have recovered. Wrap every call into the underlying SDK so nothing escapes\n * unclassified, including failures the SDK itself doesn't model as a domain error\n * (network errors, malformed responses, etc.); `classifyProviderFailure` does this.\n *\n * Cancellation is the one exception: an `AbortError` must propagate unchanged, because\n * consumers check for it before either error type.\n */\nexport interface Agent {\n\trun(prompt: string, options: RunOptions): Promise<AgentResponse>;\n}\n"]}
1
+ {"version":3,"file":"agent.js","sourceRoot":"","sources":["../../../src/agent/domain/agent.ts"],"names":[],"mappings":"AAqDA,gDAAgD;AAChD,MAAM,CAAC,MAAM,cAAc,GAAa,GAAG,EAAE,CAAC,SAAS,CAAC","sourcesContent":["import type { Tokens } from '#src/shared/domain/tokens';\nimport type { Approver } from './approval.ts';\nimport type { Asker } from './question.ts';\n\n/**\n * How a run ended when it did not fail. Every token a run spends travels with its end: here\n * when it finishes, in `RecoverableError.tokens` or `UnrecoverableError.tokens` when it fails.\n */\nexport interface AgentResponse {\n\t/** The final text. Empty when the run produced none, for example a turn that only edited files. */\n\tresponse: string;\n\t/**\n\t * Missing when any call in the run completed without reporting usage, which is not the same\n\t * as zero: a partial sum would look complete.\n\t */\n\ttokens?: Tokens;\n\t/** Wall-clock seconds the consumer waited for the run, retries and every role included. */\n\tduration: number;\n\t/**\n\t * The run the workspace's history recorded, when this run wrote through the harness's write\n\t * tools. Absent for a run that wrote nothing, and from the Agent SDK engines, whose changes\n\t * the history does not record.\n\t */\n\trunId?: string;\n}\n\nexport type Callback = (item: ProgressEvent) => void;\n\n/**\n * What a run needs besides its prompt. InstructedAgent passes it on whole. OrchestratorAgent\n * copies its own enumerable fields and replaces only `onProgress` per role; RetryingAgent does\n * the same for a run with `ask`, replacing only `ask`. A field must therefore be an own\n * enumerable one to reach every agent.\n */\nexport interface RunOptions {\n\t/** Cancels the run, which then rejects with the signal's `AbortError`. */\n\tsignal: AbortSignal;\n\t/** Receives the run's live activity. Without it, the run is silent. */\n\tonProgress?: Callback | undefined;\n\t/**\n\t * Asked before a model-backed agent runs a `destructive` tool call. Without it, such a call\n\t * is denied. An agent built with `autoApprove` never asks, and the Agent SDK engines ignore\n\t * it: their own permission systems decide.\n\t */\n\tapprove?: Approver | undefined;\n\t/**\n\t * Answers what a model-backed agent asks the user, through its `askUser` tool, when a\n\t * decision is the user's to make. Without it the agent has no such tool. The Agent SDK\n\t * engines ignore it.\n\t */\n\task?: Asker | undefined;\n}\n\n/** The `onProgress` of a run that gave none. */\nexport const ignoreProgress: Callback = () => undefined;\n\n/**\n * Live activity during a run, for narration only: the result travels in `AgentResponse`.\n * New event types can arrive in a minor release, so render the types you know and ignore\n * the rest instead of switching over them exhaustively with a `never` check.\n */\nexport type ProgressEvent =\n\t| { type: 'command'; command: string; exitCode?: number; agent?: ProgressAgent }\n\t| { type: 'reasoning'; message: string; agent?: ProgressAgent }\n\t| { type: 'search'; query: string; agent?: ProgressAgent }\n\t| {\n\t\t\ttype: 'fileChange';\n\t\t\tchanges: { path: string; kind: 'add' | 'update' | 'delete' }[];\n\t\t\tagent?: ProgressAgent;\n\t }\n\t| { type: 'mcpTool'; server: string; tool: string; status: string; agent?: ProgressAgent }\n\t| { type: 'agentMessage'; message: string; agent?: ProgressAgent }\n\t/** `id` pairs a call's end with its start when one step calls the same tool more than once. */\n\t| {\n\t\t\ttype: 'tool';\n\t\t\tid: string;\n\t\t\tname: string;\n\t\t\tagent?: ProgressAgent;\n\t\t\t/** A short, tool-owned description of the call. Narration only. */\n\t\t\tsummary?: string;\n\t\t\t/** The exit code of a configured command that ended with an error. Narration only. */\n\t\t\texitCode?: number;\n\t\t\t/**\n\t\t\t * `in_progress` only once the call is about to run, after any approval. `denied` when\n\t\t\t * it never ran because no one allowed it, with no `in_progress` before it.\n\t\t\t */\n\t\t\tstatus: 'in_progress' | 'completed' | 'error' | 'denied';\n\t\t\t/**\n\t\t\t * On `completed`, for a call that changed a file through the harness's write tools: the\n\t\t\t * change as a unified diff, for the person watching. Narration only; the run's whole\n\t\t\t * record is the history's `changes`.\n\t\t\t */\n\t\t\tdiff?: string;\n\t }\n\t| { type: 'todoList'; items: { text: string; completed: boolean }[]; agent?: ProgressAgent }\n\t| { type: 'turnStarted'; agent?: ProgressAgent }\n\t| { type: 'turnEnded'; agent?: ProgressAgent }\n\t/**\n\t * One billed call to the model by an agent on the model APIs, including one the model\n\t * stopped early or the client turned into an error: what the call sent and what it cost.\n\t * Narration only: the run's total is `AgentResponse.tokens`, or the failure's `tokens`.\n\t */\n\t| {\n\t\t\ttype: 'step';\n\t\t\t/** 1 for the first call of the run. */\n\t\t\tstep: number;\n\t\t\t/** Missing when the provider did not report this call's usage. */\n\t\t\ttokens?: Tokens;\n\t\t\tcontext: StepContext;\n\t\t\tagent?: ProgressAgent;\n\t };\n\n/**\n * What one call to the model sent, in characters by where it came from: an estimate of size\n * for finding what fills a context, not a token count.\n */\nexport interface StepContext {\n\t/** The system prompt. */\n\tinstructions: number;\n\t/** The tool definitions. */\n\ttools: number;\n\t/**\n\t * The text of the user's prompts and the model's answers, and the provider's summary of the\n\t * turns before it once it has compacted them: only what the call sent.\n\t */\n\tmessages: number;\n\t/** Reasoning the model produced earlier and is sent back: its provider's own signed or encrypted blocks. */\n\treasoning: number;\n\t/** The input of every tool call so far. */\n\ttoolCalls: number;\n\t/** Every tool result so far, by tool, largest first. */\n\ttoolResults: { tool: string; characters: number; calls: number }[];\n\t/** The largest single results, each with its call's summary when the tool gives one. */\n\tlargestResults: { tool: string; characters: number; summary?: string }[];\n}\n\n/** A role in the fixed planner → executor → reviewer workflow. */\nexport type ProgressAgent = 'planner' | 'executor' | 'reviewer';\n\n/**\n * The contract every engine (CodexAgent, ClaudeAgent, future providers) and every\n * decorator (RetryingAgent, OrchestratorAgent) is built against.\n *\n * Implementers MUST only ever reject with `RecoverableError` or `UnrecoverableError`\n * (see src/shared/domain/errors.ts) — never a raw SDK error, a plain `Error`, or anything else leaked\n * unclassified. Every consumer of `Agent` (RetryingAgent's retry decision,\n * OrchestratorAgent's failure handling) `instanceof`-checks against those two types to\n * decide what to do next; a leaked, unclassified error bypasses that decision\n * entirely — it gets retried when it shouldn't be, or crashes a run that a retry\n * would have recovered. Wrap every call into the underlying SDK so nothing escapes\n * unclassified, including failures the SDK itself doesn't model as a domain error\n * (network errors, malformed responses, etc.); `classifyProviderFailure` does this.\n *\n * Cancellation is the one exception: an `AbortError` must propagate unchanged, because\n * consumers check for it before either error type.\n */\nexport interface Agent {\n\trun(prompt: string, options: RunOptions): Promise<AgentResponse>;\n}\n"]}
@@ -0,0 +1,35 @@
1
+ /** One choice the model offers for a question. */
2
+ export interface QuestionOption {
3
+ label: string;
4
+ description: string;
5
+ }
6
+ /** A decision the model leaves to the user, with the choices it sees. */
7
+ export interface UserQuestion {
8
+ question: string;
9
+ /** Two to four. The user may always answer in their own words instead. */
10
+ options: QuestionOption[];
11
+ /** Whether more than one option may be chosen. */
12
+ multiSelect: boolean;
13
+ }
14
+ /**
15
+ * Answers the questions a model-backed agent asks, one answer per question and in order: the
16
+ * label of each option chosen, or the user's own words. A throw ends the run as an
17
+ * `UnrecoverableError`, as a failing approver does.
18
+ */
19
+ export type Asker = (questions: UserQuestion[], signal: AbortSignal) => string[] | Promise<string[]>;
20
+ /** What one run's user answered, as `rememberAnswers` keeps it. */
21
+ export interface AnsweredQuestion {
22
+ question: string;
23
+ answer: string;
24
+ }
25
+ /**
26
+ * Wraps `ask` so a question asked again with the same words gets the answer the user already
27
+ * gave, and only the others are asked. `answered` holds every answer, in the order given. The
28
+ * memory lives as long as the returned asker: one per run, so a retry never asks again.
29
+ */
30
+ export declare function rememberAnswers(ask: Asker): {
31
+ ask: Asker;
32
+ answered: AnsweredQuestion[];
33
+ };
34
+ /** The user's earlier answers, for a prompt that must not ask them again. */
35
+ export declare function describeAnswered(answered: AnsweredQuestion[]): string;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Wraps `ask` so a question asked again with the same words gets the answer the user already
3
+ * gave, and only the others are asked. `answered` holds every answer, in the order given. The
4
+ * memory lives as long as the returned asker: one per run, so a retry never asks again.
5
+ */
6
+ export function rememberAnswers(ask) {
7
+ const answered = [];
8
+ const known = new Map();
9
+ return {
10
+ answered,
11
+ ask: async (questions, signal) => {
12
+ const unknown = questions.filter(({ question }) => !known.has(question));
13
+ const fresh = unknown.length > 0 ? await ask(unknown, signal) : [];
14
+ // The agent checks the asker's answers; a short or odd list is its to refuse.
15
+ if (!Array.isArray(fresh))
16
+ return fresh;
17
+ for (const [index, { question }] of unknown.entries()) {
18
+ const answer = fresh[index];
19
+ if (typeof answer !== 'string')
20
+ return fresh;
21
+ known.set(question, answer);
22
+ answered.push({ question, answer });
23
+ }
24
+ return questions.map(({ question }) => known.get(question) ?? '');
25
+ },
26
+ };
27
+ }
28
+ /** The user's earlier answers, for a prompt that must not ask them again. */
29
+ export function describeAnswered(answered) {
30
+ return [
31
+ 'The user already answered these questions in an earlier attempt; do not ask them again:',
32
+ ...answered.map(({ question, answer }, index) => `${String(index + 1)}. ${question}\n ${answer}`),
33
+ ].join('\n');
34
+ }
35
+ //# sourceMappingURL=question.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"question.js","sourceRoot":"","sources":["../../../src/agent/domain/question.ts"],"names":[],"mappings":"AA+BA;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAAC,GAAU;IACzC,MAAM,QAAQ,GAAuB,EAAE,CAAC;IACxC,MAAM,KAAK,GAAG,IAAI,GAAG,EAAkB,CAAC;IAExC,OAAO;QACN,QAAQ;QACR,GAAG,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE;YAChC,MAAM,OAAO,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC;YACzE,MAAM,KAAK,GAAG,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YACnE,8EAA8E;YAC9E,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;gBAAE,OAAO,KAAK,CAAC;YACxC,KAAK,MAAM,CAAC,KAAK,EAAE,EAAE,QAAQ,EAAE,CAAC,IAAI,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC;gBACvD,MAAM,MAAM,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC;gBAC5B,IAAI,OAAO,MAAM,KAAK,QAAQ;oBAAE,OAAO,KAAK,CAAC;gBAC7C,KAAK,CAAC,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;gBAC5B,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;YACrC,CAAC;YACD,OAAO,SAAS,CAAC,GAAG,CAAC,CAAC,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC;QACnE,CAAC;KACD,CAAC;AACH,CAAC;AAED,6EAA6E;AAC7E,MAAM,UAAU,gBAAgB,CAAC,QAA4B;IAC5D,OAAO;QACN,yFAAyF;QACzF,GAAG,QAAQ,CAAC,GAAG,CACd,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,EAAE,CAAC,GAAG,MAAM,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,QAAQ,QAAQ,MAAM,EAAE,CAClF;KACD,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACd,CAAC","sourcesContent":["/** One choice the model offers for a question. */\nexport interface QuestionOption {\n\tlabel: string;\n\tdescription: string;\n}\n\n/** A decision the model leaves to the user, with the choices it sees. */\nexport interface UserQuestion {\n\tquestion: string;\n\t/** Two to four. The user may always answer in their own words instead. */\n\toptions: QuestionOption[];\n\t/** Whether more than one option may be chosen. */\n\tmultiSelect: boolean;\n}\n\n/**\n * Answers the questions a model-backed agent asks, one answer per question and in order: the\n * label of each option chosen, or the user's own words. A throw ends the run as an\n * `UnrecoverableError`, as a failing approver does.\n */\nexport type Asker = (\n\tquestions: UserQuestion[],\n\tsignal: AbortSignal,\n) => string[] | Promise<string[]>;\n\n/** What one run's user answered, as `rememberAnswers` keeps it. */\nexport interface AnsweredQuestion {\n\tquestion: string;\n\tanswer: string;\n}\n\n/**\n * Wraps `ask` so a question asked again with the same words gets the answer the user already\n * gave, and only the others are asked. `answered` holds every answer, in the order given. The\n * memory lives as long as the returned asker: one per run, so a retry never asks again.\n */\nexport function rememberAnswers(ask: Asker): { ask: Asker; answered: AnsweredQuestion[] } {\n\tconst answered: AnsweredQuestion[] = [];\n\tconst known = new Map<string, string>();\n\n\treturn {\n\t\tanswered,\n\t\task: async (questions, signal) => {\n\t\t\tconst unknown = questions.filter(({ question }) => !known.has(question));\n\t\t\tconst fresh = unknown.length > 0 ? await ask(unknown, signal) : [];\n\t\t\t// The agent checks the asker's answers; a short or odd list is its to refuse.\n\t\t\tif (!Array.isArray(fresh)) return fresh;\n\t\t\tfor (const [index, { question }] of unknown.entries()) {\n\t\t\t\tconst answer = fresh[index];\n\t\t\t\tif (typeof answer !== 'string') return fresh;\n\t\t\t\tknown.set(question, answer);\n\t\t\t\tanswered.push({ question, answer });\n\t\t\t}\n\t\t\treturn questions.map(({ question }) => known.get(question) ?? '');\n\t\t},\n\t};\n}\n\n/** The user's earlier answers, for a prompt that must not ask them again. */\nexport function describeAnswered(answered: AnsweredQuestion[]): string {\n\treturn [\n\t\t'The user already answered these questions in an earlier attempt; do not ask them again:',\n\t\t...answered.map(\n\t\t\t({ question, answer }, index) => `${String(index + 1)}. ${question}\\n ${answer}`,\n\t\t),\n\t].join('\\n');\n}\n"]}
@@ -0,0 +1,14 @@
1
+ import type { UserQuestion } from '#src/agent/domain/question';
2
+ import type { ToolDefinition } from '#src/llm/domain/tool';
3
+ export declare const askUserName = "askUser";
4
+ export declare const askUserDefinition: ToolDefinition;
5
+ /** The questions in a call's input, or why they cannot be asked, which the model reads. */
6
+ export declare function readQuestions(input: unknown): UserQuestion[] | string;
7
+ /**
8
+ * The asker's answers, one non-empty string per question, or nothing: an asker is host code, so
9
+ * an answer of any other shape is its bug, not the model's.
10
+ */
11
+ export declare function readAnswers(answers: unknown, questions: UserQuestion[]): string[] | undefined;
12
+ /** What the model reads back: each question with its answer. */
13
+ export declare function describeAnswers(questions: UserQuestion[], answers: string[]): string;
14
+ export declare function summarizeQuestions(questions: UserQuestion[]): string;
@@ -0,0 +1,107 @@
1
+ export const askUserName = 'askUser';
2
+ const maxQuestions = 4;
3
+ const minOptions = 2;
4
+ const maxOptions = 4;
5
+ // Strict mode allows no length bounds, so the counts live in the description and in `readQuestions`.
6
+ export const askUserDefinition = {
7
+ name: askUserName,
8
+ description: [
9
+ 'Asks the user to decide something that is theirs to decide, and waits for the answer.',
10
+ 'Ask before you build on a choice, not after. The user decides: the shape of a public API or of anything other code depends on; a new dependency; behaviour the request does not ask for; and any choice between options with real trade-offs.',
11
+ 'Do not ask what the repository, the request or a reasonable default already answers; find it out instead.',
12
+ `Ask everything you need in one call: 1 to ${String(maxQuestions)} questions, each with ${String(minOptions)} to ${String(maxOptions)} options. Say in each option's description what choosing it means. The user may answer in their own words instead.`,
13
+ ].join(' '),
14
+ inputSchema: {
15
+ type: 'object',
16
+ properties: {
17
+ questions: {
18
+ type: 'array',
19
+ description: `1 to ${String(maxQuestions)} questions.`,
20
+ items: {
21
+ type: 'object',
22
+ properties: {
23
+ question: { type: 'string', description: 'The whole question, ending with "?".' },
24
+ options: {
25
+ type: 'array',
26
+ description: `${String(minOptions)} to ${String(maxOptions)} choices.`,
27
+ items: {
28
+ type: 'object',
29
+ properties: {
30
+ label: { type: 'string', description: 'A few words.' },
31
+ description: {
32
+ type: 'string',
33
+ description: 'What choosing it means, and what it costs.',
34
+ },
35
+ },
36
+ required: ['label', 'description'],
37
+ additionalProperties: false,
38
+ },
39
+ },
40
+ multiSelect: {
41
+ type: 'boolean',
42
+ description: 'Whether more than one option may be chosen.',
43
+ },
44
+ },
45
+ required: ['question', 'options', 'multiSelect'],
46
+ additionalProperties: false,
47
+ },
48
+ },
49
+ },
50
+ required: ['questions'],
51
+ additionalProperties: false,
52
+ },
53
+ };
54
+ /** The questions in a call's input, or why they cannot be asked, which the model reads. */
55
+ export function readQuestions(input) {
56
+ const questions = isRecord(input) ? input.questions : undefined;
57
+ if (!Array.isArray(questions) || questions.length === 0 || questions.length > maxQuestions) {
58
+ return `Ask 1 to ${String(maxQuestions)} questions.`;
59
+ }
60
+ const read = [];
61
+ for (const [index, question] of questions.entries()) {
62
+ const at = `Question ${String(index + 1)}`;
63
+ if (!isRecord(question) || !isText(question.question))
64
+ return `${at} has no text.`;
65
+ const { options, multiSelect } = question;
66
+ if (!Array.isArray(options) || options.length < minOptions || options.length > maxOptions) {
67
+ return `${at} needs ${String(minOptions)} to ${String(maxOptions)} options.`;
68
+ }
69
+ if (typeof multiSelect !== 'boolean')
70
+ return `${at} must say whether it is multiSelect.`;
71
+ const choices = [];
72
+ for (const option of options) {
73
+ if (!isRecord(option) || !isText(option.label) || typeof option.description !== 'string') {
74
+ return `Every option of ${at.toLowerCase()} needs a label and a description.`;
75
+ }
76
+ choices.push({ label: option.label, description: option.description });
77
+ }
78
+ read.push({ question: question.question, options: choices, multiSelect });
79
+ }
80
+ return read;
81
+ }
82
+ /**
83
+ * The asker's answers, one non-empty string per question, or nothing: an asker is host code, so
84
+ * an answer of any other shape is its bug, not the model's.
85
+ */
86
+ export function readAnswers(answers, questions) {
87
+ if (!Array.isArray(answers) || answers.length !== questions.length)
88
+ return undefined;
89
+ return answers.every(isText) ? answers : undefined;
90
+ }
91
+ /** What the model reads back: each question with its answer. */
92
+ export function describeAnswers(questions, answers) {
93
+ return [
94
+ 'The user answered:',
95
+ ...questions.map(({ question }, index) => `${String(index + 1)}. ${question}\n ${answers[index] ?? ''}`),
96
+ ].join('\n');
97
+ }
98
+ export function summarizeQuestions(questions) {
99
+ return questions.length === 1 ? '1 question' : `${String(questions.length)} questions`;
100
+ }
101
+ function isRecord(value) {
102
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
103
+ }
104
+ function isText(value) {
105
+ return typeof value === 'string' && value.trim() !== '';
106
+ }
107
+ //# sourceMappingURL=askUser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"askUser.js","sourceRoot":"","sources":["../../../../src/engines/domain/model/askUser.ts"],"names":[],"mappings":"AAGA,MAAM,CAAC,MAAM,WAAW,GAAG,SAAS,CAAC;AAErC,MAAM,YAAY,GAAG,CAAC,CAAC;AACvB,MAAM,UAAU,GAAG,CAAC,CAAC;AACrB,MAAM,UAAU,GAAG,CAAC,CAAC;AAErB,qGAAqG;AACrG,MAAM,CAAC,MAAM,iBAAiB,GAAmB;IAChD,IAAI,EAAE,WAAW;IACjB,WAAW,EAAE;QACZ,uFAAuF;QACvF,+OAA+O;QAC/O,2GAA2G;QAC3G,6CAA6C,MAAM,CAAC,YAAY,CAAC,yBAAyB,MAAM,CAAC,UAAU,CAAC,OAAO,MAAM,CAAC,UAAU,CAAC,oHAAoH;KACzP,CAAC,IAAI,CAAC,GAAG,CAAC;IACX,WAAW,EAAE;QACZ,IAAI,EAAE,QAAQ;QACd,UAAU,EAAE;YACX,SAAS,EAAE;gBACV,IAAI,EAAE,OAAO;gBACb,WAAW,EAAE,QAAQ,MAAM,CAAC,YAAY,CAAC,aAAa;gBACtD,KAAK,EAAE;oBACN,IAAI,EAAE,QAAQ;oBACd,UAAU,EAAE;wBACX,QAAQ,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,sCAAsC,EAAE;wBACjF,OAAO,EAAE;4BACR,IAAI,EAAE,OAAO;4BACb,WAAW,EAAE,GAAG,MAAM,CAAC,UAAU,CAAC,OAAO,MAAM,CAAC,UAAU,CAAC,WAAW;4BACtE,KAAK,EAAE;gCACN,IAAI,EAAE,QAAQ;gCACd,UAAU,EAAE;oCACX,KAAK,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,cAAc,EAAE;oCACtD,WAAW,EAAE;wCACZ,IAAI,EAAE,QAAQ;wCACd,WAAW,EAAE,4CAA4C;qCACzD;iCACD;gCACD,QAAQ,EAAE,CAAC,OAAO,EAAE,aAAa,CAAC;gCAClC,oBAAoB,EAAE,KAAK;6BAC3B;yBACD;wBACD,WAAW,EAAE;4BACZ,IAAI,EAAE,SAAS;4BACf,WAAW,EAAE,6CAA6C;yBAC1D;qBACD;oBACD,QAAQ,EAAE,CAAC,UAAU,EAAE,SAAS,EAAE,aAAa,CAAC;oBAChD,oBAAoB,EAAE,KAAK;iBAC3B;aACD;SACD;QACD,QAAQ,EAAE,CAAC,WAAW,CAAC;QACvB,oBAAoB,EAAE,KAAK;KAC3B;CACD,CAAC;AAEF,2FAA2F;AAC3F,MAAM,UAAU,aAAa,CAAC,KAAc;IAC3C,MAAM,SAAS,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC;IAChE,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC,IAAI,SAAS,CAAC,MAAM,GAAG,YAAY,EAAE,CAAC;QAC5F,OAAO,YAAY,MAAM,CAAC,YAAY,CAAC,aAAa,CAAC;IACtD,CAAC;IACD,MAAM,IAAI,GAAmB,EAAE,CAAC;IAChC,KAAK,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,IAAI,SAAS,CAAC,OAAO,EAAE,EAAE,CAAC;QACrD,MAAM,EAAE,GAAG,YAAY,MAAM,CAAC,KAAK,GAAG,CAAC,CAAC,EAAE,CAAC;QAC3C,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC;YAAE,OAAO,GAAG,EAAE,eAAe,CAAC;QACnF,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,GAAG,QAAQ,CAAC;QAC1C,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,OAAO,CAAC,MAAM,GAAG,UAAU,IAAI,OAAO,CAAC,MAAM,GAAG,UAAU,EAAE,CAAC;YAC3F,OAAO,GAAG,EAAE,UAAU,MAAM,CAAC,UAAU,CAAC,OAAO,MAAM,CAAC,UAAU,CAAC,WAAW,CAAC;QAC9E,CAAC;QACD,IAAI,OAAO,WAAW,KAAK,SAAS;YAAE,OAAO,GAAG,EAAE,sCAAsC,CAAC;QACzF,MAAM,OAAO,GAAG,EAAE,CAAC;QACnB,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;YAC9B,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,OAAO,MAAM,CAAC,WAAW,KAAK,QAAQ,EAAE,CAAC;gBAC1F,OAAO,mBAAmB,EAAE,CAAC,WAAW,EAAE,mCAAmC,CAAC;YAC/E,CAAC;YACD,OAAO,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,WAAW,EAAE,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC;QACxE,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,CAAC,CAAC;IAC3E,CAAC;IACD,OAAO,IAAI,CAAC;AACb,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,WAAW,CAAC,OAAgB,EAAE,SAAyB;IACtE,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,MAAM;QAAE,OAAO,SAAS,CAAC;IACrF,OAAO,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC;AACpD,CAAC;AAED,gEAAgE;AAChE,MAAM,UAAU,eAAe,CAAC,SAAyB,EAAE,OAAiB;IAC3E,OAAO;QACN,oBAAoB;QACpB,GAAG,SAAS,CAAC,GAAG,CACf,CAAC,EAAE,QAAQ,EAAE,EAAE,KAAK,EAAE,EAAE,CAAC,GAAG,MAAM,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,QAAQ,QAAQ,OAAO,CAAC,KAAK,CAAC,IAAI,EAAE,EAAE,CACxF;KACD,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACd,CAAC;AAED,MAAM,UAAU,kBAAkB,CAAC,SAAyB;IAC3D,OAAO,SAAS,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,YAAY,CAAC;AACxF,CAAC;AAED,SAAS,QAAQ,CAAC,KAAc;IAC/B,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;AAC7E,CAAC;AAED,SAAS,MAAM,CAAC,KAAc;IAC7B,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC;AACzD,CAAC","sourcesContent":["import type { UserQuestion } from '#src/agent/domain/question';\nimport type { ToolDefinition } from '#src/llm/domain/tool';\n\nexport const askUserName = 'askUser';\n\nconst maxQuestions = 4;\nconst minOptions = 2;\nconst maxOptions = 4;\n\n// Strict mode allows no length bounds, so the counts live in the description and in `readQuestions`.\nexport const askUserDefinition: ToolDefinition = {\n\tname: askUserName,\n\tdescription: [\n\t\t'Asks the user to decide something that is theirs to decide, and waits for the answer.',\n\t\t'Ask before you build on a choice, not after. The user decides: the shape of a public API or of anything other code depends on; a new dependency; behaviour the request does not ask for; and any choice between options with real trade-offs.',\n\t\t'Do not ask what the repository, the request or a reasonable default already answers; find it out instead.',\n\t\t`Ask everything you need in one call: 1 to ${String(maxQuestions)} questions, each with ${String(minOptions)} to ${String(maxOptions)} options. Say in each option's description what choosing it means. The user may answer in their own words instead.`,\n\t].join(' '),\n\tinputSchema: {\n\t\ttype: 'object',\n\t\tproperties: {\n\t\t\tquestions: {\n\t\t\t\ttype: 'array',\n\t\t\t\tdescription: `1 to ${String(maxQuestions)} questions.`,\n\t\t\t\titems: {\n\t\t\t\t\ttype: 'object',\n\t\t\t\t\tproperties: {\n\t\t\t\t\t\tquestion: { type: 'string', description: 'The whole question, ending with \"?\".' },\n\t\t\t\t\t\toptions: {\n\t\t\t\t\t\t\ttype: 'array',\n\t\t\t\t\t\t\tdescription: `${String(minOptions)} to ${String(maxOptions)} choices.`,\n\t\t\t\t\t\t\titems: {\n\t\t\t\t\t\t\t\ttype: 'object',\n\t\t\t\t\t\t\t\tproperties: {\n\t\t\t\t\t\t\t\t\tlabel: { type: 'string', description: 'A few words.' },\n\t\t\t\t\t\t\t\t\tdescription: {\n\t\t\t\t\t\t\t\t\t\ttype: 'string',\n\t\t\t\t\t\t\t\t\t\tdescription: 'What choosing it means, and what it costs.',\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\trequired: ['label', 'description'],\n\t\t\t\t\t\t\t\tadditionalProperties: false,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tmultiSelect: {\n\t\t\t\t\t\t\ttype: 'boolean',\n\t\t\t\t\t\t\tdescription: 'Whether more than one option may be chosen.',\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\trequired: ['question', 'options', 'multiSelect'],\n\t\t\t\t\tadditionalProperties: false,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\trequired: ['questions'],\n\t\tadditionalProperties: false,\n\t},\n};\n\n/** The questions in a call's input, or why they cannot be asked, which the model reads. */\nexport function readQuestions(input: unknown): UserQuestion[] | string {\n\tconst questions = isRecord(input) ? input.questions : undefined;\n\tif (!Array.isArray(questions) || questions.length === 0 || questions.length > maxQuestions) {\n\t\treturn `Ask 1 to ${String(maxQuestions)} questions.`;\n\t}\n\tconst read: UserQuestion[] = [];\n\tfor (const [index, question] of questions.entries()) {\n\t\tconst at = `Question ${String(index + 1)}`;\n\t\tif (!isRecord(question) || !isText(question.question)) return `${at} has no text.`;\n\t\tconst { options, multiSelect } = question;\n\t\tif (!Array.isArray(options) || options.length < minOptions || options.length > maxOptions) {\n\t\t\treturn `${at} needs ${String(minOptions)} to ${String(maxOptions)} options.`;\n\t\t}\n\t\tif (typeof multiSelect !== 'boolean') return `${at} must say whether it is multiSelect.`;\n\t\tconst choices = [];\n\t\tfor (const option of options) {\n\t\t\tif (!isRecord(option) || !isText(option.label) || typeof option.description !== 'string') {\n\t\t\t\treturn `Every option of ${at.toLowerCase()} needs a label and a description.`;\n\t\t\t}\n\t\t\tchoices.push({ label: option.label, description: option.description });\n\t\t}\n\t\tread.push({ question: question.question, options: choices, multiSelect });\n\t}\n\treturn read;\n}\n\n/**\n * The asker's answers, one non-empty string per question, or nothing: an asker is host code, so\n * an answer of any other shape is its bug, not the model's.\n */\nexport function readAnswers(answers: unknown, questions: UserQuestion[]): string[] | undefined {\n\tif (!Array.isArray(answers) || answers.length !== questions.length) return undefined;\n\treturn answers.every(isText) ? answers : undefined;\n}\n\n/** What the model reads back: each question with its answer. */\nexport function describeAnswers(questions: UserQuestion[], answers: string[]): string {\n\treturn [\n\t\t'The user answered:',\n\t\t...questions.map(\n\t\t\t({ question }, index) => `${String(index + 1)}. ${question}\\n ${answers[index] ?? ''}`,\n\t\t),\n\t].join('\\n');\n}\n\nexport function summarizeQuestions(questions: UserQuestion[]): string {\n\treturn questions.length === 1 ? '1 question' : `${String(questions.length)} questions`;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction isText(value: unknown): value is string {\n\treturn typeof value === 'string' && value.trim() !== '';\n}\n"]}
@@ -67,6 +67,11 @@ export declare class LLMAgent implements Agent {
67
67
  * escapes, whatever error the tool turned it into.
68
68
  */
69
69
  private runTool;
70
+ /**
71
+ * Asks the user through the run's `ask`. Questions the model wrote wrong go back to it, to
72
+ * correct; an asker that fails or answers wrongly is host code, and ends the run.
73
+ */
74
+ private askUser;
70
75
  /** One at a time and in order, so their effects and their narration never interleave. */
71
76
  private runTools;
72
77
  /**
@@ -11,6 +11,7 @@ import { InvalidToolInputError } from '#src/tools/domain/tool';
11
11
  import { isAbortError } from '#src/shared/domain/isAbortError';
12
12
  import { addTokens } from '#src/shared/domain/tokens';
13
13
  import { measureContext } from "./stepContext.js";
14
+ import { askUserDefinition, askUserName, describeAnswers, readAnswers, readQuestions, summarizeQuestions, } from "./askUser.js";
14
15
  import { followHistory, transcriptOf, undoNote, } from "./historyFollower.js";
15
16
  function describePart(part) {
16
17
  switch (part.type) {
@@ -30,6 +31,9 @@ function describePart(part) {
30
31
  case 'providerData':
31
32
  // Opaque by design: its readable side, if any, arrives as a reasoning part.
32
33
  return undefined;
34
+ case 'compaction':
35
+ // The provider's, for its client to send back; the step's context shows its effect.
36
+ return undefined;
33
37
  }
34
38
  }
35
39
  function narrate(part, callback, summary, exitCode) {
@@ -183,12 +187,12 @@ export class LLMAgent {
183
187
  this.logger?.warn(`The summary of the undone turns failed: ${why}`);
184
188
  }, classifyHostFailure, 'LLM agent logger failed');
185
189
  }
186
- async llmCall(runMessages, after, { signal, step, callback }) {
190
+ async llmCall(runMessages, after, { signal, step, callback, tools, }) {
187
191
  // Sent as a copy, so what the run records is what the agent built, whatever the client does.
188
192
  const context = [...this.conversation.contextAfter(after), ...structuredClone(runMessages)];
189
193
  let response;
190
194
  try {
191
- response = await this.llmClient.send({ context, tools: this.toolDefinitions }, signal);
195
+ response = await this.llmClient.send({ context, tools }, signal);
192
196
  }
193
197
  catch (error) {
194
198
  const failure = classifyProviderFailure(error, 'The LLM call failed');
@@ -197,7 +201,7 @@ export class LLMAgent {
197
201
  if ((failure instanceof RecoverableError || failure instanceof UnrecoverableError) &&
198
202
  (failure.tokens !== undefined || failure.usageUnreported)) {
199
203
  try {
200
- this.narrateStep(step, failure.tokens, context, callback);
204
+ this.narrateStep(step, failure.tokens, { context, tools }, callback);
201
205
  }
202
206
  catch (hostFailure) {
203
207
  // The callback's failure ends the run, and must carry this call's tokens too.
@@ -216,8 +220,8 @@ export class LLMAgent {
216
220
  context: measureContext({
217
221
  instructions: this.llmClient.systemPrompt,
218
222
  providerDataSource: this.llmClient.providerDataSource,
219
- tools: this.toolDefinitions,
220
- context: sent,
223
+ tools: sent.tools,
224
+ context: sent.context,
221
225
  summarize: call => this.callSummaries.get(call.id),
222
226
  }),
223
227
  }, callback);
@@ -266,9 +270,12 @@ export class LLMAgent {
266
270
  * result, so the model can correct itself. Only a cancellation, or a failing approver,
267
271
  * escapes, whatever error the tool turned it into.
268
272
  */
269
- async runTool(call, { signal, onProgress: callback = ignoreProgress, approve }, effects, toolCall) {
273
+ async runTool(call, { signal, onProgress: callback = ignoreProgress, approve, ask }, effects, toolCall) {
270
274
  const result = { type: 'toolResult', callId: call.id, name: call.name };
271
275
  signal.throwIfAborted();
276
+ if (call.name === askUserName && ask && !this.tools.has(askUserName)) {
277
+ return this.askUser(call, ask, signal, callback);
278
+ }
272
279
  const tool = this.tools.get(call.name);
273
280
  if (!tool) {
274
281
  return {
@@ -358,6 +365,42 @@ export class LLMAgent {
358
365
  };
359
366
  }
360
367
  }
368
+ /**
369
+ * Asks the user through the run's `ask`. Questions the model wrote wrong go back to it, to
370
+ * correct; an asker that fails or answers wrongly is host code, and ends the run.
371
+ */
372
+ async askUser(call, ask, signal, callback) {
373
+ const result = { type: 'toolResult', callId: call.id, name: call.name };
374
+ const questions = readQuestions(call.input);
375
+ if (typeof questions === 'string') {
376
+ return { result: { ...result, output: questions, isError: true }, denied: false };
377
+ }
378
+ const summary = summarizeQuestions(questions);
379
+ this.callSummaries.set(call.id, summary);
380
+ emit({ type: 'tool', id: call.id, name: call.name, status: 'in_progress', summary }, callback);
381
+ signal.throwIfAborted();
382
+ let answers;
383
+ try {
384
+ answers = await ask(questions, signal);
385
+ }
386
+ catch (error) {
387
+ signal.throwIfAborted();
388
+ throw classifyHostFailure(error, 'The asker failed');
389
+ }
390
+ // An answer that arrives after a cancellation is not given to the model.
391
+ signal.throwIfAborted();
392
+ const read = readAnswers(answers, questions);
393
+ if (!read) {
394
+ throw new UnrecoverableError('The asker failed', {
395
+ cause: `It must answer each of the ${String(questions.length)} questions with some text, in order.`,
396
+ });
397
+ }
398
+ return {
399
+ result: { ...result, output: describeAnswers(questions, read), isError: false },
400
+ denied: false,
401
+ summary,
402
+ };
403
+ }
361
404
  /** One at a time and in order, so their effects and their narration never interleave. */
362
405
  async runTools(calls, options, effects, toolCall) {
363
406
  const { signal, onProgress: callback = ignoreProgress } = options;
@@ -474,17 +517,22 @@ export class LLMAgent {
474
517
  // After following the history, which may have made every earlier read stale.
475
518
  const reads = this.reads.begin();
476
519
  const repeats = new RepeatedCalls();
520
+ // The agent's own `askUser` exists only for a run someone can answer, and never beside
521
+ // a consumer's tool of that name, which the consumer gave first and which stays theirs.
522
+ const offersAsk = options.ask !== undefined && !this.tools.has(askUserName);
523
+ const tools = offersAsk ? [...this.toolDefinitions, askUserDefinition] : this.toolDefinitions;
477
524
  for (let step = 1; step <= this.maxSteps; step++) {
478
525
  const { response, context: sent } = await this.llmCall(runMessages, after, {
479
526
  signal,
480
527
  step,
481
528
  callback,
529
+ tools,
482
530
  });
483
531
  tokens = addTokens(tokens, response.usage);
484
532
  unreported ||= !response.usage;
485
533
  // After the tokens are counted, so a throwing callback still reports them, and for an
486
534
  // unusable answer too: the call was sent, and billed.
487
- this.narrateStep(step, response.usage, sent, callback);
535
+ this.narrateStep(step, response.usage, { context: sent, tools }, callback);
488
536
  if (response.stopReason !== 'completed') {
489
537
  // Its tokens are already counted with the run's, which the failure carries.
490
538
  throw new UnrecoverableError('The LLM stopped before completing its answer', {