@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
@@ -0,0 +1,251 @@
1
+ import type {
2
+ AIProvider,
3
+ AIProviderRegistry,
4
+ ChatMessage,
5
+ ChatRequestOptions,
6
+ } from '@genesislcap/foundation-ai';
7
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
8
+ import type { AgentConfig } from '../../config/config';
9
+ import { sumUsage } from '../../utils/sum-usage';
10
+ import { usageRows } from '../../utils/usage-rows';
11
+ // Side-effect import — MUST come before `./chat-driver` so the driver subclasses
12
+ // jsdom's EventTarget rather than Node's native one. Mirrors chat-driver.test.ts.
13
+ import './align-event-globals';
14
+ import { ChatDriver } from './chat-driver';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Sub-agent trace capture when ONE tool call invokes SEVERAL sub-agents.
18
+ //
19
+ // The capture slot used to be a single `{ trace?: ChatMessage[] }` per tool call,
20
+ // assigned by each `requestSubAgent`. That covers N tool calls × 1 sub-agent — the
21
+ // case its docblock named — but not 1 tool call × N sub-agents, which is what a
22
+ // code-driven scheduler or a retry produces. All but the last trace was dropped, so
23
+ // those children ran, were billed, and then had no record in history.
24
+ //
25
+ // The failure was invisible to every existing check: `sumUsage` and `usageRows` both
26
+ // recurse into `subAgentTrace`, so they summed a truncated input and still reconciled
27
+ // with each other perfectly. Measured at 1 of 7 traces kept on a seven-way fan-out,
28
+ // ~2.3x under the true cost.
29
+ //
30
+ // These tests assert the traces SURVIVE, which is upstream of any cost assertion.
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /** Answers by rule: concurrent children under one turn make a FIFO queue nondeterministic. */
34
+ const ruleProvider = (): AIProvider => {
35
+ let parentTurns = 0;
36
+ return {
37
+ chat: async (
38
+ _history: ChatMessage[],
39
+ _userMessage: string,
40
+ options?: ChatRequestOptions,
41
+ ): Promise<ChatMessage> => {
42
+ const tools = (options?.tools ?? []).map((t) => t.name);
43
+ // Child turn: finishes via its completion tool, and reports usage so the
44
+ // reconciliation assertions have something to add up.
45
+ if (tools.includes('work')) {
46
+ return {
47
+ role: 'assistant',
48
+ content: '',
49
+ model: 'claude-sonnet-5',
50
+ cost: 0.01,
51
+ inputTokens: 100,
52
+ outputTokens: 20,
53
+ toolCalls: [{ id: 'w1', name: 'work', args: {} }],
54
+ };
55
+ }
56
+ if (tools.includes('delegate') && parentTurns === 0) {
57
+ parentTurns += 1;
58
+ return {
59
+ role: 'assistant',
60
+ content: '',
61
+ model: 'claude-sonnet-5',
62
+ cost: 0.02,
63
+ inputTokens: 200,
64
+ outputTokens: 30,
65
+ toolCalls: [{ id: 'd0', name: 'delegate', args: {} }],
66
+ };
67
+ }
68
+ return { role: 'assistant', content: 'done', model: 'claude-sonnet-5' };
69
+ },
70
+ };
71
+ };
72
+
73
+ const makeRegistry = (provider: AIProvider): AIProviderRegistry => ({
74
+ get: () => provider,
75
+ default: () => provider,
76
+ defaultName: () => 'test',
77
+ names: () => ['test'],
78
+ getStatus: async () => null,
79
+ listStatuses: async () => [],
80
+ });
81
+
82
+ const agent = (overrides: Partial<AgentConfig> & { name: string }): AgentConfig =>
83
+ ({ description: 'test agent', ...overrides }) as AgentConfig;
84
+
85
+ /** A child that reports one unit of usage and completes. */
86
+ const worker = (name: string): AgentConfig =>
87
+ agent({
88
+ name,
89
+ toolDefinitions: [
90
+ { name: 'work', description: 'work', parameters: { type: 'object', properties: {} } },
91
+ ],
92
+ toolHandlers: {
93
+ work: async (_args, ctx) => {
94
+ ctx.completeSubAgent?.({ ok: true });
95
+ return 'worked';
96
+ },
97
+ },
98
+ });
99
+
100
+ /**
101
+ * A parent whose single `delegate` tool call invokes `names` in turn — the shape a
102
+ * code-driven scheduler produces, and the one the old single slot truncated.
103
+ */
104
+ const boss = (children: AgentConfig[], invoke: string[]): AgentConfig =>
105
+ agent({
106
+ name: 'boss',
107
+ subAgents: children,
108
+ toolDefinitions: [
109
+ { name: 'delegate', description: 'delegate', parameters: { type: 'object', properties: {} } },
110
+ ],
111
+ toolHandlers: {
112
+ delegate: async (_args, ctx) => {
113
+ await Promise.all(invoke.map((n) => ctx.requestSubAgent!(n, { task: 'go' })));
114
+ return 'delegated';
115
+ },
116
+ },
117
+ });
118
+
119
+ const run = async (config: AgentConfig): Promise<readonly ChatMessage[]> => {
120
+ const driver = new ChatDriver(makeRegistry(ruleProvider()), {
121
+ maxToolIterations: 20,
122
+ maxFoldOperations: 5,
123
+ sessionKey: '',
124
+ });
125
+ driver.applyAgent(config);
126
+ await driver.sendMessage('go');
127
+ return driver.getHistory();
128
+ };
129
+
130
+ const tracesIn = (history: readonly ChatMessage[]): readonly ChatMessage[][] =>
131
+ history.flatMap((m) =>
132
+ (m.toolCalls ?? []).flatMap((tc) => (tc.subAgentTrace ? [tc.subAgentTrace] : [])),
133
+ );
134
+
135
+ const suite = createLogicSuite('ChatDriver sub-agent trace capture');
136
+
137
+ // ---------------------------------------------------------------------------
138
+
139
+ suite('keeps every trace when one tool call invokes three sub-agents', async () => {
140
+ const names = ['gen_a', 'gen_b', 'gen_c'];
141
+ const history = await run(boss(names.map(worker), names));
142
+
143
+ const traces = tracesIn(history);
144
+ assert.is(traces.length, 1, 'one tool call, so one concatenated trace');
145
+
146
+ // Each child contributes at least its own assistant turn. Before the fix this was
147
+ // one child's worth regardless of how many ran.
148
+ const seen = new Set(traces[0].map((m) => m.agentName).filter(Boolean));
149
+ assert.equal(
150
+ [...seen].sort(),
151
+ names,
152
+ `every invoked child must appear — got ${JSON.stringify([...seen])}`,
153
+ );
154
+ });
155
+
156
+ suite('prices every child — usageRows emits a row per child and reconciles', async () => {
157
+ // The reconciliation test extended to a fan-out. The single-child version passed
158
+ // before the fix AND after it, which is exactly why it did not catch this.
159
+ const names = ['gen_a', 'gen_b', 'gen_c'];
160
+ const history = await run(boss(names.map(worker), names));
161
+
162
+ const rows = usageRows(history);
163
+ const childRows = rows.filter((r) => r.subAgentDepth === 1);
164
+ assert.is(childRows.length, names.length, 'one row per child that ran');
165
+
166
+ const rowTotal = rows.reduce((n, r) => n + (r.costUsd ?? 0) + (r.externalCostUsd ?? 0), 0);
167
+ assert.ok(
168
+ Math.abs(rowTotal - sumUsage(history).costUsd) < 1e-12,
169
+ `rows ${rowTotal} vs sumUsage ${sumUsage(history).costUsd}`,
170
+ );
171
+ // Parent turn ($0.02) + three children ($0.01 each). Asserted as a number so a
172
+ // regression that silently drops a child fails here rather than only in the
173
+ // reconciliation above, which would still agree with a truncated input.
174
+ assert.ok(Math.abs(rowTotal - 0.05) < 1e-12, `expected 0.05, got ${rowTotal}`);
175
+ });
176
+
177
+ suite('keeps both traces when the same sub-agent is invoked twice (a retry)', async () => {
178
+ // The retry shape: one handler, one child name, two invocations. Under the old slot
179
+ // the first attempt's trace was overwritten by the second.
180
+ const history = await run(boss([worker('gen_a')], ['gen_a', 'gen_a']));
181
+
182
+ const traces = tracesIn(history);
183
+ assert.is(traces.length, 1);
184
+ const childTurns = traces[0].filter((m) => m.agentName === 'gen_a' && m.cost != null);
185
+ assert.is(childTurns.length, 2, 'both attempts survive, not just the last');
186
+
187
+ const rows = usageRows(history).filter((r) => r.subAgentDepth === 1);
188
+ assert.is(rows.length, 2, 'and both are priced');
189
+ });
190
+
191
+ suite('keeps child traces when the parent handler throws after they ran', async () => {
192
+ // Children that completed were billed by the provider. If the error path drops
193
+ // their traces, the run under-reports exactly as the single-slot bug did — and
194
+ // just as silently, since `sumUsage` and `usageRows` would still agree with each
195
+ // other over the truncated input. PR review.
196
+ const names = ['gen_a', 'gen_b'];
197
+ const parent = agent({
198
+ name: 'boss',
199
+ subAgents: names.map(worker),
200
+ toolDefinitions: [
201
+ { name: 'delegate', description: 'delegate', parameters: { type: 'object', properties: {} } },
202
+ ],
203
+ toolHandlers: {
204
+ delegate: async (_args, ctx) => {
205
+ await Promise.all(names.map((n) => ctx.requestSubAgent!(n, { task: 'go' })));
206
+ throw new Error('post-processing the children failed');
207
+ },
208
+ },
209
+ });
210
+
211
+ const history = await run(parent);
212
+ const traces = tracesIn(history);
213
+ assert.is(traces.length, 1, 'the failed tool call still carries its trace');
214
+ const seen = [...new Set(traces[0].map((m) => m.agentName).filter(Boolean))].sort();
215
+ assert.equal(seen, names, 'both children survive the throw');
216
+
217
+ const childRows = usageRows(history).filter((r) => r.subAgentDepth === 1);
218
+ assert.is(childRows.length, 2, 'and both are still priced');
219
+ });
220
+
221
+ suite('leaves subAgentTrace undefined when no sub-agent ran', async () => {
222
+ // Presence is the signal readers key off (`usage-rows.ts`, the UI's `when(...)`),
223
+ // so an empty array would be a different and wrong claim.
224
+ const history = await run(
225
+ agent({
226
+ name: 'boss',
227
+ subAgents: [worker('gen_a')],
228
+ toolDefinitions: [
229
+ {
230
+ name: 'delegate',
231
+ description: 'delegate',
232
+ parameters: { type: 'object', properties: {} },
233
+ },
234
+ ],
235
+ toolHandlers: { delegate: async () => 'did it myself' },
236
+ }),
237
+ );
238
+
239
+ for (const m of history) {
240
+ for (const tc of m.toolCalls ?? []) {
241
+ assert.is(tc.subAgentTrace, undefined, 'not an empty array');
242
+ }
243
+ }
244
+ assert.equal(
245
+ usageRows(history).filter((r) => r.subAgentDepth === 1),
246
+ [],
247
+ 'no child rows',
248
+ );
249
+ });
250
+
251
+ suite.run();
@@ -8,6 +8,7 @@ import type {
8
8
  ChatFallback,
9
9
  ChatMessage,
10
10
  ChatRequestOptions,
11
+ ChatThinkingPolicy,
11
12
  ChatToolCall,
12
13
  ChatToolChoice,
13
14
  ChatToolDefinition,
@@ -38,6 +39,7 @@ import type {
38
39
  SystemPromptInput,
39
40
  TailContextInput,
40
41
  TemperatureInput,
42
+ ThinkingPolicyInput,
41
43
  ToolChoiceInput,
42
44
  ToolDefinitionsInput,
43
45
  ToolHandlersInput,
@@ -589,6 +591,12 @@ export class ChatDriver extends EventTarget implements AiDriver {
589
591
  * `undefined` requests no caching (equivalent to `{ scope: 'default' }`).
590
592
  */
591
593
  private activeCachePolicyInput?: CachePolicyInput;
594
+ /**
595
+ * Active agent's extended-thinking selector (static value or per-turn resolver). `undefined` —
596
+ * unset, or returned by the resolver — leaves the model on its own default posture, which is
597
+ * neither uniformly on nor off, so an agent that never sets this is unaffected by the option.
598
+ */
599
+ private activeThinkingPolicyInput?: ThinkingPolicyInput;
592
600
  /**
593
601
  * Active agent's tail-context selector (static value or per-turn resolver). The driver frames
594
602
  * the resolved string in a `<system-reminder>` marker and injects it at the message tail.
@@ -1029,6 +1037,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
1029
1037
  this.activeTemperatureInput = config.temperature;
1030
1038
  this.activeToolChoiceInput = config.toolChoice;
1031
1039
  this.activeCachePolicyInput = config.cachePolicy;
1040
+ this.activeThinkingPolicyInput = config.thinkingPolicy;
1032
1041
  this.activeTailContextInput = config.tailContext;
1033
1042
  this.activeResponseSchemaInput = config.responseSchema;
1034
1043
  this.activeFallbacks = config.fallbacks;
@@ -1910,11 +1919,25 @@ export class ChatDriver extends EventTarget implements AiDriver {
1910
1919
  * `condenseWhen` can register against the right call (it stamps the clocks
1911
1920
  * straight off the driver). Absent for dispatch paths with no addressable tool
1912
1921
  * call (e.g. the fold-close handler), where `condenseWhen` is a no-op.
1913
- * @param traceCapture - Optional per-invocation slot. When provided, the trace
1914
- * from any sub-agent call is written here rather than to shared instance state,
1915
- * so parallel tool calls each capture their own trace independently.
1916
- */
1917
- private buildHandlerContext(activeToolCallId?: string, traceCapture?: { trace?: ChatMessage[] }) {
1922
+ * @param traceCapture - Optional per-tool-call accumulator. When provided, every
1923
+ * sub-agent call's trace is **appended** here rather than written to shared
1924
+ * instance state, so parallel tool calls each capture their own traces
1925
+ * independently.
1926
+ *
1927
+ * Deliberately a list of traces, not one slot. A single handler may call
1928
+ * `requestSubAgent` more than once — a code-driven scheduler dispatching a
1929
+ * dependency graph, a retry of a timed-out child, any fan-out helper — and a
1930
+ * single slot kept only the last, so every other child ran, was billed by the
1931
+ * provider, and then vanished from history. The loss was silent in the worst
1932
+ * way: `sumUsage` and `usageRows` both recurse into the trace, so they summed a
1933
+ * truncated input and still agreed with each other. Measured at 1 of 7 traces
1934
+ * kept on a seven-way fan-out, reporting ~2.3x under the real cost, with the
1935
+ * error growing as the fan-out widens.
1936
+ */
1937
+ private buildHandlerContext(
1938
+ activeToolCallId?: string,
1939
+ traceCapture?: { traces: ChatMessage[][] },
1940
+ ) {
1918
1941
  return {
1919
1942
  requestInteraction: <T>(
1920
1943
  componentName: string,
@@ -1930,7 +1953,10 @@ export class ChatDriver extends EventTarget implements AiDriver {
1930
1953
  | { ok: false; result?: never; reason: SubAgentFailureReason }
1931
1954
  > =>
1932
1955
  this.invokeSubAgent<T>(name, options).then(({ outcome, trace }) => {
1933
- if (traceCapture) traceCapture.trace = trace;
1956
+ // Append, never assign: see `traceCapture` on `buildHandlerContext`. Order
1957
+ // is completion order, which is fine — every message carries its own
1958
+ // timestamp and the timeline sorts on that.
1959
+ if (traceCapture && trace) traceCapture.traces.push(trace);
1934
1960
  return outcome;
1935
1961
  }),
1936
1962
  }),
@@ -2494,6 +2520,18 @@ export class ChatDriver extends EventTarget implements AiDriver {
2494
2520
  // `iterations` because fold operations decrement iterations, which would incorrectly
2495
2521
  // re-trigger the slice on subsequent calls after a fold open/close.
2496
2522
  let firstLlmCall = !!currentInput;
2523
+ // Thinking posture for this WHOLE turn, resolved on the first model call and then held.
2524
+ //
2525
+ // Unlike temperature or toolChoice, this one cannot vary per iteration: a tool-use loop is
2526
+ // a single assistant turn, and Anthropic requires one thinking mode for its duration.
2527
+ // Toggling mid-loop does not error — the API silently disables thinking for that request
2528
+ // and strips blocks that would leave the turn structure invalid, so an `'auto' -> 'off'`
2529
+ // switch loses the reasoning continuity the opening call established while an
2530
+ // `'off' -> 'auto'` switch simply does not deliver the reasoning asked for. It also
2531
+ // invalidates the prompt cache, which costs more than the reasoning it was meant to save.
2532
+ // Resolved per USER turn instead, which is where the docs say to choose it.
2533
+ let pinnedThinkingPolicy: ChatThinkingPolicy | undefined;
2534
+ let thinkingPolicyPinned = false;
2497
2535
 
2498
2536
  while (iterations < this.maxToolIterations) {
2499
2537
  iterations += 1;
@@ -2680,6 +2718,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
2680
2718
  resolvedCachePolicy,
2681
2719
  resolvedTailContext,
2682
2720
  resolvedResponseSchema,
2721
+ firstResolvedThinkingPolicy,
2683
2722
  ] =
2684
2723
  // oxlint-disable-next-line no-await-in-loop
2685
2724
  await Promise.all([
@@ -2688,7 +2727,19 @@ export class ChatDriver extends EventTarget implements AiDriver {
2688
2727
  this.resolveTurnInput<CachePolicy>(this.activeCachePolicyInput, promptCtx),
2689
2728
  this.resolveTurnInput<string>(this.activeTailContextInput, promptCtx),
2690
2729
  this.resolveTurnInput<object | undefined>(this.activeResponseSchemaInput, promptCtx),
2730
+ // Only consulted on the first iteration (see `pinnedThinkingPolicy`); resolved
2731
+ // alongside the others so a resolver still sees the same turn context.
2732
+ thinkingPolicyPinned
2733
+ ? Promise.resolve(undefined)
2734
+ : this.resolveTurnInput<ChatThinkingPolicy | undefined>(
2735
+ this.activeThinkingPolicyInput,
2736
+ promptCtx,
2737
+ ),
2691
2738
  ]);
2739
+ if (!thinkingPolicyPinned) {
2740
+ pinnedThinkingPolicy = firstResolvedThinkingPolicy;
2741
+ thinkingPolicyPinned = true;
2742
+ }
2692
2743
  // The system prompt is always just the agent's resolved prompt — byte-stable, so it can be
2693
2744
  // cached. The framework's volatile additions (fold suffix, retry nudge) and the agent's tail
2694
2745
  // context all go to the framed tail: one uniform channel, no cache-scope branch. On a normal
@@ -2736,6 +2787,12 @@ export class ChatDriver extends EventTarget implements AiDriver {
2736
2787
  // Prompt-cache policy for this turn (Anthropic places breakpoints per scope; Gemini
2737
2788
  // caches implicitly regardless). Undefined → no caching requested.
2738
2789
  cachePolicy: resolvedCachePolicy,
2790
+ // Extended-thinking posture, pinned for the whole tool loop (one assistant turn) rather
2791
+ // than re-resolved per iteration — see `pinnedThinkingPolicy`. Undefined — unset, or the
2792
+ // resolver's answer for this turn — is NOT "off": it leaves the model on its own default,
2793
+ // so agents that never set this are priced exactly as before. Transports clamp models
2794
+ // that can't honour it.
2795
+ thinkingPolicy: pinnedThinkingPolicy,
2739
2796
  // Framed volatile context injected at the message tail (never stored). Undefined → none.
2740
2797
  tailContext,
2741
2798
  // Structured-output schema for this turn (agent/state-resolved). When set, the transport
@@ -2924,7 +2981,15 @@ export class ChatDriver extends EventTarget implements AiDriver {
2924
2981
  // leaves the key off entirely rather than carrying it as `undefined`.
2925
2982
  // JSON.stringify already drops undefined from the exported log, so this is
2926
2983
  // chiefly about keeping the in-memory message shape honest.
2927
- if (this.lastResolvedModel !== undefined) response.model = this.lastResolvedModel;
2984
+ // Fill, never overwrite — but still only when there is something to fill with.
2985
+ // A transport that already stamped a model knows something the driver does not:
2986
+ // `lastResolvedModel` is the model we ASKED for, so overwriting would relabel a
2987
+ // fallback-served turn as the requested model and misattribute its spend.
2988
+ // Written as a guard rather than `??=` because `??=` ASSIGNS undefined, which
2989
+ // would create the key and break the omit-when-unresolved contract above.
2990
+ if (response.model === undefined && this.lastResolvedModel !== undefined) {
2991
+ response.model = this.lastResolvedModel;
2992
+ }
2928
2993
  if (this.lastResolvedProvider !== undefined) response.provider = this.lastResolvedProvider;
2929
2994
  if (this.lastResolvedProviderName !== undefined) {
2930
2995
  response.providerName = this.lastResolvedProviderName;
@@ -3194,15 +3259,27 @@ export class ChatDriver extends EventTarget implements AiDriver {
3194
3259
  return;
3195
3260
  }
3196
3261
 
3197
- // Real tool execution
3262
+ // Real tool execution.
3263
+ //
3264
+ // The accumulator is declared OUTSIDE the try so the catch can attach it
3265
+ // too: a handler that ran sub-agents and then threw (post-processing their
3266
+ // results failed, say) has already spent real money on children that
3267
+ // completed. Losing their traces on the error path would under-report the
3268
+ // run exactly as the single-slot bug did — and just as silently, since
3269
+ // `sumUsage` and `usageRows` would still agree with each other.
3270
+ const traceCapture: { traces: ChatMessage[][] } = { traces: [] };
3271
+ const capturedTrace = (): ChatMessage[] | undefined =>
3272
+ traceCapture.traces.length ? traceCapture.traces.flat() : undefined;
3198
3273
  try {
3199
- const traceCapture: { trace?: ChatMessage[] } = {};
3200
3274
  const result = await handler(tc.args, this.buildHandlerContext(tc.id, traceCapture));
3201
3275
  const content = typeof result === 'string' ? result : JSON.stringify(result);
3202
3276
  executedById.set(tc.id, {
3203
3277
  toolCallId: tc.id,
3204
3278
  content,
3205
- subAgentTrace: traceCapture.trace,
3279
+ // Concatenated when a handler invoked several children, so none is lost.
3280
+ // Stays `undefined` when nothing was captured — readers key off presence,
3281
+ // and an empty array is a different claim from "no sub-agent ran".
3282
+ subAgentTrace: capturedTrace(),
3206
3283
  });
3207
3284
  anyRealToolExecuted = true;
3208
3285
  } catch (e) {
@@ -3217,6 +3294,9 @@ export class ChatDriver extends EventTarget implements AiDriver {
3217
3294
  // Structured recovery hint so the model retries or routes around a tool
3218
3295
  // failure instead of apologising and giving up.
3219
3296
  content: `Tool error: ${(e as Error).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.`,
3297
+ // Children that completed before the throw were still billed — keep
3298
+ // their traces so the run's cost stays whole.
3299
+ subAgentTrace: capturedTrace(),
3220
3300
  });
3221
3301
  anyRealToolExecuted = true; // treat errors as real work for fold op counting
3222
3302
  }
@@ -3,12 +3,19 @@ import type {
3
3
  ChatFallback,
4
4
  ChatInputDuringExecutionMode,
5
5
  ChatMessage,
6
+ ChatThinkingPolicy,
6
7
  ChatToolChoice,
7
8
  ChatToolDefinition,
8
9
  ChatToolHandlers,
9
10
  } from '@genesislcap/foundation-ai';
10
11
 
11
- export type { CachePolicy, ChatFallback, ChatInputDuringExecutionMode, ChatToolChoice };
12
+ export type {
13
+ CachePolicy,
14
+ ChatFallback,
15
+ ChatInputDuringExecutionMode,
16
+ ChatThinkingPolicy,
17
+ ChatToolChoice,
18
+ };
12
19
 
13
20
  /**
14
21
  * Context passed to `onActivate` / `onDeactivate` lifecycle hooks on an agent.
@@ -164,6 +171,36 @@ export type CachePolicyInput =
164
171
  | CachePolicy
165
172
  | ((ctx: SystemPromptContext) => CachePolicy | Promise<CachePolicy>);
166
173
 
174
+ /**
175
+ * Extended-thinking posture for an agent — whether the model reasons before answering. Either a
176
+ * static `ChatThinkingPolicy` or a function resolved **once per user turn**, at the start of the
177
+ * tool loop. Pick the function form to vary it by current state: reason in the state that makes a
178
+ * real decision, and drop it in states that only step a known sequence, where reasoning bills at
179
+ * the full output rate to re-derive what the state machine already knows.
180
+ *
181
+ * Note the resolution point, which differs from every other per-turn input here: a tool-use loop
182
+ * is a single assistant turn and its thinking mode must hold for the whole of it, so this resolver
183
+ * is consulted on the first model call of a turn and its answer reused for the rest. A resolver
184
+ * that returns a different value part-way through a loop will not see it applied until the next
185
+ * user turn.
186
+ *
187
+ * **Omit — or return `undefined` from the function — to keep the model's own default**, which is
188
+ * a distinct third state rather than a synonym for either value: Sonnet 5 and Fable 5 think by
189
+ * default, the rest do not. So an agent that leaves this unset, or a resolver that answers
190
+ * `undefined` on some turns, behaves and prices exactly as it did before this option existed.
191
+ *
192
+ * A request, not a guarantee — transports clamp what a model cannot honour (Fable 5 and Gemini
193
+ * 2.5 Pro always think) and warn once instead of failing the turn. Resolved and applied the same
194
+ * way as {@link TemperatureInput}. See `ChatThinkingPolicy`.
195
+ *
196
+ * @beta
197
+ */
198
+ export type ThinkingPolicyInput =
199
+ | ChatThinkingPolicy
200
+ | ((
201
+ ctx: SystemPromptContext,
202
+ ) => ChatThinkingPolicy | undefined | Promise<ChatThinkingPolicy | undefined>);
203
+
167
204
  /**
168
205
  * Per-turn tail context for an agent — volatile content (current file/spec, diagnostics, live
169
206
  * state) injected at the tail of every model-call so the model sees it without invalidating the
@@ -346,6 +383,18 @@ interface BaseAgentConfig {
346
383
  * @beta
347
384
  */
348
385
  cachePolicy?: CachePolicyInput;
386
+ /**
387
+ * Extended-thinking posture for this agent — whether the model reasons before answering. Either
388
+ * a static value or a function resolved **once per user turn** and held for that turn's whole
389
+ * tool loop (vary by state: `'auto'` where the turn is a genuine decision, `'off'` where it only
390
+ * steps a known sequence and reasoning is billed at the full output rate for no benefit). Omit —
391
+ * or return `undefined` — to keep the model's own default, which is neither `'auto'` nor `'off'`
392
+ * uniformly. Provider-neutral; transports clamp models that cannot honour it.
393
+ * See {@link ThinkingPolicyInput}.
394
+ *
395
+ * @beta
396
+ */
397
+ thinkingPolicy?: ThinkingPolicyInput;
349
398
  /**
350
399
  * Volatile per-turn context injected at the tail of every model-call (after the history and any
351
400
  * cache breakpoint), so it is seen each turn without busting the cached prefix — the place to put
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  CachePolicy,
3
3
  ChatMessage,
4
+ ChatThinkingPolicy,
4
5
  ChatToolChoice,
5
6
  ChatToolDefinition,
6
7
  ChatToolHandlers,
@@ -18,6 +19,7 @@ import type {
18
19
  SystemPromptInput,
19
20
  TailContextInput,
20
21
  TemperatureInput,
22
+ ThinkingPolicyInput,
21
23
  ToolChoiceInput,
22
24
  ToolDefinitionsInput,
23
25
  ToolHandlersInput,
@@ -185,6 +187,25 @@ export interface StatefulAgentInit<S> {
185
187
  | CachePolicy
186
188
  | ((ctx: StatefulAgentContext<S>) => CachePolicy | Promise<CachePolicy>);
187
189
 
190
+ /**
191
+ * Extended-thinking posture. Either a static `ChatThinkingPolicy` or a function resolved with
192
+ * the current `state` **once per user turn** — the function form is the point of this option on
193
+ * a machine agent: `'auto'` in the state that makes a real decision, `'off'` in the states that
194
+ * only step a known sequence, where reasoning is billed at the full output rate to re-derive
195
+ * what the machine already knows. Omit, or return `undefined` from the function, to keep the
196
+ * model's own default for that turn.
197
+ *
198
+ * Resolved once per turn rather than per tool-loop iteration (unlike `temperature` and
199
+ * `toolChoice`): a tool-use loop is one assistant turn and its thinking mode must hold for the
200
+ * whole of it. A state transition part-way through a loop therefore takes effect on the next
201
+ * user turn, not immediately.
202
+ */
203
+ thinkingPolicy?:
204
+ | ChatThinkingPolicy
205
+ | ((
206
+ ctx: StatefulAgentContext<S>,
207
+ ) => ChatThinkingPolicy | undefined | Promise<ChatThinkingPolicy | undefined>);
208
+
188
209
  /**
189
210
  * Volatile per-turn tail context (current file/spec, diagnostics, live state). Either a static
190
211
  * string or a function resolved each tool-loop iteration with the current `state`. Provide raw
@@ -484,6 +505,21 @@ export function defineStatefulAgent<S>(opts: StatefulAgentInit<S>): AgentConfig
484
505
  }
485
506
  : opts.cachePolicy;
486
507
 
508
+ // Unlike the resolvers above, a pre-init call returns `undefined` rather than throwing: that is
509
+ // this option's "model default" state, so degrading to it costs a turn its thinking preference
510
+ // instead of breaking the turn outright.
511
+ const wrappedThinkingPolicy: ThinkingPolicyInput | undefined =
512
+ typeof opts.thinkingPolicy === 'function'
513
+ ? async (ctx: SystemPromptContext) => {
514
+ if (!state) return undefined;
515
+ return (
516
+ opts.thinkingPolicy as (
517
+ ctx: StatefulAgentContext<S>,
518
+ ) => ChatThinkingPolicy | undefined | Promise<ChatThinkingPolicy | undefined>
519
+ )({ ...ctx, state });
520
+ }
521
+ : opts.thinkingPolicy;
522
+
487
523
  const wrappedTailContext: TailContextInput | undefined =
488
524
  typeof opts.tailContext === 'function'
489
525
  ? async (ctx: SystemPromptContext) => {
@@ -541,6 +577,7 @@ export function defineStatefulAgent<S>(opts: StatefulAgentInit<S>): AgentConfig
541
577
  temperature: wrappedTemperature,
542
578
  toolChoice: wrappedToolChoice,
543
579
  cachePolicy: wrappedCachePolicy,
580
+ thinkingPolicy: wrappedThinkingPolicy,
544
581
  tailContext: wrappedTailContext,
545
582
  onUnresolvedTool: wrappedOnUnresolvedTool,
546
583
  resumable: wrappedResumable,
@@ -178,11 +178,29 @@ const subAgentMessageRowTemplate = html<ChatMessage>`
178
178
  )}
179
179
  `;
180
180
 
181
+ /**
182
+ * Label for a trace block: the distinct agents that appear in it.
183
+ *
184
+ * One tool call can invoke several sub-agents — a scheduler dispatching a dependency
185
+ * graph, or a retry — and their conversations arrive concatenated in one trace. Naming
186
+ * only `[0]` would then label a block "gen_A trace" while `gen_B`'s turns sit inside
187
+ * it. Two agents read as "gen_A, gen_B"; beyond that it degrades to a count rather
188
+ * than growing an unbounded summary line.
189
+ */
190
+ const subAgentTraceLabel = (trace: readonly ChatMessage[]): string => {
191
+ const names = [...new Set(trace.map((m) => m.agentName).filter(Boolean))] as string[];
192
+ if (names.length === 0) return 'Sub-agent trace';
193
+ if (names.length <= 2) return `${names.join(', ')} trace`;
194
+ // Owns the whole string rather than returning a fragment the template suffixes, so the noun
195
+ // agrees with the count — a shared ` trace` suffix rendered "3 sub-agents trace".
196
+ return `${names.length} sub-agent traces`;
197
+ };
198
+
181
199
  /** Collapsed <details> trace shown inside a tool-call card once the sub-agent finishes. */
182
200
  const subAgentTraceTemplate = html<ChatToolCall>`
183
201
  <details class="sub-agent-trace">
184
202
  <summary class="sub-agent-trace-summary">
185
- ${(tc) => tc.subAgentTrace![0]?.agentName ?? 'Sub-agent'} trace
203
+ ${(tc) => subAgentTraceLabel(tc.subAgentTrace!)}
186
204
  </summary>
187
205
  ${repeat(
188
206
  (tc) => tc.subAgentTrace!.filter((m) => m.role !== 'user'),
@@ -8,7 +8,7 @@ import type { AgentConfig } from '../config/config';
8
8
  * `onDeactivate`, `getDebugSnapshot`, `onUnresolvedTool`) and the function
9
9
  * form of the per-turn resolvers (`systemPrompt`, `toolDefinitions`,
10
10
  * `displayName`, `provider`, `temperature`, `toolChoice`, `cachePolicy`,
11
- * `toolHandlers`).
11
+ * `thinkingPolicy`, `toolHandlers`).
12
12
  * 2. **Object "handler bags" whose *values* are functions** — `toolHandlers` in
13
13
  * its object form is `{ name: handler }`, so `typeof` is `'object'`, not
14
14
  * `'function'`. A by-value check on the field alone misses it, leaking a live