@genesislcap/ai-assistant 15.14.0 → 15.14.2
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 +37 -1
- package/dist/ai-assistant.d.ts +55 -0
- package/dist/chat-driver.cjs +285 -9
- package/dist/chat-driver.cjs.map +3 -3
- package/dist/chat-driver.mjs +282 -9
- package/dist/chat-driver.mjs.map +3 -3
- package/dist/custom-elements.json +101 -3
- package/dist/dts/chat-driver-node.d.ts +1 -1
- package/dist/dts/chat-driver-node.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +54 -0
- 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/esm/chat-driver-node.js +9 -1
- package/dist/esm/components/chat-driver/chat-driver.js +154 -22
- package/dist/esm/components/chat-driver/chat-driver.test.js +210 -1
- package/dist/esm/state/debug-event-log.js +1 -1
- package/dist/esm/state/debug-event-log.test.js +2 -0
- package/docs/migration-GENC-1506.md +209 -0
- package/package.json +17 -17
- package/src/chat-driver-node.ts +10 -0
- package/src/components/chat-driver/chat-driver.test.ts +294 -0
- package/src/components/chat-driver/chat-driver.ts +215 -9
- package/src/state/debug-event-log.test.ts +2 -0
- package/src/state/debug-event-log.ts +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { __awaiter, __rest } from "tslib";
|
|
2
|
-
import { BudgetExhaustedError, DEFAULT_BUDGET_EXHAUSTED_MESSAGE, isObservableAIProviderRegistry, MalformedFunctionCallError, ResponseTruncatedError, vendorTypeOfLabel, } from '@genesislcap/foundation-ai';
|
|
2
|
+
import { BudgetExhaustedError, DEFAULT_BUDGET_EXHAUSTED_MESSAGE, DEFAULT_PROVIDER_REFUSED_MESSAGE, ProviderRefusedError, isObservableAIProviderRegistry, MalformedFunctionCallError, ResponseTruncatedError, vendorTypeOfLabel, } from '@genesislcap/foundation-ai';
|
|
3
3
|
import { NOOP_ACTIVITY_BUS } from '../../channel/ai-activity-bus';
|
|
4
4
|
import { resolveChatProvider } from '../../config/validate-providers';
|
|
5
5
|
import { clearSession, getMetaEvents, mergeMetaEvents, recordMetaEvent, recordTurnError, recordTurnRetry, } from '../../state/debug-event-log';
|
|
@@ -28,6 +28,15 @@ import { TOOL_FOLD_SYMBOL } from '../../utils/tool-fold';
|
|
|
28
28
|
* `formatBlockedReason`, which returns `undefined` for a figure-less budget so a
|
|
29
29
|
* host-set explanation survives the latch.
|
|
30
30
|
*/
|
|
31
|
+
/**
|
|
32
|
+
* Lift the reportable facts off a {@link ProviderRefusedError} (GENC-1506).
|
|
33
|
+
*
|
|
34
|
+
* Unlike `budgetDetailOf` this is total — it always returns a detail. The budget version can answer
|
|
35
|
+
* `undefined` because its payload is figures the proxy may not have sent; here every field either
|
|
36
|
+
* comes from the transport that was refused (`vendorLabel`, `kind`) or is plainly optional, so there
|
|
37
|
+
* is no "nothing worth reporting" case to model.
|
|
38
|
+
*/
|
|
39
|
+
const providerRefusedDetailOf = (e) => (Object.assign(Object.assign({ vendorLabel: e.vendorLabel, kind: e.kind }, (e.upstreamStatus != null ? { upstreamStatus: e.upstreamStatus } : {})), (e.upstreamType ? { upstreamType: e.upstreamType } : {})));
|
|
31
40
|
const budgetDetailOf = (e) => {
|
|
32
41
|
var _a;
|
|
33
42
|
// The typed vendor is derived from the LABEL, not from `lastResolvedProvider`:
|
|
@@ -264,6 +273,16 @@ export class ChatDriver extends EventTarget {
|
|
|
264
273
|
* picked up on the next turn.
|
|
265
274
|
*/
|
|
266
275
|
this.resolvedStatusCache = new Map();
|
|
276
|
+
/**
|
|
277
|
+
* A provider refusal was observed this turn, so the tool loop must not call the model again
|
|
278
|
+
* (GENC-1506).
|
|
279
|
+
*
|
|
280
|
+
* Exists for the SUB-AGENT path only, exactly like `budgetExhaustedThisTurn`: this driver's own
|
|
281
|
+
* refusal returns straight out of the catch, whereas a child's refusal reaches the parent as a tool
|
|
282
|
+
* result, and without this flag the loop would issue another call into the same wall — N batched
|
|
283
|
+
* children costing N doomed calls plus a doomed parent one.
|
|
284
|
+
*/
|
|
285
|
+
this.providerRefusedThisTurn = false;
|
|
267
286
|
/**
|
|
268
287
|
* Set the moment a budget wall is observed anywhere in this turn — this
|
|
269
288
|
* driver's own 402, or a sub-agent's (which surfaces here only as a
|
|
@@ -280,12 +299,13 @@ export class ChatDriver extends EventTarget {
|
|
|
280
299
|
* Reset per turn alongside `budgetWallDetail`.
|
|
281
300
|
*/
|
|
282
301
|
this.budgetWallViaSubAgent = false;
|
|
283
|
-
const { toolHandlers = {}, toolDefinitions = [], systemPrompt, primerHistory, maxToolIterations = DEFAULT_MAX_TOOL_ITERATIONS, maxFoldOperations = DEFAULT_MAX_FOLD_OPERATIONS, condenseBatchCalls = 1, maxTurnSnapshots = DEFAULT_MAX_TURN_SNAPSHOTS, sessionKey = '', activityBus = NOOP_ACTIVITY_BUS, budgetExhaustedMessage, } = config;
|
|
302
|
+
const { toolHandlers = {}, toolDefinitions = [], systemPrompt, primerHistory, maxToolIterations = DEFAULT_MAX_TOOL_ITERATIONS, maxFoldOperations = DEFAULT_MAX_FOLD_OPERATIONS, condenseBatchCalls = 1, maxTurnSnapshots = DEFAULT_MAX_TURN_SNAPSHOTS, sessionKey = '', activityBus = NOOP_ACTIVITY_BUS, budgetExhaustedMessage, providerRefusedMessage, } = config;
|
|
284
303
|
this.maxToolIterations = maxToolIterations;
|
|
285
304
|
this.condenseBatchCalls = condenseBatchCalls;
|
|
286
305
|
this.sessionKey = sessionKey;
|
|
287
306
|
this.activityBus = activityBus;
|
|
288
307
|
this.budgetExhaustedMessageOverride = budgetExhaustedMessage;
|
|
308
|
+
this.providerRefusedMessageOverride = providerRefusedMessage;
|
|
289
309
|
if (typeof toolHandlers === 'function') {
|
|
290
310
|
this.toolHandlersFactory = toolHandlers;
|
|
291
311
|
this.toolHandlers = {};
|
|
@@ -415,12 +435,12 @@ export class ChatDriver extends EventTarget {
|
|
|
415
435
|
* (rather than set to `undefined`) so a happy-path result stays byte-identical to
|
|
416
436
|
* the historical `{ reason: 'done' }`.
|
|
417
437
|
*/
|
|
418
|
-
turnDone(failureReason, budget) {
|
|
438
|
+
turnDone(failureReason, budget, providerRefused) {
|
|
419
439
|
if (!failureReason)
|
|
420
440
|
return { reason: 'done' };
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
return
|
|
441
|
+
// Both payloads are omitted rather than set to `undefined`, so a failure that carries neither has
|
|
442
|
+
// a shape unchanged for a consumer that structurally compares it.
|
|
443
|
+
return Object.assign(Object.assign({ reason: 'done', failureReason }, (budget ? { budget } : {})), (providerRefused ? { providerRefused } : {}));
|
|
424
444
|
}
|
|
425
445
|
/**
|
|
426
446
|
* Terminal budget outcome for a wall hit **outside** the tool loop — today,
|
|
@@ -495,6 +515,44 @@ export class ChatDriver extends EventTarget {
|
|
|
495
515
|
this.appendToHistory({ role: 'assistant', content: this.budgetExhaustedBubble(e) });
|
|
496
516
|
return this.turnDone('budget-exhausted', budgetDetailOf(e));
|
|
497
517
|
}
|
|
518
|
+
/**
|
|
519
|
+
* The sentence shown when the upstream provider refuses (GENC-1506).
|
|
520
|
+
*
|
|
521
|
+
* Deliberately far simpler than `budgetExhaustedBubble`: no vendor name, no figures, no
|
|
522
|
+
* switch-provider advice, and no branching on `kind`. The copy is cause-free by design — a user who
|
|
523
|
+
* can see their own remaining spend must not be told about a limit, and we must not imply the bill
|
|
524
|
+
* has gone unpaid — so there is nothing here to compose. A host override wins verbatim.
|
|
525
|
+
*/
|
|
526
|
+
providerRefusedBubble() {
|
|
527
|
+
var _a;
|
|
528
|
+
return (_a = this.providerRefusedMessageOverride) !== null && _a !== void 0 ? _a : DEFAULT_PROVIDER_REFUSED_MESSAGE;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Terminal provider-refusal outcome. Mirrors `reportBudgetExhausted` so the two walls behave
|
|
532
|
+
* identically from the caller's side, while keeping their diagnostics distinct.
|
|
533
|
+
*
|
|
534
|
+
* `kind` reaches the debug log and the result but never the transcript: it is what lets an operator
|
|
535
|
+
* tell "top up the account" from "rotate the key", and with cause-free user copy this is the only
|
|
536
|
+
* place that distinction survives.
|
|
537
|
+
*/
|
|
538
|
+
reportProviderRefused(e) {
|
|
539
|
+
var _a;
|
|
540
|
+
const detail = providerRefusedDetailOf(e);
|
|
541
|
+
this.providerRefusedThisTurn = true;
|
|
542
|
+
this.providerRefusedDetail = detail;
|
|
543
|
+
logger.error('ChatDriver: provider refused the request', e);
|
|
544
|
+
recordTurnError(this.sessionKey, 'provider-refused', {
|
|
545
|
+
agent: this.activeAgentName,
|
|
546
|
+
provider: this.lastResolvedProviderName,
|
|
547
|
+
vendor: (_a = vendorTypeOfLabel(e.vendorLabel)) !== null && _a !== void 0 ? _a : this.lastResolvedProvider,
|
|
548
|
+
kind: e.kind,
|
|
549
|
+
upstreamStatus: e.upstreamStatus,
|
|
550
|
+
upstreamType: e.upstreamType,
|
|
551
|
+
isSubAgent: this.isSubAgent,
|
|
552
|
+
});
|
|
553
|
+
this.appendToHistory({ role: 'assistant', content: this.providerRefusedBubble() });
|
|
554
|
+
return this.turnDone('provider-refused', undefined, detail);
|
|
555
|
+
}
|
|
498
556
|
/** The typed failure reason on a loop result, or `undefined` for a clean turn / handoff. */
|
|
499
557
|
static failureReasonOf(result) {
|
|
500
558
|
return result.reason === 'done' ? result.failureReason : undefined;
|
|
@@ -768,12 +826,12 @@ export class ChatDriver extends EventTarget {
|
|
|
768
826
|
* under a separate session key, so recording here would orphan the event off
|
|
769
827
|
* the user-visible debug-log timeline.)
|
|
770
828
|
*/
|
|
771
|
-
failSubAgent(reason, budget) {
|
|
829
|
+
failSubAgent(reason, budget, providerRefused) {
|
|
772
830
|
if (!this.isSubAgent || this.subAgentFailure)
|
|
773
831
|
return;
|
|
774
|
-
//
|
|
832
|
+
// Each payload is omitted rather than set to `undefined` so a failure carrying neither has a shape
|
|
775
833
|
// unchanged for a structural comparison, matching `turnDone`.
|
|
776
|
-
this.subAgentFailure = budget ? {
|
|
834
|
+
this.subAgentFailure = Object.assign(Object.assign({ reason }, (budget ? { budget } : {})), (providerRefused ? { providerRefused } : {}));
|
|
777
835
|
}
|
|
778
836
|
/**
|
|
779
837
|
* Returns true if `releaseAgent` was called during the most recent turn.
|
|
@@ -1367,6 +1425,8 @@ export class ChatDriver extends EventTarget {
|
|
|
1367
1425
|
this.budgetExhaustedThisTurn = false;
|
|
1368
1426
|
this.budgetWallDetail = undefined;
|
|
1369
1427
|
this.budgetWallViaSubAgent = false;
|
|
1428
|
+
this.providerRefusedThisTurn = false;
|
|
1429
|
+
this.providerRefusedDetail = undefined;
|
|
1370
1430
|
this.appendToHistory({ role: 'user', content: userInput, attachments });
|
|
1371
1431
|
this.turnStartedAt = Date.now();
|
|
1372
1432
|
recordMetaEvent(this.sessionKey, 'turn.start', {
|
|
@@ -1512,7 +1572,7 @@ export class ChatDriver extends EventTarget {
|
|
|
1512
1572
|
*/
|
|
1513
1573
|
invokeSubAgent(name, options) {
|
|
1514
1574
|
return __awaiter(this, void 0, void 0, function* () {
|
|
1515
|
-
var _a, _b, _c, _d, _e;
|
|
1575
|
+
var _a, _b, _c, _d, _e, _f;
|
|
1516
1576
|
const subConfig = this.subAgentsMap.get(name);
|
|
1517
1577
|
if (!subConfig) {
|
|
1518
1578
|
const available = [...this.subAgentsMap.keys()].join(', ') || '(none)';
|
|
@@ -1710,6 +1770,20 @@ export class ChatDriver extends EventTarget {
|
|
|
1710
1770
|
this.budgetWallViaSubAgent = true;
|
|
1711
1771
|
(_e = this.budgetWallDetail) !== null && _e !== void 0 ? _e : (this.budgetWallDetail = failure === null || failure === void 0 ? void 0 : failure.budget);
|
|
1712
1772
|
}
|
|
1773
|
+
// The PROVIDER wall via a child (GENC-1506) — the same shape as the budget wall above, and needed
|
|
1774
|
+
// for the same non-obvious reason: **the child is a separate driver instance**, so the flag it set
|
|
1775
|
+
// on itself is invisible here. Without this branch the child's failure was appended as an ordinary
|
|
1776
|
+
// tool result, the parent's short-circuit never fired, and the parent issued another model call
|
|
1777
|
+
// straight into the same wall. Worse, if that call happened to succeed the turn could finish
|
|
1778
|
+
// WITHOUT `failureReason: 'provider-refused'` at all — reporting a clean turn over a dead account.
|
|
1779
|
+
//
|
|
1780
|
+
// FIRST attribution wins (`??=`), for the reason spelled out on `budgetWallDetail`: batched
|
|
1781
|
+
// delegations can pair an attributable child refusal with an unattributable one, and a plain
|
|
1782
|
+
// assignment would let the later `undefined` erase what the earlier child knew.
|
|
1783
|
+
if (reason === 'provider_refused') {
|
|
1784
|
+
this.providerRefusedThisTurn = true;
|
|
1785
|
+
(_f = this.providerRefusedDetail) !== null && _f !== void 0 ? _f : (this.providerRefusedDetail = failure === null || failure === void 0 ? void 0 : failure.providerRefused);
|
|
1786
|
+
}
|
|
1713
1787
|
return { outcome: { ok: false, reason }, trace };
|
|
1714
1788
|
});
|
|
1715
1789
|
}
|
|
@@ -1728,6 +1802,8 @@ export class ChatDriver extends EventTarget {
|
|
|
1728
1802
|
this.budgetExhaustedThisTurn = false;
|
|
1729
1803
|
this.budgetWallDetail = undefined;
|
|
1730
1804
|
this.budgetWallViaSubAgent = false;
|
|
1805
|
+
this.providerRefusedThisTurn = false;
|
|
1806
|
+
this.providerRefusedDetail = undefined;
|
|
1731
1807
|
this.turnStartedAt = Date.now();
|
|
1732
1808
|
recordMetaEvent(this.sessionKey, 'turn.start', {
|
|
1733
1809
|
phase: 'continueFromHistory',
|
|
@@ -1918,7 +1994,7 @@ export class ChatDriver extends EventTarget {
|
|
|
1918
1994
|
// oxlint-disable-next-line complexity
|
|
1919
1995
|
runToolLoop(userInput, attachments, transientPrimer) {
|
|
1920
1996
|
return __awaiter(this, void 0, void 0, function* () {
|
|
1921
|
-
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
|
|
1997
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
|
|
1922
1998
|
if (!this.systemPrompt) {
|
|
1923
1999
|
logger.warn('ChatDriver: no systemPrompt set. The assistant will have no instructions — provide a systemPrompt via agents config or the foundation-ai-assistant property.');
|
|
1924
2000
|
}
|
|
@@ -1971,6 +2047,31 @@ export class ChatDriver extends EventTarget {
|
|
|
1971
2047
|
// Scoped strictly to the budget reason: every other SubAgentFailureReason
|
|
1972
2048
|
// is something the parent can legitimately recover from, so those still
|
|
1973
2049
|
// let the loop continue.
|
|
2050
|
+
// A provider refusal seen earlier this turn ends it HERE, before another model call. Reachable
|
|
2051
|
+
// only via a sub-agent — this driver's own refusal returns straight out of the catch — and
|
|
2052
|
+
// without it the loop would call the provider again into the same wall (GENC-1506).
|
|
2053
|
+
if (this.providerRefusedThisTurn) {
|
|
2054
|
+
logger.error('ChatDriver: ending the turn — a sub-agent hit the provider wall');
|
|
2055
|
+
recordTurnError(this.sessionKey, 'provider-refused', {
|
|
2056
|
+
agent: this.activeAgentName,
|
|
2057
|
+
provider: this.lastResolvedProviderName,
|
|
2058
|
+
kind: (_a = this.providerRefusedDetail) === null || _a === void 0 ? void 0 : _a.kind,
|
|
2059
|
+
via: 'sub-agent',
|
|
2060
|
+
isSubAgent: this.isSubAgent,
|
|
2061
|
+
});
|
|
2062
|
+
if (this.isSubAgent) {
|
|
2063
|
+
// The detail is forwarded, not just the reason — this driver may itself be an INTERMEDIATE
|
|
2064
|
+
// sub-agent, and a refusal that started in a grandchild reaches its own parent only through
|
|
2065
|
+
// this call. Dropping it here still stopped the top-level turn (the reason travels), but
|
|
2066
|
+
// arrived with no vendor and no kind, which is the whole diagnostic this payload exists to
|
|
2067
|
+
// carry across hops. The adjacent budget path forwards `budgetWallDetail` for the same reason.
|
|
2068
|
+
this.failSubAgent('provider_refused', undefined, this.providerRefusedDetail);
|
|
2069
|
+
}
|
|
2070
|
+
else {
|
|
2071
|
+
this.appendToHistory({ role: 'assistant', content: this.providerRefusedBubble() });
|
|
2072
|
+
}
|
|
2073
|
+
return this.turnDone('provider-refused', undefined, this.providerRefusedDetail);
|
|
2074
|
+
}
|
|
1974
2075
|
if (this.budgetExhaustedThisTurn) {
|
|
1975
2076
|
logger.error('ChatDriver: ending the turn — a sub-agent hit the AI budget wall');
|
|
1976
2077
|
recordTurnError(this.sessionKey, 'budget-exhausted', {
|
|
@@ -1985,7 +2086,7 @@ export class ChatDriver extends EventTarget {
|
|
|
1985
2086
|
// parent's vendor here walled BOTH — the child's via its own
|
|
1986
2087
|
// tool-loop-end, the parent's via this event — and derived `blocked`
|
|
1987
2088
|
// over headroom that still existed.
|
|
1988
|
-
vendor: (
|
|
2089
|
+
vendor: (_b = this.budgetWallDetail) === null || _b === void 0 ? void 0 : _b.vendor,
|
|
1989
2090
|
via: 'sub-agent',
|
|
1990
2091
|
isSubAgent: this.isSubAgent,
|
|
1991
2092
|
});
|
|
@@ -2008,7 +2109,7 @@ export class ChatDriver extends EventTarget {
|
|
|
2008
2109
|
return this.turnDone('budget-exhausted', this.budgetWallDetail);
|
|
2009
2110
|
}
|
|
2010
2111
|
const promptCtx = {
|
|
2011
|
-
agentName: (
|
|
2112
|
+
agentName: (_c = this.activeAgentName) !== null && _c !== void 0 ? _c : '',
|
|
2012
2113
|
history: this.history,
|
|
2013
2114
|
turnIndex: iterations - 1,
|
|
2014
2115
|
signal: this.turnController.signal,
|
|
@@ -2081,7 +2182,7 @@ export class ChatDriver extends EventTarget {
|
|
|
2081
2182
|
// loses all summarized context. `normalizeForProvider` only touches
|
|
2082
2183
|
// `compacted-summary`, so it's a safe pass-through for everything else.
|
|
2083
2184
|
const primer = normalizeForProvider([
|
|
2084
|
-
...((
|
|
2185
|
+
...((_d = this.primerHistory) !== null && _d !== void 0 ? _d : []),
|
|
2085
2186
|
...(transientPrimer !== null && transientPrimer !== void 0 ? transientPrimer : []),
|
|
2086
2187
|
]);
|
|
2087
2188
|
const baseHistory = firstLlmCall ? this.history.slice(0, -1) : this.history;
|
|
@@ -2283,6 +2384,37 @@ export class ChatDriver extends EventTarget {
|
|
|
2283
2384
|
// retry here and no "try again" in the copy; the turn ends and the host
|
|
2284
2385
|
// locks the composer off the `'budget-exhausted'` failure reason (see
|
|
2285
2386
|
// `FoundationAiAssistant.blocked`).
|
|
2387
|
+
// The upstream vendor refused this ACCOUNT (GENC-1506) — its credit is gone, a usage cap is
|
|
2388
|
+
// reached, or the credential is dead. Terminal in the strongest sense available: unlike a
|
|
2389
|
+
// truncation, no smaller request gets past it, and unlike our own budget wall nobody in this
|
|
2390
|
+
// system can raise anything to clear it. So no retry and no "try again" in the copy.
|
|
2391
|
+
//
|
|
2392
|
+
// Placed before the transient-retry step below, which is the whole point: without this the
|
|
2393
|
+
// error is an untyped transport failure, gets re-issued MAX_SETUP_TRANSPORT_RETRIES times
|
|
2394
|
+
// against a wall that cannot move, and then surfaces as "something went wrong on my end" —
|
|
2395
|
+
// wrong twice over, since nothing went wrong on our end and trying again will not help.
|
|
2396
|
+
if (e instanceof ProviderRefusedError) {
|
|
2397
|
+
if (this.isSubAgent) {
|
|
2398
|
+
logger.error('ChatDriver: provider refused the request', e);
|
|
2399
|
+
recordTurnError(this.sessionKey, 'provider-refused', {
|
|
2400
|
+
agent: this.activeAgentName,
|
|
2401
|
+
provider: this.lastResolvedProviderName,
|
|
2402
|
+
vendor: (_e = vendorTypeOfLabel(e.vendorLabel)) !== null && _e !== void 0 ? _e : this.lastResolvedProvider,
|
|
2403
|
+
kind: e.kind,
|
|
2404
|
+
upstreamStatus: e.upstreamStatus,
|
|
2405
|
+
upstreamType: e.upstreamType,
|
|
2406
|
+
isSubAgent: true,
|
|
2407
|
+
});
|
|
2408
|
+
// The detail is BUBBLED, not merely flagged. Setting `providerRefusedThisTurn` on `this`
|
|
2409
|
+
// would be pointless here — `this` is the CHILD, and the parent that must stop is a
|
|
2410
|
+
// different driver instance. `invokeSubAgent` reads this payload off the child and sets its
|
|
2411
|
+
// own flag; see the branch there.
|
|
2412
|
+
const detail = providerRefusedDetailOf(e);
|
|
2413
|
+
this.failSubAgent('provider_refused', undefined, detail);
|
|
2414
|
+
return this.turnDone('provider-refused', undefined, detail);
|
|
2415
|
+
}
|
|
2416
|
+
return this.reportProviderRefused(e);
|
|
2417
|
+
}
|
|
2286
2418
|
if (e instanceof BudgetExhaustedError) {
|
|
2287
2419
|
// Flagged as well as returned: a sub-agent's wall reaches the PARENT
|
|
2288
2420
|
// only as a tool result, and the parent must not issue another model
|
|
@@ -2301,7 +2433,7 @@ export class ChatDriver extends EventTarget {
|
|
|
2301
2433
|
recordTurnError(this.sessionKey, 'budget-exhausted', {
|
|
2302
2434
|
agent: this.activeAgentName,
|
|
2303
2435
|
provider: this.lastResolvedProviderName,
|
|
2304
|
-
vendor: (
|
|
2436
|
+
vendor: (_g = (_f = vendorTypeOfLabel(e.vendorLabel)) !== null && _f !== void 0 ? _f : vendorTypeOfLabel(e.serverVendor)) !== null && _g !== void 0 ? _g : this.lastResolvedProvider,
|
|
2305
2437
|
budgetUsd: e.budgetUsd,
|
|
2306
2438
|
spentUsd: e.spentUsd,
|
|
2307
2439
|
isSubAgent: true,
|
|
@@ -2399,15 +2531,15 @@ export class ChatDriver extends EventTarget {
|
|
|
2399
2531
|
// fallback chain answered on a different model than the one we asked for.
|
|
2400
2532
|
if (response.model !== undefined)
|
|
2401
2533
|
turnSnapshot.model = response.model;
|
|
2402
|
-
const isThinkingStep = response.content && ((
|
|
2403
|
-
const isEmptyResponse = !((
|
|
2534
|
+
const isThinkingStep = response.content && ((_h = response.toolCalls) === null || _h === void 0 ? void 0 : _h.length);
|
|
2535
|
+
const isEmptyResponse = !((_j = response.content) === null || _j === void 0 ? void 0 : _j.trim()) && !((_k = response.toolCalls) === null || _k === void 0 ? void 0 : _k.length);
|
|
2404
2536
|
// A pre-output refusal (safety-classifier decline, e.g. Fable 5 `stop_reason: 'refusal'`)
|
|
2405
2537
|
// comes back with empty content, so it looks like a blank response — but it is deterministic:
|
|
2406
2538
|
// retrying re-sends the identical request and refuses again, burning up to
|
|
2407
2539
|
// MAX_EMPTY_RESPONSE_RETRIES turns on the most expensive models for the same outcome, ending
|
|
2408
2540
|
// in the misleading "blank response" message. Treat it as a terminal, non-retried failure with
|
|
2409
2541
|
// its own reason and message. (GENC-1461)
|
|
2410
|
-
const isRefusal = ((
|
|
2542
|
+
const isRefusal = ((_l = response.responseMeta) === null || _l === void 0 ? void 0 : _l.finishReason) === 'refusal';
|
|
2411
2543
|
if (isEmptyResponse) {
|
|
2412
2544
|
emptyResponseAttempts += 1;
|
|
2413
2545
|
if (!isRefusal && emptyResponseAttempts < MAX_EMPTY_RESPONSE_RETRIES) {
|
|
@@ -2481,7 +2613,7 @@ export class ChatDriver extends EventTarget {
|
|
|
2481
2613
|
emptyResponseAttempts = 0;
|
|
2482
2614
|
malformedAttempts = 0;
|
|
2483
2615
|
setupTransportAttempts = 0;
|
|
2484
|
-
if (!((
|
|
2616
|
+
if (!((_m = response.toolCalls) === null || _m === void 0 ? void 0 : _m.length)) {
|
|
2485
2617
|
break;
|
|
2486
2618
|
}
|
|
2487
2619
|
const [toolCalls, systemCalls] = response.toolCalls.reduce((acc, tc) => {
|
|
@@ -2701,7 +2833,7 @@ export class ChatDriver extends EventTarget {
|
|
|
2701
2833
|
// The response was appended before execution — find it and annotate.
|
|
2702
2834
|
let tcMsgIdx = -1;
|
|
2703
2835
|
for (let i = this.history.length - 1; i >= 0; i -= 1) {
|
|
2704
|
-
if (this.history[i].role === 'assistant' && ((
|
|
2836
|
+
if (this.history[i].role === 'assistant' && ((_o = this.history[i].toolCalls) === null || _o === void 0 ? void 0 : _o.length)) {
|
|
2705
2837
|
tcMsgIdx = i;
|
|
2706
2838
|
break;
|
|
2707
2839
|
}
|
|
@@ -2739,7 +2871,7 @@ export class ChatDriver extends EventTarget {
|
|
|
2739
2871
|
const unknownTools = [
|
|
2740
2872
|
...new Set([
|
|
2741
2873
|
...this.recentUnknownToolNames,
|
|
2742
|
-
...((
|
|
2874
|
+
...((_p = response.toolCalls) !== null && _p !== void 0 ? _p : [])
|
|
2743
2875
|
.filter((tc) => unknownToolIds.has(tc.id))
|
|
2744
2876
|
.map((tc) => tc.name),
|
|
2745
2877
|
]),
|
|
@@ -2751,7 +2883,7 @@ export class ChatDriver extends EventTarget {
|
|
|
2751
2883
|
const staleTools = [
|
|
2752
2884
|
...new Set([
|
|
2753
2885
|
...this.recentStaleToolNames,
|
|
2754
|
-
...((
|
|
2886
|
+
...((_q = response.toolCalls) !== null && _q !== void 0 ? _q : [])
|
|
2755
2887
|
.filter((tc) => staleToolIds.has(tc.id))
|
|
2756
2888
|
.map((tc) => tc.name),
|
|
2757
2889
|
]),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { __awaiter } from "tslib";
|
|
2
|
-
import { BudgetExhaustedError, DEFAULT_BUDGET_EXHAUSTED_MESSAGE, isChatToolCallUnknown, MalformedFunctionCallError, ResponseTruncatedError, } from '@genesislcap/foundation-ai';
|
|
2
|
+
import { BudgetExhaustedError, DEFAULT_PROVIDER_REFUSED_MESSAGE, ProviderRefusedError, DEFAULT_BUDGET_EXHAUSTED_MESSAGE, isChatToolCallUnknown, MalformedFunctionCallError, ResponseTruncatedError, } from '@genesislcap/foundation-ai';
|
|
3
3
|
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
4
4
|
import { AgenticActivityBus } from '../../channel/ai-activity-bus';
|
|
5
5
|
import { clearMetaEventRegistry, getMetaEvents } from '../../state/debug-event-log';
|
|
@@ -1860,6 +1860,16 @@ const budgetExhaustedProvider = () => ({
|
|
|
1860
1860
|
throw new BudgetExhaustedError('Anthropic', 25, 25.4, 'AI budget exhausted');
|
|
1861
1861
|
}),
|
|
1862
1862
|
});
|
|
1863
|
+
/** A provider the vendor refused outright (GENC-1506 — terminal, must not retry). */
|
|
1864
|
+
const providerRefusedProvider = (kind = 'spend') => ({
|
|
1865
|
+
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1866
|
+
// Both fixtures are the real captured shapes (2026-08-18), matched to their kind — an `auth`
|
|
1867
|
+
// fixture carrying a credit-balance message would read as a classification bug to the next person.
|
|
1868
|
+
throw kind === 'auth'
|
|
1869
|
+
? new ProviderRefusedError('Anthropic', 'auth', 401, 'authentication_error', 'API key is invalid.')
|
|
1870
|
+
: new ProviderRefusedError('Anthropic', 'spend', 400, 'invalid_request_error', 'Your credit balance is too low to access the Anthropic API.');
|
|
1871
|
+
}),
|
|
1872
|
+
});
|
|
1863
1873
|
/** A provider that throws a generic error (the sendMessage catch-all → 'exception'). */
|
|
1864
1874
|
const throwingProvider = () => ({
|
|
1865
1875
|
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
@@ -1950,6 +1960,89 @@ outcome('a clean turn leaves the legacy shape byte-unchanged (no failureReason)'
|
|
|
1950
1960
|
cap.stop();
|
|
1951
1961
|
}));
|
|
1952
1962
|
// ── Budget exhaustion (GENC-1464) ──────────────────────────────────────────────
|
|
1963
|
+
outcome('provider-refused surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1964
|
+
yield assertSurfacesReason('provider-refused', providerRefusedProvider(), 'provider-refused');
|
|
1965
|
+
}));
|
|
1966
|
+
outcome('a provider refusal is terminal — no retry, cause-free copy, kind in the log', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1967
|
+
var _a, _b;
|
|
1968
|
+
// GENC-1506. The measured bug this pins: the refusal used to arrive as an untyped transport error,
|
|
1969
|
+
// get re-issued MAX_SETUP_TRANSPORT_RETRIES times against a wall that cannot move, and then surface
|
|
1970
|
+
// as "something went wrong on my end" — wrong twice over, since nothing went wrong on our end and
|
|
1971
|
+
// trying again cannot help.
|
|
1972
|
+
clearMetaEventRegistry();
|
|
1973
|
+
let calls = 0;
|
|
1974
|
+
const provider = {
|
|
1975
|
+
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1976
|
+
calls += 1;
|
|
1977
|
+
throw new ProviderRefusedError('Anthropic', 'spend', 400, 'invalid_request_error', 'credit too low');
|
|
1978
|
+
}),
|
|
1979
|
+
getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'anthropic', model: 'test-model' }); }),
|
|
1980
|
+
};
|
|
1981
|
+
const config = agent({
|
|
1982
|
+
name: 'Static',
|
|
1983
|
+
toolDefinitions: [def('noop')],
|
|
1984
|
+
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
1985
|
+
});
|
|
1986
|
+
const driver = makeDriver(config, provider, 'outcome-provider-refused', outcomeBus);
|
|
1987
|
+
const result = yield driver.sendMessage('go');
|
|
1988
|
+
assert.is(calls, 1, 'a provider refusal is not retried — exactly one model call');
|
|
1989
|
+
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused', 'the turn ends with the provider-refused failure reason');
|
|
1990
|
+
// The kind reaches the CALLER, which is what lets ai-service's Sentry alert say whether an operator
|
|
1991
|
+
// must top up an account or rotate a key. It is the only place that distinction survives, because
|
|
1992
|
+
// the user-facing sentence is deliberately cause-free.
|
|
1993
|
+
const refused = result.reason === 'done' ? result.providerRefused : undefined;
|
|
1994
|
+
assert.is(refused === null || refused === void 0 ? void 0 : refused.kind, 'spend');
|
|
1995
|
+
assert.is(refused === null || refused === void 0 ? void 0 : refused.vendorLabel, 'Anthropic');
|
|
1996
|
+
assert.is(refused === null || refused === void 0 ? void 0 : refused.upstreamStatus, 400);
|
|
1997
|
+
assert.is(refused === null || refused === void 0 ? void 0 : refused.upstreamType, 'invalid_request_error');
|
|
1998
|
+
const last = driver.getHistory().at(-1);
|
|
1999
|
+
assert.ok((last === null || last === void 0 ? void 0 : last.role) === 'assistant', 'turn ends with an assistant message');
|
|
2000
|
+
// Identity, not a substring: the sentence is provisional copy shared with ai-service via one
|
|
2001
|
+
// exported const, and this is what stops a second copy drifting in.
|
|
2002
|
+
assert.is(last.content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
|
|
2003
|
+
assert.not.ok(last.content.includes('something went wrong'), 'must not fall through to the generic apology');
|
|
2004
|
+
// The copy must not leak the cause, whatever the kind — a user who can see remaining spend must not
|
|
2005
|
+
// be told about a limit, and we must not imply the bill went unpaid.
|
|
2006
|
+
for (const banned of ['credit', 'billing', 'balance', 'quota', 'limit']) {
|
|
2007
|
+
assert.not.ok(last.content.toLowerCase().includes(banned), `copy must not mention "${banned}"`);
|
|
2008
|
+
}
|
|
2009
|
+
const err = getMetaEvents('outcome-provider-refused').find((e) => e.type === 'turn.error');
|
|
2010
|
+
assert.is((_a = err === null || err === void 0 ? void 0 : err.detail) === null || _a === void 0 ? void 0 : _a.reason, 'provider-refused');
|
|
2011
|
+
assert.is((_b = err === null || err === void 0 ? void 0 : err.detail) === null || _b === void 0 ? void 0 : _b.kind, 'spend');
|
|
2012
|
+
}));
|
|
2013
|
+
outcome('an auth refusal shows the SAME sentence but logs a different kind', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2014
|
+
var _a;
|
|
2015
|
+
// The whole justification for one type with two kinds: identical user experience, distinct
|
|
2016
|
+
// diagnostics. If these ever diverge in the transcript, the copy decision has been undone.
|
|
2017
|
+
clearMetaEventRegistry();
|
|
2018
|
+
const config = agent({
|
|
2019
|
+
name: 'Static',
|
|
2020
|
+
toolDefinitions: [def('noop')],
|
|
2021
|
+
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
2022
|
+
});
|
|
2023
|
+
const driver = makeDriver(config, providerRefusedProvider('auth'), 'outcome-refused-auth', outcomeBus);
|
|
2024
|
+
const result = yield driver.sendMessage('go');
|
|
2025
|
+
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused');
|
|
2026
|
+
assert.is(result.reason === 'done' ? (_a = result.providerRefused) === null || _a === void 0 ? void 0 : _a.kind : undefined, 'auth');
|
|
2027
|
+
assert.is(driver.getHistory().at(-1).content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
|
|
2028
|
+
}));
|
|
2029
|
+
outcome('a host override replaces the provider-refusal sentence verbatim', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2030
|
+
const config = agent({
|
|
2031
|
+
name: 'Static',
|
|
2032
|
+
toolDefinitions: [def('noop')],
|
|
2033
|
+
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
2034
|
+
});
|
|
2035
|
+
const driver = new ChatDriver(makeRegistry(providerRefusedProvider()), {
|
|
2036
|
+
maxToolIterations: 50,
|
|
2037
|
+
maxFoldOperations: 5,
|
|
2038
|
+
sessionKey: 'outcome-refused-override',
|
|
2039
|
+
providerRefusedMessage: 'Bespoke wording for a white-labelled host.',
|
|
2040
|
+
});
|
|
2041
|
+
driver.applyAgent(config);
|
|
2042
|
+
yield driver.sendMessage('go');
|
|
2043
|
+
assert.is(driver.getHistory().at(-1).content, 'Bespoke wording for a white-labelled host.');
|
|
2044
|
+
driver.dispose();
|
|
2045
|
+
}));
|
|
1953
2046
|
outcome('budget-exhausted surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1954
2047
|
yield assertSurfacesReason('budget', budgetExhaustedProvider(), 'budget-exhausted');
|
|
1955
2048
|
}));
|
|
@@ -2247,6 +2340,122 @@ const walledWorker = () => agent({
|
|
|
2247
2340
|
toolDefinitions: [def('finish')],
|
|
2248
2341
|
toolHandlers: { finish: () => __awaiter(void 0, void 0, void 0, function* () { return 'x'; }) },
|
|
2249
2342
|
});
|
|
2343
|
+
/**
|
|
2344
|
+
* The child-refused / parent-fine provider, mirroring `parentOkChildWalled` — the parent's turn call
|
|
2345
|
+
* succeeds and delegates, and only the child's provider refuses.
|
|
2346
|
+
*/
|
|
2347
|
+
const parentOkChildRefused = (kind = 'spend') => {
|
|
2348
|
+
let parentCalls = 0;
|
|
2349
|
+
return {
|
|
2350
|
+
parentCalls: () => parentCalls,
|
|
2351
|
+
provider: {
|
|
2352
|
+
chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
2353
|
+
var _a;
|
|
2354
|
+
const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
|
|
2355
|
+
if (names.includes('delegate')) {
|
|
2356
|
+
parentCalls += 1;
|
|
2357
|
+
return callsTool('delegate', `d${parentCalls}`);
|
|
2358
|
+
}
|
|
2359
|
+
throw kind === 'auth'
|
|
2360
|
+
? new ProviderRefusedError('Anthropic', 'auth', 401, 'authentication_error', 'API key is invalid.')
|
|
2361
|
+
: new ProviderRefusedError('Anthropic', 'spend', 400, 'invalid_request_error', 'credit too low');
|
|
2362
|
+
}),
|
|
2363
|
+
},
|
|
2364
|
+
};
|
|
2365
|
+
};
|
|
2366
|
+
outcome('a sub-agent provider refusal ends the parent turn without another model call', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2367
|
+
/*
|
|
2368
|
+
* The seam a review caught, and it is genuinely counter-intuitive: the child is a SEPARATE driver
|
|
2369
|
+
* instance, so the flag it sets on itself is invisible to the parent. The refusal reaches the parent
|
|
2370
|
+
* only as a tool result, and without an `invokeSubAgent` branch that result was ordinary — so the
|
|
2371
|
+
* parent issued another model call straight into the same wall, and if that call had happened to
|
|
2372
|
+
* succeed the turn could have finished with no `failureReason` at all, reporting a clean turn over a
|
|
2373
|
+
* dead account. `parentCalls() === 1` is the assertion that pins it.
|
|
2374
|
+
*/
|
|
2375
|
+
clearMetaEventRegistry();
|
|
2376
|
+
const { parentCalls, provider } = parentOkChildRefused();
|
|
2377
|
+
const parent = delegatingParent(walledWorker(), () => undefined);
|
|
2378
|
+
const driver = makeDriver(parent, provider, 'outcome-subagent-refused', outcomeBus);
|
|
2379
|
+
const result = yield driver.sendMessage('go');
|
|
2380
|
+
assert.is(parentCalls(), 1, 'the parent never calls the model again after the refusal');
|
|
2381
|
+
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused');
|
|
2382
|
+
assert.is(driver.getHistory().at(-1).content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
|
|
2383
|
+
}));
|
|
2384
|
+
outcome("a sub-agent refusal carries the CHILD's kind and vendor to the parent", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2385
|
+
// The other half of the same finding. The parent can only report what the child bubbled, so without a
|
|
2386
|
+
// payload on the failure the `providerRefused` diagnostic promised on `ChatDriverResult` would be
|
|
2387
|
+
// absent — and with cause-free user copy that payload is the ONLY place an operator learns whether to
|
|
2388
|
+
// top up an account or rotate a credential.
|
|
2389
|
+
clearMetaEventRegistry();
|
|
2390
|
+
const { provider } = parentOkChildRefused('auth');
|
|
2391
|
+
const parent = delegatingParent(walledWorker(), () => undefined);
|
|
2392
|
+
const driver = makeDriver(parent, provider, 'outcome-subagent-refused-kind', outcomeBus);
|
|
2393
|
+
const result = yield driver.sendMessage('go');
|
|
2394
|
+
const refused = result.reason === 'done' ? result.providerRefused : undefined;
|
|
2395
|
+
assert.is(refused === null || refused === void 0 ? void 0 : refused.kind, 'auth', "the child's kind survives the hop");
|
|
2396
|
+
assert.is(refused === null || refused === void 0 ? void 0 : refused.vendorLabel, 'Anthropic');
|
|
2397
|
+
assert.is(refused === null || refused === void 0 ? void 0 : refused.upstreamStatus, 401);
|
|
2398
|
+
}));
|
|
2399
|
+
outcome("a GRANDCHILD's refusal keeps its kind and vendor across two hops", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2400
|
+
/*
|
|
2401
|
+
* The third finding, and the one a single-hop test cannot reach. An INTERMEDIATE sub-agent latches its
|
|
2402
|
+
* grandchild's payload correctly via `invokeSubAgent`, but then has to hand it on through
|
|
2403
|
+
* `failSubAgent` — and passing only the reason there still stopped the top-level turn (the reason
|
|
2404
|
+
* travels) while arriving with no vendor and no kind. The turn looked handled and the diagnostics were
|
|
2405
|
+
* gone, which is the failure mode this payload exists to prevent.
|
|
2406
|
+
*
|
|
2407
|
+
* Three tiers: boss -> middle -> worker. Only the worker's turn is refused; every other model call
|
|
2408
|
+
* succeeds, so nothing but the forwarding can carry the detail to the top.
|
|
2409
|
+
*/
|
|
2410
|
+
clearMetaEventRegistry();
|
|
2411
|
+
const worker = agent({
|
|
2412
|
+
name: 'worker',
|
|
2413
|
+
toolDefinitions: [def('finish')],
|
|
2414
|
+
toolHandlers: { finish: () => __awaiter(void 0, void 0, void 0, function* () { return 'x'; }) },
|
|
2415
|
+
});
|
|
2416
|
+
const middle = agent({
|
|
2417
|
+
name: 'middle',
|
|
2418
|
+
subAgents: [worker],
|
|
2419
|
+
toolDefinitions: [def('sub_delegate')],
|
|
2420
|
+
toolHandlers: {
|
|
2421
|
+
sub_delegate: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
2422
|
+
const o = yield ctx.requestSubAgent('worker', { task: 'deeper' });
|
|
2423
|
+
return o.ok ? 'ok' : `failed: ${o.reason}`;
|
|
2424
|
+
}),
|
|
2425
|
+
},
|
|
2426
|
+
});
|
|
2427
|
+
const boss = agent({
|
|
2428
|
+
name: 'boss',
|
|
2429
|
+
subAgents: [middle],
|
|
2430
|
+
toolDefinitions: [def('delegate')],
|
|
2431
|
+
toolHandlers: {
|
|
2432
|
+
delegate: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
2433
|
+
const o = yield ctx.requestSubAgent('middle', { task: 'do it' });
|
|
2434
|
+
return o.ok ? 'ok' : `failed: ${o.reason}`;
|
|
2435
|
+
}),
|
|
2436
|
+
},
|
|
2437
|
+
});
|
|
2438
|
+
// Routed by the tool surface each tier is given, so only the deepest turn throws.
|
|
2439
|
+
const provider = {
|
|
2440
|
+
chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
2441
|
+
var _a;
|
|
2442
|
+
const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
|
|
2443
|
+
if (names.includes('delegate'))
|
|
2444
|
+
return callsTool('delegate', 'd1');
|
|
2445
|
+
if (names.includes('sub_delegate'))
|
|
2446
|
+
return callsTool('sub_delegate', 's1');
|
|
2447
|
+
throw new ProviderRefusedError('Anthropic', 'auth', 401, 'authentication_error', 'API key is invalid.');
|
|
2448
|
+
}),
|
|
2449
|
+
};
|
|
2450
|
+
const driver = makeDriver(boss, provider, 'outcome-refused-grandchild', outcomeBus);
|
|
2451
|
+
const result = yield driver.sendMessage('go');
|
|
2452
|
+
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused');
|
|
2453
|
+
const refused = result.reason === 'done' ? result.providerRefused : undefined;
|
|
2454
|
+
assert.is(refused === null || refused === void 0 ? void 0 : refused.kind, 'auth', "the grandchild's kind survives BOTH hops");
|
|
2455
|
+
assert.is(refused === null || refused === void 0 ? void 0 : refused.vendorLabel, 'Anthropic');
|
|
2456
|
+
assert.is(refused === null || refused === void 0 ? void 0 : refused.upstreamStatus, 401);
|
|
2457
|
+
assert.is(driver.getHistory().at(-1).content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
|
|
2458
|
+
}));
|
|
2250
2459
|
outcome('a sub-agent budget wall ends the parent turn without another model call', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2251
2460
|
clearMetaEventRegistry();
|
|
2252
2461
|
const { parentCalls, provider } = parentOkChildWalled();
|
|
@@ -210,7 +210,7 @@ export const DEBUG_LOG_README = [
|
|
|
210
210
|
"kind:'turn'.`agentSnapshot` — the active agent's own view of its internal state, captured at that turn. An agent opts into this by exposing a `getDebugSnapshot()` that returns JSON-serializable per-state info; stateful/flow agents wire it automatically, so you can watch a flow advance turn-by-turn (e.g. current step, cursor, collected fields, pending changes). Absent for agents that don't expose one.",
|
|
211
211
|
"kind:'event' — a meta/lifecycle event. `type` names it (see below); `detail` carries structured data. `detail.placement` is the emitting UI instance: 'bubble' (collapsed), 'panel' (popped-out), or 'standalone'.",
|
|
212
212
|
"Each 'event' also has an `importance`: 'high' (failures/limits — turn.error, tool.failed, subagent.failed, file.read-failed, suggestions.failed, context.threshold-crossed), 'normal' (session flow — connects, turns, retries, handoffs, agent/provider changes, interactions, sub-agent start/complete), or 'low' (skippable UI/bookkeeping noise — panel.toggled, attachment.added, driver.wired/unwired, context.updated, context.condensed). To skim, ignore importance:'low'; to triage a failure, filter to importance:'high' then read the nearby messages and turns. A 'high' turn.error is often preceded by one or more 'normal' turn.retry events for the same reason — read them together to see how many attempts were made before bailing. 'message' and 'turn' entries carry no importance — they are the substance, always read them.",
|
|
213
|
-
'Event types: assistant.connected/disconnected (mount + placement + whether the session was created or restored), assistant.popout/popin (window placement), driver.created/wired/unwired (which driver is live and why it stops/starts responding across a popout), state.changed (idle↔loading), turn.start/turn.end (turn boundary; turn.end carries durationMs), turn.retry (a recoverable in-turn retry — detail.reason plus attempt/maxAttempts; for malformed calls also finishMessage; for empty responses also the provider finishReason + thoughtsTokens + parts breakdown), turn.error (a turn failed or hit a guardrail — detail.reason is one of exception/malformed-function-call/empty-response/unknown-tool-limit/max-iterations/response-truncated/refusal/budget-exhausted, plus reason-specific diagnostics: attempts (for empty-response also finishReason + thoughtsTokens + a parts breakdown, distinguishing a thinking-only STOP from a truly empty turn), finishMessage, for response-truncated the model + maxTokens + outputTokens + tools, unknownTools (split into staleTools — real earlier this activation but retired by the current state or hidden behind an open exclusive fold — and hallucinatedTools — never advertised) + availableTools, iterations + limit, for budget-exhausted the budgetUsd + spentUsd figures reported by the proxy plus the resolved vendor, or name + message for exceptions), tool.failed (a tool threw), tool.unresolved (the model called a tool that could not be dispatched — detail.kind is folded/fold-hidden/stale/unknown, plus tool + agent and, for the counted kinds, the consecutive streak; the recurring lead-up to an unknown-tool-limit turn.error), subagent.started/completed/failed (the lifecycle of a `requestSubAgent` delegation — detail.agent names the sub-agent; these bracket the sub-agent turns that appear as kind:turn entries with an N-M `turnIndex`; subagent.failed also carries detail.reason, one of max_iterations/malformed_tool_call/empty_response/unknown_tool_limit/timeout/response_truncated/refusal/budget_exhausted; budget_exhausted
|
|
213
|
+
'Event types: assistant.connected/disconnected (mount + placement + whether the session was created or restored), assistant.popout/popin (window placement), driver.created/wired/unwired (which driver is live and why it stops/starts responding across a popout), state.changed (idle↔loading), turn.start/turn.end (turn boundary; turn.end carries durationMs), turn.retry (a recoverable in-turn retry — detail.reason plus attempt/maxAttempts; for malformed calls also finishMessage; for empty responses also the provider finishReason + thoughtsTokens + parts breakdown), turn.error (a turn failed or hit a guardrail — detail.reason is one of exception/malformed-function-call/empty-response/unknown-tool-limit/max-iterations/response-truncated/refusal/budget-exhausted/provider-refused, plus reason-specific diagnostics: attempts (for empty-response also finishReason + thoughtsTokens + a parts breakdown, distinguishing a thinking-only STOP from a truly empty turn), finishMessage, for response-truncated the model + maxTokens + outputTokens + tools, unknownTools (split into staleTools — real earlier this activation but retired by the current state or hidden behind an open exclusive fold — and hallucinatedTools — never advertised) + availableTools, iterations + limit, for budget-exhausted the budgetUsd + spentUsd figures reported by the proxy plus the resolved vendor, for provider-refused the kind (spend|auth) plus the upstream status and error type — the vendor refusing the ACCOUNT rather than us refusing to spend, so the remedy is a top-up or a key rotation rather than a raised cap, and the kind is the only place that distinction survives because the user-facing copy is deliberately cause-free, or name + message for exceptions), tool.failed (a tool threw), tool.unresolved (the model called a tool that could not be dispatched — detail.kind is folded/fold-hidden/stale/unknown, plus tool + agent and, for the counted kinds, the consecutive streak; the recurring lead-up to an unknown-tool-limit turn.error), subagent.started/completed/failed (the lifecycle of a `requestSubAgent` delegation — detail.agent names the sub-agent; these bracket the sub-agent turns that appear as kind:turn entries with an N-M `turnIndex`; subagent.failed also carries detail.reason, one of max_iterations/malformed_tool_call/empty_response/unknown_tool_limit/timeout/response_truncated/refusal/budget_exhausted/provider_refused; budget_exhausted and provider_refused are both terminal for the PARENT turn too — the parent stops rather than calling the model again into the same wall), agent.handoff (routing; from=null is the initial activation), agent.pinned/unpinned (forced routing), provider.selected (the resolved provider for the upcoming turns — detail.provider is the registry SLOT/tier name, detail.model the concrete model behind it and detail.vendor its vendor; emitted only when the slot CHANGES, so read the per-turn `model` for the model of any given call rather than assuming the nearest event still applies), interaction.requested/resolved (blocking user widgets — explain quiet gaps; note that when a sub-agent opens a widget, detail.agent — and the agentName on the interaction message — is the HOST agent that owns the widget, NOT the sub-agent that asked, because widgets render and resolve on the host driver), context.updated/threshold-crossed (token + cost), context.condensed (a stale tool payload was collapsed out of the model-bound history by a `condenseWhen` declaration on the tool — detail.tool + toolCallId, target args|response, trigger (superseded:<key> or age:<n>), stubLen, and an estimated tokensSaved; stored history and this log keep the FULL payload, so the model-visible slice at any point is the full history minus the condensations recorded up to then), panel.toggled, attachment.added, file.read-failed, suggestions.failed.',
|
|
214
214
|
'Sub-agent meta events: a sub-agent\'s own turn.retry/turn.error/tool.failed/tool.unresolved events are merged into this same timeline, tagged with `detail.subAgent` — a `"<parent> › <sub-agent>"` breadcrumb that composes when nested (e.g. `"UI Builder › Planner › Grounding"`) — and interleaved by their original timestamps within the subagent.started→completed/failed bracket. These are the per-attempt/per-failure signals that do NOT appear among the sub-agent\'s (hoisted) messages: a malformed/empty attempt that gets retried produces no message, and the stale-vs-hallucinated split and streak counts live only on the event. A sub-agent\'s high-volume, message-derivable events (turn.start/turn.end, provider.selected, context.updated) are intentionally NOT merged — read its hoisted messages for model/tokens/cost and turn-by-turn activity, and the bracketing subagent.* events for the run\'s span.',
|
|
215
215
|
"`meta` holds context captured at export time: agentSummary (full agent configs), context (active model, contextTokens/contextLimit/contextUsagePercent for the last call, and the session totals — sessionCostUsd, sessionTokensConsumed, and the four-bucket sessionUsage lifted to the top of this log), activeDebugSnapshot (the active agent's `getDebugSnapshot()` taken fresh at export — reflects state NOW, which may have advanced beyond the last turn's agentSnapshot), debug (optional host-supplied debug state), host, and the export timestamp.",
|
|
216
216
|
'Note the two different scopes in `meta.context`: `contextTokens` is the prompt size of the LAST call (against `contextLimit`, the model context window), while `sessionUsage`/`sessionTokensConsumed` are cumulative BILLED throughput. Every turn resends the conversation, so the cumulative figure counts each turn’s prompt again in the next turn’s and is expected to dwarf the context size — that is not double-counting.',
|
|
@@ -84,6 +84,7 @@ const ALL_TURN_FAILURE_REASONS = Object.keys({
|
|
|
84
84
|
'response-truncated': true,
|
|
85
85
|
refusal: true,
|
|
86
86
|
'budget-exhausted': true,
|
|
87
|
+
'provider-refused': true,
|
|
87
88
|
});
|
|
88
89
|
const ALL_SUB_AGENT_FAILURE_REASONS = Object.keys({
|
|
89
90
|
max_iterations: true,
|
|
@@ -94,6 +95,7 @@ const ALL_SUB_AGENT_FAILURE_REASONS = Object.keys({
|
|
|
94
95
|
response_truncated: true,
|
|
95
96
|
refusal: true,
|
|
96
97
|
budget_exhausted: true,
|
|
98
|
+
provider_refused: true,
|
|
97
99
|
});
|
|
98
100
|
const README_TEXT = DEBUG_LOG_README.join('\n');
|
|
99
101
|
suite('DEBUG_LOG_README enumerates every TurnFailureReason', () => {
|