@genesislcap/ai-assistant 15.6.2 → 15.7.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 +422 -6
- package/dist/ai-assistant.d.ts +654 -19
- package/dist/chat-driver.cjs +285 -26
- package/dist/chat-driver.cjs.map +3 -3
- package/dist/chat-driver.mjs +285 -26
- package/dist/chat-driver.mjs.map +3 -3
- package/dist/custom-elements.json +289 -32
- 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/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/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/settings-modal/settings-modal.styles.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/settings-modal.template.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 +435 -19
- 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/state/ai-assistant-slice.d.ts +162 -6
- 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/session-store.d.ts +11 -0
- package/dist/dts/state/session-store.d.ts.map +1 -1
- package/dist/dts/utils/banked-usage-baselines.d.ts +51 -0
- package/dist/dts/utils/banked-usage-baselines.d.ts.map +1 -0
- package/dist/esm/components/chat-driver/chat-driver.js +263 -21
- package/dist/esm/components/chat-driver/chat-driver.test.js +464 -1
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +312 -0
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +89 -4
- package/dist/esm/components/settings-modal/settings-modal.styles.js +63 -18
- package/dist/esm/components/settings-modal/settings-modal.template.js +36 -7
- package/dist/esm/main/blocked-state.test.js +969 -0
- package/dist/esm/main/cost-session-banking.test.js +238 -8
- package/dist/esm/main/main.js +826 -48
- package/dist/esm/main/main.styles.js +72 -0
- package/dist/esm/main/main.styles.test.js +99 -0
- package/dist/esm/main/main.template.js +121 -4
- 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 +7 -2
- package/dist/esm/state/debug-event-log.test.js +49 -1
- package/dist/esm/state/persistence/session-snapshot.test.js +18 -0
- package/dist/esm/utils/banked-usage-baselines.js +84 -0
- 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/components/settings-modal/settings-modal.styles.ts +63 -18
- package/src/components/settings-modal/settings-modal.template.ts +38 -7
- package/src/main/blocked-state.test.ts +1316 -0
- package/src/main/cost-session-banking.test.ts +283 -11
- package/src/main/main.styles.test.ts +130 -0
- package/src/main/main.styles.ts +72 -0
- package/src/main/main.template.ts +131 -4
- package/src/main/main.ts +827 -42
- 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/src/utils/banked-usage-baselines.ts +90 -0
|
@@ -21,9 +21,12 @@ import type {
|
|
|
21
21
|
TurnFailureReason,
|
|
22
22
|
} from '@genesislcap/foundation-ai';
|
|
23
23
|
import {
|
|
24
|
+
BudgetExhaustedError,
|
|
25
|
+
DEFAULT_BUDGET_EXHAUSTED_MESSAGE,
|
|
24
26
|
isObservableAIProviderRegistry,
|
|
25
27
|
MalformedFunctionCallError,
|
|
26
28
|
ResponseTruncatedError,
|
|
29
|
+
vendorTypeOfLabel,
|
|
27
30
|
} from '@genesislcap/foundation-ai';
|
|
28
31
|
import { type ActivityBus, NOOP_ACTIVITY_BUS } from '../../channel/ai-activity-bus';
|
|
29
32
|
import type {
|
|
@@ -69,6 +72,53 @@ import { sumUsage } from '../../utils/sum-usage';
|
|
|
69
72
|
import { TOOL_FOLD_SYMBOL, type ToolFold } from '../../utils/tool-fold';
|
|
70
73
|
import type { AiDriver, AllAgentSummary } from '../ai-driver/ai-driver';
|
|
71
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The `budget` payload carried on a `'budget-exhausted'` {@link ChatDriverResult}.
|
|
77
|
+
* Derived from the type rather than restated so the two cannot drift.
|
|
78
|
+
*/
|
|
79
|
+
type BudgetDetail = NonNullable<Extract<ChatDriverResult, { reason: 'done' }>['budget']>;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Lift the reportable facts off a {@link BudgetExhaustedError}, or `undefined`
|
|
83
|
+
* when it carries none.
|
|
84
|
+
*
|
|
85
|
+
* "None" means **no figures AND no attributable vendor** — not merely no
|
|
86
|
+
* figures. Dropping the whole object on a figure-less 402 also dropped
|
|
87
|
+
* `vendorLabel`, which is the authoritative attribution source, and left the
|
|
88
|
+
* element's latch falling back to the driver's last-resolved provider, i.e. the
|
|
89
|
+
* PREVIOUS turn's vendor. A turn-1 Gemini call followed by a turn-2 classifier
|
|
90
|
+
* refused by Anthropic with a figure-less 402 then walled *Gemini* and advised
|
|
91
|
+
* switching to the vendor that had actually run out. Three live shapes reach
|
|
92
|
+
* here with no figures — a bare gateway 402, a code-only body, and the framed
|
|
93
|
+
* err frame whose figures are `null` (which the proxy now prefers over a
|
|
94
|
+
* misleading `0`, so this path is getting more common, not less).
|
|
95
|
+
*
|
|
96
|
+
* The figures alone still decide whether banner copy is composed — see
|
|
97
|
+
* `formatBlockedReason`, which returns `undefined` for a figure-less budget so a
|
|
98
|
+
* host-set explanation survives the latch.
|
|
99
|
+
*/
|
|
100
|
+
const budgetDetailOf = (e: BudgetExhaustedError): BudgetDetail | undefined => {
|
|
101
|
+
// The typed vendor is derived from the LABEL, not from `lastResolvedProvider`:
|
|
102
|
+
// the label comes from the transport that was actually refused, whereas the
|
|
103
|
+
// last-resolved provider is stale on the classification seam (an orchestrated
|
|
104
|
+
// turn classifies against the registry default, which this driver may never
|
|
105
|
+
// have resolved). The proxy's own `vendor` is the fallback for a transport
|
|
106
|
+
// whose static label no vendor claims (a multiplexing or white-labelled
|
|
107
|
+
// gateway). Omitted rather than set to `undefined` when neither resolves, so
|
|
108
|
+
// "we know the vendor" stays testable by presence alone.
|
|
109
|
+
const vendor = vendorTypeOfLabel(e.vendorLabel) ?? vendorTypeOfLabel(e.serverVendor);
|
|
110
|
+
if (e.budgetUsd == null && e.spentUsd == null && !vendor) return undefined;
|
|
111
|
+
return {
|
|
112
|
+
budgetUsd: e.budgetUsd,
|
|
113
|
+
spentUsd: e.spentUsd,
|
|
114
|
+
vendorLabel: e.vendorLabel,
|
|
115
|
+
...(vendor ? { vendor } : {}),
|
|
116
|
+
// Server-known truth the client cannot re-derive, so it rides all the way to
|
|
117
|
+
// the banner rather than being re-guessed there from registry membership.
|
|
118
|
+
...(e.otherVendorAvailable != null ? { otherVendorAvailable: e.otherVendorAvailable } : {}),
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
|
|
72
122
|
const DEFAULT_MAX_TOOL_ITERATIONS = 50;
|
|
73
123
|
const DEFAULT_MAX_FOLD_OPERATIONS = 5;
|
|
74
124
|
// TODO: dedup system prompts in-memory to allow raising this cap much higher.
|
|
@@ -238,6 +288,22 @@ export interface ChatDriverConfig {
|
|
|
238
288
|
* it defaults to {@link NOOP_ACTIVITY_BUS} so no `BroadcastChannel` is ever opened.
|
|
239
289
|
*/
|
|
240
290
|
activityBus?: ActivityBus;
|
|
291
|
+
/**
|
|
292
|
+
* Transcript copy appended when the AI-spend budget wall is hit (GENC-1464).
|
|
293
|
+
* Defaults to `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`.
|
|
294
|
+
*
|
|
295
|
+
* The assistant element already lets a host override the blocked **banner**
|
|
296
|
+
* via `setBlocked(true, reason)`; without this the transcript **bubble** stayed
|
|
297
|
+
* on the default, so a white-labelled host got its own copy in the banner and
|
|
298
|
+
* the shipped default directly below it. Passing the same effective copy here
|
|
299
|
+
* keeps the two surfaces saying one thing.
|
|
300
|
+
*
|
|
301
|
+
* Deliberately a driver-config field rather than something read off a chat
|
|
302
|
+
* config: `ChatDriver` has no `chatConfig` and is used standalone (see
|
|
303
|
+
* `chat-driver-node`), so threading one in would be a much larger and less
|
|
304
|
+
* reversible change.
|
|
305
|
+
*/
|
|
306
|
+
budgetExhaustedMessage?: string;
|
|
241
307
|
}
|
|
242
308
|
|
|
243
309
|
/**
|
|
@@ -430,8 +496,12 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
430
496
|
* Set when a sub-agent's tool loop ends without `completeSubAgent` being
|
|
431
497
|
* called. Read by the parent's `invokeSubAgent` to build the `{ ok: false }`
|
|
432
498
|
* branch of `requestSubAgent`. Only ever set when `isSubAgent` is true.
|
|
499
|
+
*
|
|
500
|
+
* `budget` rides along on a `'budget_exhausted'` failure so the parent inherits
|
|
501
|
+
* the child's ATTRIBUTION, not just the fact of a wall — see
|
|
502
|
+
* `budgetWallDetail`.
|
|
433
503
|
*/
|
|
434
|
-
private subAgentFailure: { reason: SubAgentFailureReason } | undefined;
|
|
504
|
+
private subAgentFailure: { reason: SubAgentFailureReason; budget?: BudgetDetail } | undefined;
|
|
435
505
|
/**
|
|
436
506
|
* Set by `releaseAgent` inside a top-level tool handler — typically a stateful
|
|
437
507
|
* agent's terminal-state handler signalling that its flow is complete and the
|
|
@@ -582,6 +652,39 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
582
652
|
private readonly sessionKey: string;
|
|
583
653
|
/** Injected activity bus; defaults to a no-op off-browser (Node/tests/headless). */
|
|
584
654
|
private readonly activityBus: ActivityBus;
|
|
655
|
+
/** Transcript copy for a budget wall — see `ChatDriverConfig.budgetExhaustedMessage`. */
|
|
656
|
+
private readonly budgetExhaustedMessage: string;
|
|
657
|
+
/**
|
|
658
|
+
* Set the moment a budget wall is observed anywhere in this turn — this
|
|
659
|
+
* driver's own 402, or a sub-agent's (which surfaces here only as a
|
|
660
|
+
* `'budget_exhausted'` tool result). Read at the top of the tool loop to end
|
|
661
|
+
* the turn before issuing another model call that would hit the same wall.
|
|
662
|
+
* Reset per turn alongside the other per-turn counters.
|
|
663
|
+
*/
|
|
664
|
+
private budgetExhaustedThisTurn = false;
|
|
665
|
+
/**
|
|
666
|
+
* The refusing vendor's own attribution for the wall `budgetExhaustedThisTurn`
|
|
667
|
+
* records, when it was knowable. Kept SEPARATE from the flag rather than
|
|
668
|
+
* replacing it: a figure-less 402 from a transport no vendor claims yields no
|
|
669
|
+
* detail at all (`budgetDetailOf` returns `undefined`), and folding the two
|
|
670
|
+
* would make that case stop ending the turn.
|
|
671
|
+
*
|
|
672
|
+
* It matters most for a sub-agent's wall. The child can sit on a different
|
|
673
|
+
* vendor from its parent — `applyAgent` reads `config.provider` — so without
|
|
674
|
+
* this the parent's short-circuit reports `lastResolvedProvider`, i.e. the one
|
|
675
|
+
* vendor that did NOT refuse. Under a mixed registry that walls Gemini because
|
|
676
|
+
* an Anthropic child 402'd, and if those are the only two reachable vendors the
|
|
677
|
+
* host then derives `blocked` and locks a composer that still had headroom.
|
|
678
|
+
*/
|
|
679
|
+
private budgetWallDetail?: BudgetDetail;
|
|
680
|
+
/**
|
|
681
|
+
* Whether this turn's budget wall came from a SUB-AGENT rather than this
|
|
682
|
+
* driver's own request. Decides whether `lastResolvedProvider` is a valid
|
|
683
|
+
* attribution fallback: for an own wall it is the refusing vendor, for a
|
|
684
|
+
* child's wall it is the parent's vendor — the one known NOT to have refused.
|
|
685
|
+
* Reset per turn alongside `budgetWallDetail`.
|
|
686
|
+
*/
|
|
687
|
+
private budgetWallViaSubAgent = false;
|
|
585
688
|
|
|
586
689
|
constructor(
|
|
587
690
|
private readonly providerRegistry: AIProviderRegistry,
|
|
@@ -599,11 +702,13 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
599
702
|
maxTurnSnapshots = DEFAULT_MAX_TURN_SNAPSHOTS,
|
|
600
703
|
sessionKey = '',
|
|
601
704
|
activityBus = NOOP_ACTIVITY_BUS,
|
|
705
|
+
budgetExhaustedMessage = DEFAULT_BUDGET_EXHAUSTED_MESSAGE,
|
|
602
706
|
} = config;
|
|
603
707
|
this.maxToolIterations = maxToolIterations;
|
|
604
708
|
this.condenseBatchCalls = condenseBatchCalls;
|
|
605
709
|
this.sessionKey = sessionKey;
|
|
606
710
|
this.activityBus = activityBus;
|
|
711
|
+
this.budgetExhaustedMessage = budgetExhaustedMessage;
|
|
607
712
|
if (typeof toolHandlers === 'function') {
|
|
608
713
|
this.toolHandlersFactory = toolHandlers;
|
|
609
714
|
this.toolHandlers = {};
|
|
@@ -732,8 +837,65 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
732
837
|
* (rather than set to `undefined`) so a happy-path result stays byte-identical to
|
|
733
838
|
* the historical `{ reason: 'done' }`.
|
|
734
839
|
*/
|
|
735
|
-
private turnDone(failureReason?: TurnFailureReason): ChatDriverResult {
|
|
736
|
-
|
|
840
|
+
private turnDone(failureReason?: TurnFailureReason, budget?: BudgetDetail): ChatDriverResult {
|
|
841
|
+
if (!failureReason) return { reason: 'done' };
|
|
842
|
+
// `budget` is likewise omitted rather than set to `undefined`, so a non-budget
|
|
843
|
+
// failure's shape is unchanged for a consumer that structurally compares it.
|
|
844
|
+
return budget ? { reason: 'done', failureReason, budget } : { reason: 'done', failureReason };
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Terminal budget outcome for a wall hit **outside** the tool loop — today,
|
|
849
|
+
* `OrchestratingDriver`'s classification phase, which calls the provider
|
|
850
|
+
* directly and so never enters `runToolLoop`.
|
|
851
|
+
*
|
|
852
|
+
* Does **not** publish `tool-loop-end`: no `tool-loop-start` was published for
|
|
853
|
+
* the classify phase, and an unbalanced end would break start/end pairing for
|
|
854
|
+
* subscribers that rely on it. The driver **return value** is what reports this
|
|
855
|
+
* case — see `FoundationAiAssistant`'s latch, which reads both seams for
|
|
856
|
+
* exactly this reason.
|
|
857
|
+
*
|
|
858
|
+
* The non-sub-agent tail of the in-loop `BudgetExhaustedError` branch lives
|
|
859
|
+
* here so there is one copy of the log line, the debug-log entry, the
|
|
860
|
+
* transcript bubble and the result shape rather than two that can drift.
|
|
861
|
+
*
|
|
862
|
+
* @param pendingUserMessage - a user message that has NOT yet been appended,
|
|
863
|
+
* appended first so the answer does not end up replying to nothing. Only the
|
|
864
|
+
* classification seam passes it: `OrchestratingDriver` dispatches the user's
|
|
865
|
+
* text as an optimistic `history-updated` detail and leaves the real append
|
|
866
|
+
* to `chatDriver.sendMessage`, which never runs when `classify()` throws — so
|
|
867
|
+
* the bubble below would re-dispatch a history the user's own message was
|
|
868
|
+
* never in, and it would vanish from the transcript on the next render. The
|
|
869
|
+
* in-loop caller has already appended it and passes nothing.
|
|
870
|
+
*
|
|
871
|
+
* @internal
|
|
872
|
+
*/
|
|
873
|
+
reportBudgetExhausted(
|
|
874
|
+
e: BudgetExhaustedError,
|
|
875
|
+
pendingUserMessage?: ChatMessage,
|
|
876
|
+
): ChatDriverResult {
|
|
877
|
+
if (pendingUserMessage) this.appendToHistory(pendingUserMessage);
|
|
878
|
+
this.budgetExhaustedThisTurn = true;
|
|
879
|
+
logger.error('ChatDriver: AI budget exhausted', e);
|
|
880
|
+
recordTurnError(this.sessionKey, 'budget-exhausted', {
|
|
881
|
+
agent: this.activeAgentName,
|
|
882
|
+
provider: this.lastResolvedProviderName,
|
|
883
|
+
// The registry ALIAS (e.g. 'high') is what `provider` records; the vendor
|
|
884
|
+
// is the thing a per-vendor budget is actually scoped to, and it was known
|
|
885
|
+
// at both ends and discarded in the middle until now. Taken from the
|
|
886
|
+
// refusing transport's label first — this method also serves the
|
|
887
|
+
// classification seam, where `lastResolvedProvider` is the PREVIOUS turn's
|
|
888
|
+
// vendor (or nothing), because classify runs against the registry default.
|
|
889
|
+
vendor:
|
|
890
|
+
vendorTypeOfLabel(e.vendorLabel) ??
|
|
891
|
+
vendorTypeOfLabel(e.serverVendor) ??
|
|
892
|
+
this.lastResolvedProvider,
|
|
893
|
+
budgetUsd: e.budgetUsd,
|
|
894
|
+
spentUsd: e.spentUsd,
|
|
895
|
+
isSubAgent: this.isSubAgent,
|
|
896
|
+
});
|
|
897
|
+
this.appendToHistory({ role: 'assistant', content: this.budgetExhaustedMessage });
|
|
898
|
+
return this.turnDone('budget-exhausted', budgetDetailOf(e));
|
|
737
899
|
}
|
|
738
900
|
|
|
739
901
|
/** The typed failure reason on a loop result, or `undefined` for a clean turn / handoff. */
|
|
@@ -745,12 +907,48 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
745
907
|
* Build the `tool-loop-end` event detail for a turn's result. A failure carries a
|
|
746
908
|
* `{ failureReason }` detail; a clean turn emits `undefined` — the historical shape,
|
|
747
909
|
* kept byte-identical so subscribers see exactly what they always have.
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
910
|
+
*
|
|
911
|
+
* A budget failure additionally carries `vendor` — the concrete vendor
|
|
912
|
+
* (`'anthropic'`/`'gemini'`) the walled turn resolved to, which the driver knows
|
|
913
|
+
* and used to discard. Optional and additive: a subscriber reading only
|
|
914
|
+
* `failureReason` is unaffected, a non-budget failure still emits the historical
|
|
915
|
+
* `{ failureReason }` with no `vendor` key, and the value is a plain string so
|
|
916
|
+
* the detail stays structured-cloneable for the cross-tab hop. It is the field a
|
|
917
|
+
* per-vendor budget model needs and the one that would be awkward to retrofit.
|
|
918
|
+
*/
|
|
919
|
+
private loopEndDetail(result: ChatDriverResult):
|
|
920
|
+
| {
|
|
921
|
+
failureReason: TurnFailureReason;
|
|
922
|
+
vendor?: AIProviderType;
|
|
923
|
+
budget?: BudgetDetail;
|
|
924
|
+
}
|
|
925
|
+
| undefined {
|
|
752
926
|
const failureReason = ChatDriver.failureReasonOf(result);
|
|
753
|
-
|
|
927
|
+
if (!failureReason) return undefined;
|
|
928
|
+
if (failureReason !== 'budget-exhausted') {
|
|
929
|
+
return { failureReason };
|
|
930
|
+
}
|
|
931
|
+
// The figures ride the event, not just the return value: this publish happens in
|
|
932
|
+
// sendMessage's `finally`, so for an in-loop wall it reaches the host's latch FIRST
|
|
933
|
+
// and the return-value seam is then a no-op (the latch is idempotent). Omitted
|
|
934
|
+
// entirely — never set to undefined — when the proxy sent no figures.
|
|
935
|
+
const budget = result.reason === 'done' ? result.budget : undefined;
|
|
936
|
+
// Prefer the refusing transport's own attribution over the driver's
|
|
937
|
+
// last-resolved provider, for the same staleness reason as `budgetDetailOf`.
|
|
938
|
+
//
|
|
939
|
+
// The fallback is legitimate ONLY for this driver's own wall — its resolved
|
|
940
|
+
// provider IS the refuser then. When the wall came from a SUB-AGENT
|
|
941
|
+
// (`budgetWallViaSubAgent`), the refuser is the child's vendor, and on a
|
|
942
|
+
// mixed registry `lastResolvedProvider` is the one vendor known NOT to have
|
|
943
|
+
// refused; an unattributable child wall degrades to the vendor-agnostic
|
|
944
|
+
// event instead, which the host's latch handles fail-safe.
|
|
945
|
+
const vendor =
|
|
946
|
+
budget?.vendor ?? (this.budgetWallViaSubAgent ? undefined : this.lastResolvedProvider);
|
|
947
|
+
return {
|
|
948
|
+
failureReason,
|
|
949
|
+
...(vendor ? { vendor } : {}),
|
|
950
|
+
...(budget ? { budget } : {}),
|
|
951
|
+
};
|
|
754
952
|
}
|
|
755
953
|
|
|
756
954
|
/**
|
|
@@ -969,7 +1167,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
969
1167
|
* `completeSubAgent`, if any. Called by a parent `ChatDriver` after running
|
|
970
1168
|
* this instance as a sub-agent.
|
|
971
1169
|
*/
|
|
972
|
-
getSubAgentFailure(): { reason: SubAgentFailureReason } | undefined {
|
|
1170
|
+
getSubAgentFailure(): { reason: SubAgentFailureReason; budget?: BudgetDetail } | undefined {
|
|
973
1171
|
return this.subAgentFailure;
|
|
974
1172
|
}
|
|
975
1173
|
|
|
@@ -981,9 +1179,11 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
981
1179
|
* under a separate session key, so recording here would orphan the event off
|
|
982
1180
|
* the user-visible debug-log timeline.)
|
|
983
1181
|
*/
|
|
984
|
-
private failSubAgent(reason: SubAgentFailureReason): void {
|
|
1182
|
+
private failSubAgent(reason: SubAgentFailureReason, budget?: BudgetDetail): void {
|
|
985
1183
|
if (!this.isSubAgent || this.subAgentFailure) return;
|
|
986
|
-
|
|
1184
|
+
// Omitted rather than set to `undefined` so a non-budget failure's shape is
|
|
1185
|
+
// unchanged for a structural comparison, matching `turnDone`.
|
|
1186
|
+
this.subAgentFailure = budget ? { reason, budget } : { reason };
|
|
987
1187
|
}
|
|
988
1188
|
|
|
989
1189
|
/**
|
|
@@ -1623,6 +1823,9 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1623
1823
|
this.subAgentCompletion = undefined;
|
|
1624
1824
|
this.subAgentFailure = undefined;
|
|
1625
1825
|
this.agentReleaseRequested = false;
|
|
1826
|
+
this.budgetExhaustedThisTurn = false;
|
|
1827
|
+
this.budgetWallDetail = undefined;
|
|
1828
|
+
this.budgetWallViaSubAgent = false;
|
|
1626
1829
|
this.appendToHistory({ role: 'user', content: userInput, attachments });
|
|
1627
1830
|
this.turnStartedAt = Date.now();
|
|
1628
1831
|
recordMetaEvent(this.sessionKey, 'turn.start', {
|
|
@@ -1659,7 +1862,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1659
1862
|
});
|
|
1660
1863
|
this.busy = false;
|
|
1661
1864
|
this.endTurn();
|
|
1662
|
-
this.activityBus.publish('tool-loop-end',
|
|
1865
|
+
this.activityBus.publish('tool-loop-end', this.loopEndDetail(result));
|
|
1663
1866
|
}
|
|
1664
1867
|
}
|
|
1665
1868
|
|
|
@@ -1974,12 +2177,35 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1974
2177
|
// provider ignored forced tool use and returned text). The previous
|
|
1975
2178
|
// final-text fallback is intentionally gone — sub-agents return a
|
|
1976
2179
|
// structured outcome only, and the parent handler decides how to recover.
|
|
1977
|
-
const
|
|
2180
|
+
const failure = child.getSubAgentFailure();
|
|
2181
|
+
const reason = failure?.reason ?? 'max_iterations';
|
|
1978
2182
|
// Record under THIS (parent) driver's session so the failure lands on the
|
|
1979
2183
|
// user-visible debug-log timeline — the child ran under its own session key.
|
|
1980
2184
|
// This is also the only telemetry for the defensive default above, where the
|
|
1981
2185
|
// child's loop ended without recording an explicit failure reason.
|
|
1982
2186
|
recordMetaEvent(this.sessionKey, 'subagent.failed', { agent: name, reason });
|
|
2187
|
+
// A child that hit the budget wall walls this driver too — the cap is shared,
|
|
2188
|
+
// and the parent's very next model call would 402 as well. Flagged (not
|
|
2189
|
+
// thrown) because the handler's return value is still appended as a tool
|
|
2190
|
+
// result: the loop reads the flag before the next provider call and ends the
|
|
2191
|
+
// turn there, which is what makes the `'budget_exhausted'` doc's "terminal
|
|
2192
|
+
// for the parent too" actually true.
|
|
2193
|
+
//
|
|
2194
|
+
// The child's attribution is inherited with it: the vendor that refused is
|
|
2195
|
+
// the child's, which under a mixed registry is not this driver's. See
|
|
2196
|
+
// `budgetWallDetail` for what mis-attributing it costs.
|
|
2197
|
+
//
|
|
2198
|
+
// FIRST attribution wins (`??=`). With batched delegations, child A walling
|
|
2199
|
+
// WITH attribution can be followed by child B walling WITHOUT one (a
|
|
2200
|
+
// figure-less 402 behind a transport label no vendor claims); a plain
|
|
2201
|
+
// assignment reset the field to `undefined` and the short-circuit's
|
|
2202
|
+
// fallback then named the parent's own vendor — the one known NOT to have
|
|
2203
|
+
// refused.
|
|
2204
|
+
if (reason === 'budget_exhausted') {
|
|
2205
|
+
this.budgetExhaustedThisTurn = true;
|
|
2206
|
+
this.budgetWallViaSubAgent = true;
|
|
2207
|
+
this.budgetWallDetail ??= failure?.budget;
|
|
2208
|
+
}
|
|
1983
2209
|
return { outcome: { ok: false, reason }, trace };
|
|
1984
2210
|
}
|
|
1985
2211
|
|
|
@@ -1994,6 +2220,9 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
1994
2220
|
this.beginTurn();
|
|
1995
2221
|
this.subAgentCompletion = undefined;
|
|
1996
2222
|
this.subAgentFailure = undefined;
|
|
2223
|
+
this.budgetExhaustedThisTurn = false;
|
|
2224
|
+
this.budgetWallDetail = undefined;
|
|
2225
|
+
this.budgetWallViaSubAgent = false;
|
|
1997
2226
|
this.turnStartedAt = Date.now();
|
|
1998
2227
|
recordMetaEvent(this.sessionKey, 'turn.start', {
|
|
1999
2228
|
phase: 'continueFromHistory',
|
|
@@ -2028,7 +2257,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
2028
2257
|
});
|
|
2029
2258
|
this.busy = false;
|
|
2030
2259
|
this.endTurn();
|
|
2031
|
-
this.activityBus.publish('tool-loop-end',
|
|
2260
|
+
this.activityBus.publish('tool-loop-end', this.loopEndDetail(result));
|
|
2032
2261
|
}
|
|
2033
2262
|
}
|
|
2034
2263
|
|
|
@@ -2245,6 +2474,46 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
2245
2474
|
return this.completeAbortedTurn();
|
|
2246
2475
|
}
|
|
2247
2476
|
|
|
2477
|
+
// A budget wall observed earlier this turn ends it HERE, before another
|
|
2478
|
+
// model call. Reachable only via a sub-agent (this driver's own 402
|
|
2479
|
+
// returns straight out of the catch below) — `invokeSubAgent` sets the
|
|
2480
|
+
// flag, its `{ ok: false, reason: 'budget_exhausted' }` outcome is
|
|
2481
|
+
// appended as a tool result, and without this the loop would call the
|
|
2482
|
+
// provider again into the same wall: N batched sub-agent calls otherwise
|
|
2483
|
+
// cost N doomed children AND a doomed parent call.
|
|
2484
|
+
//
|
|
2485
|
+
// Scoped strictly to the budget reason: every other SubAgentFailureReason
|
|
2486
|
+
// is something the parent can legitimately recover from, so those still
|
|
2487
|
+
// let the loop continue.
|
|
2488
|
+
if (this.budgetExhaustedThisTurn) {
|
|
2489
|
+
logger.error('ChatDriver: ending the turn — a sub-agent hit the AI budget wall');
|
|
2490
|
+
recordTurnError(this.sessionKey, 'budget-exhausted', {
|
|
2491
|
+
agent: this.activeAgentName,
|
|
2492
|
+
provider: this.lastResolvedProviderName,
|
|
2493
|
+
// The CHILD's vendor when it knew one, and NOTHING otherwise. On this
|
|
2494
|
+
// path the wall is definitionally the child's, and on a mixed registry
|
|
2495
|
+
// `lastResolvedProvider` is this driver's own vendor — the one known
|
|
2496
|
+
// NOT to have refused. An unattributable child wall must degrade to
|
|
2497
|
+
// the vendor-agnostic block (which `latchBlockedFrom` handles
|
|
2498
|
+
// fail-safe), never to a vendor that is known to be wrong: naming the
|
|
2499
|
+
// parent's vendor here walled BOTH — the child's via its own
|
|
2500
|
+
// tool-loop-end, the parent's via this event — and derived `blocked`
|
|
2501
|
+
// over headroom that still existed.
|
|
2502
|
+
vendor: this.budgetWallDetail?.vendor,
|
|
2503
|
+
via: 'sub-agent',
|
|
2504
|
+
isSubAgent: this.isSubAgent,
|
|
2505
|
+
});
|
|
2506
|
+
if (this.isSubAgent) {
|
|
2507
|
+
this.failSubAgent('budget_exhausted', this.budgetWallDetail);
|
|
2508
|
+
} else {
|
|
2509
|
+
this.appendToHistory({ role: 'assistant', content: this.budgetExhaustedMessage });
|
|
2510
|
+
}
|
|
2511
|
+
// Carried onto the result so `loopEndDetail` publishes the refusing
|
|
2512
|
+
// vendor rather than falling through to `lastResolvedProvider`, and the
|
|
2513
|
+
// host's latch walls the vendor that actually ran out.
|
|
2514
|
+
return this.turnDone('budget-exhausted', this.budgetWallDetail);
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2248
2517
|
const promptCtx: SystemPromptContext = {
|
|
2249
2518
|
agentName: this.activeAgentName ?? '',
|
|
2250
2519
|
history: this.history,
|
|
@@ -2511,6 +2780,47 @@ export class ChatDriver extends EventTarget implements AiDriver {
|
|
|
2511
2780
|
}
|
|
2512
2781
|
return this.turnDone('response-truncated');
|
|
2513
2782
|
}
|
|
2783
|
+
// The AI-spend budget is gone and the proxy refused the request (HTTP
|
|
2784
|
+
// 402). Terminal in the strongest sense available to us: unlike a
|
|
2785
|
+
// truncation — which a smaller request would get past — *no* request
|
|
2786
|
+
// succeeds until someone raises the budget out of band. So there is no
|
|
2787
|
+
// retry here and no "try again" in the copy; the turn ends and the host
|
|
2788
|
+
// locks the composer off the `'budget-exhausted'` failure reason (see
|
|
2789
|
+
// `FoundationAiAssistant.blocked`).
|
|
2790
|
+
if (e instanceof BudgetExhaustedError) {
|
|
2791
|
+
// Flagged as well as returned: a sub-agent's wall reaches the PARENT
|
|
2792
|
+
// only as a tool result, and the parent must not issue another model
|
|
2793
|
+
// call after it (see the short-circuit at the top of this loop).
|
|
2794
|
+
this.budgetExhaustedThisTurn = true;
|
|
2795
|
+
this.budgetWallDetail = budgetDetailOf(e);
|
|
2796
|
+
// Paired with the detail everywhere it is written: this is the
|
|
2797
|
+
// driver's OWN wall, so the sub-agent marker must not survive from an
|
|
2798
|
+
// earlier child and suppress the `lastResolvedProvider` fallback that
|
|
2799
|
+
// is legitimate here. Unreachable today (the short-circuit fires
|
|
2800
|
+
// before a second wall can land in one turn) — kept structural so the
|
|
2801
|
+
// pairing does not depend on that ordering staying true.
|
|
2802
|
+
this.budgetWallViaSubAgent = false;
|
|
2803
|
+
if (this.isSubAgent) {
|
|
2804
|
+
logger.error('ChatDriver: AI budget exhausted', e);
|
|
2805
|
+
recordTurnError(this.sessionKey, 'budget-exhausted', {
|
|
2806
|
+
agent: this.activeAgentName,
|
|
2807
|
+
provider: this.lastResolvedProviderName,
|
|
2808
|
+
vendor:
|
|
2809
|
+
vendorTypeOfLabel(e.vendorLabel) ??
|
|
2810
|
+
vendorTypeOfLabel(e.serverVendor) ??
|
|
2811
|
+
this.lastResolvedProvider,
|
|
2812
|
+
budgetUsd: e.budgetUsd,
|
|
2813
|
+
spentUsd: e.spentUsd,
|
|
2814
|
+
isSubAgent: true,
|
|
2815
|
+
});
|
|
2816
|
+
// Bubble a typed failure to the parent instead of speaking to the
|
|
2817
|
+
// user, carrying the attribution so the parent walls the vendor that
|
|
2818
|
+
// actually refused rather than its own.
|
|
2819
|
+
this.failSubAgent('budget_exhausted', this.budgetWallDetail);
|
|
2820
|
+
return this.turnDone('budget-exhausted', this.budgetWallDetail);
|
|
2821
|
+
}
|
|
2822
|
+
return this.reportBudgetExhausted(e);
|
|
2823
|
+
}
|
|
2514
2824
|
// A request timeout from the transport (tagged `TimeoutError`) is not a
|
|
2515
2825
|
// bug on our end — surface it distinctly instead of letting it fall
|
|
2516
2826
|
// through to the generic "something went wrong" catch. No auto-retry:
|