@genesislcap/ai-assistant 15.11.0 → 15.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/ai-assistant.api.json +135 -0
  2. package/dist/ai-assistant.d.ts +75 -3
  3. package/dist/chat-driver.cjs +540 -117
  4. package/dist/chat-driver.cjs.map +4 -4
  5. package/dist/chat-driver.mjs +524 -116
  6. package/dist/chat-driver.mjs.map +4 -4
  7. package/dist/custom-elements.json +1822 -1483
  8. package/dist/dts/chat-driver-node.d.ts +5 -2
  9. package/dist/dts/chat-driver-node.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/chat-driver.d.ts +20 -3
  11. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  12. package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts +2 -0
  13. package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts.map +1 -0
  14. package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts +2 -0
  15. package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts.map +1 -0
  16. package/dist/dts/config/config.d.ts +39 -2
  17. package/dist/dts/config/config.d.ts.map +1 -1
  18. package/dist/dts/config/define-stateful-agent.d.ts +15 -1
  19. package/dist/dts/config/define-stateful-agent.d.ts.map +1 -1
  20. package/dist/dts/main/main.template.d.ts.map +1 -1
  21. package/dist/dts/utils/strip-agent-handlers.d.ts +1 -1
  22. package/dist/dts/utils/sum-usage.d.ts +37 -4
  23. package/dist/dts/utils/sum-usage.d.ts.map +1 -1
  24. package/dist/dts/utils/usage-rows.d.ts +102 -0
  25. package/dist/dts/utils/usage-rows.d.ts.map +1 -0
  26. package/dist/dts/utils/usage-rows.test.d.ts +2 -0
  27. package/dist/dts/utils/usage-rows.test.d.ts.map +1 -0
  28. package/dist/esm/chat-driver-node.js +36 -1
  29. package/dist/esm/components/chat-driver/chat-driver.js +73 -10
  30. package/dist/esm/components/chat-driver/chat-driver.thinking-policy.test.js +137 -0
  31. package/dist/esm/components/chat-driver/chat-driver.trace-capture.test.js +200 -0
  32. package/dist/esm/config/define-stateful-agent.js +11 -0
  33. package/dist/esm/main/main.template.js +20 -1
  34. package/dist/esm/utils/strip-agent-handlers.js +1 -1
  35. package/dist/esm/utils/sum-usage.js +37 -4
  36. package/dist/esm/utils/usage-rows.js +90 -0
  37. package/dist/esm/utils/usage-rows.test.js +189 -0
  38. package/dist/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +17 -17
  40. package/src/chat-driver-node.ts +58 -0
  41. package/src/components/chat-driver/chat-driver.thinking-policy.test.ts +185 -0
  42. package/src/components/chat-driver/chat-driver.trace-capture.test.ts +251 -0
  43. package/src/components/chat-driver/chat-driver.ts +90 -10
  44. package/src/config/config.ts +50 -1
  45. package/src/config/define-stateful-agent.ts +37 -0
  46. package/src/main/main.template.ts +19 -1
  47. package/src/utils/strip-agent-handlers.ts +1 -1
  48. package/src/utils/sum-usage.ts +37 -4
  49. package/src/utils/usage-rows.test.ts +237 -0
  50. package/src/utils/usage-rows.ts +187 -0
@@ -584,6 +584,7 @@ export class ChatDriver extends EventTarget {
584
584
  this.activeTemperatureInput = config.temperature;
585
585
  this.activeToolChoiceInput = config.toolChoice;
586
586
  this.activeCachePolicyInput = config.cachePolicy;
587
+ this.activeThinkingPolicyInput = config.thinkingPolicy;
587
588
  this.activeTailContextInput = config.tailContext;
588
589
  this.activeResponseSchemaInput = config.responseSchema;
589
590
  this.activeFallbacks = config.fallbacks;
@@ -1403,15 +1404,29 @@ export class ChatDriver extends EventTarget {
1403
1404
  * `condenseWhen` can register against the right call (it stamps the clocks
1404
1405
  * straight off the driver). Absent for dispatch paths with no addressable tool
1405
1406
  * call (e.g. the fold-close handler), where `condenseWhen` is a no-op.
1406
- * @param traceCapture - Optional per-invocation slot. When provided, the trace
1407
- * from any sub-agent call is written here rather than to shared instance state,
1408
- * so parallel tool calls each capture their own trace independently.
1407
+ * @param traceCapture - Optional per-tool-call accumulator. When provided, every
1408
+ * sub-agent call's trace is **appended** here rather than written to shared
1409
+ * instance state, so parallel tool calls each capture their own traces
1410
+ * independently.
1411
+ *
1412
+ * Deliberately a list of traces, not one slot. A single handler may call
1413
+ * `requestSubAgent` more than once — a code-driven scheduler dispatching a
1414
+ * dependency graph, a retry of a timed-out child, any fan-out helper — and a
1415
+ * single slot kept only the last, so every other child ran, was billed by the
1416
+ * provider, and then vanished from history. The loss was silent in the worst
1417
+ * way: `sumUsage` and `usageRows` both recurse into the trace, so they summed a
1418
+ * truncated input and still agreed with each other. Measured at 1 of 7 traces
1419
+ * kept on a seven-way fan-out, reporting ~2.3x under the real cost, with the
1420
+ * error growing as the fan-out widens.
1409
1421
  */
1410
1422
  buildHandlerContext(activeToolCallId, traceCapture) {
1411
1423
  return Object.assign(Object.assign({ requestInteraction: (componentName, data, options) => this.requestInteraction(componentName, data, options) }, (this.subAgentsMap.size > 0 && {
1412
1424
  requestSubAgent: (name, options) => this.invokeSubAgent(name, options).then(({ outcome, trace }) => {
1413
- if (traceCapture)
1414
- traceCapture.trace = trace;
1425
+ // Append, never assign: see `traceCapture` on `buildHandlerContext`. Order
1426
+ // is completion order, which is fine — every message carries its own
1427
+ // timestamp and the timeline sorts on that.
1428
+ if (traceCapture && trace)
1429
+ traceCapture.traces.push(trace);
1415
1430
  return outcome;
1416
1431
  }),
1417
1432
  })), { completeSubAgent: (result) => {
@@ -1908,6 +1923,18 @@ export class ChatDriver extends EventTarget {
1908
1923
  // `iterations` because fold operations decrement iterations, which would incorrectly
1909
1924
  // re-trigger the slice on subsequent calls after a fold open/close.
1910
1925
  let firstLlmCall = !!currentInput;
1926
+ // Thinking posture for this WHOLE turn, resolved on the first model call and then held.
1927
+ //
1928
+ // Unlike temperature or toolChoice, this one cannot vary per iteration: a tool-use loop is
1929
+ // a single assistant turn, and Anthropic requires one thinking mode for its duration.
1930
+ // Toggling mid-loop does not error — the API silently disables thinking for that request
1931
+ // and strips blocks that would leave the turn structure invalid, so an `'auto' -> 'off'`
1932
+ // switch loses the reasoning continuity the opening call established while an
1933
+ // `'off' -> 'auto'` switch simply does not deliver the reasoning asked for. It also
1934
+ // invalidates the prompt cache, which costs more than the reasoning it was meant to save.
1935
+ // Resolved per USER turn instead, which is where the docs say to choose it.
1936
+ let pinnedThinkingPolicy;
1937
+ let thinkingPolicyPinned = false;
1911
1938
  while (iterations < this.maxToolIterations) {
1912
1939
  iterations += 1;
1913
1940
  // Monotonic across the driver's life — the age clock. Unlike `iterations`,
@@ -2071,7 +2098,7 @@ export class ChatDriver extends EventTarget {
2071
2098
  // provider is resolved — static value or a function of the turn context
2072
2099
  // (which carries the live state for stateful agents). Resolved before the
2073
2100
  // snapshot so the debug log records the exact request config the model saw.
2074
- const [resolvedTemperature, resolvedToolChoice, resolvedCachePolicy, resolvedTailContext, resolvedResponseSchema,] =
2101
+ const [resolvedTemperature, resolvedToolChoice, resolvedCachePolicy, resolvedTailContext, resolvedResponseSchema, firstResolvedThinkingPolicy,] =
2075
2102
  // oxlint-disable-next-line no-await-in-loop
2076
2103
  yield Promise.all([
2077
2104
  this.resolveTurnInput(this.activeTemperatureInput, promptCtx),
@@ -2079,7 +2106,16 @@ export class ChatDriver extends EventTarget {
2079
2106
  this.resolveTurnInput(this.activeCachePolicyInput, promptCtx),
2080
2107
  this.resolveTurnInput(this.activeTailContextInput, promptCtx),
2081
2108
  this.resolveTurnInput(this.activeResponseSchemaInput, promptCtx),
2109
+ // Only consulted on the first iteration (see `pinnedThinkingPolicy`); resolved
2110
+ // alongside the others so a resolver still sees the same turn context.
2111
+ thinkingPolicyPinned
2112
+ ? Promise.resolve(undefined)
2113
+ : this.resolveTurnInput(this.activeThinkingPolicyInput, promptCtx),
2082
2114
  ]);
2115
+ if (!thinkingPolicyPinned) {
2116
+ pinnedThinkingPolicy = firstResolvedThinkingPolicy;
2117
+ thinkingPolicyPinned = true;
2118
+ }
2083
2119
  // The system prompt is always just the agent's resolved prompt — byte-stable, so it can be
2084
2120
  // cached. The framework's volatile additions (fold suffix, retry nudge) and the agent's tail
2085
2121
  // context all go to the framed tail: one uniform channel, no cache-scope branch. On a normal
@@ -2124,6 +2160,12 @@ export class ChatDriver extends EventTarget {
2124
2160
  // Prompt-cache policy for this turn (Anthropic places breakpoints per scope; Gemini
2125
2161
  // caches implicitly regardless). Undefined → no caching requested.
2126
2162
  cachePolicy: resolvedCachePolicy,
2163
+ // Extended-thinking posture, pinned for the whole tool loop (one assistant turn) rather
2164
+ // than re-resolved per iteration — see `pinnedThinkingPolicy`. Undefined — unset, or the
2165
+ // resolver's answer for this turn — is NOT "off": it leaves the model on its own default,
2166
+ // so agents that never set this are priced exactly as before. Transports clamp models
2167
+ // that can't honour it.
2168
+ thinkingPolicy: pinnedThinkingPolicy,
2127
2169
  // Framed volatile context injected at the message tail (never stored). Undefined → none.
2128
2170
  tailContext,
2129
2171
  // Structured-output schema for this turn (agent/state-resolved). When set, the transport
@@ -2303,8 +2345,15 @@ export class ChatDriver extends EventTarget {
2303
2345
  // leaves the key off entirely rather than carrying it as `undefined`.
2304
2346
  // JSON.stringify already drops undefined from the exported log, so this is
2305
2347
  // chiefly about keeping the in-memory message shape honest.
2306
- if (this.lastResolvedModel !== undefined)
2348
+ // Fill, never overwrite — but still only when there is something to fill with.
2349
+ // A transport that already stamped a model knows something the driver does not:
2350
+ // `lastResolvedModel` is the model we ASKED for, so overwriting would relabel a
2351
+ // fallback-served turn as the requested model and misattribute its spend.
2352
+ // Written as a guard rather than `??=` because `??=` ASSIGNS undefined, which
2353
+ // would create the key and break the omit-when-unresolved contract above.
2354
+ if (response.model === undefined && this.lastResolvedModel !== undefined) {
2307
2355
  response.model = this.lastResolvedModel;
2356
+ }
2308
2357
  if (this.lastResolvedProvider !== undefined)
2309
2358
  response.provider = this.lastResolvedProvider;
2310
2359
  if (this.lastResolvedProviderName !== undefined) {
@@ -2517,15 +2566,26 @@ export class ChatDriver extends EventTarget {
2517
2566
  }
2518
2567
  return;
2519
2568
  }
2520
- // Real tool execution
2569
+ // Real tool execution.
2570
+ //
2571
+ // The accumulator is declared OUTSIDE the try so the catch can attach it
2572
+ // too: a handler that ran sub-agents and then threw (post-processing their
2573
+ // results failed, say) has already spent real money on children that
2574
+ // completed. Losing their traces on the error path would under-report the
2575
+ // run exactly as the single-slot bug did — and just as silently, since
2576
+ // `sumUsage` and `usageRows` would still agree with each other.
2577
+ const traceCapture = { traces: [] };
2578
+ const capturedTrace = () => traceCapture.traces.length ? traceCapture.traces.flat() : undefined;
2521
2579
  try {
2522
- const traceCapture = {};
2523
2580
  const result = yield handler(tc.args, this.buildHandlerContext(tc.id, traceCapture));
2524
2581
  const content = typeof result === 'string' ? result : JSON.stringify(result);
2525
2582
  executedById.set(tc.id, {
2526
2583
  toolCallId: tc.id,
2527
2584
  content,
2528
- subAgentTrace: traceCapture.trace,
2585
+ // Concatenated when a handler invoked several children, so none is lost.
2586
+ // Stays `undefined` when nothing was captured — readers key off presence,
2587
+ // and an empty array is a different claim from "no sub-agent ran".
2588
+ subAgentTrace: capturedTrace(),
2529
2589
  });
2530
2590
  anyRealToolExecuted = true;
2531
2591
  }
@@ -2541,6 +2601,9 @@ export class ChatDriver extends EventTarget {
2541
2601
  // Structured recovery hint so the model retries or routes around a tool
2542
2602
  // failure instead of apologising and giving up.
2543
2603
  content: `Tool error: ${e.message}\nRECOVERY: this tool failed once — you may retry it, or take a different valid action to make progress. Do NOT abandon the task, ask the user to rephrase, or claim you cannot make changes. If a planning tool failed, retry it or proceed with the information you already have.`,
2604
+ // Children that completed before the throw were still billed — keep
2605
+ // their traces so the run's cost stays whole.
2606
+ subAgentTrace: capturedTrace(),
2544
2607
  });
2545
2608
  anyRealToolExecuted = true; // treat errors as real work for fold op counting
2546
2609
  }
@@ -0,0 +1,137 @@
1
+ import { __awaiter } from "tslib";
2
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
3
+ // Side-effect import — MUST come before `./chat-driver` so the driver subclasses
4
+ // jsdom's EventTarget rather than Node's native one. Mirrors chat-driver.test.ts.
5
+ import './align-event-globals';
6
+ import { ChatDriver } from './chat-driver';
7
+ // ---------------------------------------------------------------------------
8
+ // Per-turn `thinkingPolicy` resolution.
9
+ //
10
+ // The cost case for this option is the SHAPE of a tool loop, not a single call: the
11
+ // opening turn is a real decision and worth reasoning over, while the iterations that
12
+ // follow mostly pick the next tool from a narrow set — and on a thinking model each of
13
+ // those bills reasoning at the full output rate. That saving only exists if the
14
+ // resolver runs per iteration, so these tests assert the sequence across a loop, not
15
+ // just that one value arrives.
16
+ //
17
+ // The other half is the undefined case. Every agent written before this option leaves
18
+ // it unset, and a resolver may answer `undefined` on any given turn; both must reach
19
+ // the transport as `undefined` so the model keeps its own default. `undefined` reaching
20
+ // the wire as `'off'` would be a silent capability regression, and as `'auto'` a silent
21
+ // bill increase — so it is asserted explicitly rather than assumed.
22
+ // ---------------------------------------------------------------------------
23
+ /** Captures the policy seen on each model call; calls one tool, then finishes. */
24
+ const capturingProvider = () => {
25
+ const seen = [];
26
+ let turns = 0;
27
+ return {
28
+ seen,
29
+ chat: (_history, _userMessage, options) => __awaiter(void 0, void 0, void 0, function* () {
30
+ seen.push(options === null || options === void 0 ? void 0 : options.thinkingPolicy);
31
+ turns += 1;
32
+ // Two tool calls, so the loop runs three model calls in total — enough for a
33
+ // per-turn resolver to say something different on the later ones.
34
+ if (turns <= 2) {
35
+ return {
36
+ role: 'assistant',
37
+ content: '',
38
+ toolCalls: [{ id: `t${turns}`, name: 'step', args: {} }],
39
+ };
40
+ }
41
+ return { role: 'assistant', content: 'done' };
42
+ }),
43
+ };
44
+ };
45
+ const makeRegistry = (provider) => ({
46
+ get: () => provider,
47
+ default: () => provider,
48
+ defaultName: () => 'test',
49
+ names: () => ['test'],
50
+ getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
51
+ listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
52
+ });
53
+ const agent = (overrides) => (Object.assign({ name: 'worker', description: 'test agent', toolDefinitions: [
54
+ { name: 'step', description: 'step', parameters: { type: 'object', properties: {} } },
55
+ ], toolHandlers: { step: () => __awaiter(void 0, void 0, void 0, function* () { return 'stepped'; }) } }, overrides));
56
+ /** Run one user turn through a driver carrying `config`, and return the policies seen. */
57
+ const policiesFor = (config) => __awaiter(void 0, void 0, void 0, function* () {
58
+ const provider = capturingProvider();
59
+ const driver = new ChatDriver(makeRegistry(provider), {
60
+ maxToolIterations: 10,
61
+ maxFoldOperations: 5,
62
+ sessionKey: '',
63
+ });
64
+ driver.applyAgent(agent(config));
65
+ yield driver.sendMessage('go');
66
+ return provider.seen;
67
+ });
68
+ const suite = createLogicSuite('ChatDriver thinkingPolicy');
69
+ suite('leaves the policy undefined when the agent does not set one', () => __awaiter(void 0, void 0, void 0, function* () {
70
+ // The compatibility case: every existing agent. Undefined must reach the transport
71
+ // as undefined so each model keeps its own default posture — not silently coerced
72
+ // to 'off' (a capability regression) or 'auto' (a bill increase).
73
+ const seen = yield policiesFor({});
74
+ assert.ok(seen.length >= 3, `expected a multi-call loop, got ${seen.length}`);
75
+ assert.equal(seen.filter((p) => p !== undefined), [], 'no turn invents a policy');
76
+ }));
77
+ suite('applies a static policy to every turn of the loop', () => __awaiter(void 0, void 0, void 0, function* () {
78
+ const seen = yield policiesFor({ thinkingPolicy: 'off' });
79
+ assert.ok(seen.length >= 3);
80
+ assert.equal([...new Set(seen)], ['off'], 'a static value is not just a first-turn setting');
81
+ }));
82
+ suite('pins the resolved policy for the whole tool loop', () => __awaiter(void 0, void 0, void 0, function* () {
83
+ // A tool-use loop is ONE assistant turn and Anthropic requires a single thinking mode
84
+ // for its duration. Toggling part-way does not error — the API silently disables
85
+ // thinking for that request and strips blocks that would leave the turn structure
86
+ // invalid, so an 'auto' -> 'off' switch loses the continuity the opening call
87
+ // established and 'off' -> 'auto' never delivers the reasoning asked for. It also
88
+ // invalidates the prompt cache, costing more than the reasoning it meant to save.
89
+ //
90
+ // So a resolver that changes its mind mid-loop must NOT be honoured mid-loop. This
91
+ // asserts the opposite of what it looks like it should: the later values are ignored.
92
+ let call = 0;
93
+ const seen = yield policiesFor({
94
+ thinkingPolicy: () => {
95
+ call += 1;
96
+ return call === 1 ? 'auto' : 'off';
97
+ },
98
+ });
99
+ assert.ok(seen.length >= 3, `expected a multi-call loop, got ${seen.length}`);
100
+ assert.equal([...new Set(seen)], ['auto'], 'the first call decides; later resolutions do not take effect until the next user turn');
101
+ }));
102
+ suite('re-resolves on the next user turn', () => __awaiter(void 0, void 0, void 0, function* () {
103
+ // The flip side: pinning is per turn, not for the driver's lifetime, so a state change
104
+ // between turns still lands.
105
+ const provider = capturingProvider();
106
+ const driver = new ChatDriver(makeRegistry(provider), {
107
+ maxToolIterations: 10,
108
+ maxFoldOperations: 5,
109
+ sessionKey: '',
110
+ });
111
+ let turn = 0;
112
+ driver.applyAgent(agent({
113
+ thinkingPolicy: () => (turn === 0 ? 'auto' : 'off'),
114
+ }));
115
+ yield driver.sendMessage('go');
116
+ const firstTurn = [...provider.seen];
117
+ turn = 1;
118
+ yield driver.sendMessage('again');
119
+ const secondTurn = provider.seen.slice(firstTurn.length);
120
+ assert.equal([...new Set(firstTurn)], ['auto'], 'turn one holds its posture');
121
+ assert.equal([...new Set(secondTurn)], ['off'], 'turn two picks up the new one');
122
+ }));
123
+ suite('passes undefined through when the resolver declines to choose', () => __awaiter(void 0, void 0, void 0, function* () {
124
+ // A resolver may answer on some turns and not others. `undefined` is its third answer —
125
+ // "leave this model alone" — and must not be normalised into a value. Asserted across
126
+ // the whole loop because the first call's answer is the one that gets pinned.
127
+ const seen = yield policiesFor({ thinkingPolicy: () => undefined });
128
+ assert.ok(seen.length >= 3);
129
+ assert.equal(seen.filter((p) => p !== undefined), [], 'declining is not the same as choosing');
130
+ }));
131
+ suite('awaits an async resolver', () => __awaiter(void 0, void 0, void 0, function* () {
132
+ const seen = yield policiesFor({
133
+ thinkingPolicy: () => __awaiter(void 0, void 0, void 0, function* () { return 'off'; }),
134
+ });
135
+ assert.equal([...new Set(seen)], ['off'], 'a promise is resolved, not passed through');
136
+ }));
137
+ suite.run();
@@ -0,0 +1,200 @@
1
+ import { __awaiter } from "tslib";
2
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
3
+ import { sumUsage } from '../../utils/sum-usage';
4
+ import { usageRows } from '../../utils/usage-rows';
5
+ // Side-effect import — MUST come before `./chat-driver` so the driver subclasses
6
+ // jsdom's EventTarget rather than Node's native one. Mirrors chat-driver.test.ts.
7
+ import './align-event-globals';
8
+ import { ChatDriver } from './chat-driver';
9
+ // ---------------------------------------------------------------------------
10
+ // Sub-agent trace capture when ONE tool call invokes SEVERAL sub-agents.
11
+ //
12
+ // The capture slot used to be a single `{ trace?: ChatMessage[] }` per tool call,
13
+ // assigned by each `requestSubAgent`. That covers N tool calls × 1 sub-agent — the
14
+ // case its docblock named — but not 1 tool call × N sub-agents, which is what a
15
+ // code-driven scheduler or a retry produces. All but the last trace was dropped, so
16
+ // those children ran, were billed, and then had no record in history.
17
+ //
18
+ // The failure was invisible to every existing check: `sumUsage` and `usageRows` both
19
+ // recurse into `subAgentTrace`, so they summed a truncated input and still reconciled
20
+ // with each other perfectly. Measured at 1 of 7 traces kept on a seven-way fan-out,
21
+ // ~2.3x under the true cost.
22
+ //
23
+ // These tests assert the traces SURVIVE, which is upstream of any cost assertion.
24
+ // ---------------------------------------------------------------------------
25
+ /** Answers by rule: concurrent children under one turn make a FIFO queue nondeterministic. */
26
+ const ruleProvider = () => {
27
+ let parentTurns = 0;
28
+ return {
29
+ chat: (_history, _userMessage, options) => __awaiter(void 0, void 0, void 0, function* () {
30
+ var _a;
31
+ const tools = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
32
+ // Child turn: finishes via its completion tool, and reports usage so the
33
+ // reconciliation assertions have something to add up.
34
+ if (tools.includes('work')) {
35
+ return {
36
+ role: 'assistant',
37
+ content: '',
38
+ model: 'claude-sonnet-5',
39
+ cost: 0.01,
40
+ inputTokens: 100,
41
+ outputTokens: 20,
42
+ toolCalls: [{ id: 'w1', name: 'work', args: {} }],
43
+ };
44
+ }
45
+ if (tools.includes('delegate') && parentTurns === 0) {
46
+ parentTurns += 1;
47
+ return {
48
+ role: 'assistant',
49
+ content: '',
50
+ model: 'claude-sonnet-5',
51
+ cost: 0.02,
52
+ inputTokens: 200,
53
+ outputTokens: 30,
54
+ toolCalls: [{ id: 'd0', name: 'delegate', args: {} }],
55
+ };
56
+ }
57
+ return { role: 'assistant', content: 'done', model: 'claude-sonnet-5' };
58
+ }),
59
+ };
60
+ };
61
+ const makeRegistry = (provider) => ({
62
+ get: () => provider,
63
+ default: () => provider,
64
+ defaultName: () => 'test',
65
+ names: () => ['test'],
66
+ getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
67
+ listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
68
+ });
69
+ const agent = (overrides) => (Object.assign({ description: 'test agent' }, overrides));
70
+ /** A child that reports one unit of usage and completes. */
71
+ const worker = (name) => agent({
72
+ name,
73
+ toolDefinitions: [
74
+ { name: 'work', description: 'work', parameters: { type: 'object', properties: {} } },
75
+ ],
76
+ toolHandlers: {
77
+ work: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
78
+ var _a;
79
+ (_a = ctx.completeSubAgent) === null || _a === void 0 ? void 0 : _a.call(ctx, { ok: true });
80
+ return 'worked';
81
+ }),
82
+ },
83
+ });
84
+ /**
85
+ * A parent whose single `delegate` tool call invokes `names` in turn — the shape a
86
+ * code-driven scheduler produces, and the one the old single slot truncated.
87
+ */
88
+ const boss = (children, invoke) => agent({
89
+ name: 'boss',
90
+ subAgents: children,
91
+ toolDefinitions: [
92
+ { name: 'delegate', description: 'delegate', parameters: { type: 'object', properties: {} } },
93
+ ],
94
+ toolHandlers: {
95
+ delegate: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
96
+ yield Promise.all(invoke.map((n) => ctx.requestSubAgent(n, { task: 'go' })));
97
+ return 'delegated';
98
+ }),
99
+ },
100
+ });
101
+ const run = (config) => __awaiter(void 0, void 0, void 0, function* () {
102
+ const driver = new ChatDriver(makeRegistry(ruleProvider()), {
103
+ maxToolIterations: 20,
104
+ maxFoldOperations: 5,
105
+ sessionKey: '',
106
+ });
107
+ driver.applyAgent(config);
108
+ yield driver.sendMessage('go');
109
+ return driver.getHistory();
110
+ });
111
+ const tracesIn = (history) => history.flatMap((m) => { var _a; return ((_a = m.toolCalls) !== null && _a !== void 0 ? _a : []).flatMap((tc) => (tc.subAgentTrace ? [tc.subAgentTrace] : [])); });
112
+ const suite = createLogicSuite('ChatDriver sub-agent trace capture');
113
+ // ---------------------------------------------------------------------------
114
+ suite('keeps every trace when one tool call invokes three sub-agents', () => __awaiter(void 0, void 0, void 0, function* () {
115
+ const names = ['gen_a', 'gen_b', 'gen_c'];
116
+ const history = yield run(boss(names.map(worker), names));
117
+ const traces = tracesIn(history);
118
+ assert.is(traces.length, 1, 'one tool call, so one concatenated trace');
119
+ // Each child contributes at least its own assistant turn. Before the fix this was
120
+ // one child's worth regardless of how many ran.
121
+ const seen = new Set(traces[0].map((m) => m.agentName).filter(Boolean));
122
+ assert.equal([...seen].sort(), names, `every invoked child must appear — got ${JSON.stringify([...seen])}`);
123
+ }));
124
+ suite('prices every child — usageRows emits a row per child and reconciles', () => __awaiter(void 0, void 0, void 0, function* () {
125
+ // The reconciliation test extended to a fan-out. The single-child version passed
126
+ // before the fix AND after it, which is exactly why it did not catch this.
127
+ const names = ['gen_a', 'gen_b', 'gen_c'];
128
+ const history = yield run(boss(names.map(worker), names));
129
+ const rows = usageRows(history);
130
+ const childRows = rows.filter((r) => r.subAgentDepth === 1);
131
+ assert.is(childRows.length, names.length, 'one row per child that ran');
132
+ const rowTotal = rows.reduce((n, r) => { var _a, _b; return n + ((_a = r.costUsd) !== null && _a !== void 0 ? _a : 0) + ((_b = r.externalCostUsd) !== null && _b !== void 0 ? _b : 0); }, 0);
133
+ assert.ok(Math.abs(rowTotal - sumUsage(history).costUsd) < 1e-12, `rows ${rowTotal} vs sumUsage ${sumUsage(history).costUsd}`);
134
+ // Parent turn ($0.02) + three children ($0.01 each). Asserted as a number so a
135
+ // regression that silently drops a child fails here rather than only in the
136
+ // reconciliation above, which would still agree with a truncated input.
137
+ assert.ok(Math.abs(rowTotal - 0.05) < 1e-12, `expected 0.05, got ${rowTotal}`);
138
+ }));
139
+ suite('keeps both traces when the same sub-agent is invoked twice (a retry)', () => __awaiter(void 0, void 0, void 0, function* () {
140
+ // The retry shape: one handler, one child name, two invocations. Under the old slot
141
+ // the first attempt's trace was overwritten by the second.
142
+ const history = yield run(boss([worker('gen_a')], ['gen_a', 'gen_a']));
143
+ const traces = tracesIn(history);
144
+ assert.is(traces.length, 1);
145
+ const childTurns = traces[0].filter((m) => m.agentName === 'gen_a' && m.cost != null);
146
+ assert.is(childTurns.length, 2, 'both attempts survive, not just the last');
147
+ const rows = usageRows(history).filter((r) => r.subAgentDepth === 1);
148
+ assert.is(rows.length, 2, 'and both are priced');
149
+ }));
150
+ suite('keeps child traces when the parent handler throws after they ran', () => __awaiter(void 0, void 0, void 0, function* () {
151
+ // Children that completed were billed by the provider. If the error path drops
152
+ // their traces, the run under-reports exactly as the single-slot bug did — and
153
+ // just as silently, since `sumUsage` and `usageRows` would still agree with each
154
+ // other over the truncated input. PR review.
155
+ const names = ['gen_a', 'gen_b'];
156
+ const parent = agent({
157
+ name: 'boss',
158
+ subAgents: names.map(worker),
159
+ toolDefinitions: [
160
+ { name: 'delegate', description: 'delegate', parameters: { type: 'object', properties: {} } },
161
+ ],
162
+ toolHandlers: {
163
+ delegate: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
164
+ yield Promise.all(names.map((n) => ctx.requestSubAgent(n, { task: 'go' })));
165
+ throw new Error('post-processing the children failed');
166
+ }),
167
+ },
168
+ });
169
+ const history = yield run(parent);
170
+ const traces = tracesIn(history);
171
+ assert.is(traces.length, 1, 'the failed tool call still carries its trace');
172
+ const seen = [...new Set(traces[0].map((m) => m.agentName).filter(Boolean))].sort();
173
+ assert.equal(seen, names, 'both children survive the throw');
174
+ const childRows = usageRows(history).filter((r) => r.subAgentDepth === 1);
175
+ assert.is(childRows.length, 2, 'and both are still priced');
176
+ }));
177
+ suite('leaves subAgentTrace undefined when no sub-agent ran', () => __awaiter(void 0, void 0, void 0, function* () {
178
+ var _a;
179
+ // Presence is the signal readers key off (`usage-rows.ts`, the UI's `when(...)`),
180
+ // so an empty array would be a different and wrong claim.
181
+ const history = yield run(agent({
182
+ name: 'boss',
183
+ subAgents: [worker('gen_a')],
184
+ toolDefinitions: [
185
+ {
186
+ name: 'delegate',
187
+ description: 'delegate',
188
+ parameters: { type: 'object', properties: {} },
189
+ },
190
+ ],
191
+ toolHandlers: { delegate: () => __awaiter(void 0, void 0, void 0, function* () { return 'did it myself'; }) },
192
+ }));
193
+ for (const m of history) {
194
+ for (const tc of (_a = m.toolCalls) !== null && _a !== void 0 ? _a : []) {
195
+ assert.is(tc.subAgentTrace, undefined, 'not an empty array');
196
+ }
197
+ }
198
+ assert.equal(usageRows(history).filter((r) => r.subAgentDepth === 1), [], 'no child rows');
199
+ }));
200
+ suite.run();
@@ -199,6 +199,16 @@ export function defineStatefulAgent(opts) {
199
199
  return opts.cachePolicy(Object.assign(Object.assign({}, ctx), { state }));
200
200
  })
201
201
  : opts.cachePolicy;
202
+ // Unlike the resolvers above, a pre-init call returns `undefined` rather than throwing: that is
203
+ // this option's "model default" state, so degrading to it costs a turn its thinking preference
204
+ // instead of breaking the turn outright.
205
+ const wrappedThinkingPolicy = typeof opts.thinkingPolicy === 'function'
206
+ ? (ctx) => __awaiter(this, void 0, void 0, function* () {
207
+ if (!state)
208
+ return undefined;
209
+ return opts.thinkingPolicy(Object.assign(Object.assign({}, ctx), { state }));
210
+ })
211
+ : opts.thinkingPolicy;
202
212
  const wrappedTailContext = typeof opts.tailContext === 'function'
203
213
  ? (ctx) => __awaiter(this, void 0, void 0, function* () {
204
214
  if (!state) {
@@ -249,6 +259,7 @@ export function defineStatefulAgent(opts) {
249
259
  temperature: wrappedTemperature,
250
260
  toolChoice: wrappedToolChoice,
251
261
  cachePolicy: wrappedCachePolicy,
262
+ thinkingPolicy: wrappedThinkingPolicy,
252
263
  tailContext: wrappedTailContext,
253
264
  onUnresolvedTool: wrappedOnUnresolvedTool,
254
265
  resumable: wrappedResumable,
@@ -144,11 +144,30 @@ const subAgentToolResultTemplate = html `
144
144
  const subAgentMessageRowTemplate = html `
145
145
  ${when((m) => { var _a; return m.role === 'assistant' && !((_a = m.toolCalls) === null || _a === void 0 ? void 0 : _a.length) && !!m.content; }, subAgentAssistantTemplate)}${when((m) => { var _a; return !!((_a = m.toolCalls) === null || _a === void 0 ? void 0 : _a.length); }, subAgentToolCallTemplate)}${when((m) => { var _a; return m.role === 'tool' && !!((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.content); }, subAgentToolResultTemplate)}
146
146
  `;
147
+ /**
148
+ * Label for a trace block: the distinct agents that appear in it.
149
+ *
150
+ * One tool call can invoke several sub-agents — a scheduler dispatching a dependency
151
+ * graph, or a retry — and their conversations arrive concatenated in one trace. Naming
152
+ * only `[0]` would then label a block "gen_A trace" while `gen_B`'s turns sit inside
153
+ * it. Two agents read as "gen_A, gen_B"; beyond that it degrades to a count rather
154
+ * than growing an unbounded summary line.
155
+ */
156
+ const subAgentTraceLabel = (trace) => {
157
+ const names = [...new Set(trace.map((m) => m.agentName).filter(Boolean))];
158
+ if (names.length === 0)
159
+ return 'Sub-agent trace';
160
+ if (names.length <= 2)
161
+ return `${names.join(', ')} trace`;
162
+ // Owns the whole string rather than returning a fragment the template suffixes, so the noun
163
+ // agrees with the count — a shared ` trace` suffix rendered "3 sub-agents trace".
164
+ return `${names.length} sub-agent traces`;
165
+ };
147
166
  /** Collapsed <details> trace shown inside a tool-call card once the sub-agent finishes. */
148
167
  const subAgentTraceTemplate = html `
149
168
  <details class="sub-agent-trace">
150
169
  <summary class="sub-agent-trace-summary">
151
- ${(tc) => { var _a, _b; return (_b = (_a = tc.subAgentTrace[0]) === null || _a === void 0 ? void 0 : _a.agentName) !== null && _b !== void 0 ? _b : 'Sub-agent'; }} trace
170
+ ${(tc) => subAgentTraceLabel(tc.subAgentTrace)}
152
171
  </summary>
153
172
  ${repeat((tc) => tc.subAgentTrace.filter((m) => m.role !== 'user'), subAgentMessageRowTemplate)}
154
173
  </details>
@@ -6,7 +6,7 @@
6
6
  * `onDeactivate`, `getDebugSnapshot`, `onUnresolvedTool`) and the function
7
7
  * form of the per-turn resolvers (`systemPrompt`, `toolDefinitions`,
8
8
  * `displayName`, `provider`, `temperature`, `toolChoice`, `cachePolicy`,
9
- * `toolHandlers`).
9
+ * `thinkingPolicy`, `toolHandlers`).
10
10
  * 2. **Object "handler bags" whose *values* are functions** — `toolHandlers` in
11
11
  * its object form is `{ name: handler }`, so `typeof` is `'object'`, not
12
12
  * `'function'`. A by-value check on the field alone misses it, leaking a live
@@ -2,7 +2,7 @@
2
2
  * Zeroed {@link AggregateUsage}. A factory, not a shared const, because callers
3
3
  * accumulate into the returned object.
4
4
  *
5
- * @internal
5
+ * @beta
6
6
  */
7
7
  export function emptyUsage() {
8
8
  return {
@@ -23,7 +23,7 @@ export function emptyUsage() {
23
23
  * NOT the count of distinct tokens in the conversation, so it grows
24
24
  * super-linearly with turn count and is expected to dwarf the context size.
25
25
  *
26
- * @internal
26
+ * @beta
27
27
  */
28
28
  export function totalTokens(usage) {
29
29
  return (usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens + usage.outputTokens);
@@ -35,7 +35,7 @@ export function totalTokens(usage) {
35
35
  * proves. Pure rather than in-place because both operands are live state a caller
36
36
  * must not mutate.
37
37
  *
38
- * @internal
38
+ * @beta
39
39
  */
40
40
  export function addUsage(a, b) {
41
41
  return {
@@ -70,7 +70,40 @@ export function addUsage(a, b) {
70
70
  * Returns a zeroed total when no message carries usage — providers that report
71
71
  * none (e.g. Chrome built-in) are skipped silently rather than guessed at.
72
72
  *
73
- * @internal
73
+ * @remarks
74
+ * This sums the WHOLE array, which is the right answer only when the array is the
75
+ * whole of the work being billed. A driver whose conversation **continues across
76
+ * rounds** opens its history with turns an earlier round already paid for, so
77
+ * summing it again bills them twice — and the session total then grows
78
+ * quadratically in the number of rounds while every individual figure in it stays
79
+ * correct. Nothing about the result looks wrong, which is why it is called out
80
+ * here rather than left to the caller to notice.
81
+ *
82
+ * Do NOT solve this with a "sum from this message onwards" marker. Compaction
83
+ * *replaces* the messages it summarises — and compaction is one of this function's
84
+ * own call sites — so the marker message can be absent from history by the time
85
+ * the sum runs, leaving the caller to either fail or silently sum everything,
86
+ * which is the double-count the marker was meant to prevent.
87
+ *
88
+ * Instead hold a **banked baseline**: spend that predates the current transcript,
89
+ * carried as a value, with `usage = banked + live` composed via {@link addUsage}.
90
+ * `resolveBankedUsage` in `utils/cost-session-history.ts` is the worked form, and
91
+ * the shape of its signature is the load-bearing part. It separates three cases:
92
+ *
93
+ * 1. no prior record — a genuine first round, so nothing is banked;
94
+ * 2. prior record, and the live transcript already accounts for it (a restored
95
+ * session) — carry the prior record's own banked figure forward;
96
+ * 3. prior record, and the transcript starts empty — bank the prior record's
97
+ * whole total, because nothing in the transcript can re-derive it.
98
+ *
99
+ * Case 1 and case 3 take the same arithmetic and mean opposite things, so the
100
+ * prior record must be a **required** input with no zero-defaulting path: a
101
+ * caller who carries a transcript and forgets its baseline reports the entire
102
+ * prior conversation as this round's spend. Distinguishing 2 from 3 is a separate
103
+ * decision, and getting it wrong is destructive in the other direction — it
104
+ * rewrites a total down to the current page-load and drops earlier spend.
105
+ *
106
+ * @beta
74
107
  */
75
108
  export function sumUsage(messages) {
76
109
  const total = emptyUsage();