@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { __awaiter } from "tslib";
|
|
2
|
-
import { isChatToolCallUnknown, MalformedFunctionCallError, ResponseTruncatedError, } from '@genesislcap/foundation-ai';
|
|
2
|
+
import { BudgetExhaustedError, 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';
|
|
@@ -1854,6 +1854,12 @@ const truncatedProvider = () => ({
|
|
|
1854
1854
|
throw new ResponseTruncatedError('test-model', 1024, 1024, ['noop']);
|
|
1855
1855
|
}),
|
|
1856
1856
|
});
|
|
1857
|
+
/** A provider refused for budget (deterministic, terminal — must not retry). */
|
|
1858
|
+
const budgetExhaustedProvider = () => ({
|
|
1859
|
+
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1860
|
+
throw new BudgetExhaustedError('Anthropic', 25, 25.4, 'AI budget exhausted');
|
|
1861
|
+
}),
|
|
1862
|
+
});
|
|
1857
1863
|
/** A provider that throws a generic error (the sendMessage catch-all → 'exception'). */
|
|
1858
1864
|
const throwingProvider = () => ({
|
|
1859
1865
|
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
@@ -1943,4 +1949,461 @@ outcome('a clean turn leaves the legacy shape byte-unchanged (no failureReason)'
|
|
|
1943
1949
|
assert.not.ok(getMetaEvents('outcome-ok').some((e) => e.type === 'turn.error'), 'a clean turn records no turn.error');
|
|
1944
1950
|
cap.stop();
|
|
1945
1951
|
}));
|
|
1952
|
+
// ── Budget exhaustion (GENC-1464) ──────────────────────────────────────────────
|
|
1953
|
+
outcome('budget-exhausted surfaces at both seams', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1954
|
+
yield assertSurfacesReason('budget', budgetExhaustedProvider(), 'budget-exhausted');
|
|
1955
|
+
}));
|
|
1956
|
+
outcome('a budget wall is terminal — no transient retry, and actionable copy', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1957
|
+
var _a, _b, _c, _d;
|
|
1958
|
+
clearMetaEventRegistry();
|
|
1959
|
+
let calls = 0;
|
|
1960
|
+
const provider = {
|
|
1961
|
+
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
1962
|
+
calls += 1;
|
|
1963
|
+
throw new BudgetExhaustedError('Anthropic', 25, 25.4);
|
|
1964
|
+
}),
|
|
1965
|
+
// Resolvable vendor, so the per-vendor fields below are exercised.
|
|
1966
|
+
getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'anthropic', model: 'test-model' }); }),
|
|
1967
|
+
};
|
|
1968
|
+
const config = agent({
|
|
1969
|
+
name: 'Static',
|
|
1970
|
+
toolDefinitions: [def('noop')],
|
|
1971
|
+
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
1972
|
+
});
|
|
1973
|
+
const driver = makeDriver(config, provider, 'outcome-budget-once', outcomeBus);
|
|
1974
|
+
const cap = captureLoopEnd();
|
|
1975
|
+
const result = yield driver.sendMessage('go');
|
|
1976
|
+
// The whole point of the typed branch: it sits BEFORE the transient-retry
|
|
1977
|
+
// catch, so the driver must not burn MAX_SETUP_TRANSPORT_RETRIES attempts
|
|
1978
|
+
// against a wall that no retry can clear.
|
|
1979
|
+
assert.is(calls, 1, 'a budget wall is not retried — exactly one model call');
|
|
1980
|
+
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted', 'the turn ends with the budget-exhausted failure reason');
|
|
1981
|
+
// The figures now reach the CALLER, not just the debug log — this is what lets
|
|
1982
|
+
// the element render "$25.40 of $25" in its banner without catching the
|
|
1983
|
+
// transport error itself.
|
|
1984
|
+
const budget = result.reason === 'done' ? result.budget : undefined;
|
|
1985
|
+
assert.is(budget === null || budget === void 0 ? void 0 : budget.budgetUsd, 25);
|
|
1986
|
+
assert.is(budget === null || budget === void 0 ? void 0 : budget.spentUsd, 25.4);
|
|
1987
|
+
assert.is(budget === null || budget === void 0 ? void 0 : budget.vendorLabel, 'Anthropic');
|
|
1988
|
+
// The bus detail carries the resolved vendor beside the reason.
|
|
1989
|
+
assert.is(cap.detail().failureReason, 'budget-exhausted');
|
|
1990
|
+
assert.is(cap.detail().vendor, 'anthropic');
|
|
1991
|
+
cap.stop();
|
|
1992
|
+
const last = driver.getHistory().at(-1);
|
|
1993
|
+
assert.ok((last === null || last === void 0 ? void 0 : last.role) === 'assistant', 'turn ends with an assistant message');
|
|
1994
|
+
// Identity, not substrings: the transcript bubble and the element's blocked
|
|
1995
|
+
// banner now read from one shared const, and this is what pins them together.
|
|
1996
|
+
assert.is(last.content, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
|
|
1997
|
+
assert.not.ok(last.content.includes('something went wrong'), 'must not fall through to the generic apology');
|
|
1998
|
+
// The budget figures ride the debug-log entry so a support ticket can show them.
|
|
1999
|
+
const err = getMetaEvents('outcome-budget-once').find((e) => e.type === 'turn.error');
|
|
2000
|
+
assert.is((_a = err.detail) === null || _a === void 0 ? void 0 : _a.budgetUsd, 25);
|
|
2001
|
+
assert.is((_b = err.detail) === null || _b === void 0 ? void 0 : _b.spentUsd, 25.4);
|
|
2002
|
+
// The registry ALIAS and the VENDOR are different things; record both.
|
|
2003
|
+
assert.is((_c = err.detail) === null || _c === void 0 ? void 0 : _c.provider, 'test');
|
|
2004
|
+
assert.is((_d = err.detail) === null || _d === void 0 ? void 0 : _d.vendor, 'anthropic');
|
|
2005
|
+
}));
|
|
2006
|
+
outcome('a non-budget failure emits the historical detail shape, with no vendor', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2007
|
+
// Back-compat for subscribers that structurally compare the `tool-loop-end`
|
|
2008
|
+
// detail: only a budget failure grew a field.
|
|
2009
|
+
clearMetaEventRegistry();
|
|
2010
|
+
const config = agent({
|
|
2011
|
+
name: 'Static',
|
|
2012
|
+
toolDefinitions: [def('noop')],
|
|
2013
|
+
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
2014
|
+
});
|
|
2015
|
+
const driver = makeDriver(config, throwingProvider(), 'outcome-vendor-compat', outcomeBus);
|
|
2016
|
+
const cap = captureLoopEnd();
|
|
2017
|
+
yield driver.sendMessage('go');
|
|
2018
|
+
assert.equal(cap.detail(), { failureReason: 'exception' }, 'no vendor key was added');
|
|
2019
|
+
cap.stop();
|
|
2020
|
+
}));
|
|
2021
|
+
outcome('a 402 with no figures still carries the vendor that refused it', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2022
|
+
var _a;
|
|
2023
|
+
// The regression this pins: the driver used to drop the ENTIRE budget object
|
|
2024
|
+
// when both figures were absent, and `vendorLabel` — the authoritative
|
|
2025
|
+
// attribution source — went with it. The element then fell back to the
|
|
2026
|
+
// driver's last-resolved provider, i.e. the PREVIOUS turn's vendor, and walled
|
|
2027
|
+
// the wrong one. Three live shapes arrive here with no figures: a bare gateway
|
|
2028
|
+
// 402, a code-only body, and the framed err frame whose figures are `null`.
|
|
2029
|
+
clearMetaEventRegistry();
|
|
2030
|
+
const provider = {
|
|
2031
|
+
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2032
|
+
throw new BudgetExhaustedError('Anthropic');
|
|
2033
|
+
}),
|
|
2034
|
+
};
|
|
2035
|
+
const config = agent({
|
|
2036
|
+
name: 'Static',
|
|
2037
|
+
toolDefinitions: [def('noop')],
|
|
2038
|
+
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
2039
|
+
});
|
|
2040
|
+
const driver = makeDriver(config, provider, 'outcome-budget-nofigures', outcomeBus);
|
|
2041
|
+
const cap = captureLoopEnd();
|
|
2042
|
+
const result = yield driver.sendMessage('go');
|
|
2043
|
+
const budget = result.reason === 'done' ? result.budget : undefined;
|
|
2044
|
+
assert.ok(budget, 'a figure-less wall still reports who refused it');
|
|
2045
|
+
assert.is(budget.vendorLabel, 'Anthropic');
|
|
2046
|
+
assert.is(budget.vendor, 'anthropic');
|
|
2047
|
+
// Still no figures — the element reads their ABSENCE as "leave any host-set
|
|
2048
|
+
// banner copy alone", so they must not be invented.
|
|
2049
|
+
assert.is(budget.budgetUsd, undefined);
|
|
2050
|
+
assert.is(budget.spentUsd, undefined);
|
|
2051
|
+
// Both seams, since the bus one lands first and wins the latch.
|
|
2052
|
+
assert.is((_a = cap.detail().budget) === null || _a === void 0 ? void 0 : _a.vendor, 'anthropic');
|
|
2053
|
+
cap.stop();
|
|
2054
|
+
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
|
|
2055
|
+
}));
|
|
2056
|
+
outcome('a wall with nothing to report at all still omits the budget key', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2057
|
+
// The other direction: `budget` is present when there is something to say and
|
|
2058
|
+
// absent when there is not, so presence keeps meaning something. No figures AND
|
|
2059
|
+
// an unclaimed label leaves nothing to carry — and the element's fail-safe
|
|
2060
|
+
// (block everything) is the correct outcome for an unattributable wall.
|
|
2061
|
+
clearMetaEventRegistry();
|
|
2062
|
+
const provider = {
|
|
2063
|
+
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2064
|
+
throw new BudgetExhaustedError('Acme AI');
|
|
2065
|
+
}),
|
|
2066
|
+
};
|
|
2067
|
+
const config = agent({
|
|
2068
|
+
name: 'Static',
|
|
2069
|
+
toolDefinitions: [def('noop')],
|
|
2070
|
+
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
2071
|
+
});
|
|
2072
|
+
const driver = makeDriver(config, provider, 'outcome-budget-nothing', outcomeBus);
|
|
2073
|
+
const result = yield driver.sendMessage('go');
|
|
2074
|
+
assert.not.ok('budget' in result, 'nothing reportable → no budget key');
|
|
2075
|
+
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
|
|
2076
|
+
}));
|
|
2077
|
+
outcome('a figure-less wall attributes to the refusing transport, not the last turn', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2078
|
+
var _a;
|
|
2079
|
+
// The exact failure: turn 1 runs on Gemini, turn 2's request is refused by
|
|
2080
|
+
// Anthropic with a figure-less 402. `lastResolvedProvider` still says `gemini`,
|
|
2081
|
+
// so the pre-fix driver reported Gemini and the user was told to switch to the
|
|
2082
|
+
// vendor that had actually run out.
|
|
2083
|
+
clearMetaEventRegistry();
|
|
2084
|
+
let walled = false;
|
|
2085
|
+
const provider = {
|
|
2086
|
+
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2087
|
+
if (!walled) {
|
|
2088
|
+
walled = true;
|
|
2089
|
+
return { role: 'assistant', content: 'first turn, on Gemini' };
|
|
2090
|
+
}
|
|
2091
|
+
throw new BudgetExhaustedError('Anthropic');
|
|
2092
|
+
}),
|
|
2093
|
+
};
|
|
2094
|
+
// The registry resolves to Gemini, so `lastResolvedProvider` is `gemini` by the
|
|
2095
|
+
// time the Anthropic wall lands — the disagreement has to be seeded to prove
|
|
2096
|
+
// which source is believed.
|
|
2097
|
+
const registry = {
|
|
2098
|
+
get: () => provider,
|
|
2099
|
+
default: () => provider,
|
|
2100
|
+
defaultName: () => 'test',
|
|
2101
|
+
names: () => ['test'],
|
|
2102
|
+
getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'gemini', model: 'gemini-test' }); }),
|
|
2103
|
+
listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
|
|
2104
|
+
};
|
|
2105
|
+
const driver = new ChatDriver(registry, {
|
|
2106
|
+
sessionKey: 'outcome-budget-stale-vendor',
|
|
2107
|
+
activityBus: outcomeBus,
|
|
2108
|
+
});
|
|
2109
|
+
driver.applyAgent(agent({ name: 'Static', toolDefinitions: [def('noop')], toolHandlers: {} }));
|
|
2110
|
+
yield driver.sendMessage('one');
|
|
2111
|
+
const result = yield driver.sendMessage('two');
|
|
2112
|
+
const budget = result.reason === 'done' ? result.budget : undefined;
|
|
2113
|
+
assert.is(budget === null || budget === void 0 ? void 0 : budget.vendor, 'anthropic', 'the transport that said no, not the one before it');
|
|
2114
|
+
const err = getMetaEvents('outcome-budget-stale-vendor').find((e) => e.type === 'turn.error');
|
|
2115
|
+
assert.is((_a = err.detail) === null || _a === void 0 ? void 0 : _a.vendor, 'anthropic', 'and the debug log agrees');
|
|
2116
|
+
}));
|
|
2117
|
+
outcome("the proxy's own `vendor` rescues an unclaimed transport label", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2118
|
+
// A multiplexing or white-labelled gateway fronts several upstreams behind one
|
|
2119
|
+
// transport, so its static label names none of them. The 402's `vendor` is then
|
|
2120
|
+
// the only thing that knows which pot was actually booked against — second
|
|
2121
|
+
// opinion, never first: the transport's own label still wins where it resolves.
|
|
2122
|
+
clearMetaEventRegistry();
|
|
2123
|
+
const provider = {
|
|
2124
|
+
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2125
|
+
throw new BudgetExhaustedError('Acme AI', undefined, undefined, undefined, {
|
|
2126
|
+
serverVendor: 'Gemini',
|
|
2127
|
+
});
|
|
2128
|
+
}),
|
|
2129
|
+
};
|
|
2130
|
+
const config = agent({
|
|
2131
|
+
name: 'Static',
|
|
2132
|
+
toolDefinitions: [def('noop')],
|
|
2133
|
+
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
2134
|
+
});
|
|
2135
|
+
const driver = makeDriver(config, provider, 'outcome-budget-server-vendor', outcomeBus);
|
|
2136
|
+
const result = yield driver.sendMessage('go');
|
|
2137
|
+
const budget = result.reason === 'done' ? result.budget : undefined;
|
|
2138
|
+
assert.is(budget === null || budget === void 0 ? void 0 : budget.vendorLabel, 'Acme AI', 'the transport label is reported verbatim');
|
|
2139
|
+
assert.is(budget === null || budget === void 0 ? void 0 : budget.vendor, 'gemini', 'and attribution still lands');
|
|
2140
|
+
}));
|
|
2141
|
+
outcome("the proxy's `otherVendorAvailable` verdict reaches both seams", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2142
|
+
var _a, _b;
|
|
2143
|
+
// The one fact the element cannot re-derive: registry membership says which
|
|
2144
|
+
// vendors EXIST, never which still have budget. It has to survive the trip.
|
|
2145
|
+
clearMetaEventRegistry();
|
|
2146
|
+
const provider = {
|
|
2147
|
+
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2148
|
+
throw new BudgetExhaustedError('Anthropic', 25, 25, undefined, {
|
|
2149
|
+
otherVendorAvailable: false,
|
|
2150
|
+
});
|
|
2151
|
+
}),
|
|
2152
|
+
};
|
|
2153
|
+
const config = agent({
|
|
2154
|
+
name: 'Static',
|
|
2155
|
+
toolDefinitions: [def('noop')],
|
|
2156
|
+
toolHandlers: { noop: () => __awaiter(void 0, void 0, void 0, function* () { return 'ok'; }) },
|
|
2157
|
+
});
|
|
2158
|
+
const driver = makeDriver(config, provider, 'outcome-budget-other-vendor', outcomeBus);
|
|
2159
|
+
const cap = captureLoopEnd();
|
|
2160
|
+
const result = yield driver.sendMessage('go');
|
|
2161
|
+
assert.is(result.reason === 'done' ? (_a = result.budget) === null || _a === void 0 ? void 0 : _a.otherVendorAvailable : undefined, false, 'the return-value seam');
|
|
2162
|
+
assert.is((_b = cap.detail().budget) === null || _b === void 0 ? void 0 : _b.otherVendorAvailable, false, 'and the bus seam, which lands first and wins the latch');
|
|
2163
|
+
cap.stop();
|
|
2164
|
+
}));
|
|
2165
|
+
outcome('a host-supplied budgetExhaustedMessage replaces the transcript copy', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2166
|
+
clearMetaEventRegistry();
|
|
2167
|
+
const provider = {
|
|
2168
|
+
chat: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2169
|
+
throw new BudgetExhaustedError('Anthropic', 25, 25.4);
|
|
2170
|
+
}),
|
|
2171
|
+
};
|
|
2172
|
+
const driver = new ChatDriver(makeRegistry(provider), {
|
|
2173
|
+
sessionKey: 'outcome-budget-custom',
|
|
2174
|
+
activityBus: outcomeBus,
|
|
2175
|
+
budgetExhaustedMessage: 'Contact ops@acme.com',
|
|
2176
|
+
});
|
|
2177
|
+
driver.applyAgent(agent({ name: 'Static', toolDefinitions: [def('noop')], toolHandlers: {} }));
|
|
2178
|
+
yield driver.sendMessage('go');
|
|
2179
|
+
// The banner was already overridable; without this seam a white-labelled host
|
|
2180
|
+
// got its own copy in the banner and 'contact Genesis' directly below it.
|
|
2181
|
+
assert.is(driver.getHistory().at(-1).content, 'Contact ops@acme.com');
|
|
2182
|
+
}));
|
|
2183
|
+
// ── A sub-agent's wall is terminal for the parent too (GENC-1464) ──────────────
|
|
2184
|
+
// `SubAgentFailureReason.budget_exhausted` is documented as "terminal for the
|
|
2185
|
+
// parent too". Nothing parent-side read it, so the parent's tool loop appended
|
|
2186
|
+
// the failed outcome as a tool result and called the model again — into the same
|
|
2187
|
+
// wall. With N batched delegations that was N doomed children AND a doomed parent
|
|
2188
|
+
// call, and the element's `failureReason === 'budget-exhausted'` latch never fired.
|
|
2189
|
+
/** A sub-agent whose own turn is refused for budget; the parent's turns are not. */
|
|
2190
|
+
const parentOkChildWalled = () => {
|
|
2191
|
+
let parentCalls = 0;
|
|
2192
|
+
return {
|
|
2193
|
+
parentCalls: () => parentCalls,
|
|
2194
|
+
provider: {
|
|
2195
|
+
chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
2196
|
+
var _a;
|
|
2197
|
+
const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
|
|
2198
|
+
if (names.includes('delegate')) {
|
|
2199
|
+
parentCalls += 1;
|
|
2200
|
+
return callsTool('delegate', `d${parentCalls}`);
|
|
2201
|
+
}
|
|
2202
|
+
throw new BudgetExhaustedError('Anthropic', 25, 25.4);
|
|
2203
|
+
}),
|
|
2204
|
+
},
|
|
2205
|
+
};
|
|
2206
|
+
};
|
|
2207
|
+
const walledWorker = () => agent({
|
|
2208
|
+
name: 'worker',
|
|
2209
|
+
toolDefinitions: [def('finish')],
|
|
2210
|
+
toolHandlers: { finish: () => __awaiter(void 0, void 0, void 0, function* () { return 'x'; }) },
|
|
2211
|
+
});
|
|
2212
|
+
outcome('a sub-agent budget wall ends the parent turn without another model call', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2213
|
+
clearMetaEventRegistry();
|
|
2214
|
+
const { parentCalls, provider } = parentOkChildWalled();
|
|
2215
|
+
const parent = delegatingParent(walledWorker(), () => undefined);
|
|
2216
|
+
const driver = makeDriver(parent, provider, 'outcome-subagent-budget', outcomeBus);
|
|
2217
|
+
const result = yield driver.sendMessage('go');
|
|
2218
|
+
assert.is(parentCalls(), 1, 'the parent never calls the model again after the wall');
|
|
2219
|
+
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
|
|
2220
|
+
assert.is(driver.getHistory().at(-1).content, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
|
|
2221
|
+
}));
|
|
2222
|
+
outcome("a sub-agent's wall is attributed to the CHILD's vendor, not the parent's", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2223
|
+
var _a;
|
|
2224
|
+
// A sub-agent can sit on a different vendor from its parent (`applyAgent` reads
|
|
2225
|
+
// `config.provider`). The wall reaches the parent only as a tool result, so
|
|
2226
|
+
// while the parent carried nothing but a boolean it reported
|
|
2227
|
+
// `lastResolvedProvider` — its own vendor, i.e. precisely the one that did NOT
|
|
2228
|
+
// refuse. Under a two-vendor registry that walls Gemini because an Anthropic
|
|
2229
|
+
// child 402'd, and the host then blocks a composer that still had headroom.
|
|
2230
|
+
clearMetaEventRegistry();
|
|
2231
|
+
const { provider } = parentOkChildWalled();
|
|
2232
|
+
// Seeded disagreement: the registry resolves Gemini, so the parent's
|
|
2233
|
+
// `lastResolvedProvider` is `gemini` while the child is refused by Anthropic.
|
|
2234
|
+
const registry = {
|
|
2235
|
+
get: () => provider,
|
|
2236
|
+
default: () => provider,
|
|
2237
|
+
defaultName: () => 'test',
|
|
2238
|
+
names: () => ['test'],
|
|
2239
|
+
getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'gemini', model: 'gemini-test' }); }),
|
|
2240
|
+
listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
|
|
2241
|
+
};
|
|
2242
|
+
const driver = new ChatDriver(registry, {
|
|
2243
|
+
sessionKey: 'outcome-subagent-budget-vendor',
|
|
2244
|
+
activityBus: outcomeBus,
|
|
2245
|
+
});
|
|
2246
|
+
driver.applyAgent(delegatingParent(walledWorker(), () => undefined));
|
|
2247
|
+
const result = yield driver.sendMessage('go');
|
|
2248
|
+
const budget = result.reason === 'done' ? result.budget : undefined;
|
|
2249
|
+
assert.is(budget === null || budget === void 0 ? void 0 : budget.vendor, 'anthropic', "the child's refusing vendor survives the hop");
|
|
2250
|
+
assert.is(budget === null || budget === void 0 ? void 0 : budget.budgetUsd, 25, "and so do the child's figures");
|
|
2251
|
+
assert.is(budget === null || budget === void 0 ? void 0 : budget.spentUsd, 25.4);
|
|
2252
|
+
const err = getMetaEvents('outcome-subagent-budget-vendor').find((e) => e.type === 'turn.error');
|
|
2253
|
+
assert.is((_a = err.detail) === null || _a === void 0 ? void 0 : _a.vendor, 'anthropic', 'and the debug log agrees');
|
|
2254
|
+
}));
|
|
2255
|
+
outcome('N batched sub-agent calls still produce at most one doomed parent call', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2256
|
+
clearMetaEventRegistry();
|
|
2257
|
+
let parentCalls = 0;
|
|
2258
|
+
const provider = {
|
|
2259
|
+
chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
2260
|
+
var _a;
|
|
2261
|
+
const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
|
|
2262
|
+
if (names.includes('delegate')) {
|
|
2263
|
+
parentCalls += 1;
|
|
2264
|
+
return {
|
|
2265
|
+
role: 'assistant',
|
|
2266
|
+
content: '',
|
|
2267
|
+
toolCalls: [
|
|
2268
|
+
{ id: 'd1', name: 'delegate', args: {} },
|
|
2269
|
+
{ id: 'd2', name: 'delegate', args: {} },
|
|
2270
|
+
],
|
|
2271
|
+
};
|
|
2272
|
+
}
|
|
2273
|
+
throw new BudgetExhaustedError('Anthropic', 25, 25.4);
|
|
2274
|
+
}),
|
|
2275
|
+
};
|
|
2276
|
+
const parent = delegatingParent(walledWorker(), () => undefined);
|
|
2277
|
+
const driver = makeDriver(parent, provider, 'outcome-subagent-budget-batched', outcomeBus);
|
|
2278
|
+
const result = yield driver.sendMessage('go');
|
|
2279
|
+
assert.is(parentCalls, 1);
|
|
2280
|
+
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'budget-exhausted');
|
|
2281
|
+
}));
|
|
2282
|
+
outcome('a non-terminal sub-agent failure still lets the parent continue', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2283
|
+
// The short-circuit is scoped strictly to the budget reason — every other
|
|
2284
|
+
// SubAgentFailureReason is something the parent can legitimately recover from.
|
|
2285
|
+
clearMetaEventRegistry();
|
|
2286
|
+
let parentCalls = 0;
|
|
2287
|
+
const provider = {
|
|
2288
|
+
chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
2289
|
+
var _a;
|
|
2290
|
+
const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
|
|
2291
|
+
if (names.includes('delegate')) {
|
|
2292
|
+
parentCalls += 1;
|
|
2293
|
+
return parentCalls === 1
|
|
2294
|
+
? callsTool('delegate', 'd1')
|
|
2295
|
+
: { role: 'assistant', content: 'carried on regardless' };
|
|
2296
|
+
}
|
|
2297
|
+
// The child answers in free text without calling its completion tool →
|
|
2298
|
+
// `max_iterations`, a recoverable failure.
|
|
2299
|
+
return { role: 'assistant', content: 'no structured result' };
|
|
2300
|
+
}),
|
|
2301
|
+
};
|
|
2302
|
+
let captured;
|
|
2303
|
+
const parent = delegatingParent(walledWorker(), (o) => {
|
|
2304
|
+
captured = o;
|
|
2305
|
+
});
|
|
2306
|
+
const driver = makeDriver(parent, provider, 'outcome-subagent-nonterminal', outcomeBus);
|
|
2307
|
+
const result = yield driver.sendMessage('go');
|
|
2308
|
+
assert.equal(captured, { ok: false, reason: 'max_iterations' });
|
|
2309
|
+
assert.is(parentCalls, 2, 'the parent loop kept running');
|
|
2310
|
+
assert.is(result.reason === 'done' ? result.failureReason : undefined, undefined);
|
|
2311
|
+
}));
|
|
2312
|
+
outcome("a later unattributable child wall does not erase the first child's attribution", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2313
|
+
// Batched delegations: child A is refused by Anthropic WITH attribution,
|
|
2314
|
+
// child B by a transport whose label no vendor claims (figure-less 402 → no
|
|
2315
|
+
// BudgetDetail at all). A plain assignment let B's `undefined` clobber A's
|
|
2316
|
+
// attribution, and the fallback then reported the PARENT's vendor — under a
|
|
2317
|
+
// Gemini-resolving registry, precisely the vendor that did not refuse.
|
|
2318
|
+
clearMetaEventRegistry();
|
|
2319
|
+
let childCalls = 0;
|
|
2320
|
+
const provider = {
|
|
2321
|
+
chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
2322
|
+
var _a;
|
|
2323
|
+
const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
|
|
2324
|
+
if (names.includes('delegate')) {
|
|
2325
|
+
return {
|
|
2326
|
+
role: 'assistant',
|
|
2327
|
+
content: '',
|
|
2328
|
+
toolCalls: [
|
|
2329
|
+
{ id: 'd1', name: 'delegate', args: {} },
|
|
2330
|
+
{ id: 'd2', name: 'delegate', args: {} },
|
|
2331
|
+
],
|
|
2332
|
+
};
|
|
2333
|
+
}
|
|
2334
|
+
childCalls += 1;
|
|
2335
|
+
throw childCalls === 1
|
|
2336
|
+
? new BudgetExhaustedError('Anthropic', 25, 25.4)
|
|
2337
|
+
: new BudgetExhaustedError('Acme AI'); // unclaimed label, no figures
|
|
2338
|
+
}),
|
|
2339
|
+
};
|
|
2340
|
+
const registry = {
|
|
2341
|
+
get: () => provider,
|
|
2342
|
+
default: () => provider,
|
|
2343
|
+
defaultName: () => 'test',
|
|
2344
|
+
names: () => ['test'],
|
|
2345
|
+
getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'gemini', model: 'gemini-test' }); }),
|
|
2346
|
+
listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
|
|
2347
|
+
};
|
|
2348
|
+
const driver = new ChatDriver(registry, {
|
|
2349
|
+
sessionKey: 'outcome-subagent-budget-clobber',
|
|
2350
|
+
activityBus: outcomeBus,
|
|
2351
|
+
});
|
|
2352
|
+
driver.applyAgent(delegatingParent(walledWorker(), () => undefined));
|
|
2353
|
+
const result = yield driver.sendMessage('one');
|
|
2354
|
+
const budget = result.reason === 'done' ? result.budget : undefined;
|
|
2355
|
+
assert.is(budget === null || budget === void 0 ? void 0 : budget.vendor, 'anthropic', "child A's attribution survives child B's blank wall");
|
|
2356
|
+
}));
|
|
2357
|
+
outcome('a fully unattributable child wall degrades vendor-agnostic, never to the parent', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
2358
|
+
var _a;
|
|
2359
|
+
// When NO child wall carries attribution, the honest event is vendor-less.
|
|
2360
|
+
// Falling back to `lastResolvedProvider` named the parent's vendor — walling
|
|
2361
|
+
// BOTH vendors (the child's via its own tool-loop-end, the parent's via this
|
|
2362
|
+
// one) and deriving `blocked` over headroom that still existed.
|
|
2363
|
+
clearMetaEventRegistry();
|
|
2364
|
+
const events = [];
|
|
2365
|
+
const bus = new AgenticActivityBus();
|
|
2366
|
+
bus.subscribe('tool-loop-end', (d) => events.push(d));
|
|
2367
|
+
// The provider must carry its own `getStatus` — `lastResolvedProvider` is
|
|
2368
|
+
// stamped from the PROVIDER's status, not the registry's, and a status-less
|
|
2369
|
+
// mock leaves it unset, which lets the buggy fallback pass vacuously (there
|
|
2370
|
+
// is no wrong vendor to fall back to).
|
|
2371
|
+
const provider = {
|
|
2372
|
+
getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'gemini', model: 'gemini-test' }); }),
|
|
2373
|
+
chat: (_h, _u, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
2374
|
+
var _a;
|
|
2375
|
+
const names = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
|
|
2376
|
+
if (names.includes('delegate'))
|
|
2377
|
+
return callsTool('delegate', 'd1');
|
|
2378
|
+
// The CHILD's turn: refused by a transport whose label no vendor claims.
|
|
2379
|
+
throw new BudgetExhaustedError('Acme AI'); // no figures either
|
|
2380
|
+
}),
|
|
2381
|
+
};
|
|
2382
|
+
const registry = {
|
|
2383
|
+
get: () => provider,
|
|
2384
|
+
default: () => provider,
|
|
2385
|
+
defaultName: () => 'test',
|
|
2386
|
+
names: () => ['test'],
|
|
2387
|
+
getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ provider: 'gemini', model: 'gemini-test' }); }),
|
|
2388
|
+
listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
|
|
2389
|
+
};
|
|
2390
|
+
const driver = new ChatDriver(registry, {
|
|
2391
|
+
sessionKey: 'outcome-subagent-budget-agnostic',
|
|
2392
|
+
activityBus: bus,
|
|
2393
|
+
});
|
|
2394
|
+
driver.applyAgent(delegatingParent(walledWorker(), () => undefined));
|
|
2395
|
+
yield driver.sendMessage('go');
|
|
2396
|
+
// The PARENT's loop-end is the last one on the bus. It must carry the
|
|
2397
|
+
// failure without naming the parent's (gemini) vendor.
|
|
2398
|
+
const parentEnd = events.at(-1);
|
|
2399
|
+
assert.is(parentEnd === null || parentEnd === void 0 ? void 0 : parentEnd.failureReason, 'budget-exhausted');
|
|
2400
|
+
assert.is(parentEnd === null || parentEnd === void 0 ? void 0 : parentEnd.vendor, undefined, 'no vendor is better than the wrong vendor');
|
|
2401
|
+
// The CHILD's folded-in turn.error legitimately carries its own best-effort
|
|
2402
|
+
// self-attribution (its resolved provider really did make the refused call);
|
|
2403
|
+
// the record this fix governs is the PARENT's short-circuit, tagged
|
|
2404
|
+
// `via: 'sub-agent'`.
|
|
2405
|
+
const err = getMetaEvents('outcome-subagent-budget-agnostic').find((e) => { var _a; return e.type === 'turn.error' && ((_a = e.detail) === null || _a === void 0 ? void 0 : _a.via) === 'sub-agent'; });
|
|
2406
|
+
assert.ok(err, "the parent's short-circuit recorded the wall");
|
|
2407
|
+
assert.is((_a = err.detail) === null || _a === void 0 ? void 0 : _a.vendor, undefined, 'and named no vendor rather than the wrong one');
|
|
2408
|
+
}));
|
|
1946
2409
|
outcome.run();
|