@nxisai/agent-harness 0.4.1 → 0.6.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 (137) hide show
  1. package/README.md +25 -2
  2. package/dist/handoff/a2a.d.ts +152 -0
  3. package/dist/handoff/a2a.d.ts.map +1 -0
  4. package/dist/handoff/a2a.js +227 -0
  5. package/dist/handoff/a2a.js.map +1 -0
  6. package/dist/handoff/controller.d.ts +181 -0
  7. package/dist/handoff/controller.d.ts.map +1 -0
  8. package/dist/handoff/controller.js +543 -0
  9. package/dist/handoff/controller.js.map +1 -0
  10. package/dist/handoff/envelope.d.ts +130 -0
  11. package/dist/handoff/envelope.d.ts.map +1 -0
  12. package/dist/handoff/envelope.js +178 -0
  13. package/dist/handoff/envelope.js.map +1 -0
  14. package/dist/handoff/index.d.ts +16 -0
  15. package/dist/handoff/index.d.ts.map +1 -0
  16. package/dist/handoff/index.js +12 -0
  17. package/dist/handoff/index.js.map +1 -0
  18. package/dist/handoff/tools.d.ts +36 -0
  19. package/dist/handoff/tools.d.ts.map +1 -0
  20. package/dist/handoff/tools.js +148 -0
  21. package/dist/handoff/tools.js.map +1 -0
  22. package/dist/harness/harness.d.ts +4 -2
  23. package/dist/harness/harness.d.ts.map +1 -1
  24. package/dist/harness/harness.js +61 -16
  25. package/dist/harness/harness.js.map +1 -1
  26. package/dist/harness/router/coordinator.d.ts +13 -0
  27. package/dist/harness/router/coordinator.d.ts.map +1 -1
  28. package/dist/harness/router/coordinator.js +7 -0
  29. package/dist/harness/router/coordinator.js.map +1 -1
  30. package/dist/harness/tools/action-tools.d.ts.map +1 -1
  31. package/dist/harness/tools/action-tools.js +7 -5
  32. package/dist/harness/tools/action-tools.js.map +1 -1
  33. package/dist/harness/tools/bridge.d.ts.map +1 -1
  34. package/dist/harness/tools/bridge.js +33 -12
  35. package/dist/harness/tools/bridge.js.map +1 -1
  36. package/dist/harness/tools/discovery.d.ts +12 -1
  37. package/dist/harness/tools/discovery.d.ts.map +1 -1
  38. package/dist/harness/tools/discovery.js +19 -1
  39. package/dist/harness/tools/discovery.js.map +1 -1
  40. package/dist/harness/tools/local-context.d.ts +49 -9
  41. package/dist/harness/tools/local-context.d.ts.map +1 -1
  42. package/dist/harness/tools/local-context.js +377 -22
  43. package/dist/harness/tools/local-context.js.map +1 -1
  44. package/dist/harness/tools/open-page.d.ts +16 -0
  45. package/dist/harness/tools/open-page.d.ts.map +1 -1
  46. package/dist/harness/tools/open-page.js +45 -9
  47. package/dist/harness/tools/open-page.js.map +1 -1
  48. package/dist/harness/tools/retry.d.ts +53 -0
  49. package/dist/harness/tools/retry.d.ts.map +1 -0
  50. package/dist/harness/tools/retry.js +96 -0
  51. package/dist/harness/tools/retry.js.map +1 -0
  52. package/dist/harness/tools/schema.d.ts +18 -0
  53. package/dist/harness/tools/schema.d.ts.map +1 -1
  54. package/dist/harness/tools/schema.js +38 -0
  55. package/dist/harness/tools/schema.js.map +1 -1
  56. package/dist/harness/tools/site-actions.d.ts.map +1 -1
  57. package/dist/harness/tools/site-actions.js +3 -2
  58. package/dist/harness/tools/site-actions.js.map +1 -1
  59. package/dist/harness/tools/site-nav.d.ts +168 -0
  60. package/dist/harness/tools/site-nav.d.ts.map +1 -0
  61. package/dist/harness/tools/site-nav.js +316 -0
  62. package/dist/harness/tools/site-nav.js.map +1 -0
  63. package/dist/harness/tools/site-pages.d.ts +4 -0
  64. package/dist/harness/tools/site-pages.d.ts.map +1 -1
  65. package/dist/harness/tools/site-pages.js +19 -3
  66. package/dist/harness/tools/site-pages.js.map +1 -1
  67. package/dist/harness/tools/source.d.ts +9 -0
  68. package/dist/harness/tools/source.d.ts.map +1 -1
  69. package/dist/harness/tools/source.js +54 -14
  70. package/dist/harness/tools/source.js.map +1 -1
  71. package/dist/harness/tools/webmcp.d.ts +39 -3
  72. package/dist/harness/tools/webmcp.d.ts.map +1 -1
  73. package/dist/harness/tools/webmcp.js +93 -14
  74. package/dist/harness/tools/webmcp.js.map +1 -1
  75. package/dist/index.d.ts +6 -1
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +5 -1
  78. package/dist/index.js.map +1 -1
  79. package/dist/types.d.ts +2 -0
  80. package/dist/types.d.ts.map +1 -1
  81. package/dist/ui/agent-fullscreen.d.ts.map +1 -1
  82. package/dist/ui/agent-fullscreen.js +276 -2
  83. package/dist/ui/agent-fullscreen.js.map +1 -1
  84. package/dist/ui/tailwind.generated.d.ts +1 -1
  85. package/dist/ui/tailwind.generated.d.ts.map +1 -1
  86. package/dist/ui/tailwind.generated.js +1 -1
  87. package/dist/ui/tailwind.generated.js.map +1 -1
  88. package/package.json +2 -1
  89. package/src/a2ui/cards.ts +177 -0
  90. package/src/a2ui/emit.ts +147 -0
  91. package/src/a2ui/form.ts +117 -0
  92. package/src/a2ui/host.ts +73 -0
  93. package/src/a2ui/index.ts +22 -0
  94. package/src/a2ui/stepper.ts +211 -0
  95. package/src/a2ui/theme.ts +134 -0
  96. package/src/a2ui/to-v09.ts +118 -0
  97. package/src/engine.ts +701 -0
  98. package/src/handoff/a2a.ts +326 -0
  99. package/src/handoff/controller.ts +661 -0
  100. package/src/handoff/envelope.ts +216 -0
  101. package/src/handoff/index.ts +53 -0
  102. package/src/handoff/tools.ts +178 -0
  103. package/src/harness/harness.ts +981 -0
  104. package/src/harness/plan.ts +91 -0
  105. package/src/harness/policy.ts +59 -0
  106. package/src/harness/renderer.ts +208 -0
  107. package/src/harness/router/coordinator.ts +241 -0
  108. package/src/harness/router/rules.ts +65 -0
  109. package/src/harness/slots.ts +117 -0
  110. package/src/harness/steps.ts +156 -0
  111. package/src/harness/tools/action-tools.ts +870 -0
  112. package/src/harness/tools/bridge.ts +393 -0
  113. package/src/harness/tools/client.ts +26 -0
  114. package/src/harness/tools/discovery.ts +73 -0
  115. package/src/harness/tools/local-context.ts +480 -0
  116. package/src/harness/tools/open-page.ts +273 -0
  117. package/src/harness/tools/pending-action.ts +232 -0
  118. package/src/harness/tools/resume.ts +134 -0
  119. package/src/harness/tools/retry.ts +117 -0
  120. package/src/harness/tools/schema.ts +245 -0
  121. package/src/harness/tools/site-actions.ts +356 -0
  122. package/src/harness/tools/site-nav.ts +432 -0
  123. package/src/harness/tools/site-pages.ts +288 -0
  124. package/src/harness/tools/source.ts +353 -0
  125. package/src/harness/tools/webmcp.ts +200 -0
  126. package/src/index.ts +196 -0
  127. package/src/providers/capability.ts +167 -0
  128. package/src/providers/proxy.ts +191 -0
  129. package/src/providers/types.ts +150 -0
  130. package/src/types.ts +258 -0
  131. package/src/ui/agent-fullscreen.ts +4262 -0
  132. package/src/ui/agent.tw.css +49 -0
  133. package/src/ui/config.ts +138 -0
  134. package/src/ui/index.ts +32 -0
  135. package/src/ui/journey.ts +57 -0
  136. package/src/ui/tailwind.generated.ts +2 -0
  137. package/src/webmcp-contract.ts +65 -0
@@ -0,0 +1,981 @@
1
+ /**
2
+ * The turn loop: discover tools, generate, parse the tool call, resolve what is
3
+ * still unknown, decide whether to ask or act, run the tool, report the result.
4
+ *
5
+ * The harness decides; a {@link HarnessRenderer} draws. What a turn does
6
+ * depends on the tool's own annotations and on how much the model supplied:
7
+ *
8
+ * | Tool | Missing values | Outcome |
9
+ * |---|---|---|
10
+ * | read | none | runs immediately, no UI |
11
+ * | read | some | asks for those, then runs |
12
+ * | write | none | read-only summary and one Submit |
13
+ * | write | several | a stepper, then a confirmation |
14
+ *
15
+ * @module
16
+ */
17
+
18
+ import {
19
+ AgentEngine,
20
+ parseToolCall,
21
+ stripMarkers,
22
+ humanize,
23
+ type ChatMessage,
24
+ } from '../engine.js';
25
+ import type { ModelContextToolInfo, ModelContextToolResponse } from '../types.js';
26
+ import { LocalGemmaProvider } from '../engine.js';
27
+ import type { CacheHint, Completion, ModelProvider } from '../providers/types.js';
28
+ import { WebMcpTools } from './tools/webmcp.js';
29
+ import {
30
+ handoffFor,
31
+ selectSiteTools,
32
+ withoutShadowedTools,
33
+ withoutSiteSuffix,
34
+ HANDOFF_RESULT,
35
+ } from './tools/action-tools.js';
36
+ import { visitFor, OPEN_PAGE_TOOL } from './tools/open-page.js';
37
+ import { extractResult, missingRequired, reconcileArgs, toolSchema } from './tools/schema.js';
38
+ import { decide, policyFor, type Decision } from './policy.js';
39
+ import { buildPlan } from './plan.js';
40
+ import { resolveSlots, summarySlots, type Resolution } from './slots.js';
41
+ import { beginJourney, journeySteps, recordStep, type AgentStep } from './steps.js';
42
+ import { HANDOFF_SESSION_RESULT, TALK_TO_A_PERSON_TOOL, isHandoffTool } from '../handoff/tools.js';
43
+ import type { HarnessRenderer, StreamingBubble } from './renderer.js';
44
+
45
+ /** Configuration for an {@link AgentHarness}. */
46
+ export interface HarnessOptions {
47
+ /**
48
+ * Where completions come from. Defaults to a {@link LocalGemmaProvider}.
49
+ *
50
+ * Several agents may share one provider, so a coordinator and its specialists
51
+ * need only one model between them.
52
+ */
53
+ provider?: ModelProvider;
54
+ /** Where tools come from. Defaults to the page's WebMCP registry. */
55
+ toolSource?: WebMcpTools;
56
+ /** The UI to drive. The only required option. */
57
+ renderer: HarnessRenderer;
58
+ /**
59
+ * This agent's instructions, asserted at the start of each of its turns.
60
+ *
61
+ * Agents sharing a provider can each hold a different prompt, since the
62
+ * prompt travels with the agent rather than with the model.
63
+ */
64
+ systemPrompt?: string;
65
+ /**
66
+ * Filter deciding which of the page's tools this agent may use.
67
+ *
68
+ * Tool definitions dominate what the model reads each turn, so narrowing them
69
+ * shortens the prompt and reduces the number of wrong tools available to be
70
+ * chosen. Defaults to every tool on the page.
71
+ *
72
+ * @param tool - A tool reported by the tool source.
73
+ * @returns True to expose it to this agent.
74
+ */
75
+ tools?(tool: ModelContextToolInfo): boolean;
76
+ /**
77
+ * How many of the site's own action tools a turn may carry.
78
+ *
79
+ * A site registers one tool per form, for every page, and every tool's
80
+ * schema is written into every prompt. Past this many, the ones belonging
81
+ * to the current page are kept and the rest compete on how well they answer
82
+ * what was asked. Defaults to {@link DEFAULT_SITE_TOOL_LIMIT}; the page's
83
+ * own tools and the retrieval tools are never dropped.
84
+ */
85
+ maxSiteTools?: number;
86
+ /** This agent's name, used in warnings and the default unavailable message. */
87
+ name?: string;
88
+ /**
89
+ * What to say when none of this agent's tools are on the page.
90
+ *
91
+ * Worth setting per agent, so the message can name what cannot be done here
92
+ * and where it can be done instead.
93
+ */
94
+ unavailable?: string;
95
+ /**
96
+ * Standing values for a tool's schema, such as a saved name or email.
97
+ *
98
+ * @param schema - The tool's input schema.
99
+ * @returns Values keyed by schema property name.
100
+ */
101
+ profile?(schema: unknown): Record<string, unknown>;
102
+ /**
103
+ * Values the host page knows, such as the record currently being viewed.
104
+ *
105
+ * @param tool - The tool about to be called.
106
+ * @returns Values keyed by schema property name.
107
+ */
108
+ context?(tool: ModelContextToolInfo): Record<string, unknown>;
109
+ /**
110
+ * Whether related fields share a step when asking for missing values.
111
+ * Defaults to true. Pass false to ask one question at a time.
112
+ */
113
+ groupRelated?: boolean;
114
+ /** Called whenever the conversation changes, for hosts that persist it. */
115
+ onMessages?(messages: ChatMessage[]): void;
116
+ }
117
+
118
+ /**
119
+ * Whether a reply reads as the model asking rather than answering.
120
+ *
121
+ * Intentionally crude: it gates one extra generation, so a false positive costs
122
+ * a wasted round rather than a wrong action.
123
+ */
124
+ function looksLikeQuestion(text: string): boolean {
125
+ return /\?\s*$/.test(stripMarkers(text).trim());
126
+ }
127
+
128
+ /** How a turn ended. */
129
+ export interface TurnResult {
130
+ /**
131
+ * The outcome:
132
+ *
133
+ * - a {@link Decision}, when a tool call was resolved;
134
+ * - `'none'`, when the model answered in prose;
135
+ * - `'handback'`, when the model named a tool this agent does not hold, with
136
+ * the name in {@link TurnResult.wanted};
137
+ * - `'unavailable'`, when none of this agent's tools are on the page;
138
+ * - `'stopped'`, when the user cancelled the turn;
139
+ * - `'handoff'`, when the conversation is handed off and the message went to
140
+ * the target instead (see {@link HandoffController}).
141
+ */
142
+ decision: Decision | 'none' | 'handback' | 'unavailable' | 'stopped' | 'handoff';
143
+ /** The tool that was called, when one was. */
144
+ tool?: ModelContextToolInfo;
145
+ /** The arguments it was called with. */
146
+ values?: Record<string, unknown>;
147
+ /**
148
+ * The tool the model asked for and this agent does not hold.
149
+ *
150
+ * Set only with `decision: 'handback'`, and used by
151
+ * {@link AgentCoordinator} to move the turn to the agent that holds it.
152
+ */
153
+ wanted?: string;
154
+ }
155
+
156
+ /** Stands in when a reply was neither prose nor a recognisable tool call. */
157
+ const WORKING_ON_IT = 'Working on that.';
158
+
159
+ /**
160
+ * Whether an error represents a cancelled operation rather than a failure.
161
+ *
162
+ * @param err - The caught value.
163
+ * @returns True for a DOM `AbortError`, as thrown by an aborted `fetch`.
164
+ */
165
+ export function isAbort(err: unknown): boolean {
166
+ return (err as { name?: string })?.name === 'AbortError';
167
+ }
168
+
169
+ /**
170
+ * A sentence to show for a turn whose model output was only a tool call.
171
+ *
172
+ * Claims no more than the harness knows at that point: which tool was chosen,
173
+ * and whether it reads or writes.
174
+ *
175
+ * @param call - The parsed tool call, or null if there was none.
176
+ * @param tools - The tools available this turn, used to read the annotations.
177
+ * @returns A short line to display in place of the model's own words.
178
+ */
179
+ export function narrateCall(
180
+ call: ReturnType<typeof parseToolCall>,
181
+ tools: ModelContextToolInfo[],
182
+ ): string {
183
+ if (!call) return WORKING_ON_IT;
184
+ const tool = tools.find((t) => t.name === call.name);
185
+ if (tool && policyFor(tool).readOnly) return 'Looking that up.';
186
+ if (isHandoffTool(call.name)) {
187
+ return call.name === TALK_TO_A_PERSON_TOOL ? "I can put you through to the team. Here's what I'd send them." : "I can hand you over. Here's what I'd send.";
188
+ }
189
+ return `Filling in the ${humanize(call.name).toLowerCase()}.`;
190
+ }
191
+
192
+ /**
193
+ * A tool call the model wrote out as text, in the harness's own notation.
194
+ *
195
+ * The transcript records each call as `[called name with {...}]`, and a model
196
+ * reading that history back sometimes answers in the same form instead of
197
+ * making a call. Left as text it reaches the visitor as a line of JSON and
198
+ * nothing happens, so it is read as the call it was meant to be. It then
199
+ * meets the same policy as any other call: a write is still confirmed.
200
+ *
201
+ * @param text - The model's reply.
202
+ * @returns The call and whatever was said before it, or null when the reply
203
+ * contains no such line or its arguments are not a JSON object.
204
+ */
205
+ export function readTranscribedCall(
206
+ text: string,
207
+ ): { said: string; call: { name: string; args: Record<string, unknown> } } | null {
208
+ const m = /\[called ([\w.-]+) with ([\s\S]*)\]\s*$/.exec(text ?? '');
209
+ if (!m) return null;
210
+ const args = readArgs(m[2].trim());
211
+ if (!args) return null;
212
+ return { said: text.slice(0, m.index).trim(), call: { name: m[1], args } };
213
+ }
214
+
215
+ /**
216
+ * The arguments of a written-out call, however the model wrote them.
217
+ *
218
+ * The transcript uses JSON, and a model copying the form often keeps the shape
219
+ * but not the syntax: `page="Quick Fix Plumbing LLC"` rather than
220
+ * `{"page":"Quick Fix Plumbing LLC"}`. Both are the same call, and refusing the
221
+ * second leaves the visitor reading the notation instead of going anywhere.
222
+ */
223
+ function readArgs(raw: string): Record<string, unknown> | null {
224
+ if (raw.startsWith('{')) {
225
+ try {
226
+ const parsed: unknown = JSON.parse(raw);
227
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
228
+ ? (parsed as Record<string, unknown>)
229
+ : null;
230
+ } catch {
231
+ return null;
232
+ }
233
+ }
234
+ // key="value", key='value', key=value — separated by commas or spaces.
235
+ const args: Record<string, unknown> = {};
236
+ const pair = /([\w.-]+)\s*=\s*("([^"]*)"|'([^']*)'|[^,]+)/g;
237
+ for (let m = pair.exec(raw); m; m = pair.exec(raw)) {
238
+ args[m[1]] = (m[3] ?? m[4] ?? m[2]).trim();
239
+ }
240
+ return Object.keys(args).length ? args : null;
241
+ }
242
+
243
+ /** A completion with a written-out call read as a real one. */
244
+ function withTranscribedCall(completion: Completion): Completion {
245
+ if (completion.call) return completion;
246
+ const read = readTranscribedCall(completion.text);
247
+ return read ? { ...completion, text: read.said, call: read.call } : completion;
248
+ }
249
+
250
+ /**
251
+ * How many records a tool response contained.
252
+ *
253
+ * Best-effort: it feeds a summary line, so an unrecognised shape reports
254
+ * nothing rather than guessing.
255
+ *
256
+ * @param res - The tool response, whose `structuredContent` is preferred.
257
+ * @param text - The same response flattened to text, parsed as JSON if needed.
258
+ * @returns The record count, or undefined when the shape is not recognised.
259
+ */
260
+ export function countRecords(res: ModelContextToolResponse, text: string): number | undefined {
261
+ const structured = res?.structuredContent as { results?: unknown[]; total?: number } | undefined;
262
+ if (Array.isArray(structured?.results)) return structured.results.length;
263
+ if (typeof structured?.total === 'number') return structured.total;
264
+ try {
265
+ const parsed = JSON.parse(text) as { results?: unknown[]; total?: number };
266
+ if (Array.isArray(parsed?.results)) return parsed.results.length;
267
+ if (typeof parsed?.total === 'number') return parsed.total;
268
+ } catch {
269
+ // A plain-text response carries no count.
270
+ }
271
+ return undefined;
272
+ }
273
+
274
+ /**
275
+ * How a write's confirmation should describe pressing submit.
276
+ *
277
+ * Submitting a form that lives on another page of the site also takes the
278
+ * visitor there, and a button marked "Submit" would not have said so. The
279
+ * button gets a short label that fits a narrow card, and the destination
280
+ * travels separately for the renderer to name in a sentence.
281
+ *
282
+ * @param tool - The tool about to be called.
283
+ * @param values - The values resolved for it.
284
+ * @returns Fields to spread into the confirmation; empty when the call runs
285
+ * on this page.
286
+ */
287
+ function handoffPrompt(
288
+ tool: ModelContextToolInfo,
289
+ values: Record<string, unknown>,
290
+ ): { submitLabel?: string; destination?: string } {
291
+ // Handing the conversation to someone: what is pressed is Send, and nothing is submitted.
292
+ if (isHandoffTool(tool.name)) return { submitLabel: 'Send' };
293
+ const handoff = handoffFor(tool, values);
294
+ return handoff ? { submitLabel: 'Open & submit', destination: handoff.label } : {};
295
+ }
296
+
297
+ /**
298
+ * One agent: a provider, a set of tools, and the loop that runs a turn.
299
+ *
300
+ * Use it directly for a single-agent page, or several behind an
301
+ * {@link AgentCoordinator} to route between specialists.
302
+ *
303
+ * @example
304
+ * ```ts
305
+ * const harness = new AgentHarness({ renderer });
306
+ * const messages: ChatMessage[] = [];
307
+ * await harness.turn('what are your opening hours?', messages);
308
+ * ```
309
+ */
310
+ export class AgentHarness {
311
+ /** Where completions come from. May be shared between agents. */
312
+ readonly provider: ModelProvider;
313
+ /** Where tools come from. */
314
+ readonly toolSource: WebMcpTools;
315
+
316
+ private renderer: HarnessRenderer;
317
+ private opts: HarnessOptions;
318
+ /** Values typed into a prompt that is still open, kept across turns. */
319
+ private entered: Record<string, unknown> = {};
320
+ private liveTool: ModelContextToolInfo | null = null;
321
+ /** The in-flight turn's controller, so it can be cancelled. */
322
+ private abort: AbortController | null = null;
323
+ /** The previous request's system prompt and tool names, for the cache hint. */
324
+ private lastPrompt = '';
325
+ private lastTools = '';
326
+
327
+ constructor(opts: HarnessOptions) {
328
+ this.provider = opts.provider ?? new LocalGemmaProvider();
329
+ this.toolSource = opts.toolSource ?? new WebMcpTools();
330
+ this.renderer = opts.renderer;
331
+ this.opts = opts;
332
+ }
333
+
334
+ /**
335
+ * End a turn that the user stopped.
336
+ *
337
+ * Discards any partial answer and keeps the question, recording it if the
338
+ * stop arrived before the turn had done so, so the transcript matches what is
339
+ * on screen.
340
+ */
341
+ private stopTurn(bubble: StreamingBubble, messages: ChatMessage[], goal: string): TurnResult {
342
+ bubble.finalize('');
343
+ this.renderer.status('');
344
+ const asked = [...messages].reverse().find((m) => m.role === 'user');
345
+ if (asked?.content !== goal) {
346
+ messages.push({ role: 'user', content: goal });
347
+ this.opts.onMessages?.(messages);
348
+ }
349
+ return this.finished({ decision: 'stopped' as const });
350
+ }
351
+
352
+ /** Report a finished turn and clear the stoppable state. */
353
+ private finished<T extends TurnResult>(result: T): T {
354
+ this.renderer.stoppable?.(false);
355
+ return result;
356
+ }
357
+
358
+ /** Whether the current turn has been stopped. */
359
+ private get stopped(): boolean {
360
+ return !!this.abort?.signal.aborted;
361
+ }
362
+
363
+ /**
364
+ * Abandon the turn in flight.
365
+ *
366
+ * Aborts tool discovery or generation, whichever is running. The turn
367
+ * resolves as `'stopped'` rather than rejecting, so a cancellation is not
368
+ * reported as a failure. Safe to call when nothing is running.
369
+ *
370
+ * A tool call already in progress is not interrupted, since it may have
371
+ * reached a server already.
372
+ */
373
+ stop(): void {
374
+ // Aborted, not discarded. The controller is what `stopped` and every
375
+ // `signal` read downstream go through, so clearing it here would make a
376
+ // stop that lands early in a turn invisible to the rest of it: the checks
377
+ // would read false and the provider would be handed no signal at all.
378
+ // `turn()` replaces it on the next turn regardless.
379
+ this.abort?.abort();
380
+ }
381
+
382
+ /**
383
+ * Render a completion for the transcript.
384
+ *
385
+ * The transcript must record the tool call, or the model repeats it on the
386
+ * next turn. Providers returning structured calls have no text form for one,
387
+ * so this writes it. Never displayed: the UI shows `completion.text`.
388
+ */
389
+ private transcribe(completion: Completion): string {
390
+ if (!completion.call) return completion.text;
391
+ const args = JSON.stringify(completion.call.args ?? {});
392
+ const said = completion.text ? `${completion.text}\n` : '';
393
+ return `${said}[called ${completion.call.name} with ${args}]`;
394
+ }
395
+
396
+ /**
397
+ * Compare this request with the previous one to build a {@link CacheHint}.
398
+ *
399
+ * Must be called after `applySystemPrompt`, since a handoff between agents
400
+ * replaces both the system message and the tool slice.
401
+ */
402
+ private cacheHint(messages: ChatMessage[], tools: ModelContextToolInfo[]): CacheHint {
403
+ const names = tools.map((t) => t.name).join(',');
404
+ const prompt = messages[0]?.role === 'system' ? messages[0].content : '';
405
+ const hint: CacheHint = {
406
+ stableMessages: prompt && prompt === this.lastPrompt ? 1 : 0,
407
+ toolsStable: names === this.lastTools,
408
+ };
409
+ this.lastPrompt = prompt;
410
+ this.lastTools = names;
411
+ return hint;
412
+ }
413
+
414
+ /**
415
+ * Run one turn.
416
+ *
417
+ * The turn moves through phases the renderer is told about as they happen
418
+ * (loading the model, finding tools, generating, running the tool), each on a
419
+ * {@link HarnessRenderer.streaming} bubble with its own label. A renderer
420
+ * should expect the first bubble before any token exists.
421
+ *
422
+ * Starting a turn supersedes any turn still running.
423
+ *
424
+ * @param goal - The user's message.
425
+ * @param messages - The live conversation, appended to in place. The caller
426
+ * keeps ownership of persistence; see {@link HarnessOptions.onMessages}.
427
+ * @returns How the turn ended. A page with no tools, a tool that errors, and
428
+ * a cancellation all resolve normally; see {@link TurnResult.decision}.
429
+ * @throws If the model cannot be loaded (no WebGPU, unreachable weights) or a
430
+ * generation fails for a reason other than being aborted.
431
+ */
432
+ async turn(goal: string, messages: ChatMessage[]): Promise<TurnResult> {
433
+ // Created before the first await, so `stopped` reads this turn's signal
434
+ // rather than the previous turn's.
435
+ this.abort?.abort();
436
+ this.abort = new AbortController();
437
+ this.renderer.stoppable?.(true);
438
+
439
+ // Opened before the model is touched: loading weights is the longest
440
+ // silence in a turn, and the thread should not be empty during it.
441
+ const bubble = this.renderer.streaming();
442
+ bubble.thinking(this.provider.ready ? 'Thinking…' : 'Loading model…');
443
+
444
+ try {
445
+ await this.provider.load?.();
446
+ } catch (err) {
447
+ // A stop during a load is not a failure to load. The download continues
448
+ // and its bytes are cached, so the next attempt resumes rather than
449
+ // starting over.
450
+ if (this.stopped || isAbort(err)) return this.stopTurn(bubble, messages, goal);
451
+ bubble.finalize('');
452
+ throw err;
453
+ }
454
+
455
+ // Asserted per turn rather than seeded once, because agents share the
456
+ // transcript and each has its own prompt.
457
+ this.applySystemPrompt(messages);
458
+
459
+ if (this.stopped) {
460
+ return this.stopTurn(bubble, messages, goal);
461
+ }
462
+
463
+ // A new request, so the last one's steps are not shown beside this one's.
464
+ beginJourney();
465
+
466
+ bubble.thinking('Finding tools…');
467
+ const registered = await this.toolSource.discoverTools(this.abort?.signal);
468
+ if (this.stopped) {
469
+ return this.stopTurn(bubble, messages, goal);
470
+ }
471
+ const tools = this.visibleTools(registered, goal);
472
+ if (!tools.length) {
473
+ bubble.finalize('');
474
+ this.renderer.status('');
475
+
476
+ // A page with no tools at all is a host misconfiguration; this agent
477
+ // having none of them is answerable to the user. The slice is never
478
+ // widened to compensate, since using the wrong tool succeeds at the wrong
479
+ // thing rather than failing visibly.
480
+ if (registered.length) {
481
+ console.warn(
482
+ `[nxis-agent] ${this.opts.name ?? 'agent'} has no tools on this page ` +
483
+ `(page has: ${registered.map((t) => t.name).join(', ')})`,
484
+ );
485
+ this.renderer.message(
486
+ 'assistant',
487
+ this.opts.unavailable ??
488
+ 'I cannot do that from this page — the tools for it are not available here.',
489
+ );
490
+ return { decision: 'unavailable' };
491
+ }
492
+
493
+ // Actionable only by the host, so the detail goes to the console.
494
+ console.warn('No tools are registered on this page yet — make sure nxis.js has loaded.');
495
+ this.renderer.message('assistant', 'No tools are registered on this page yet.');
496
+ return { decision: 'none' };
497
+ }
498
+
499
+ messages.push({ role: 'user', content: goal });
500
+
501
+ bubble.thinking('Thinking…');
502
+ this.renderer.status('Thinking…');
503
+ let completion: Completion;
504
+ try {
505
+ completion = await this.provider.complete({
506
+ messages,
507
+ tools,
508
+ onToken: (t) => bubble.update(t),
509
+ signal: this.abort?.signal,
510
+ cache: this.cacheHint(messages, tools),
511
+ });
512
+ } catch (err) {
513
+ // A remote provider rejects on abort (as `fetch` does); the local one
514
+ // returns what it had. Neither is a failure to report.
515
+ if (this.stopped || isAbort(err)) return this.stopTurn(bubble, messages, goal);
516
+ bubble.finalize('');
517
+ throw err;
518
+ }
519
+ completion = withTranscribedCall(completion);
520
+ // Nothing the model produced survives a stop, on screen or in the
521
+ // transcript: a model that reads its own unfinished reply back as history
522
+ // will continue from it or contradict it.
523
+ if (this.stopped) {
524
+ return this.stopTurn(bubble, messages, goal);
525
+ }
526
+
527
+ // A reply is often nothing but the tool call. Left empty the bubble would
528
+ // be dropped, and the thread would jump from the question to a rendered
529
+ // card with no turn between them.
530
+ bubble.finalize(completion.text || narrateCall(completion.call ?? null, tools));
531
+ messages.push({ role: 'assistant', content: this.transcribe(completion) });
532
+ this.opts.onMessages?.(messages);
533
+ this.renderer.status('');
534
+
535
+ let call = completion.call ?? null;
536
+
537
+ // Small models sometimes ask in prose instead of calling the tool. One
538
+ // nudge, then accept the question: a clarifying question is better than a
539
+ // retry loop.
540
+ if (!call && looksLikeQuestion(completion.text)) {
541
+ const retry = await this.retryForToolCall(messages, tools, bubble);
542
+ if (retry) call = retry;
543
+ else if (this.stopped) return this.finished({ decision: 'stopped' as const });
544
+ }
545
+
546
+ // Still prose: a legitimate turn, already rendered and in the transcript.
547
+ if (!call) return { decision: 'none' };
548
+
549
+ const tool = tools.find((t) => t.name === call.name);
550
+ if (!tool) {
551
+ // A tool the page has but this agent may not use is a routing problem,
552
+ // which a coordinator can correct. A tool no page has was invented by the
553
+ // model.
554
+ const elsewhere = registered.some((t) => t.name === call.name);
555
+ if (elsewhere) return { decision: 'handback', wanted: call.name };
556
+
557
+ this.renderer.message('assistant', `I picked an unknown tool ("${call.name}"). Try rephrasing.`);
558
+ return { decision: 'none' };
559
+ }
560
+
561
+ return this.act(tool, call.args, messages);
562
+ }
563
+
564
+ /**
565
+ * Narrow the page's tools to this agent's slice.
566
+ *
567
+ * @param registered - Every tool the tool source reported.
568
+ * @returns The tools matching {@link HarnessOptions.tools}, or all of them
569
+ * when no filter is set.
570
+ */
571
+ private visibleTools(tools: ModelContextToolInfo[], goal = ''): ModelContextToolInfo[] {
572
+ // One route per action first: where a site-wide tool covers this page's
573
+ // own form tool, the page tool is its implementation, not an alternative.
574
+ const routed = withoutShadowedTools(tools);
575
+ // Then what fits: a site's forms are many, and all of them reach the
576
+ // prompt unless this turn's request narrows them.
577
+ const budgeted = selectSiteTools(routed, goal, { limit: this.opts.maxSiteTools });
578
+ const filter = this.opts.tools;
579
+ return filter ? budgeted.filter((t) => filter(t)) : budgeted;
580
+ }
581
+
582
+ /**
583
+ * The tools this agent would be offered for a request, right now.
584
+ *
585
+ * What a surface should list when it shows what the agent can use — not
586
+ * every tool registered on the page: a page tool that a site-wide tool
587
+ * covers is hidden, the per-request budget applies, and so does this
588
+ * agent's own filter. It can change as the page registers tools, and with
589
+ * the request, which is what `goal` is for.
590
+ *
591
+ * Reads the registry as it stands instead of waiting for tools to appear,
592
+ * so a menu opened before the page has finished registering shows what is
593
+ * there rather than hanging.
594
+ *
595
+ * @param goal - The request to budget against. Empty for "before anyone
596
+ * has asked", which keeps this page's tools first.
597
+ * @returns The tools, in the order the registry reported them.
598
+ */
599
+ async offeredTools(goal = ''): Promise<ModelContextToolInfo[]> {
600
+ let registered: ModelContextToolInfo[] = [];
601
+ try {
602
+ registered = (await this.toolSource.modelContext()?.getTools()) ?? [];
603
+ } catch {
604
+ // A registry that fails to list has nothing to show.
605
+ }
606
+ return this.visibleTools(registered, goal);
607
+ }
608
+
609
+ /**
610
+ * Whether this agent's filter accepts a tool, by name.
611
+ *
612
+ * Called by {@link AgentCoordinator} when another agent hands a turn back.
613
+ * The filter receives a tool with only its name set, so a filter that
614
+ * inspects annotations should reject a bare name and let the fallback take
615
+ * the turn.
616
+ *
617
+ * @param name - The tool name to test.
618
+ * @returns True when this agent would use that tool.
619
+ */
620
+ owns(name: string): boolean {
621
+ const filter = this.opts.tools;
622
+ if (!filter) return true;
623
+ try {
624
+ return filter({ name } as ModelContextToolInfo);
625
+ } catch {
626
+ return false;
627
+ }
628
+ }
629
+
630
+ /**
631
+ * Place this agent's instructions at the head of the shared transcript,
632
+ * replacing another agent's if one is there.
633
+ */
634
+ private applySystemPrompt(messages: ChatMessage[]): void {
635
+ const prompt = this.opts.systemPrompt;
636
+ if (!prompt) return;
637
+ if (messages[0]?.role === 'system') {
638
+ if (messages[0].content !== prompt) messages[0] = { role: 'system', content: prompt };
639
+ } else {
640
+ messages.unshift({ role: 'system', content: prompt });
641
+ }
642
+ }
643
+
644
+ /**
645
+ * Generate once more, instructing the model to call a tool.
646
+ *
647
+ * The instruction is added as a user turn, which carries the most weight in a
648
+ * chat template mid-conversation, and is removed again afterwards so it never
649
+ * remains in the transcript as something the user said.
650
+ *
651
+ * @returns The tool call, or null if the model still did not produce one.
652
+ */
653
+ private async retryForToolCall(
654
+ messages: ChatMessage[],
655
+ tools: ModelContextToolInfo[],
656
+ bubble: StreamingBubble,
657
+ ): Promise<ReturnType<typeof parseToolCall>> {
658
+ const nudge: ChatMessage = {
659
+ role: 'user',
660
+ content:
661
+ 'Call the appropriate tool now with the fields you already have. ' +
662
+ 'Omit any field you do not know — do not ask me for it.',
663
+ };
664
+ messages.push(nudge);
665
+
666
+ let completion: Completion;
667
+ try {
668
+ completion = await this.provider.complete({
669
+ messages,
670
+ tools,
671
+ onToken: (t) => bubble.update(t),
672
+ signal: this.abort?.signal,
673
+ });
674
+ } catch (err) {
675
+ // The nudge is the harness's own text, so it is removed however the retry
676
+ // ends rather than being left in the transcript.
677
+ messages.splice(messages.indexOf(nudge), 1);
678
+ if (this.stopped || isAbort(err)) return null;
679
+ throw err;
680
+ }
681
+ completion = withTranscribedCall(completion);
682
+ const call = completion.call ?? null;
683
+
684
+ if (this.stopped) {
685
+ messages.splice(messages.indexOf(nudge), 1);
686
+ return null;
687
+ }
688
+
689
+ if (call) {
690
+ // Keep the assistant's tool call; drop the nudge the user never said.
691
+ messages.splice(messages.indexOf(nudge), 1);
692
+ messages.push({ role: 'assistant', content: this.transcribe(completion) });
693
+ bubble.finalize(completion.text || narrateCall(call, tools));
694
+ } else {
695
+ messages.splice(messages.indexOf(nudge), 1);
696
+ }
697
+ return call;
698
+ }
699
+
700
+ /**
701
+ * Resolve a proposed call's arguments, then run it, confirm it, or ask.
702
+ *
703
+ * @internal Used by {@link act}; see {@link handoffPrompt}.
704
+ *
705
+ * @param tool - The tool the model chose.
706
+ * @param rawArgs - Its arguments, before reconciliation against the schema.
707
+ * @param messages - The live conversation.
708
+ * @returns The decision taken and the values it was taken with.
709
+ */
710
+ private async act(
711
+ tool: ModelContextToolInfo,
712
+ rawArgs: Record<string, unknown> | undefined,
713
+ messages: ChatMessage[],
714
+ ): Promise<TurnResult> {
715
+ // Named for what is being used — "Quote form" — since a surface showing
716
+ // the journey reads this step as its title.
717
+ const named = humanize(withoutSiteSuffix(tool.name));
718
+ this.emitStep({
719
+ id: 'choose',
720
+ label: named.charAt(0).toUpperCase() + named.slice(1),
721
+ status: 'done',
722
+ });
723
+ const schema = toolSchema(tool);
724
+ const modelArgs = reconcileArgs(rawArgs, schema);
725
+
726
+ // Keep values already typed while the model revises a call to the same
727
+ // tool; discard them when it moves to a different one.
728
+ const entered = this.liveTool?.name === tool.name ? this.entered : {};
729
+
730
+ const resolution = resolveSlots(schema, {
731
+ modelArgs,
732
+ entered,
733
+ profile: this.opts.profile?.(schema),
734
+ context: this.opts.context?.(tool),
735
+ });
736
+
737
+ const policy = policyFor(tool);
738
+ const decision = decide(policy, resolution.missing.length);
739
+
740
+ if (decision === 'execute') {
741
+ await this.run(tool, resolution.values, messages);
742
+ return { decision, tool, values: resolution.values };
743
+ }
744
+
745
+ if (decision === 'confirm') {
746
+ this.liveTool = tool;
747
+ this.emitStep({ id: 'confirm', label: 'Waiting for you to confirm', status: 'active' });
748
+ this.renderer.confirm({
749
+ tool,
750
+ slots: summarySlots(resolution),
751
+ values: resolution.values,
752
+ ...handoffPrompt(tool, resolution.values),
753
+ onSubmit: (values) => void this.run(tool, values, messages),
754
+ onCancel: () => this.cancel(),
755
+ onEdit: () => this.askAll(tool, resolution, messages),
756
+ });
757
+ return { decision, tool, values: resolution.values };
758
+ }
759
+
760
+ // decision === 'ask': the gaps only, stepped when there are several.
761
+ this.liveTool = tool;
762
+ this.emitStep({
763
+ id: 'collect',
764
+ label: 'Collecting details',
765
+ status: 'active',
766
+ detail: resolution.missing.map((s) => humanize(s.name).toLowerCase()).join(', '),
767
+ });
768
+ const plan = buildPlan(resolution.missing, this.opts.groupRelated !== false);
769
+ this.renderer.message(
770
+ 'assistant',
771
+ plan.stepped
772
+ ? `I need ${plan.steps.length} more details for ${humanize(tool.name)}.`
773
+ : `One more thing for ${humanize(tool.name)}.`,
774
+ );
775
+ this.renderer.ask({
776
+ tool,
777
+ plan,
778
+ resolution,
779
+ onComplete: (answers) => {
780
+ const values = { ...resolution.values, ...answers };
781
+ this.entered = values;
782
+ this.emitStep({ id: 'collect', label: 'Collected your details', status: 'done' });
783
+ // A read was waiting only on the gap. A write still needs its
784
+ // confirmation, now a summary rather than a form.
785
+ if (policy.confirmWhenSatisfied) {
786
+ this.renderer.confirm({
787
+ tool,
788
+ slots: summarySlots(resolveSlots(schema, { modelArgs: values })),
789
+ values,
790
+ ...handoffPrompt(tool, values),
791
+ onSubmit: (v) => void this.run(tool, v, messages),
792
+ onCancel: () => this.cancel(),
793
+ onEdit: () => this.askAll(tool, resolution, messages),
794
+ });
795
+ } else {
796
+ void this.run(tool, values, messages);
797
+ }
798
+ },
799
+ onCancel: () => this.cancel(),
800
+ });
801
+ return { decision, tool, values: resolution.values };
802
+ }
803
+
804
+ /**
805
+ * Reopen a confirmation as an editable form covering every field, not only
806
+ * the gaps. Reached through {@link ConfirmRequest.onEdit}.
807
+ */
808
+ private askAll(
809
+ tool: ModelContextToolInfo,
810
+ resolution: Resolution,
811
+ messages: ChatMessage[],
812
+ ): void {
813
+ const editable = resolution.all.filter((s) => s.name in resolution.values || s.required);
814
+ this.renderer.ask({
815
+ tool,
816
+ plan: buildPlan(editable, false),
817
+ resolution,
818
+ onComplete: (answers) => void this.run(tool, { ...resolution.values, ...answers }, messages),
819
+ onCancel: () => this.cancel(),
820
+ });
821
+ }
822
+
823
+ /**
824
+ * Record a step, and tell the renderer about it.
825
+ *
826
+ * The journal is the source of truth — it survives the page change, and a
827
+ * surface the host owns subscribes to it directly. Passing the step to the
828
+ * renderer as well saves a renderer from subscribing to anything.
829
+ */
830
+ private emitStep(step: AgentStep): void {
831
+ recordStep(step);
832
+ this.renderer.step?.(step, journeySteps());
833
+ }
834
+
835
+ /** Discard a live prompt and the values typed into it. */
836
+ private cancel(): void {
837
+ // A journey that stops here should say so, not sit waiting for a press
838
+ // that will never come.
839
+ this.emitStep({ id: 'submit', label: 'Cancelled', status: 'failed' });
840
+ this.entered = {};
841
+ this.liveTool = null;
842
+ this.renderer.dismissPrompt?.();
843
+ this.renderer.message('assistant', 'Okay — cancelled. Tell me what you’d like to do.');
844
+ }
845
+
846
+ /**
847
+ * Execute a fully resolved call and render its result.
848
+ *
849
+ * Read tools render as records, writes as a detail card. Either way the
850
+ * outcome is written into the transcript so the model can refer back to it.
851
+ */
852
+ private async run(
853
+ tool: ModelContextToolInfo,
854
+ values: Record<string, unknown>,
855
+ messages: ChatMessage[],
856
+ ): Promise<void> {
857
+ this.renderer.dismissPrompt?.();
858
+ // Past this point the turn cannot be stopped: the call may reach a server
859
+ // before an abort could arrive.
860
+ this.renderer.stoppable?.(false);
861
+ this.renderer.status(`Running ${tool.name}…`);
862
+ // A remote tool call is another silence, and the generation bubble that
863
+ // covered the last one has been finalized by now.
864
+ const working = this.renderer.streaming();
865
+ working.thinking(`Running ${humanize(tool.name)}…`);
866
+ // Asked before the call, not after: a registry may hand back only the
867
+ // text of a response, dropping the structured part that would have said
868
+ // so. The tool's own annotations are already enough to know.
869
+ const visiting = visitFor(tool, values);
870
+ const leaving = handoffFor(tool, values) ?? visiting;
871
+ const readOnly = policyFor(tool).readOnly;
872
+ if (!readOnly) this.emitStep({ id: 'confirm', label: 'You confirmed', status: 'done' });
873
+ // Recorded before the call, because the call is what navigates: the
874
+ // courier persists the journey as it leaves, and this step has to be in
875
+ // it or the page it lands on shows no reason for being there.
876
+ const handing = isHandoffTool(tool.name);
877
+ this.emitStep(
878
+ handing
879
+ ? { id: 'fill', label: 'Handing the conversation over', status: 'active' }
880
+ : leaving
881
+ ? { id: 'navigate', label: `Opening ${leaving.label}`, status: 'active' }
882
+ : {
883
+ id: 'fill',
884
+ label: readOnly ? 'Looking that up' : `Filling ${humanize(tool.name)}`,
885
+ status: 'active',
886
+ },
887
+ );
888
+ try {
889
+ const res: ModelContextToolResponse = await this.toolSource.execTool(tool, tool.name, values);
890
+ const text = extractResult(res);
891
+ working.finalize('');
892
+ this.renderer.status('');
893
+
894
+ this.renderer.trace?.({
895
+ tool,
896
+ args: values,
897
+ text,
898
+ isError: !!res?.isError,
899
+ count: countRecords(res, text),
900
+ });
901
+
902
+ if (res?.isError) {
903
+ this.emitStep({ id: 'submit', label: 'It did not go through', status: 'failed', detail: text });
904
+ this.renderer.toolError(tool, text || 'Failed');
905
+ return;
906
+ }
907
+
908
+ this.entered = {};
909
+ this.liveTool = null;
910
+
911
+ // A call that left for another page has submitted nothing yet, so it
912
+ // gets a line saying where it went rather than a card claiming it was
913
+ // sent. The page it is going to finishes the job.
914
+ if (leaving || res?.structuredContent?.[HANDOFF_RESULT]) {
915
+ // Said from what we know, not from what came back: a registry may
916
+ // return the whole response object as JSON, and that is not a
917
+ // sentence to put in front of anyone.
918
+ this.renderer.message(
919
+ 'assistant',
920
+ visiting
921
+ ? `Opening ${visiting.label}…`
922
+ : leaving
923
+ ? `Opening ${leaving.label} to submit this…`
924
+ : 'Opening the page that can submit this…',
925
+ );
926
+ return;
927
+ }
928
+
929
+ // Handed off: what the target says arrives through the handoff controller's events, which the
930
+ // renderer draws. There is nothing to show as "Submitted"; the model learns where it went.
931
+ const started = res?.structuredContent?.[HANDOFF_SESSION_RESULT] as { label?: string } | undefined;
932
+ if (handing && started) {
933
+ const who = started.label ?? 'another agent';
934
+ this.emitStep({ id: 'fill', label: `Handed to ${who}`, status: 'done' });
935
+ messages.push({
936
+ role: 'user',
937
+ content: `[${tool.name}: the conversation was handed to ${who}. Their replies go to the visitor directly.]`,
938
+ });
939
+ this.opts.onMessages?.(messages);
940
+ return;
941
+ }
942
+
943
+ // Opening the page already open, or one that could not be told: said
944
+ // in words, since there are no records to draw.
945
+ if (tool.name === OPEN_PAGE_TOOL) {
946
+ this.emitStep({ id: 'submit', label: text, status: 'done' });
947
+ this.renderer.message('assistant', text);
948
+ messages.push({ role: 'user', content: `[${tool.name} returned: ${text}]` });
949
+ this.opts.onMessages?.(messages);
950
+ return;
951
+ }
952
+
953
+ this.emitStep({ id: 'fill', label: readOnly ? 'Looked it up' : 'Filled the form', status: 'done' });
954
+ this.emitStep({
955
+ id: 'submit',
956
+ label: readOnly ? 'Answered' : 'Submitted',
957
+ status: 'done',
958
+ });
959
+
960
+ if (readOnly) {
961
+ this.renderer.results(tool, res);
962
+ messages.push({ role: 'user', content: `[${tool.name} returned: ${text.slice(0, 400)}]` });
963
+ } else {
964
+ this.renderer.detail(tool, values, text);
965
+ messages.push({
966
+ role: 'user',
967
+ content: `[${tool.name} submitted with: ${JSON.stringify(values)}]. Keep these unless I ask to change them.`,
968
+ });
969
+ }
970
+ this.opts.onMessages?.(messages);
971
+ } catch (err) {
972
+ const message = String((err as Error)?.message || err);
973
+ this.emitStep({ id: 'submit', label: 'It did not go through', status: 'failed', detail: message });
974
+ working.finalize('');
975
+ this.renderer.status('');
976
+ // A call that threw is the main reason to open a trace.
977
+ this.renderer.trace?.({ tool, args: values, text: message, isError: true });
978
+ this.renderer.toolError(tool, message);
979
+ }
980
+ }
981
+ }