@genesislcap/ai-assistant 15.6.2 → 15.7.0-ts7.1
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/dist/ai-assistant.api.json +392 -6
- package/dist/ai-assistant.d.ts +616 -24
- package/dist/chat-driver.cjs +315 -46
- package/dist/chat-driver.cjs.map +3 -3
- package/dist/chat-driver.mjs +315 -46
- package/dist/chat-driver.mjs.map +3 -3
- package/dist/custom-elements.json +254 -10
- package/dist/dts/channel/ai-activity-bus.d.ts.map +1 -1
- package/dist/dts/channel/ai-activity-channel.d.ts +51 -1
- package/dist/dts/channel/ai-activity-channel.d.ts.map +1 -1
- package/dist/dts/components/activity-halo/activity-halo.d.ts.map +1 -1
- package/dist/dts/components/agent-picker/agent-picker.d.ts.map +1 -1
- package/dist/dts/components/agent-picker/agent-picker.template.d.ts.map +1 -1
- package/dist/dts/components/chat-bubble/chat-bubble.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +99 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
- package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.d.ts.map +1 -1
- package/dist/dts/components/chat-markdown/chat-markdown.d.ts.map +1 -1
- package/dist/dts/components/flowing-waves-indicator.d.ts.map +1 -1
- package/dist/dts/components/halo-overlay.d.ts.map +1 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts +2 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts.map +1 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +14 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
- package/dist/dts/components/popout-manager/popout-manager.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
- package/dist/dts/components/waves-indicator.d.ts.map +1 -1
- package/dist/dts/main/blocked-state.test.d.ts +2 -0
- package/dist/dts/main/blocked-state.test.d.ts.map +1 -0
- package/dist/dts/main/main.d.ts +394 -6
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/main/main.styles.d.ts.map +1 -1
- package/dist/dts/main/main.styles.test.d.ts +2 -0
- package/dist/dts/main/main.styles.test.d.ts.map +1 -0
- package/dist/dts/main/main.template.d.ts +53 -0
- package/dist/dts/main/main.template.d.ts.map +1 -1
- package/dist/dts/main/main.types.d.ts +2 -2
- package/dist/dts/main/main.types.d.ts.map +1 -1
- package/dist/dts/state/ai-assistant-slice.d.ts +163 -7
- package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
- package/dist/dts/state/debug-event-log.d.ts +6 -1
- package/dist/dts/state/debug-event-log.d.ts.map +1 -1
- package/dist/dts/state/persistence/session-persistence-provider.d.ts.map +1 -1
- package/dist/dts/state/persistence/session-persister.d.ts.map +1 -1
- package/dist/dts/state/session-store.d.ts +12 -1
- package/dist/dts/state/session-store.d.ts.map +1 -1
- package/dist/dts/suggestions/chat-suggestions.d.ts.map +1 -1
- package/dist/dts/utils/animated-panel-toggle.d.ts.map +1 -1
- package/dist/dts/utils/message-partition.d.ts.map +1 -1
- package/dist/esm/components/agent-picker/agent-picker.js +2 -1
- package/dist/esm/components/agent-picker/agent-picker.template.js +2 -2
- package/dist/esm/components/chat-bubble/chat-bubble.js +2 -1
- package/dist/esm/components/chat-driver/align-event-globals.js +1 -0
- package/dist/esm/components/chat-driver/chat-driver.js +281 -38
- package/dist/esm/components/chat-driver/chat-driver.test.js +471 -14
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +312 -0
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +94 -9
- package/dist/esm/components/popout-manager/popout-manager.js +4 -1
- package/dist/esm/components/settings-modal/settings-modal.template.js +5 -8
- package/dist/esm/main/blocked-state.test.js +969 -0
- package/dist/esm/main/main.js +834 -104
- package/dist/esm/main/main.styles.js +47 -0
- package/dist/esm/main/main.styles.test.js +87 -0
- package/dist/esm/main/main.template.js +131 -20
- package/dist/esm/state/ai-assistant-slice.js +145 -7
- package/dist/esm/state/ai-assistant-slice.test.js +138 -1
- package/dist/esm/state/debug-event-log.js +9 -3
- package/dist/esm/state/debug-event-log.test.js +49 -1
- package/dist/esm/state/persistence/session-persister.js +13 -10
- package/dist/esm/state/persistence/session-snapshot.js +3 -2
- package/dist/esm/state/persistence/session-snapshot.test.js +18 -0
- package/dist/esm/utils/collect-session-models.test.js +2 -1
- package/dist/esm/utils/condense-history.js +6 -4
- package/dist/esm/utils/condense-history.test.js +15 -21
- package/dist/esm/utils/flatten-sub-agent-messages.js +4 -3
- package/dist/esm/utils/history-transform.js +2 -1
- package/dist/esm/utils/resolve-cost-history-config.js +4 -3
- package/dist/esm/utils/resolve-preference-baseline.js +2 -1
- package/dist/esm/utils/sum-usage.js +7 -6
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/docs/migration-GENC-1464.md +562 -0
- package/docs/sub_agent.md +20 -3
- package/package.json +17 -17
- package/src/channel/ai-activity-channel.ts +56 -2
- package/src/components/chat-driver/chat-driver.test.ts +549 -0
- package/src/components/chat-driver/chat-driver.ts +324 -14
- package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +438 -0
- package/src/components/orchestrating-driver/orchestrating-driver.ts +101 -6
- package/src/main/blocked-state.test.ts +1316 -0
- package/src/main/main.styles.test.ts +103 -0
- package/src/main/main.styles.ts +47 -0
- package/src/main/main.template.ts +131 -4
- package/src/main/main.ts +704 -10
- package/src/state/ai-assistant-slice.test.ts +215 -0
- package/src/state/ai-assistant-slice.ts +218 -8
- package/src/state/debug-event-log.test.ts +63 -0
- package/src/state/debug-event-log.ts +7 -2
- package/src/state/persistence/session-snapshot.test.ts +22 -0
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { __awaiter } from "tslib";
|
|
2
|
+
import { BudgetExhaustedError } from '@genesislcap/foundation-ai';
|
|
3
|
+
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
4
|
+
import { AgenticActivityBus } from '../../channel/ai-activity-bus';
|
|
5
|
+
// Side-effect import — MUST precede `./orchestrating-driver` so the driver
|
|
6
|
+
// subclasses jsdom's EventTarget (CustomEvent dispatch then works in node).
|
|
7
|
+
import '../chat-driver/align-event-globals';
|
|
8
|
+
import { OrchestratingDriver } from './orchestrating-driver';
|
|
9
|
+
// GENC-1464 — the orchestrated turn's OUTCOME.
|
|
10
|
+
//
|
|
11
|
+
// `createDriver` returns an `OrchestratingDriver` for every agents-configured
|
|
12
|
+
// host, and its `sendMessage` used to end with a hardcoded `return { reason:
|
|
13
|
+
// 'done' }`. That made every typed failure unreachable for those hosts —
|
|
14
|
+
// including `'budget-exhausted'`, which the assistant element latches its blocked
|
|
15
|
+
// state off. A 402 therefore produced an apology over a still-enabled composer
|
|
16
|
+
// for exactly the configurations this workstream exists to fix.
|
|
17
|
+
//
|
|
18
|
+
// The classifier compounds it: `classify()` is a SECOND retry ladder stacked on
|
|
19
|
+
// the transport's, so an untyped catch re-issued the 402 `classifierRetries + 1`
|
|
20
|
+
// times and then ran a fallback turn into the same wall.
|
|
21
|
+
const makeRegistry = (provider) => ({
|
|
22
|
+
get: () => provider,
|
|
23
|
+
default: () => provider,
|
|
24
|
+
defaultName: () => 'test',
|
|
25
|
+
names: () => ['test'],
|
|
26
|
+
getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
|
|
27
|
+
listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
|
|
28
|
+
});
|
|
29
|
+
const agent = (overrides) => (Object.assign({ description: 'test agent' }, overrides));
|
|
30
|
+
/** A provider that ends every turn immediately with a plain-text reply (no tools). */
|
|
31
|
+
const okProvider = () => ({
|
|
32
|
+
chat: () => __awaiter(void 0, void 0, void 0, function* () { return ({ role: 'assistant', content: 'ok' }); }),
|
|
33
|
+
});
|
|
34
|
+
/** A provider that counts calls and always refuses for budget. */
|
|
35
|
+
const walledProvider = (vendorLabel = 'Anthropic') => {
|
|
36
|
+
let n = 0;
|
|
37
|
+
return {
|
|
38
|
+
calls: () => n,
|
|
39
|
+
provider: {
|
|
40
|
+
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
41
|
+
n += 1;
|
|
42
|
+
throw new BudgetExhaustedError(vendorLabel, 25, 25.4);
|
|
43
|
+
}),
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
const failureReasonOf = (r) => r.reason === 'done' ? r.failureReason : undefined;
|
|
48
|
+
const Suite = createLogicSuite('orchestrating-driver budget wall');
|
|
49
|
+
// ── 1. The showstopper: a walled AGENT turn must reach the caller ───────────
|
|
50
|
+
Suite('a budget wall on the agent turn surfaces as the orchestrated result', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
51
|
+
// ONE specialist and no fallback, so `classify` short-circuits without calling
|
|
52
|
+
// the provider — the wall is hit by the AGENT turn, inside a real tool loop.
|
|
53
|
+
// Against the old hardcoded return this deep-equal fails.
|
|
54
|
+
const { provider } = walledProvider();
|
|
55
|
+
const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
|
|
56
|
+
sessionKey: 'budget-1',
|
|
57
|
+
});
|
|
58
|
+
const result = yield driver.sendMessage('go');
|
|
59
|
+
assert.equal(result, {
|
|
60
|
+
reason: 'done',
|
|
61
|
+
failureReason: 'budget-exhausted',
|
|
62
|
+
budget: { budgetUsd: 25, spentUsd: 25.4, vendorLabel: 'Anthropic', vendor: 'anthropic' },
|
|
63
|
+
});
|
|
64
|
+
}));
|
|
65
|
+
Suite("the vendor is the refusing transport's, not a hardcoded default", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
66
|
+
// The label rides the contract; `vendor` is its normalised key. Without a
|
|
67
|
+
// second vendor exercised here, a constant would pass every other assertion in
|
|
68
|
+
// this file — and per-vendor blocking would wall the wrong vendor forever.
|
|
69
|
+
const { provider } = walledProvider('Gemini');
|
|
70
|
+
const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
|
|
71
|
+
sessionKey: 'budget-gemini',
|
|
72
|
+
});
|
|
73
|
+
const result = yield driver.sendMessage('go');
|
|
74
|
+
assert.equal(result.reason === 'done' ? result.budget : undefined, {
|
|
75
|
+
budgetUsd: 25,
|
|
76
|
+
spentUsd: 25.4,
|
|
77
|
+
vendorLabel: 'Gemini',
|
|
78
|
+
vendor: 'gemini',
|
|
79
|
+
});
|
|
80
|
+
}));
|
|
81
|
+
Suite('an unrecognised vendor label carries no typed vendor at all', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
82
|
+
// Attribution degrades to "unattributable" rather than to a wrong vendor —
|
|
83
|
+
// which makes the element fall back to its vendor-agnostic block. Safe.
|
|
84
|
+
const { provider } = walledProvider('Acme AI');
|
|
85
|
+
const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
|
|
86
|
+
sessionKey: 'budget-unknown-vendor',
|
|
87
|
+
});
|
|
88
|
+
const result = yield driver.sendMessage('go');
|
|
89
|
+
assert.equal(result.reason === 'done' ? result.budget : undefined, {
|
|
90
|
+
budgetUsd: 25,
|
|
91
|
+
spentUsd: 25.4,
|
|
92
|
+
vendorLabel: 'Acme AI',
|
|
93
|
+
});
|
|
94
|
+
}));
|
|
95
|
+
Suite('the wall also reaches the tool-loop-end bus detail through the orchestrator', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
96
|
+
var _a;
|
|
97
|
+
const bus = new AgenticActivityBus();
|
|
98
|
+
const seen = [];
|
|
99
|
+
const stop = bus.subscribe('tool-loop-end', (d) => seen.push(d));
|
|
100
|
+
const { provider } = walledProvider();
|
|
101
|
+
const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
|
|
102
|
+
sessionKey: 'budget-2',
|
|
103
|
+
activityBus: bus,
|
|
104
|
+
});
|
|
105
|
+
yield driver.sendMessage('go');
|
|
106
|
+
assert.is(seen.length, 1, 'exactly one tool-loop-end for the one agent turn');
|
|
107
|
+
assert.is((_a = seen[0]) === null || _a === void 0 ? void 0 : _a.failureReason, 'budget-exhausted');
|
|
108
|
+
stop();
|
|
109
|
+
bus.close();
|
|
110
|
+
}));
|
|
111
|
+
Suite('the bus detail carries the proxy figures, not just the failure reason', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
112
|
+
var _a, _b, _c;
|
|
113
|
+
// The element latches off THIS event for an in-loop wall — the publish sits in
|
|
114
|
+
// sendMessage's `finally`, so it lands before the return-value seam and the
|
|
115
|
+
// idempotent latch makes the later one a no-op. If the figures rode only the
|
|
116
|
+
// return value they would be unreachable in the common case and the banner would
|
|
117
|
+
// show the generic copy in exactly the path they were added for.
|
|
118
|
+
const bus = new AgenticActivityBus();
|
|
119
|
+
const seen = [];
|
|
120
|
+
const stop = bus.subscribe('tool-loop-end', (d) => seen.push(d));
|
|
121
|
+
const { provider } = walledProvider();
|
|
122
|
+
const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
|
|
123
|
+
sessionKey: 'budget-figures',
|
|
124
|
+
activityBus: bus,
|
|
125
|
+
});
|
|
126
|
+
const result = yield driver.sendMessage('go');
|
|
127
|
+
assert.equal((_a = seen[0]) === null || _a === void 0 ? void 0 : _a.budget, { budgetUsd: 25, spentUsd: 25.4, vendorLabel: 'Anthropic', vendor: 'anthropic' }, 'the event carries the same figures as the return value');
|
|
128
|
+
assert.is((_b = seen[0]) === null || _b === void 0 ? void 0 : _b.vendor, 'anthropic', 'and the top-level vendor agrees with the refusing transport, not with a stale last-resolved provider');
|
|
129
|
+
assert.equal(result.reason === 'done' ? result.budget : undefined, (_c = seen[0]) === null || _c === void 0 ? void 0 : _c.budget, 'event and return value agree — neither is the sole carrier');
|
|
130
|
+
stop();
|
|
131
|
+
bus.close();
|
|
132
|
+
}));
|
|
133
|
+
Suite('a clean turn publishes no budget key at all', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
134
|
+
const bus = new AgenticActivityBus();
|
|
135
|
+
const seen = [];
|
|
136
|
+
const stop = bus.subscribe('tool-loop-end', (d) => seen.push(d));
|
|
137
|
+
const provider = {
|
|
138
|
+
chat: () => __awaiter(void 0, void 0, void 0, function* () { return ({ role: 'assistant', content: 'ok' }); }),
|
|
139
|
+
};
|
|
140
|
+
const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
|
|
141
|
+
sessionKey: 'budget-clean',
|
|
142
|
+
activityBus: bus,
|
|
143
|
+
});
|
|
144
|
+
yield driver.sendMessage('go');
|
|
145
|
+
assert.ok(seen.every((d) => d === undefined || !('budget' in d)), 'no budget key on a non-budget turn — the historical detail shape is unchanged');
|
|
146
|
+
stop();
|
|
147
|
+
bus.close();
|
|
148
|
+
}));
|
|
149
|
+
// ── 2. No regression on the happy / handoff paths ───────────────────────────
|
|
150
|
+
Suite('a clean turn still returns the bare legacy shape', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
151
|
+
const driver = new OrchestratingDriver(makeRegistry(okProvider()), [agent({ name: 'Solo' })], {
|
|
152
|
+
sessionKey: 'budget-3',
|
|
153
|
+
});
|
|
154
|
+
const result = yield driver.sendMessage('go');
|
|
155
|
+
assert.equal(result, { reason: 'done' }, 'no failureReason on a clean turn');
|
|
156
|
+
}));
|
|
157
|
+
Suite('a handoff-capped turn never leaks agent-handoff to the caller', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
158
|
+
// `'agent-handoff'` is an internal routing protocol. The two breaks that hold a
|
|
159
|
+
// non-'done' inner result both carry it, so the collapse rule in `sendMessage`
|
|
160
|
+
// is what keeps "report the turn that ended the loop" from changing the
|
|
161
|
+
// discriminant a host matches on.
|
|
162
|
+
let n = 0;
|
|
163
|
+
const nextId = () => {
|
|
164
|
+
n += 1;
|
|
165
|
+
return n;
|
|
166
|
+
};
|
|
167
|
+
const handoffProvider = {
|
|
168
|
+
chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
169
|
+
var _a;
|
|
170
|
+
// The classifier call carries the routing tool; answer it with index 0.
|
|
171
|
+
if ((_a = options === null || options === void 0 ? void 0 : options.tools) === null || _a === void 0 ? void 0 : _a.some((t) => t.name === 'select_agent')) {
|
|
172
|
+
return {
|
|
173
|
+
role: 'assistant',
|
|
174
|
+
content: '',
|
|
175
|
+
toolCalls: [{ id: `c-${nextId()}`, name: 'select_agent', args: { agent_index: 0 } }],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
// Every agent turn asks to hand off, so the handoff cap is what ends it.
|
|
179
|
+
return {
|
|
180
|
+
role: 'assistant',
|
|
181
|
+
content: '',
|
|
182
|
+
toolCalls: [
|
|
183
|
+
{
|
|
184
|
+
id: `h-${nextId()}`,
|
|
185
|
+
name: 'request_continuation',
|
|
186
|
+
args: { summary: 's', remaining_task: 'more' },
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
};
|
|
190
|
+
}),
|
|
191
|
+
};
|
|
192
|
+
const driver = new OrchestratingDriver(makeRegistry(handoffProvider), [agent({ name: 'A' }), agent({ name: 'B' })], { sessionKey: 'budget-4', maxHandoffs: 1 });
|
|
193
|
+
const result = yield driver.sendMessage('go');
|
|
194
|
+
assert.is(result.reason, 'done', 'the internal handoff protocol never escapes');
|
|
195
|
+
}));
|
|
196
|
+
Suite('a wall on a post-handoff classify does not duplicate the user message', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
197
|
+
// `classify()` runs at TWO seams. Before the first agent turn the user's
|
|
198
|
+
// message is unappended (only the optimistic `history-updated` echo exists),
|
|
199
|
+
// so the wall-catch must append it. At the END of a handoff iteration
|
|
200
|
+
// `chatDriver.sendMessage` has already appended it — passing it again
|
|
201
|
+
// produced ['user','assistant','tool','user','assistant'] with 'go' twice,
|
|
202
|
+
// persisting into the snapshot and into the history sent to the model once
|
|
203
|
+
// the budget was raised. The single-specialist narrowness guard below cannot
|
|
204
|
+
// catch this: with one specialist, classify short-circuits and the handoff
|
|
205
|
+
// seam never runs.
|
|
206
|
+
let n = 0;
|
|
207
|
+
const provider = {
|
|
208
|
+
chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
209
|
+
var _a;
|
|
210
|
+
n += 1;
|
|
211
|
+
if ((_a = options === null || options === void 0 ? void 0 : options.tools) === null || _a === void 0 ? void 0 : _a.some((t) => t.name === 'select_agent')) {
|
|
212
|
+
// First classify routes; the post-handoff classify hits the wall.
|
|
213
|
+
if (n > 2)
|
|
214
|
+
throw new BudgetExhaustedError('Anthropic', 25, 25.4);
|
|
215
|
+
return {
|
|
216
|
+
role: 'assistant',
|
|
217
|
+
content: '',
|
|
218
|
+
toolCalls: [{ id: `c-${n}`, name: 'select_agent', args: { agent_index: 0 } }],
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
// The agent turn asks to hand off, forcing the second classify.
|
|
222
|
+
return {
|
|
223
|
+
role: 'assistant',
|
|
224
|
+
content: '',
|
|
225
|
+
toolCalls: [
|
|
226
|
+
{
|
|
227
|
+
id: `h-${n}`,
|
|
228
|
+
name: 'request_continuation',
|
|
229
|
+
args: { summary: 's', remaining_task: 'more' },
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
};
|
|
233
|
+
}),
|
|
234
|
+
};
|
|
235
|
+
const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'A' }), agent({ name: 'B' })], { sessionKey: 'budget-4b' });
|
|
236
|
+
const result = yield driver.sendMessage('go');
|
|
237
|
+
assert.is(failureReasonOf(result), 'budget-exhausted');
|
|
238
|
+
const history = driver.getRawHistory();
|
|
239
|
+
const userMessages = history.filter((m) => m.role === 'user' && m.content === 'go');
|
|
240
|
+
assert.is(userMessages.length, 1, "the user's message appears exactly once");
|
|
241
|
+
}));
|
|
242
|
+
// ── 3. The classifier's own retry ladder ───────────────────────────────────
|
|
243
|
+
Suite('a wall during classification costs exactly one provider call', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
244
|
+
// TWO specialists and no fallback, so `classify` really runs. Before the narrow
|
|
245
|
+
// rethrow this was FOUR calls: `classifierRetries + 1` classifier attempts, then
|
|
246
|
+
// a doomed agent turn.
|
|
247
|
+
const { calls, provider } = walledProvider();
|
|
248
|
+
const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'A' }), agent({ name: 'B' })], { sessionKey: 'budget-5' });
|
|
249
|
+
const result = yield driver.sendMessage('go');
|
|
250
|
+
assert.is(calls(), 1, 'no ladder clears a budget wall — do not climb it');
|
|
251
|
+
assert.is(failureReasonOf(result), 'budget-exhausted');
|
|
252
|
+
}));
|
|
253
|
+
Suite('a classify-time wall lands on the transcript, without a routing apology', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
254
|
+
// The old ordering emitted "I'm not sure how to help with that…" from the
|
|
255
|
+
// no-fallback exit and THEN routed anyway, so the user read a routing excuse
|
|
256
|
+
// the classifier never earned, immediately above the real error.
|
|
257
|
+
const { provider } = walledProvider();
|
|
258
|
+
const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'A' }), agent({ name: 'B' })], { sessionKey: 'budget-6' });
|
|
259
|
+
yield driver.sendMessage('go');
|
|
260
|
+
const history = driver.getRawHistory();
|
|
261
|
+
const text = history.map((m) => m.content).join('\n');
|
|
262
|
+
assert.ok(text.includes('AI usage limit'), 'the real reason is what the user reads');
|
|
263
|
+
assert.not.ok(text.includes("I'm not sure how to help with that"));
|
|
264
|
+
// The user's own message must survive. `runOrchestratedTurn` dispatches it
|
|
265
|
+
// optimistically and leaves the real append to `chatDriver.sendMessage`, which
|
|
266
|
+
// a classify-time wall never reaches — so the driver's history held only the
|
|
267
|
+
// assistant bubble and the next render dropped what the user had typed.
|
|
268
|
+
assert.equal(history.map((m) => m.role), ['user', 'assistant'], 'the question stays above the answer');
|
|
269
|
+
assert.is(history[0].content, 'go');
|
|
270
|
+
}));
|
|
271
|
+
Suite('an in-loop wall does not double-append the user message', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
272
|
+
// Narrowness guard for the append above: `reportBudgetExhausted` also serves
|
|
273
|
+
// the in-loop path, where `sendMessage` has ALREADY appended the user message.
|
|
274
|
+
// Only the classify seam passes one, so this path must be unchanged. ONE
|
|
275
|
+
// specialist and no fallback, so `classify` short-circuits and the wall is hit
|
|
276
|
+
// inside a real tool loop.
|
|
277
|
+
const { provider } = walledProvider();
|
|
278
|
+
const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
|
|
279
|
+
sessionKey: 'budget-6b',
|
|
280
|
+
});
|
|
281
|
+
yield driver.sendMessage('go');
|
|
282
|
+
const history = driver.getRawHistory();
|
|
283
|
+
assert.is(history.filter((m) => m.role === 'user').length, 1, 'one user message, not two');
|
|
284
|
+
}));
|
|
285
|
+
Suite('a wall during classification never starts the fallback turn', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
286
|
+
const { calls, provider } = walledProvider();
|
|
287
|
+
const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'A' }), agent({ name: 'B' }), agent({ name: 'Catch-all', fallback: true })], { sessionKey: 'budget-7' });
|
|
288
|
+
const result = yield driver.sendMessage('go');
|
|
289
|
+
assert.is(calls(), 1, 'the fallback agent is not run into the same wall');
|
|
290
|
+
assert.is(failureReasonOf(result), 'budget-exhausted');
|
|
291
|
+
}));
|
|
292
|
+
Suite('a plain classifier failure still retries and falls back (narrowness guard)', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
293
|
+
// Proves the rethrow did not make every classify failure terminal: only the
|
|
294
|
+
// budget wall short-circuits, because only the budget wall is unclearable.
|
|
295
|
+
let classifierCalls = 0;
|
|
296
|
+
const provider = {
|
|
297
|
+
chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
298
|
+
var _a;
|
|
299
|
+
if ((_a = options === null || options === void 0 ? void 0 : options.tools) === null || _a === void 0 ? void 0 : _a.some((t) => t.name === 'select_agent')) {
|
|
300
|
+
classifierCalls += 1;
|
|
301
|
+
throw new Error('classifier is having a moment');
|
|
302
|
+
}
|
|
303
|
+
return { role: 'assistant', content: 'fallback handled it' };
|
|
304
|
+
}),
|
|
305
|
+
};
|
|
306
|
+
const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'A' }), agent({ name: 'B' }), agent({ name: 'Catch-all', fallback: true })], { sessionKey: 'budget-8' });
|
|
307
|
+
const result = yield driver.sendMessage('go');
|
|
308
|
+
assert.is(classifierCalls, 3, 'classifierRetries (2) + the first attempt');
|
|
309
|
+
assert.is(result.reason, 'done');
|
|
310
|
+
assert.is(failureReasonOf(result), undefined, 'the fallback turn succeeded');
|
|
311
|
+
}));
|
|
312
|
+
Suite.run();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { __awaiter } from "tslib";
|
|
2
|
+
import { BudgetExhaustedError } from '@genesislcap/foundation-ai';
|
|
2
3
|
import { validateStaticAgentProviders } from '../../config/validate-providers';
|
|
3
4
|
import { recordMetaEvent } from '../../state/debug-event-log';
|
|
4
5
|
import { transformHistoryForAgent } from '../../utils/history-transform';
|
|
@@ -68,6 +69,17 @@ export class OrchestratingDriver extends EventTarget {
|
|
|
68
69
|
* user stops. Reset at the start of each `sendMessage`.
|
|
69
70
|
*/
|
|
70
71
|
this.cancelled = false;
|
|
72
|
+
/**
|
|
73
|
+
* Whether the CURRENT turn's user message has reached the inner driver's
|
|
74
|
+
* history. False through the pre-first-turn classify (where the only echo of
|
|
75
|
+
* the message is an optimistic `history-updated` dispatch), true from the
|
|
76
|
+
* moment `chatDriver.sendMessage` is entered — including through every later
|
|
77
|
+
* handoff classify. Read by the budget-wall catch in `sendMessage` to decide
|
|
78
|
+
* whether `reportBudgetExhausted` must append the message itself: appending
|
|
79
|
+
* it when already appended duplicated it; not appending it when unappended
|
|
80
|
+
* made it vanish. Reset at the top of each `runOrchestratedTurn`.
|
|
81
|
+
*/
|
|
82
|
+
this.userMessageAppended = false;
|
|
71
83
|
/**
|
|
72
84
|
* Sticky user pick from the picker (or the host's `setAgent` API). Only
|
|
73
85
|
* changes on explicit user action. Survives flow completion: when a stateful
|
|
@@ -92,8 +104,7 @@ export class OrchestratingDriver extends EventTarget {
|
|
|
92
104
|
this.restoredAgentStates = {};
|
|
93
105
|
this.sessionKey = (_a = options.sessionKey) !== null && _a !== void 0 ? _a : '';
|
|
94
106
|
this.maxHandoffs = (_b = options.maxHandoffs) !== null && _b !== void 0 ? _b : DEFAULT_MAX_HANDOFFS;
|
|
95
|
-
this.classifierHistoryLength =
|
|
96
|
-
(_c = options.classifierHistoryLength) !== null && _c !== void 0 ? _c : DEFAULT_CLASSIFIER_HISTORY_LENGTH;
|
|
107
|
+
this.classifierHistoryLength = (_c = options.classifierHistoryLength) !== null && _c !== void 0 ? _c : DEFAULT_CLASSIFIER_HISTORY_LENGTH;
|
|
97
108
|
this.classifierRetries = (_d = options.classifierRetries) !== null && _d !== void 0 ? _d : DEFAULT_CLASSIFIER_RETRIES;
|
|
98
109
|
// Static-name validation: walk every agent (and nested sub-agents). Any
|
|
99
110
|
// `provider: '<name>'` that isn't registered, or resolves to a provider
|
|
@@ -115,6 +126,7 @@ export class OrchestratingDriver extends EventTarget {
|
|
|
115
126
|
maxTurnSnapshots: options.maxTurnSnapshots,
|
|
116
127
|
sessionKey: this.sessionKey,
|
|
117
128
|
activityBus: options.activityBus,
|
|
129
|
+
budgetExhaustedMessage: options.budgetExhaustedMessage,
|
|
118
130
|
});
|
|
119
131
|
// Proxy events from the shared driver
|
|
120
132
|
this.chatDriver.addEventListener('history-updated', (e) => {
|
|
@@ -210,8 +222,39 @@ export class OrchestratingDriver extends EventTarget {
|
|
|
210
222
|
});
|
|
211
223
|
}
|
|
212
224
|
sendMessage(input, attachments) {
|
|
225
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
226
|
+
try {
|
|
227
|
+
return yield this.runOrchestratedTurn(input, attachments);
|
|
228
|
+
}
|
|
229
|
+
catch (e) {
|
|
230
|
+
// The classifier calls the provider directly, outside any ChatDriver turn,
|
|
231
|
+
// so a budget wall raised there would escape as an unhandled rejection —
|
|
232
|
+
// `FoundationAiAssistant.send()` has a `finally` but no `catch`. Land it on
|
|
233
|
+
// the driver that owns the transcript, which appends the user-facing copy
|
|
234
|
+
// and returns the same terminal result an in-loop wall would.
|
|
235
|
+
//
|
|
236
|
+
// The user's message rides along ONLY while it is genuinely unappended.
|
|
237
|
+
// `classify()` runs at two seams: before the first agent turn — where the
|
|
238
|
+
// `history-updated` dispatch in `runOrchestratedTurn` is optimistic only
|
|
239
|
+
// and the real append lives inside `chatDriver.sendMessage`, which the
|
|
240
|
+
// wall prevents from ever running — and again at the END of every handoff
|
|
241
|
+
// iteration, by which point `chatDriver.sendMessage` HAS appended it.
|
|
242
|
+
// Passing the message unconditionally duplicated it on the handoff seam:
|
|
243
|
+
// `['user','assistant','tool','user','assistant']` in the transcript, the
|
|
244
|
+
// snapshot, and the history sent to the model once the budget is raised.
|
|
245
|
+
if (e instanceof BudgetExhaustedError) {
|
|
246
|
+
return this.chatDriver.reportBudgetExhausted(e, this.userMessageAppended
|
|
247
|
+
? undefined
|
|
248
|
+
: Object.assign({ role: 'user', content: input }, (attachments ? { attachments } : {})));
|
|
249
|
+
}
|
|
250
|
+
throw e;
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
runOrchestratedTurn(input, attachments) {
|
|
213
255
|
return __awaiter(this, void 0, void 0, function* () {
|
|
214
256
|
this.cancelled = false;
|
|
257
|
+
this.userMessageAppended = false;
|
|
215
258
|
const history = this.chatDriver.getHistory();
|
|
216
259
|
// Emit the user message immediately so the UI reflects it during the classify
|
|
217
260
|
// round-trip — without this the chat appears frozen until classify returns.
|
|
@@ -226,6 +269,8 @@ export class OrchestratingDriver extends EventTarget {
|
|
|
226
269
|
let handoffs = 0;
|
|
227
270
|
let handoffSummary = '';
|
|
228
271
|
let remainingTask = '';
|
|
272
|
+
// The outcome of the agent turn that ENDS the loop — see the return below.
|
|
273
|
+
let lastResult;
|
|
229
274
|
while (true) {
|
|
230
275
|
// Cancelled before a (next) agent turn started — e.g. during classify.
|
|
231
276
|
// The chat driver appends its own "Stopped." marker when a turn it was
|
|
@@ -243,9 +288,15 @@ export class OrchestratingDriver extends EventTarget {
|
|
|
243
288
|
result = yield this.chatDriver.continueFromHistory(contextPrimer);
|
|
244
289
|
}
|
|
245
290
|
else {
|
|
291
|
+
// From here the inner driver owns the user message — it appends it as
|
|
292
|
+
// its first act, before any provider call can throw. Set BEFORE the
|
|
293
|
+
// await so a wall raised anywhere inside the turn (or on a later
|
|
294
|
+
// handoff classify) sees the append as done.
|
|
295
|
+
this.userMessageAppended = true;
|
|
246
296
|
// oxlint-disable-next-line no-await-in-loop
|
|
247
297
|
result = yield this.chatDriver.sendMessage(input, attachments);
|
|
248
298
|
}
|
|
299
|
+
lastResult = result;
|
|
249
300
|
// Release check: a stateful agent called `releaseAgent` from a terminal
|
|
250
301
|
// tool handler. Fire onDeactivate, clear the pin, drop the user back to
|
|
251
302
|
// classifier-mode. The LLM has already emitted its final wrap-up message
|
|
@@ -273,7 +324,20 @@ export class OrchestratingDriver extends EventTarget {
|
|
|
273
324
|
// oxlint-disable-next-line no-await-in-loop
|
|
274
325
|
currentAgent = yield this.classify(remainingTask, updatedHistory);
|
|
275
326
|
}
|
|
276
|
-
|
|
327
|
+
// The orchestrated turn reports the outcome of the agent turn that ENDED it.
|
|
328
|
+
// The hardcoded `{ reason: 'done' }` that used to live here made every
|
|
329
|
+
// typed failure unreachable for an agents-configured host — including
|
|
330
|
+
// `'budget-exhausted'`, which the element latches its blocked state off.
|
|
331
|
+
//
|
|
332
|
+
// "Last wins" cannot hide an earlier failure behind a later success: a
|
|
333
|
+
// failing inner turn always ends the loop, because the loop only continues
|
|
334
|
+
// on `reason === 'agent-handoff'`. The two breaks that hold a non-'done'
|
|
335
|
+
// result (the handoff cap, and the pinned/fallback break) both carry
|
|
336
|
+
// `'agent-handoff'`, which is an internal routing protocol that must never
|
|
337
|
+
// escape to the host — the collapse below strips it. A cancel before the
|
|
338
|
+
// first turn leaves `lastResult` undefined; the release break returns the
|
|
339
|
+
// inner turn's real result, which is correct, because that turn ran.
|
|
340
|
+
return (lastResult === null || lastResult === void 0 ? void 0 : lastResult.reason) === 'done' ? lastResult : { reason: 'done' };
|
|
277
341
|
});
|
|
278
342
|
}
|
|
279
343
|
continueFromHistory(transientPrimer) {
|
|
@@ -464,7 +528,8 @@ export class OrchestratingDriver extends EventTarget {
|
|
|
464
528
|
}
|
|
465
529
|
classify(input, history, contextAgent) {
|
|
466
530
|
return __awaiter(this, void 0, void 0, function* () {
|
|
467
|
-
var _a
|
|
531
|
+
var _a;
|
|
532
|
+
var _b;
|
|
468
533
|
// Single-candidate short-circuits. No point asking the LLM to route
|
|
469
534
|
// when there's only one viable choice. Skipped if a fallback is
|
|
470
535
|
// configured — that's an explicit "escape hatch" signal from the
|
|
@@ -486,7 +551,7 @@ export class OrchestratingDriver extends EventTarget {
|
|
|
486
551
|
return routable[0];
|
|
487
552
|
}
|
|
488
553
|
}
|
|
489
|
-
return (
|
|
554
|
+
return (_b = this.fallback) !== null && _b !== void 0 ? _b : { name: 'Assistant', fallback: true };
|
|
490
555
|
}
|
|
491
556
|
const agentList = this.specialists
|
|
492
557
|
.map((a, i) => `${i}: ${a.name} — ${a.description}`)
|
|
@@ -514,6 +579,11 @@ export class OrchestratingDriver extends EventTarget {
|
|
|
514
579
|
},
|
|
515
580
|
},
|
|
516
581
|
};
|
|
582
|
+
// True only when the classifier RAN and answered "no match" (index -1). The
|
|
583
|
+
// no-fallback apology below is gated on it: an exhausted-retries exit never
|
|
584
|
+
// earned that apology, and emitting it there put a routing excuse ahead of
|
|
585
|
+
// the real error the routed turn is about to produce.
|
|
586
|
+
let routedNoMatch = false;
|
|
517
587
|
for (let attempt = 0; attempt <= this.classifierRetries; attempt += 1) {
|
|
518
588
|
try {
|
|
519
589
|
const options = {
|
|
@@ -529,15 +599,24 @@ export class OrchestratingDriver extends EventTarget {
|
|
|
529
599
|
}
|
|
530
600
|
// oxlint-disable-next-line no-await-in-loop
|
|
531
601
|
const response = yield classifierProvider.chat([], input, options);
|
|
532
|
-
const tc = (
|
|
602
|
+
const tc = (_a = response.toolCalls) === null || _a === void 0 ? void 0 : _a[0];
|
|
533
603
|
const index = (tc === null || tc === void 0 ? void 0 : tc.name) === 'select_agent' ? tc.args.agent_index : -1;
|
|
534
604
|
if (index >= 0 && index < this.specialists.length) {
|
|
535
605
|
return this.specialists[index];
|
|
536
606
|
}
|
|
537
607
|
// index === -1 — fall through to fallback
|
|
608
|
+
routedNoMatch = true;
|
|
538
609
|
break;
|
|
539
610
|
}
|
|
540
611
|
catch (e) {
|
|
612
|
+
// Terminal: no ladder clears a budget wall. Short-circuit the
|
|
613
|
+
// classifier's own retries the same way 402 short-circuits the
|
|
614
|
+
// transport's — this loop is a SECOND retry ladder stacked on that one,
|
|
615
|
+
// so without this a single wall costs `classifierRetries + 1` doomed
|
|
616
|
+
// classifier calls and then a doomed fallback turn on top. Deliberately
|
|
617
|
+
// narrow: a generic classifier failure must still retry and fall back.
|
|
618
|
+
if (e instanceof BudgetExhaustedError)
|
|
619
|
+
throw e;
|
|
541
620
|
logger.warn(`OrchestratingDriver: classifier attempt ${attempt + 1} failed:`, e);
|
|
542
621
|
if (attempt === this.classifierRetries) {
|
|
543
622
|
logger.error('OrchestratingDriver: classifier failed after all retries, using fallback');
|
|
@@ -546,9 +625,15 @@ export class OrchestratingDriver extends EventTarget {
|
|
|
546
625
|
}
|
|
547
626
|
if (this.fallback)
|
|
548
627
|
return this.fallback;
|
|
549
|
-
// No fallback configured
|
|
550
|
-
|
|
551
|
-
|
|
628
|
+
// No fallback configured. Route to the first specialist as a no-op — but
|
|
629
|
+
// only APOLOGISE when the classifier actually said "none of these". On an
|
|
630
|
+
// exhausted-retries exit the classifier never expressed an opinion, so the
|
|
631
|
+
// apology would be a routing excuse the user reads immediately above the
|
|
632
|
+
// real error that turn is about to produce.
|
|
633
|
+
if (routedNoMatch) {
|
|
634
|
+
const specialistNames = this.specialists.map((s) => s.name).join(', ');
|
|
635
|
+
this.appendInlineMessage(`I'm not sure how to help with that. I can assist with: ${specialistNames}.`);
|
|
636
|
+
}
|
|
552
637
|
return this.specialists[0];
|
|
553
638
|
});
|
|
554
639
|
}
|
|
@@ -44,6 +44,9 @@ let FoundationAiPopoutManager = class FoundationAiPopoutManager extends GenesisE
|
|
|
44
44
|
this.isDocked = false;
|
|
45
45
|
}
|
|
46
46
|
connectedCallback() {
|
|
47
|
+
const _super = Object.create(null, {
|
|
48
|
+
connectedCallback: { get: () => super.connectedCallback }
|
|
49
|
+
});
|
|
47
50
|
super.connectedCallback();
|
|
48
51
|
// oxlint-disable-next-line typescript/no-this-alias -- module-level singleton, not a local alias
|
|
49
52
|
_aiPopoutManager = this;
|
|
@@ -81,7 +84,7 @@ let FoundationAiPopoutManager = class FoundationAiPopoutManager extends GenesisE
|
|
|
81
84
|
bubble.appendChild(this.assistantEl);
|
|
82
85
|
}
|
|
83
86
|
this.syncBubbleVisibility();
|
|
84
|
-
yield (
|
|
87
|
+
yield (_a = this.dockProvider) === null || _a === void 0 ? void 0 : _a.onUndock();
|
|
85
88
|
}));
|
|
86
89
|
this.unsubBus = () => {
|
|
87
90
|
unsubPopout();
|
|
@@ -92,12 +92,9 @@ const chatBotToggleRow = (title, description, switchTag, part, checked, onChange
|
|
|
92
92
|
></${switchTag}>
|
|
93
93
|
`);
|
|
94
94
|
const platformContextUsageTemplate = (progressTag) => html `
|
|
95
|
-
${when((x) => {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
x.contextTokens != null &&
|
|
99
|
-
x.contextLimit != null;
|
|
100
|
-
}, html `
|
|
95
|
+
${when((x) => { var _a; return ((_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.showContextUsage) !== false &&
|
|
96
|
+
x.contextTokens != null &&
|
|
97
|
+
x.contextLimit != null; }, html `
|
|
101
98
|
<div class="settings-context-usage" part="context-usage">
|
|
102
99
|
<div class="settings-context-header">
|
|
103
100
|
<span class="settings-context-label">Context window</span>
|
|
@@ -167,7 +164,7 @@ const appSettingsTemplate = (switchTag, iconTag) => html `
|
|
|
167
164
|
<${switchTag}
|
|
168
165
|
part="${(toggle) => { var _a; return (_a = toggle.part) !== null && _a !== void 0 ? _a : `app-toggle-${toggle.id}`; }}"
|
|
169
166
|
:checked=${(toggle, c) => c.parent.getAppSettingValue(toggle.id)}
|
|
170
|
-
?disabled=${(toggle) => { var _a
|
|
167
|
+
?disabled=${(toggle) => { var _a; var _b; return (_b = (_a = toggle.disabled) === null || _a === void 0 ? void 0 : _a.call(toggle)) !== null && _b !== void 0 ? _b : false; }}
|
|
171
168
|
@change=${(toggle, c) => {
|
|
172
169
|
c.parent.setAppSettingValue(toggle.id, readSwitchChecked(c.event));
|
|
173
170
|
}}
|
|
@@ -175,7 +172,7 @@ const appSettingsTemplate = (switchTag, iconTag) => html `
|
|
|
175
172
|
</div>
|
|
176
173
|
</div>
|
|
177
174
|
${when((toggle) => { var _a; return !!((_a = toggle.hint) === null || _a === void 0 ? void 0 : _a.call(toggle)); }, html `
|
|
178
|
-
<div class="settings-toggle-hint">${(toggle) => { var _a
|
|
175
|
+
<div class="settings-toggle-hint">${(toggle) => { var _a; var _b; return (_b = (_a = toggle.hint) === null || _a === void 0 ? void 0 : _a.call(toggle)) !== null && _b !== void 0 ? _b : ''; }}</div>
|
|
179
176
|
`)}
|
|
180
177
|
</div>
|
|
181
178
|
`)}
|