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