@mikode13/harness 5.0.0 → 5.2.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 (47) hide show
  1. package/README.md +24 -2
  2. package/dist/agent/domain/agent.d.ts +63 -1
  3. package/dist/agent/domain/agent.js.map +1 -1
  4. package/dist/engines/domain/model/llmAgent.d.ts +2 -0
  5. package/dist/engines/domain/model/llmAgent.js +101 -26
  6. package/dist/engines/domain/model/llmAgent.js.map +1 -1
  7. package/dist/engines/domain/model/stepContext.d.ts +16 -0
  8. package/dist/engines/domain/model/stepContext.js +60 -0
  9. package/dist/engines/domain/model/stepContext.js.map +1 -0
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.js.map +1 -1
  12. package/dist/llm/domain/llm.d.ts +7 -0
  13. package/dist/llm/domain/llm.js.map +1 -1
  14. package/dist/llm/infrastructure/claudeLLMClient.d.ts +2 -1
  15. package/dist/llm/infrastructure/claudeLLMClient.js +1 -0
  16. package/dist/llm/infrastructure/claudeLLMClient.js.map +1 -1
  17. package/dist/llm/infrastructure/openAILLMClient.d.ts +2 -1
  18. package/dist/llm/infrastructure/openAILLMClient.js +2 -1
  19. package/dist/llm/infrastructure/openAILLMClient.js.map +1 -1
  20. package/dist/orchestration/domain/model/orchestratorAgent.d.ts +10 -5
  21. package/dist/orchestration/domain/model/orchestratorAgent.js +24 -6
  22. package/dist/orchestration/domain/model/orchestratorAgent.js.map +1 -1
  23. package/dist/tools/domain/preparedCall.d.ts +14 -1
  24. package/dist/tools/domain/preparedCall.js +12 -0
  25. package/dist/tools/domain/preparedCall.js.map +1 -1
  26. package/dist/tools/domain/tool.d.ts +14 -0
  27. package/dist/tools/domain/tool.js +7 -1
  28. package/dist/tools/domain/tool.js.map +1 -1
  29. package/dist/tools/infrastructure/commandTool.d.ts +1 -1
  30. package/dist/tools/infrastructure/commandTool.js +32 -1
  31. package/dist/tools/infrastructure/commandTool.js.map +1 -1
  32. package/dist/tools/infrastructure/defineTool.d.ts +5 -3
  33. package/dist/tools/infrastructure/defineTool.js +14 -2
  34. package/dist/tools/infrastructure/defineTool.js.map +1 -1
  35. package/dist/tools/infrastructure/editFormats.js +26 -1
  36. package/dist/tools/infrastructure/editFormats.js.map +1 -1
  37. package/dist/tools/infrastructure/showChangesTool.js +1 -0
  38. package/dist/tools/infrastructure/showChangesTool.js.map +1 -1
  39. package/dist/tools/infrastructure/trackedReadFile.js +2 -1
  40. package/dist/tools/infrastructure/trackedReadFile.js.map +1 -1
  41. package/dist/tools/infrastructure/validatedSummarizer.d.ts +7 -0
  42. package/dist/tools/infrastructure/validatedSummarizer.js +15 -0
  43. package/dist/tools/infrastructure/validatedSummarizer.js.map +1 -0
  44. package/dist/tools/infrastructure/workspaceTools.d.ts +6 -0
  45. package/dist/tools/infrastructure/workspaceTools.js +16 -0
  46. package/dist/tools/infrastructure/workspaceTools.js.map +1 -1
  47. package/package.json +1 -1
package/README.md CHANGED
@@ -153,6 +153,15 @@ releases, so render the ones you know and ignore the rest. Swapping the agent fo
153
153
  reviewer workflow, changes nothing else in the snippet above.
154
154
  `createOrchestrator({ provider: 'anthropic' })` runs every role on one provider, for
155
155
  example when the other one is out of quota.
156
+
157
+ The optional `agent` field on an event identifies the orchestrator role that produced
158
+ it (`planner`, `executor`, or `reviewer`). The optional `summary` on a `tool` event
159
+ is narration only: it describes the model's call and is not the tool result. A failed
160
+ configured command may also carry its optional `exitCode`; its output and changes
161
+ still arrive in the tool result. A role label is applied by the orchestrator,
162
+ including retries and later rounds, so a nested agent cannot accidentally attribute
163
+ its events to another role. Consumers should continue to handle events without these
164
+ optional fields.
156
165
  `systemPrompts: { planner, executor, reviewer }` replaces a role's instructions;
157
166
  a role left out keeps the harness's own, and the reviewer must still answer with
158
167
  the JSON decision described on the option.
@@ -241,7 +250,10 @@ bills the key instead of the subscription.
241
250
  `createLLMAgent` takes a system prompt and the tools the model may call, and
242
251
  returns the same `Agent`. `defineTool` builds a tool from a Zod 4 schema; a field the
243
252
  model may leave out is `.nullable()`, not optional, because both APIs call tools in
244
- strict mode:
253
+ strict mode. An optional `summarize` function receives the same input after Zod
254
+ validation and supplies the tool's short progress narration. It is called once per
255
+ model call; its output is never sent to the provider, and a throwing summarizer
256
+ ends the run as an `UnrecoverableError` before the tool is prepared or executed:
245
257
 
246
258
  ```ts
247
259
  import {
@@ -257,6 +269,7 @@ const now = defineTool({
257
269
  description: 'The current time, in ISO 8601.',
258
270
  input: z.object({}),
259
271
  risk: 'safe',
272
+ summarize: () => 'current time',
260
273
  execute: async () => new Date().toISOString(),
261
274
  });
262
275
 
@@ -530,7 +543,16 @@ nor the agent decides what runs, or for how long:
530
543
  ```
531
544
 
532
545
  The CLI shows each command as the line it will run before asking, and "always" allows
533
- that command again, not every command.
546
+ that command again, not every command. Tool calls finish as one dimmed line with an
547
+ icon, role, tool name, summary and duration; a failed configured command includes
548
+ `exit N` beside its duration, while a command event also includes its exit code.
549
+ A denied call is marked `denied`. Messages remain normal text under a bold role
550
+ heading. Reasoning is dim italic and complete by default; pass
551
+ `--thinking-headings` to show only Markdown bold headings as `∴ …`. A tool start
552
+ updates the spinner rather than printing a second line, and cancelling a turn clears
553
+ its pending spinner state. Pass `--steps <file>` to append one JSON line per call to the
554
+ model to that file: the call's tokens and what its context held, by origin, for measuring
555
+ what a run spends.
534
556
 
535
557
  `cli/cli.ts` currently enables `autoApprove` for its Agent SDK agents, not for `--llm`.
536
558
  This maps to each provider's permission-bypass mode and grants those processes
@@ -22,7 +22,10 @@ export interface AgentResponse {
22
22
  runId?: string;
23
23
  }
24
24
  export type Callback = (item: ProgressEvent) => void;
25
- /** What a run needs besides its prompt. Decorators pass it on whole, so a new field reaches every agent. */
25
+ /**
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.
28
+ */
26
29
  export interface RunOptions {
27
30
  /** Cancels the run, which then rejects with the signal's `AbortError`. */
28
31
  signal: AbortSignal;
@@ -46,32 +49,43 @@ export type ProgressEvent = {
46
49
  type: 'command';
47
50
  command: string;
48
51
  exitCode?: number;
52
+ agent?: ProgressAgent;
49
53
  } | {
50
54
  type: 'reasoning';
51
55
  message: string;
56
+ agent?: ProgressAgent;
52
57
  } | {
53
58
  type: 'search';
54
59
  query: string;
60
+ agent?: ProgressAgent;
55
61
  } | {
56
62
  type: 'fileChange';
57
63
  changes: {
58
64
  path: string;
59
65
  kind: 'add' | 'update' | 'delete';
60
66
  }[];
67
+ agent?: ProgressAgent;
61
68
  } | {
62
69
  type: 'mcpTool';
63
70
  server: string;
64
71
  tool: string;
65
72
  status: string;
73
+ agent?: ProgressAgent;
66
74
  } | {
67
75
  type: 'agentMessage';
68
76
  message: string;
77
+ agent?: ProgressAgent;
69
78
  }
70
79
  /** `id` pairs a call's end with its start when one step calls the same tool more than once. */
71
80
  | {
72
81
  type: 'tool';
73
82
  id: string;
74
83
  name: string;
84
+ agent?: ProgressAgent;
85
+ /** A short, tool-owned description of the call. Narration only. */
86
+ summary?: string;
87
+ /** The exit code of a configured command that ended with an error. Narration only. */
88
+ exitCode?: number;
75
89
  /**
76
90
  * `in_progress` only once the call is about to run, after any approval. `denied` when
77
91
  * it never ran because no one allowed it, with no `in_progress` before it.
@@ -89,11 +103,59 @@ export type ProgressEvent = {
89
103
  text: string;
90
104
  completed: boolean;
91
105
  }[];
106
+ agent?: ProgressAgent;
92
107
  } | {
93
108
  type: 'turnStarted';
109
+ agent?: ProgressAgent;
94
110
  } | {
95
111
  type: 'turnEnded';
112
+ agent?: ProgressAgent;
113
+ }
114
+ /**
115
+ * One call to the model by an agent on the model APIs whose answer the client returned,
116
+ * even one the model stopped early: what the call sent and what it cost. Narration only:
117
+ * a call the client turned into an error is not a step, so the run's total is
118
+ * `AgentResponse.tokens`, or the failure's `tokens`.
119
+ */
120
+ | {
121
+ type: 'step';
122
+ /** 1 for the first call of the run. */
123
+ step: number;
124
+ /** Missing when the provider did not report this call's usage. */
125
+ tokens?: Tokens;
126
+ context: StepContext;
127
+ agent?: ProgressAgent;
96
128
  };
129
+ /**
130
+ * What one call to the model sent, in characters by where it came from: an estimate of size
131
+ * for finding what fills a context, not a token count.
132
+ */
133
+ export interface StepContext {
134
+ /** The system prompt. */
135
+ instructions: number;
136
+ /** The tool definitions. */
137
+ tools: number;
138
+ /** The text of the user's prompts and the model's answers. */
139
+ messages: number;
140
+ /** Reasoning the model produced earlier and is sent back: its provider's own signed or encrypted blocks. */
141
+ reasoning: number;
142
+ /** The input of every tool call so far. */
143
+ toolCalls: number;
144
+ /** Every tool result so far, by tool, largest first. */
145
+ toolResults: {
146
+ tool: string;
147
+ characters: number;
148
+ calls: number;
149
+ }[];
150
+ /** The largest single results, each with its call's summary when the tool gives one. */
151
+ largestResults: {
152
+ tool: string;
153
+ characters: number;
154
+ summary?: string;
155
+ }[];
156
+ }
157
+ /** A role in the fixed planner → executor → reviewer workflow. */
158
+ export type ProgressAgent = 'planner' | 'executor' | 'reviewer';
97
159
  /**
98
160
  * The contract every engine (CodexAgent, ClaudeAgent, future providers) and every
99
161
  * decorator (RetryingAgent, OrchestratorAgent) is built against.
@@ -1 +1 @@
1
- {"version":3,"file":"agent.js","sourceRoot":"","sources":["../../../src/agent/domain/agent.ts"],"names":[],"mappings":"AAyCA,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/** What a run needs besides its prompt. Decorators pass it on whole, so a new field reaches every agent. */\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 }\n\t| { type: 'reasoning'; message: string }\n\t| { type: 'search'; query: string }\n\t| { type: 'fileChange'; changes: { path: string; kind: 'add' | 'update' | 'delete' }[] }\n\t| { type: 'mcpTool'; server: string; tool: string; status: string }\n\t| { type: 'agentMessage'; message: string }\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\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 }[] }\n\t| { type: 'turnStarted' }\n\t| { type: 'turnEnded' };\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":"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 call to the model by an agent on the model APIs whose answer the client returned,\n\t * even one the model stopped early: what the call sent and what it cost. Narration only:\n\t * a call the client turned into an error is not a step, so the run's total is\n\t * `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"]}
@@ -16,6 +16,7 @@ export declare class LLMAgent implements Agent {
16
16
  private readonly conversation;
17
17
  private readonly tools;
18
18
  private readonly toolDefinitions;
19
+ private readonly callSummaries;
19
20
  private readonly maxSteps;
20
21
  private readonly autoApprove;
21
22
  private readonly logger;
@@ -53,6 +54,7 @@ export declare class LLMAgent implements Agent {
53
54
  private summarizeUndone;
54
55
  private warnSummaryFailed;
55
56
  private llmCall;
57
+ private narrateStep;
56
58
  /**
57
59
  * Undefined when the call may run; otherwise why it may not, which the model receives in its
58
60
  * place. Only a `destructive` call is asked about: git can undo a `mutating` one. A throwing
@@ -5,10 +5,12 @@ import { InvalidAgentConfigError, RecoverableError, UnrecoverableError, withSpen
5
5
  import { classifyHostFailure, classifyLocalFailure, classifyProviderFailure, describeFailure, treatErrors, } from '#src/shared/domain/providerFailure';
6
6
  import { Conversation } from '#src/llm/domain/conversation';
7
7
  import { asNative, nativeOf } from '#src/llm/domain/tool';
8
- import { prepareCall } from '#src/tools/domain/preparedCall';
8
+ import { prepareCall, ToolExecutionError, } from '#src/tools/domain/preparedCall';
9
9
  import { ReadRegistry } from '#src/tools/domain/readRegistry';
10
+ import { InvalidToolInputError } from '#src/tools/domain/tool';
10
11
  import { isAbortError } from '#src/shared/domain/isAbortError';
11
12
  import { addTokens } from '#src/shared/domain/tokens';
13
+ import { measureContext } from "./stepContext.js";
12
14
  import { followHistory, transcriptOf, undoNote, } from "./historyFollower.js";
13
15
  function describePart(part) {
14
16
  switch (part.type) {
@@ -30,10 +32,17 @@ function describePart(part) {
30
32
  return undefined;
31
33
  }
32
34
  }
33
- function narrate(part, callback) {
35
+ function narrate(part, callback, summary, exitCode) {
34
36
  const event = describePart(part);
35
- if (event)
36
- emit(event, callback);
37
+ if (!event)
38
+ return;
39
+ if (event.type === 'tool') {
40
+ if (summary !== undefined)
41
+ event.summary = summary;
42
+ if (exitCode !== undefined)
43
+ event.exitCode = exitCode;
44
+ }
45
+ emit(event, callback);
37
46
  }
38
47
  function emit(event, callback) {
39
48
  treatErrors(() => {
@@ -78,6 +87,9 @@ export class LLMAgent {
78
87
  conversation;
79
88
  tools;
80
89
  toolDefinitions;
90
+ // Each call's summary, as its tool gave it when the call ran, so measuring a step that
91
+ // resends the call never runs a consumer's summarizer again.
92
+ callSummaries = new Map();
81
93
  maxSteps;
82
94
  autoApprove;
83
95
  logger;
@@ -172,26 +184,30 @@ export class LLMAgent {
172
184
  }, classifyHostFailure, 'LLM agent logger failed');
173
185
  }
174
186
  async llmCall(runMessages, after, signal) {
187
+ // Sent as a copy, so what the run records is what the agent built, whatever the client does.
188
+ const context = [...this.conversation.contextAfter(after), ...structuredClone(runMessages)];
175
189
  let response;
176
190
  try {
177
- // Sent as a copy, so what the run records is what the agent built, whatever the client does.
178
- response = await this.llmClient.send({
179
- context: [...this.conversation.contextAfter(after), ...structuredClone(runMessages)],
180
- tools: this.toolDefinitions,
181
- }, signal);
191
+ response = await this.llmClient.send({ context, tools: this.toolDefinitions }, signal);
182
192
  }
183
193
  catch (error) {
184
194
  throw classifyProviderFailure(error, 'The LLM call failed');
185
195
  }
186
- if (response.stopReason !== 'completed') {
187
- throw new UnrecoverableError('The LLM stopped before completing its answer', {
188
- cause: `The model stopped with "${response.stopReason}".`,
189
- // The call was billed even though its answer is unusable.
190
- tokens: response.usage,
191
- usageUnreported: !response.usage,
192
- });
193
- }
194
- return response;
196
+ return { response, context };
197
+ }
198
+ narrateStep(step, response, sent, callback) {
199
+ emit({
200
+ type: 'step',
201
+ step,
202
+ ...(response.usage ? { tokens: response.usage } : {}),
203
+ context: measureContext({
204
+ instructions: this.llmClient.systemPrompt,
205
+ providerDataSource: this.llmClient.providerDataSource,
206
+ tools: this.toolDefinitions,
207
+ context: sent,
208
+ summarize: call => this.callSummaries.get(call.id),
209
+ }),
210
+ }, callback);
195
211
  }
196
212
  /**
197
213
  * Undefined when the call may run; otherwise why it may not, which the model receives in its
@@ -239,6 +255,7 @@ export class LLMAgent {
239
255
  */
240
256
  async runTool(call, { signal, onProgress: callback = ignoreProgress, approve }, effects, toolCall) {
241
257
  const result = { type: 'toolResult', callId: call.id, name: call.name };
258
+ signal.throwIfAborted();
242
259
  const tool = this.tools.get(call.name);
243
260
  if (!tool) {
244
261
  return {
@@ -250,6 +267,25 @@ export class LLMAgent {
250
267
  denied: false,
251
268
  };
252
269
  }
270
+ let summary;
271
+ try {
272
+ // This is deliberately before preparation and approval. A consumer-supplied
273
+ // summarizer is host code, and a failed one must never leave an ambiguous call that
274
+ // could be replayed by RetryingAgent.
275
+ summary = tool.summarize?.(call.input);
276
+ if (summary !== undefined)
277
+ this.callSummaries.set(call.id, summary);
278
+ }
279
+ catch (error) {
280
+ signal.throwIfAborted();
281
+ if (error instanceof InvalidToolInputError) {
282
+ return {
283
+ result: { ...result, output: describeFailure(error), isError: true },
284
+ denied: false,
285
+ };
286
+ }
287
+ throw classifyHostFailure(error, 'The tool call summarizer failed');
288
+ }
253
289
  let prepared;
254
290
  try {
255
291
  prepared = await prepareCall(tool, call.input, signal, toolCall);
@@ -261,6 +297,7 @@ export class LLMAgent {
261
297
  return {
262
298
  result: { ...result, output: describeFailure(error), isError: true },
263
299
  denied: false,
300
+ ...(summary === undefined ? {} : { summary }),
264
301
  };
265
302
  }
266
303
  // Preparing may have finished after a cancellation it did not notice; nobody is asked to
@@ -268,12 +305,22 @@ export class LLMAgent {
268
305
  signal.throwIfAborted();
269
306
  const denial = await this.denial(call, prepared, signal, approve);
270
307
  if (denial !== undefined) {
271
- return { result: { ...result, output: denial, isError: true }, denied: true };
308
+ return {
309
+ result: { ...result, output: denial, isError: true },
310
+ denied: true,
311
+ ...(summary === undefined ? {} : { summary }),
312
+ };
272
313
  }
273
314
  // Deciding took at least one await, and the run may have been cancelled meanwhile.
274
315
  signal.throwIfAborted();
275
316
  // Announced only once it will run: a missing, failing-to-judge or denied call never is.
276
- narrate(call, callback);
317
+ emit({
318
+ type: 'tool',
319
+ id: call.id,
320
+ name: call.name,
321
+ status: 'in_progress',
322
+ ...(summary === undefined ? {} : { summary }),
323
+ }, callback);
277
324
  // Checked again at the last moment: the consumer may cancel on the announcement itself.
278
325
  signal.throwIfAborted();
279
326
  // Set before `execute`, so a tool that fails halfway still counts: it may have had effects.
@@ -282,6 +329,7 @@ export class LLMAgent {
282
329
  return {
283
330
  result: { ...result, output: await prepared.run(signal), isError: false },
284
331
  denied: false,
332
+ ...(summary === undefined ? {} : { summary }),
285
333
  ...(prepared.diff === undefined ? {} : { diff: prepared.diff }),
286
334
  };
287
335
  }
@@ -290,6 +338,10 @@ export class LLMAgent {
290
338
  return {
291
339
  result: { ...result, output: describeFailure(error), isError: true },
292
340
  denied: false,
341
+ ...(summary === undefined ? {} : { summary }),
342
+ ...(error instanceof ToolExecutionError && error.exitCode !== undefined
343
+ ? { exitCode: error.exitCode }
344
+ : {}),
293
345
  };
294
346
  }
295
347
  }
@@ -303,15 +355,29 @@ export class LLMAgent {
303
355
  signal.throwIfAborted();
304
356
  // `runTool` announces the call only once it is allowed to run, so a consumer never
305
357
  // shows a call as running early, nor while the user is still being asked.
306
- const { result, denied, diff } = await this.runTool(call, options, effects, toolCall);
307
- if (denied)
308
- emit({ type: 'tool', id: call.id, name: call.name, status: 'denied' }, callback);
358
+ const { result, denied, diff, summary, exitCode } = await this.runTool(call, options, effects, toolCall);
359
+ if (denied) {
360
+ emit({
361
+ type: 'tool',
362
+ id: call.id,
363
+ name: call.name,
364
+ status: 'denied',
365
+ ...(summary === undefined ? {} : { summary }),
366
+ }, callback);
367
+ }
309
368
  // The diff of a change it made, for the person watching; only once it was made.
310
369
  else if (diff !== undefined) {
311
- emit({ type: 'tool', id: call.id, name: call.name, status: 'completed', diff }, callback);
370
+ emit({
371
+ type: 'tool',
372
+ id: call.id,
373
+ name: call.name,
374
+ status: 'completed',
375
+ diff,
376
+ ...(summary === undefined ? {} : { summary }),
377
+ }, callback);
312
378
  }
313
379
  else
314
- narrate(result, callback);
380
+ narrate(result, callback, summary, exitCode);
315
381
  results.push(result);
316
382
  }
317
383
  return { role: 'tool', content: results };
@@ -396,9 +462,18 @@ export class LLMAgent {
396
462
  const reads = this.reads.begin();
397
463
  const repeats = new RepeatedCalls();
398
464
  for (let step = 1; step <= this.maxSteps; step++) {
399
- const response = await this.llmCall(runMessages, after, signal);
465
+ const { response, context: sent } = await this.llmCall(runMessages, after, signal);
400
466
  tokens = addTokens(tokens, response.usage);
401
467
  unreported ||= !response.usage;
468
+ // After the tokens are counted, so a throwing callback still reports them, and for an
469
+ // unusable answer too: the call was sent, and billed.
470
+ this.narrateStep(step, response, sent, callback);
471
+ if (response.stopReason !== 'completed') {
472
+ // Its tokens are already counted with the run's, which the failure carries.
473
+ throw new UnrecoverableError('The LLM stopped before completing its answer', {
474
+ cause: `The model stopped with "${response.stopReason}".`,
475
+ });
476
+ }
402
477
  runMessages.push(response.message);
403
478
  // Tool calls are narrated as they start, in `runTools`, and never if they do not run.
404
479
  for (const part of response.message.content) {