@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.
- package/README.md +25 -2
- package/dist/handoff/a2a.d.ts +152 -0
- package/dist/handoff/a2a.d.ts.map +1 -0
- package/dist/handoff/a2a.js +227 -0
- package/dist/handoff/a2a.js.map +1 -0
- package/dist/handoff/controller.d.ts +181 -0
- package/dist/handoff/controller.d.ts.map +1 -0
- package/dist/handoff/controller.js +543 -0
- package/dist/handoff/controller.js.map +1 -0
- package/dist/handoff/envelope.d.ts +130 -0
- package/dist/handoff/envelope.d.ts.map +1 -0
- package/dist/handoff/envelope.js +178 -0
- package/dist/handoff/envelope.js.map +1 -0
- package/dist/handoff/index.d.ts +16 -0
- package/dist/handoff/index.d.ts.map +1 -0
- package/dist/handoff/index.js +12 -0
- package/dist/handoff/index.js.map +1 -0
- package/dist/handoff/tools.d.ts +36 -0
- package/dist/handoff/tools.d.ts.map +1 -0
- package/dist/handoff/tools.js +148 -0
- package/dist/handoff/tools.js.map +1 -0
- package/dist/harness/harness.d.ts +4 -2
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +61 -16
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/router/coordinator.d.ts +13 -0
- package/dist/harness/router/coordinator.d.ts.map +1 -1
- package/dist/harness/router/coordinator.js +7 -0
- package/dist/harness/router/coordinator.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/discovery.d.ts +12 -1
- package/dist/harness/tools/discovery.d.ts.map +1 -1
- package/dist/harness/tools/discovery.js +19 -1
- package/dist/harness/tools/discovery.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 +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -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 +276 -2
- 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/handoff/a2a.ts +326 -0
- package/src/handoff/controller.ts +661 -0
- package/src/handoff/envelope.ts +216 -0
- package/src/handoff/index.ts +53 -0
- package/src/handoff/tools.ts +178 -0
- package/src/harness/harness.ts +981 -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 +241 -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 +73 -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 +196 -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 +4262 -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,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arranges missing values into the steps a renderer asks for them in.
|
|
3
|
+
*
|
|
4
|
+
* A single missing value is one question; several become a stepper.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Slot } from './slots.js';
|
|
10
|
+
|
|
11
|
+
/** One screen of the plan. */
|
|
12
|
+
export interface PlanStep {
|
|
13
|
+
/** Position in {@link ClarificationPlan.steps}, from zero. */
|
|
14
|
+
index: number;
|
|
15
|
+
/** Slots asked together. One by default; grouped when they belong together. */
|
|
16
|
+
slots: Slot[];
|
|
17
|
+
title: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The questions to ask before a tool call can proceed. */
|
|
21
|
+
export interface ClarificationPlan {
|
|
22
|
+
steps: PlanStep[];
|
|
23
|
+
/** True when the renderer should draw stepper chrome (Back, Step n of m). */
|
|
24
|
+
stepped: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Field names conventionally asked together, so they share a step.
|
|
29
|
+
*
|
|
30
|
+
* Matched by exact field name. Kept short by design: the more fields are
|
|
31
|
+
* grouped, the less the step count communicates.
|
|
32
|
+
*/
|
|
33
|
+
const AFFINITY: string[][] = [
|
|
34
|
+
['full_name', 'name', 'email', 'phone'],
|
|
35
|
+
['city', 'state', 'zip', 'postal_code'],
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/** Index of the {@link AFFINITY} group a field belongs to, or -1. */
|
|
39
|
+
function affinityGroup(name: string): number {
|
|
40
|
+
return AFFINITY.findIndex(group => group.includes(name));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Arrange missing slots into steps.
|
|
45
|
+
*
|
|
46
|
+
* @param missing - The slots still needing values, in the order to ask for them.
|
|
47
|
+
* @param groupRelated - Whether to place related fields (see {@link AFFINITY})
|
|
48
|
+
* on one step. Pass `false` to ask for every field separately.
|
|
49
|
+
* @returns A plan whose `stepped` flag is true when it has more than one step.
|
|
50
|
+
*/
|
|
51
|
+
export function buildPlan(missing: Slot[], groupRelated = true): ClarificationPlan {
|
|
52
|
+
if (!missing.length) return { steps: [], stepped: false };
|
|
53
|
+
|
|
54
|
+
const steps: PlanStep[] = [];
|
|
55
|
+
|
|
56
|
+
if (groupRelated) {
|
|
57
|
+
const used = new Set<Slot>();
|
|
58
|
+
for (const slot of missing) {
|
|
59
|
+
if (used.has(slot)) continue;
|
|
60
|
+
const group = affinityGroup(slot.name);
|
|
61
|
+
const together = group === -1
|
|
62
|
+
? [slot]
|
|
63
|
+
: missing.filter(s => !used.has(s) && affinityGroup(s.name) === group);
|
|
64
|
+
together.forEach(s => used.add(s));
|
|
65
|
+
steps.push({ index: steps.length, slots: together, title: stepTitle(together) });
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
for (const slot of missing) {
|
|
69
|
+
steps.push({ index: steps.length, slots: [slot], title: stepTitle([slot]) });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Renumber after grouping so index always matches position.
|
|
74
|
+
steps.forEach((s, i) => (s.index = i));
|
|
75
|
+
return { steps, stepped: steps.length > 1 };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A human label for a slot: its description, or its name made readable. */
|
|
79
|
+
function label(slot: Slot): string {
|
|
80
|
+
return (
|
|
81
|
+
slot.schema.description ||
|
|
82
|
+
slot.name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ')
|
|
83
|
+
.replace(/^\w/, c => c.toUpperCase())
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The heading for a step, joining grouped slots with an ampersand. */
|
|
88
|
+
function stepTitle(slots: Slot[]): string {
|
|
89
|
+
if (slots.length === 1) return label(slots[0]);
|
|
90
|
+
return slots.map(label).join(' & ');
|
|
91
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool policy: whether a resolved tool call runs immediately or waits for a
|
|
3
|
+
* human confirmation.
|
|
4
|
+
*
|
|
5
|
+
* The decision comes from the tool's own `readOnlyHint` annotation, so a tool
|
|
6
|
+
* source that annotates its tools correctly gets the right behaviour with no
|
|
7
|
+
* further configuration.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ModelContextToolInfo } from '../types.js';
|
|
13
|
+
|
|
14
|
+
/** What the harness should do once arguments are resolved. */
|
|
15
|
+
export type Decision =
|
|
16
|
+
/** Run it now, with no interstitial UI. */
|
|
17
|
+
| 'execute'
|
|
18
|
+
/** Ask for the missing values, then run without a further confirmation. */
|
|
19
|
+
| 'ask'
|
|
20
|
+
/** Nothing is missing, but a human still presses Submit. */
|
|
21
|
+
| 'confirm';
|
|
22
|
+
|
|
23
|
+
/** How a single tool should be treated once its arguments are known. */
|
|
24
|
+
export interface ToolPolicy {
|
|
25
|
+
/** Taken from `annotations.readOnlyHint`. Read-only tools never write. */
|
|
26
|
+
readOnly: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Whether a call with no missing values still needs a human press.
|
|
29
|
+
*
|
|
30
|
+
* True for writes, whose effects reach a real system and cannot be undone by
|
|
31
|
+
* the harness. The confirmation displays the values read-only: nothing is
|
|
32
|
+
* missing, so there is nothing to edit.
|
|
33
|
+
*/
|
|
34
|
+
confirmWhenSatisfied: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Derive a tool's policy from its annotations.
|
|
39
|
+
*
|
|
40
|
+
* @param tool - The tool as reported by the tool source.
|
|
41
|
+
* @returns The policy the harness applies to calls of this tool.
|
|
42
|
+
*/
|
|
43
|
+
export function policyFor(tool: ModelContextToolInfo): ToolPolicy {
|
|
44
|
+
const readOnly = !!tool.annotations?.readOnlyHint;
|
|
45
|
+
return { readOnly, confirmWhenSatisfied: !readOnly };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the decision from a tool's policy and what is still unknown.
|
|
50
|
+
*
|
|
51
|
+
* @param policy - The policy for the tool being called.
|
|
52
|
+
* @param gapCount - How many required values are still missing.
|
|
53
|
+
* @returns `'ask'` when anything is missing, otherwise `'confirm'` for writes
|
|
54
|
+
* and `'execute'` for reads.
|
|
55
|
+
*/
|
|
56
|
+
export function decide(policy: ToolPolicy, gapCount: number): Decision {
|
|
57
|
+
if (gapCount > 0) return 'ask';
|
|
58
|
+
return policy.confirmWhenSatisfied ? 'confirm' : 'execute';
|
|
59
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interface between the harness and a user interface.
|
|
3
|
+
*
|
|
4
|
+
* The harness makes every decision about what to do; a renderer is asked only
|
|
5
|
+
* to draw and to report what a person did. Keeping this interface narrow is
|
|
6
|
+
* what allows the same harness to drive the bundled web component, an A2UI
|
|
7
|
+
* surface, or a UI of your own.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ModelContextToolInfo, ModelContextToolResponse } from '../types.js';
|
|
13
|
+
import type { ClarificationPlan } from './plan.js';
|
|
14
|
+
import type { Resolution, Slot } from './slots.js';
|
|
15
|
+
import type { AgentStep } from './steps.js';
|
|
16
|
+
|
|
17
|
+
/** One tool call and its response, for renderers that show their work. */
|
|
18
|
+
export interface TraceEntry {
|
|
19
|
+
/** The tool that was called. */
|
|
20
|
+
tool: ModelContextToolInfo;
|
|
21
|
+
/** Arguments as sent, after the harness reconciled and filled them. */
|
|
22
|
+
args: Record<string, unknown>;
|
|
23
|
+
/** The response, flattened to text. */
|
|
24
|
+
text: string;
|
|
25
|
+
/** Whether the tool reported a failure. */
|
|
26
|
+
isError: boolean;
|
|
27
|
+
/** How many records came back, when the response was a record set. */
|
|
28
|
+
count?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The assistant's bubble for one phase of a turn.
|
|
33
|
+
*
|
|
34
|
+
* A turn is not one continuous stream. The harness opens a bubble, moves it
|
|
35
|
+
* through its phases and finalizes it, then opens another when the next silence
|
|
36
|
+
* begins, so {@link HarnessRenderer.streaming} is called more than once per turn
|
|
37
|
+
* and each bubble should be treated as independent.
|
|
38
|
+
*
|
|
39
|
+
* The usual order:
|
|
40
|
+
*
|
|
41
|
+
* ```ts
|
|
42
|
+
* bubble.thinking('Loading model…'); // no output yet
|
|
43
|
+
* bubble.update('The nearest plumb'); // text has started arriving
|
|
44
|
+
* bubble.finalize('The nearest plumber is…'); // the phase is over
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export interface StreamingBubble {
|
|
48
|
+
/**
|
|
49
|
+
* Show that work is happening, before there is anything to show.
|
|
50
|
+
*
|
|
51
|
+
* A turn can be silent for a long time: loading model weights takes tens of
|
|
52
|
+
* seconds on a first visit. Safe to call repeatedly, since each call replaces
|
|
53
|
+
* the label in place rather than adding a bubble.
|
|
54
|
+
*
|
|
55
|
+
* @param label - The current phase, such as `'Loading model…'`. Omit it to
|
|
56
|
+
* indicate work with nothing worth naming.
|
|
57
|
+
*/
|
|
58
|
+
thinking(label?: string): void;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Stream the answer as it is produced.
|
|
62
|
+
*
|
|
63
|
+
* @param text - The whole visible answer so far, not the newest fragment.
|
|
64
|
+
* Replace the bubble's contents with it; appending accumulates the answer on
|
|
65
|
+
* itself. Tool-call markers are already stripped.
|
|
66
|
+
*/
|
|
67
|
+
update(text: string): void;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Close the bubble with its final text.
|
|
71
|
+
*
|
|
72
|
+
* Usually the last call a bubble receives, but not guaranteed to be: when the
|
|
73
|
+
* model answers with a question instead of calling a tool, the harness runs a
|
|
74
|
+
* second generation into the same bubble, so `update` can follow a non-empty
|
|
75
|
+
* `finalize`. Implementations should stay usable rather than throwing.
|
|
76
|
+
*
|
|
77
|
+
* @param text - The text to keep. Empty means the phase produced nothing
|
|
78
|
+
* worth keeping (a failed load, a finished tool call, a generation that was
|
|
79
|
+
* only a tool call), and the bubble should be removed rather than left blank.
|
|
80
|
+
*/
|
|
81
|
+
finalize(text: string): void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A write tool with every value resolved, awaiting a human press. */
|
|
85
|
+
export interface ConfirmRequest {
|
|
86
|
+
/** The tool about to be called. */
|
|
87
|
+
tool: ModelContextToolInfo;
|
|
88
|
+
/** Rows to display read-only. Nothing is missing, so nothing is editable. */
|
|
89
|
+
slots: Slot[];
|
|
90
|
+
/** The values that will be sent. */
|
|
91
|
+
values: Record<string, unknown>;
|
|
92
|
+
/**
|
|
93
|
+
* What the submit button should say, when "Submit" would understate it.
|
|
94
|
+
*
|
|
95
|
+
* Set for a form on another page of the site, where pressing submit also
|
|
96
|
+
* takes the visitor there: `'Open & submit'`. Short on purpose, since a
|
|
97
|
+
* card in a window or a rail has little room; {@link destination} carries
|
|
98
|
+
* the rest. A renderer that ignores both still works, but the page change
|
|
99
|
+
* arrives unannounced.
|
|
100
|
+
*/
|
|
101
|
+
submitLabel?: string;
|
|
102
|
+
/**
|
|
103
|
+
* The page pressing submit will open, named for the visitor:
|
|
104
|
+
* `'Royal Oak Remodeling'`. Set with {@link submitLabel}, for a renderer
|
|
105
|
+
* to say in a sentence what the button no longer has room to.
|
|
106
|
+
*/
|
|
107
|
+
destination?: string;
|
|
108
|
+
/** Call to proceed, optionally with values the user amended. */
|
|
109
|
+
onSubmit(values: Record<string, unknown>): void;
|
|
110
|
+
/** Call if the user declines. */
|
|
111
|
+
onCancel(): void;
|
|
112
|
+
/** Call to open the full editable form instead of confirming. */
|
|
113
|
+
onEdit(): void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A tool call missing required values, and the plan for asking about them. */
|
|
117
|
+
export interface AskRequest {
|
|
118
|
+
/** The tool waiting on the answers. */
|
|
119
|
+
tool: ModelContextToolInfo;
|
|
120
|
+
/** The questions to ask, as one step or several. */
|
|
121
|
+
plan: ClarificationPlan;
|
|
122
|
+
/** What is already known, and from where. */
|
|
123
|
+
resolution: Resolution;
|
|
124
|
+
/** Call with the merged values once the last step is answered. */
|
|
125
|
+
onComplete(values: Record<string, unknown>): void;
|
|
126
|
+
/** Call if the user abandons the questions. */
|
|
127
|
+
onCancel(): void;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Everything the harness needs from a user interface.
|
|
132
|
+
*
|
|
133
|
+
* Required members are called during an ordinary turn. Optional ones may be
|
|
134
|
+
* omitted by a renderer that has nothing to show for them.
|
|
135
|
+
*/
|
|
136
|
+
export interface HarnessRenderer {
|
|
137
|
+
/** Add a complete message, already known in full. */
|
|
138
|
+
message(role: 'user' | 'assistant', text: string): void;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Open a bubble for the phase about to start.
|
|
142
|
+
*
|
|
143
|
+
* Called several times per turn: once before the model is touched, so the
|
|
144
|
+
* thread is never silent while weights download, and again around each tool
|
|
145
|
+
* call. Each bubble is finalized before the next is requested.
|
|
146
|
+
*
|
|
147
|
+
* @returns The bubble the harness will drive through that phase.
|
|
148
|
+
*/
|
|
149
|
+
streaming(): StreamingBubble;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Show a one-line status outside the thread, such as a header strip.
|
|
153
|
+
*
|
|
154
|
+
* @param text - The status. Empty clears it.
|
|
155
|
+
* @param isError - Whether to present the status as a failure.
|
|
156
|
+
*/
|
|
157
|
+
status(text: string, isError?: boolean): void;
|
|
158
|
+
|
|
159
|
+
/** Ask for missing values, as one question or a stepper. */
|
|
160
|
+
ask(req: AskRequest): void;
|
|
161
|
+
/** Present a write for confirmation. Nothing is missing. */
|
|
162
|
+
confirm(req: ConfirmRequest): void;
|
|
163
|
+
|
|
164
|
+
/** A read tool returned records. */
|
|
165
|
+
results(tool: ModelContextToolInfo, res: ModelContextToolResponse): void;
|
|
166
|
+
/** A write tool succeeded. */
|
|
167
|
+
detail(tool: ModelContextToolInfo, values: Record<string, unknown>, text: string): void;
|
|
168
|
+
/** A tool failed, or returned something that cannot be rendered. */
|
|
169
|
+
toolError(tool: ModelContextToolInfo, message: string): void;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Record the exchange behind a rendered card.
|
|
173
|
+
*
|
|
174
|
+
* Distinct from {@link HarnessRenderer.results} and
|
|
175
|
+
* {@link HarnessRenderer.detail}, which draw the outcome the user asked for.
|
|
176
|
+
* A trace is the underlying call and raw response, suited to a collapsed
|
|
177
|
+
* panel. Omit it to record nothing.
|
|
178
|
+
*/
|
|
179
|
+
trace?(entry: TraceEntry): void;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Report what the agent is doing, for a surface that shows progress.
|
|
183
|
+
*
|
|
184
|
+
* The thread says what was said; this says that the page is about to
|
|
185
|
+
* change, or that a form is being filled right now. A chat panel can ignore
|
|
186
|
+
* it — the status line and the cards already cover the same ground — and a
|
|
187
|
+
* copilot surface beside the page is built on it.
|
|
188
|
+
*
|
|
189
|
+
* @param step - The step that changed.
|
|
190
|
+
* @param all - Every step of this journey, oldest first, including the ones
|
|
191
|
+
* taken before a page change.
|
|
192
|
+
*/
|
|
193
|
+
step?(step: AgentStep, all: AgentStep[]): void;
|
|
194
|
+
|
|
195
|
+
/** Tear down any live ask or confirm UI. */
|
|
196
|
+
dismissPrompt?(): void;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Report whether the turn can still be stopped.
|
|
200
|
+
*
|
|
201
|
+
* Called with `true` when a turn starts and `false` when it ends, and also
|
|
202
|
+
* `false` as soon as a tool is invoked: past that point the call may already
|
|
203
|
+
* have reached a server, so it can no longer be cancelled.
|
|
204
|
+
*
|
|
205
|
+
* @param canStop - Whether {@link AgentHarness.stop} would still take effect.
|
|
206
|
+
*/
|
|
207
|
+
stoppable?(canStop: boolean): void;
|
|
208
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routes each turn to one of several {@link AgentHarness} instances.
|
|
3
|
+
*
|
|
4
|
+
* The coordinator holds no tools and produces no answers of its own. Its only
|
|
5
|
+
* job is choosing which agent handles a turn, so that agent sends the model its
|
|
6
|
+
* own few tool definitions rather than every tool on the page.
|
|
7
|
+
*
|
|
8
|
+
* It decides in four stages, cheapest first:
|
|
9
|
+
*
|
|
10
|
+
* | Stage | Cost | When it applies |
|
|
11
|
+
* |---|---|---|
|
|
12
|
+
* | `rules` | microseconds | A rule table matches the goal unambiguously. |
|
|
13
|
+
* | `sticky` | none | The rules abstained, and this conversation is already with an agent. |
|
|
14
|
+
* | `model` | one generation | The rules abstained and there is no history. Requires `provider`. |
|
|
15
|
+
* | `fallback` | none | Nothing else decided. |
|
|
16
|
+
*
|
|
17
|
+
* After the fact it applies one correction: if the chosen agent reports that
|
|
18
|
+
* the model asked for a tool it does not hold, the turn moves once to the agent
|
|
19
|
+
* that does.
|
|
20
|
+
*
|
|
21
|
+
* @module
|
|
22
|
+
*/
|
|
23
|
+
import type { ChatMessage } from '../../types.js';
|
|
24
|
+
import type { ModelProvider } from '../../providers/types.js';
|
|
25
|
+
import type { AgentHarness, TurnResult } from '../harness.js';
|
|
26
|
+
import type { HandoffController } from '../../handoff/controller.js';
|
|
27
|
+
import { matchRoute, type RouteDecision, type RouteRule } from './rules.js';
|
|
28
|
+
|
|
29
|
+
/** How a turn's agent was chosen. */
|
|
30
|
+
/** Which stage of {@link AgentCoordinator} chose the agent for a turn. */
|
|
31
|
+
export type RouteSource = 'rules' | 'sticky' | 'model' | 'fallback' | 'handback';
|
|
32
|
+
|
|
33
|
+
/** Configuration for an {@link AgentCoordinator}. */
|
|
34
|
+
export interface CoordinatorOptions {
|
|
35
|
+
/** The agents to route between, keyed by the name the rules refer to. */
|
|
36
|
+
agents: Record<string, AgentHarness>;
|
|
37
|
+
/** The rule table scored against each goal. May be empty. */
|
|
38
|
+
rules: RouteRule[];
|
|
39
|
+
/**
|
|
40
|
+
* The agent an otherwise unroutable turn goes to.
|
|
41
|
+
*
|
|
42
|
+
* Must name a key of {@link CoordinatorOptions.agents}; the constructor
|
|
43
|
+
* throws if it does not.
|
|
44
|
+
*/
|
|
45
|
+
fallback: string;
|
|
46
|
+
/**
|
|
47
|
+
* Enables the model classification stage, which is otherwise skipped.
|
|
48
|
+
*
|
|
49
|
+
* This is the only part of routing that costs a generation, so it runs only
|
|
50
|
+
* when the rules abstain and no stickiness applies.
|
|
51
|
+
*/
|
|
52
|
+
provider?: ModelProvider;
|
|
53
|
+
/** One line per agent describing what it handles, shown to the classifier. */
|
|
54
|
+
descriptions?: Record<string, string>;
|
|
55
|
+
/** Called with every routing decision, including corrections. */
|
|
56
|
+
onRoute?(decision: RoutedTurn): void;
|
|
57
|
+
/**
|
|
58
|
+
* Handoffs to remote agents and people.
|
|
59
|
+
*
|
|
60
|
+
* While a conversation is handed off, its turns go to the target rather than
|
|
61
|
+
* to any agent here, and resolve with `decision: 'handoff'`. Routing returns
|
|
62
|
+
* to the agents when the target finishes or the visitor ends the handoff.
|
|
63
|
+
*/
|
|
64
|
+
handoff?: {
|
|
65
|
+
controller: HandoffController;
|
|
66
|
+
/** The id the controller knows this conversation by. */
|
|
67
|
+
conversationId(messages: ChatMessage[]): string | undefined;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A routing decision, reported through {@link CoordinatorOptions.onRoute}. */
|
|
72
|
+
export interface RoutedTurn extends RouteDecision {
|
|
73
|
+
/** The agent that ran, which may differ from {@link RouteDecision.route}. */
|
|
74
|
+
agent: string;
|
|
75
|
+
/** Which stage chose the agent. */
|
|
76
|
+
via: RouteSource;
|
|
77
|
+
/** True when no stage decided and the fallback was used. */
|
|
78
|
+
fellBack: boolean;
|
|
79
|
+
/** The user message that was routed. */
|
|
80
|
+
goal: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Token ceiling for the classification pass, whose answer is one agent name. */
|
|
84
|
+
const CLASSIFY_MAX_TOKENS = 12;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Routes turns between several agents. See the module description for the
|
|
88
|
+
* stages it decides in.
|
|
89
|
+
*/
|
|
90
|
+
export class AgentCoordinator {
|
|
91
|
+
/**
|
|
92
|
+
* The agent each conversation is currently with.
|
|
93
|
+
*
|
|
94
|
+
* Keyed by the transcript array itself, so several conversations can be open
|
|
95
|
+
* at once and a discarded one takes its routing state with it.
|
|
96
|
+
*/
|
|
97
|
+
private readonly sticky = new WeakMap<ChatMessage[], string>();
|
|
98
|
+
/** The classification pass in flight, if any. */
|
|
99
|
+
private classifying: AbortController | null = null;
|
|
100
|
+
/** The agent handling the current turn, so {@link stop} can reach it. */
|
|
101
|
+
private working: AgentHarness | null = null;
|
|
102
|
+
|
|
103
|
+
constructor(private readonly opts: CoordinatorOptions) {
|
|
104
|
+
if (!opts.agents[opts.fallback]) {
|
|
105
|
+
throw new Error(`Coordinator fallback "${opts.fallback}" is not one of its agents.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Abandon the turn in flight, whichever stage it has reached.
|
|
111
|
+
*
|
|
112
|
+
* Aborts the classification pass and delegates to the agent handling the
|
|
113
|
+
* turn, which resolves as `'stopped'`. Safe to call when nothing is running.
|
|
114
|
+
*/
|
|
115
|
+
stop(): void {
|
|
116
|
+
this.classifying?.abort();
|
|
117
|
+
this.classifying = null;
|
|
118
|
+
this.working?.stop();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Decide which agent should take a turn, without running it.
|
|
123
|
+
*
|
|
124
|
+
* Covers the rules, sticky and fallback stages. The model stage is skipped,
|
|
125
|
+
* since it is asynchronous and costs a generation.
|
|
126
|
+
*
|
|
127
|
+
* @param goal - The user's message.
|
|
128
|
+
* @param messages - The conversation, consulted for stickiness. Optional.
|
|
129
|
+
* @returns The chosen agent and the stage that chose it.
|
|
130
|
+
*/
|
|
131
|
+
route(goal: string, messages?: ChatMessage[]): RoutedTurn {
|
|
132
|
+
const decision = matchRoute(goal, this.opts.rules);
|
|
133
|
+
const named = decision.route && this.opts.agents[decision.route] ? decision.route : null;
|
|
134
|
+
if (named) return { ...decision, agent: named, via: 'rules', fellBack: false, goal };
|
|
135
|
+
|
|
136
|
+
const held = messages && this.sticky.get(messages);
|
|
137
|
+
if (held && this.opts.agents[held]) {
|
|
138
|
+
return { ...decision, agent: held, via: 'sticky', fellBack: false, goal };
|
|
139
|
+
}
|
|
140
|
+
return { ...decision, agent: this.opts.fallback, via: 'fallback', fellBack: true, goal };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Route a turn, run it, and re-route it once if it reached the wrong agent.
|
|
145
|
+
*
|
|
146
|
+
* @param goal - The user's message.
|
|
147
|
+
* @param messages - The live conversation, appended to in place.
|
|
148
|
+
* @returns The chosen agent's {@link TurnResult}.
|
|
149
|
+
*/
|
|
150
|
+
async turn(goal: string, messages: ChatMessage[]): Promise<TurnResult> {
|
|
151
|
+
// A handed-off conversation belongs to its target until it hands back.
|
|
152
|
+
const handoff = this.opts.handoff;
|
|
153
|
+
const conversationId = handoff?.conversationId(messages);
|
|
154
|
+
if (handoff && handoff.controller.active(conversationId)) {
|
|
155
|
+
await handoff.controller.send(conversationId!, goal);
|
|
156
|
+
return { decision: 'handoff' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let routed = this.route(goal, messages);
|
|
160
|
+
|
|
161
|
+
// The model is only asked when nothing cheaper had an opinion.
|
|
162
|
+
if (routed.via === 'fallback' && this.opts.provider) {
|
|
163
|
+
const guessed = await this.classify(goal);
|
|
164
|
+
if (guessed) routed = { ...routed, agent: guessed, via: 'model', fellBack: false };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
this.opts.onRoute?.(routed);
|
|
168
|
+
this.sticky.set(messages, routed.agent);
|
|
169
|
+
|
|
170
|
+
this.working = this.opts.agents[routed.agent];
|
|
171
|
+
const result = await this.working.turn(goal, messages);
|
|
172
|
+
|
|
173
|
+
// Release stickiness after a refusal, so the next unclear follow-up is not
|
|
174
|
+
// routed back to the one agent known to lack the tools for it.
|
|
175
|
+
if (result.decision === 'unavailable') this.sticky.delete(messages);
|
|
176
|
+
|
|
177
|
+
if (result.decision !== 'handback' || !result.wanted) return result;
|
|
178
|
+
|
|
179
|
+
// Exactly one correction: the owner's answer stands whatever it is. Without
|
|
180
|
+
// the cap, two agents that disagree could pass a turn back and forth.
|
|
181
|
+
const owner = this.ownerOf(result.wanted, routed.agent);
|
|
182
|
+
if (!owner) return result;
|
|
183
|
+
|
|
184
|
+
const moved: RoutedTurn = { ...routed, agent: owner, via: 'handback', fellBack: false };
|
|
185
|
+
this.opts.onRoute?.(moved);
|
|
186
|
+
this.sticky.set(messages, owner);
|
|
187
|
+
this.working = this.opts.agents[owner];
|
|
188
|
+
return this.working.turn(goal, messages);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The agent holding a tool, ignoring the one that handed the turn back. */
|
|
192
|
+
private ownerOf(tool: string, except: string): string | null {
|
|
193
|
+
for (const [name, agent] of Object.entries(this.opts.agents)) {
|
|
194
|
+
if (name !== except && agent.owns(tool)) return name;
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Ask the model which agent should take the turn.
|
|
201
|
+
*
|
|
202
|
+
* One short generation with no tools. Any reply that does not name a known
|
|
203
|
+
* agent returns null, leaving the fallback in place.
|
|
204
|
+
*/
|
|
205
|
+
private async classify(goal: string): Promise<string | null> {
|
|
206
|
+
const provider = this.opts.provider;
|
|
207
|
+
if (!provider) return null;
|
|
208
|
+
const names = Object.keys(this.opts.agents);
|
|
209
|
+
const menu = names
|
|
210
|
+
.map((n) => `- ${n}: ${this.opts.descriptions?.[n] ?? 'handles ' + n + ' requests'}`)
|
|
211
|
+
.join('\n');
|
|
212
|
+
|
|
213
|
+
this.classifying = new AbortController();
|
|
214
|
+
try {
|
|
215
|
+
const reply = await provider.complete({
|
|
216
|
+
messages: [
|
|
217
|
+
{
|
|
218
|
+
role: 'system',
|
|
219
|
+
content:
|
|
220
|
+
'Choose which assistant should handle the message. ' +
|
|
221
|
+
`Answer with exactly one of these names and nothing else:\n${menu}`,
|
|
222
|
+
},
|
|
223
|
+
{ role: 'user', content: goal },
|
|
224
|
+
],
|
|
225
|
+
tools: [],
|
|
226
|
+
maxTokens: CLASSIFY_MAX_TOKENS,
|
|
227
|
+
signal: this.classifying.signal,
|
|
228
|
+
});
|
|
229
|
+
const said = reply.text.toLowerCase();
|
|
230
|
+
// Longest name first, so a shorter name that is a substring of another
|
|
231
|
+
// does not match in its place.
|
|
232
|
+
const found = [...names]
|
|
233
|
+
.sort((a, b) => b.length - a.length)
|
|
234
|
+
.find((n) => said.includes(n.toLowerCase()));
|
|
235
|
+
return found ?? null;
|
|
236
|
+
} catch {
|
|
237
|
+
// Routing must not fail a turn: the fallback covers a failed pass.
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern-based routing, used before falling back to a model classification.
|
|
3
|
+
*
|
|
4
|
+
* Matching a table of regular expressions costs microseconds, where a
|
|
5
|
+
* classification pass costs a generation. Rules run first and may abstain by
|
|
6
|
+
* returning a null route, which is what tells {@link AgentCoordinator} that a
|
|
7
|
+
* model pass is worth its cost.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** One pattern, and the agent it votes for. */
|
|
13
|
+
export interface RouteRule {
|
|
14
|
+
/** The agent this rule votes for. */
|
|
15
|
+
route: string;
|
|
16
|
+
/** Tested against the goal, lower-cased. */
|
|
17
|
+
match: RegExp;
|
|
18
|
+
/**
|
|
19
|
+
* How much this rule's vote counts. Defaults to 1.
|
|
20
|
+
*
|
|
21
|
+
* Raise it so a specific phrase outscores a general one when both match.
|
|
22
|
+
*/
|
|
23
|
+
weight?: number;
|
|
24
|
+
/** A note on the rule's purpose, carried through to {@link RouteDecision}. */
|
|
25
|
+
because?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The result of scoring a goal against a rule table. */
|
|
29
|
+
export interface RouteDecision {
|
|
30
|
+
/** The winning agent, or null when no rule matched or two rules tied. */
|
|
31
|
+
route: string | null;
|
|
32
|
+
/** Total score per route, highest first. Empty when nothing matched. */
|
|
33
|
+
scores: [string, number][];
|
|
34
|
+
/** Every rule that matched, in table order. */
|
|
35
|
+
matched: RouteRule[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Score a goal against a rule table and return the highest-scoring agent.
|
|
40
|
+
*
|
|
41
|
+
* Ties abstain rather than picking arbitrarily, since routing to an agent that
|
|
42
|
+
* lacks the required tool is more costly than the model pass that resolves the
|
|
43
|
+
* ambiguity.
|
|
44
|
+
*
|
|
45
|
+
* @param goal - The user's message. Matched lower-cased.
|
|
46
|
+
* @param rules - The table to score against.
|
|
47
|
+
* @returns The winning route, or a null route when nothing matched or the top
|
|
48
|
+
* two scores are equal.
|
|
49
|
+
*/
|
|
50
|
+
export function matchRoute(goal: string, rules: RouteRule[]): RouteDecision {
|
|
51
|
+
const text = goal.toLowerCase();
|
|
52
|
+
const matched = rules.filter((r) => r.match.test(text));
|
|
53
|
+
|
|
54
|
+
const totals = new Map<string, number>();
|
|
55
|
+
for (const rule of matched) {
|
|
56
|
+
totals.set(rule.route, (totals.get(rule.route) ?? 0) + (rule.weight ?? 1));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const scores = [...totals.entries()].sort((a, b) => b[1] - a[1]);
|
|
60
|
+
if (!scores.length) return { route: null, scores, matched };
|
|
61
|
+
if (scores.length > 1 && scores[0][1] === scores[1][1]) {
|
|
62
|
+
return { route: null, scores, matched };
|
|
63
|
+
}
|
|
64
|
+
return { route: scores[0][0], scores, matched };
|
|
65
|
+
}
|