@genesislcap/ai-assistant 15.10.6 → 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 (70) hide show
  1. package/dist/ai-assistant.api.json +226 -0
  2. package/dist/ai-assistant.d.ts +100 -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 +439 -66
  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/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
  17. package/dist/dts/components/settings-modal/settings-modal.template.d.ts +1 -1
  18. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  19. package/dist/dts/config/config.d.ts +39 -2
  20. package/dist/dts/config/config.d.ts.map +1 -1
  21. package/dist/dts/config/define-stateful-agent.d.ts +15 -1
  22. package/dist/dts/config/define-stateful-agent.d.ts.map +1 -1
  23. package/dist/dts/main/main.d.ts +25 -0
  24. package/dist/dts/main/main.d.ts.map +1 -1
  25. package/dist/dts/main/main.template.d.ts.map +1 -1
  26. package/dist/dts/react.d.ts +9 -8
  27. package/dist/dts/utils/strip-agent-handlers.d.ts +1 -1
  28. package/dist/dts/utils/sum-usage.d.ts +37 -4
  29. package/dist/dts/utils/sum-usage.d.ts.map +1 -1
  30. package/dist/dts/utils/usage-rows.d.ts +102 -0
  31. package/dist/dts/utils/usage-rows.d.ts.map +1 -0
  32. package/dist/dts/utils/usage-rows.test.d.ts +2 -0
  33. package/dist/dts/utils/usage-rows.test.d.ts.map +1 -0
  34. package/dist/esm/chat-driver-node.js +36 -1
  35. package/dist/esm/components/chat-driver/chat-driver.js +73 -10
  36. package/dist/esm/components/chat-driver/chat-driver.thinking-policy.test.js +137 -0
  37. package/dist/esm/components/chat-driver/chat-driver.trace-capture.test.js +200 -0
  38. package/dist/esm/components/settings-modal/settings-modal.styles.js +22 -0
  39. package/dist/esm/components/settings-modal/settings-modal.template.js +25 -4
  40. package/dist/esm/components/settings-modal/settings-modal.template.test.js +1 -1
  41. package/dist/esm/config/define-stateful-agent.js +11 -0
  42. package/dist/esm/main/blocked-state.test.js +1 -0
  43. package/dist/esm/main/budget-meter.test.js +42 -0
  44. package/dist/esm/main/main.js +49 -0
  45. package/dist/esm/main/main.template.js +20 -1
  46. package/dist/esm/utils/strip-agent-handlers.js +1 -1
  47. package/dist/esm/utils/sum-usage.js +37 -4
  48. package/dist/esm/utils/usage-rows.js +90 -0
  49. package/dist/esm/utils/usage-rows.test.js +189 -0
  50. package/dist/react.cjs +11 -6
  51. package/dist/react.mjs +10 -5
  52. package/dist/tsconfig.tsbuildinfo +1 -1
  53. package/package.json +17 -17
  54. package/src/chat-driver-node.ts +58 -0
  55. package/src/components/chat-driver/chat-driver.thinking-policy.test.ts +185 -0
  56. package/src/components/chat-driver/chat-driver.trace-capture.test.ts +251 -0
  57. package/src/components/chat-driver/chat-driver.ts +90 -10
  58. package/src/components/settings-modal/settings-modal.styles.ts +22 -0
  59. package/src/components/settings-modal/settings-modal.template.test.ts +1 -1
  60. package/src/components/settings-modal/settings-modal.template.ts +27 -2
  61. package/src/config/config.ts +50 -1
  62. package/src/config/define-stateful-agent.ts +37 -0
  63. package/src/main/blocked-state.test.ts +1 -0
  64. package/src/main/budget-meter.test.ts +50 -0
  65. package/src/main/main.template.ts +19 -1
  66. package/src/main/main.ts +47 -0
  67. package/src/utils/strip-agent-handlers.ts +1 -1
  68. package/src/utils/sum-usage.ts +37 -4
  69. package/src/utils/usage-rows.test.ts +237 -0
  70. package/src/utils/usage-rows.ts +187 -0
@@ -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
  }
@@ -307,6 +307,28 @@ export const settingsModalStyles = css`
307
307
  color: var(--neutral-foreground-rest);
308
308
  }
309
309
 
310
+ /* Freshness footer (GENC-1464): refresh button + label under the vendor
311
+ rows, left-aligned — a footnote to the figures above it. The button's
312
+ inline padding is pinned and cancelled by the container margin so the
313
+ ICON GLYPH (not the button box) sits flush with the budget labels'
314
+ left edge, whatever the density tokens say. */
315
+ .settings-budget-toolbar {
316
+ display: flex;
317
+ justify-content: flex-start;
318
+ align-items: center;
319
+ gap: 4px;
320
+ margin-inline-start: -4px;
321
+ }
322
+
323
+ .settings-budget-refresh::part(control) {
324
+ padding-inline: 4px;
325
+ }
326
+
327
+ .settings-budget-updated {
328
+ color: var(--neutral-foreground-hint);
329
+ font-size: 11px;
330
+ }
331
+
310
332
  /*
311
333
  * ONE rhythm for every meter row. The outer wrapper holds the context
312
334
  * indicator and the budget meter; the inner one holds the budget meter's
@@ -29,7 +29,7 @@ FoundationAiAssistant;
29
29
 
30
30
  const Suite = createLogicSuite('settings-modal usage meters structure');
31
31
 
32
- const template = settingsUsageMetersTemplate('test-progress');
32
+ const template = settingsUsageMetersTemplate('test-progress', 'test-button', 'test-icon');
33
33
 
34
34
  let storeSeq = 0;
35
35
 
@@ -217,6 +217,8 @@ const platformContextUsageTemplate = (
217
217
  */
218
218
  const platformBudgetUsageTemplate = (
219
219
  progressTag: string,
220
+ buttonTag: string,
221
+ iconTag: string,
220
222
  ): ViewTemplate<FoundationAiAssistant> => html<FoundationAiAssistant>`
221
223
  ${when(
222
224
  (x) => x.settingsBudgetUsageVisible,
@@ -237,6 +239,26 @@ const platformBudgetUsageTemplate = (
237
239
  </div>
238
240
  `,
239
241
  )}
242
+ <!--
243
+ Freshness footer (GENC-1464): the rows above update per turn and at
244
+ the wall, which reads as "stale" to anyone watching the modal — the
245
+ label says when the figures arrived, the button asks the host for new
246
+ ones (see FoundationAiAssistant.requestBudgetRefresh). Below the rows
247
+ and left-aligned, reading as a footnote to the figures it describes.
248
+ -->
249
+ <div class="settings-budget-toolbar" part="budget-toolbar">
250
+ <${buttonTag}
251
+ appearance="stealth"
252
+ class="settings-budget-refresh"
253
+ part="budget-refresh"
254
+ title="Refresh budget figures"
255
+ aria-label="Refresh budget figures"
256
+ @click=${(x: FoundationAiAssistant) => x.requestBudgetRefresh()}
257
+ >
258
+ <${iconTag} name="arrows-rotate"></${iconTag}>
259
+ </${buttonTag}>
260
+ <span class="settings-budget-updated">${(x) => x.settingsBudgetUpdatedLabel}</span>
261
+ </div>
240
262
  </div>
241
263
  `,
242
264
  )}
@@ -265,12 +287,15 @@ const platformBudgetUsageTemplate = (
265
287
  */
266
288
  export const settingsUsageMetersTemplate = (
267
289
  progressTag: string,
290
+ buttonTag: string,
291
+ iconTag: string,
268
292
  ): ViewTemplate<FoundationAiAssistant> => html<FoundationAiAssistant>`
269
293
  ${when(
270
294
  (x) => x.settingsContextUsageVisible || x.settingsBudgetUsageVisible,
271
295
  html<FoundationAiAssistant>`
272
296
  <div class="settings-usage-meters">
273
- ${platformContextUsageTemplate(progressTag)} ${platformBudgetUsageTemplate(progressTag)}
297
+ ${platformContextUsageTemplate(progressTag)}
298
+ ${platformBudgetUsageTemplate(progressTag, buttonTag, iconTag)}
274
299
  </div>
275
300
  `,
276
301
  )}
@@ -735,7 +760,7 @@ export const SettingsModalTemplate = (
735
760
  </h3>
736
761
  <div class="settings-modal-section-body">
737
762
  <slot name="settings-model" ${slotted('settingsModelSlotted')}></slot>
738
- ${settingsUsageMetersTemplate(progressTag)}
763
+ ${settingsUsageMetersTemplate(progressTag, buttonTag, iconTag)}
739
764
  </div>
740
765
  </section>
741
766
  <!--
@@ -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,
@@ -325,6 +325,7 @@ Suite('a wall with figures snaps the vendor meter at latch time', () => {
325
325
  reachable(el, 'anthropic', 'gemini');
326
326
  latch(el, 'budget-exhausted', { vendorLabel: 'Anthropic', budgetUsd: 20, spentUsd: 20.1 });
327
327
  assert.equal(storedBudgets(el)['anthropic'], { budgetUsd: 20, spentUsd: 20.1 });
328
+ assert.ok(typeof el.budgetMeterLastFedAt === 'number', 'the wall feed stamps freshness too');
328
329
 
329
330
  // A later 402 for the same (already-walled) vendor carries fresher spend —
330
331
  // the feed is deliberately outside the idempotence guard, like the sweep.
@@ -424,4 +424,54 @@ Suite('figures alone are enough to surface the AI Model Settings section', () =>
424
424
  assert.is(el.settingsModelSectionVisible, false, 'and the config flag takes it back out');
425
425
  });
426
426
 
427
+ // ── Freshness stamp + refresh request (GENC-1464 refresh affordance) ─────────
428
+
429
+ Suite('every feed stamps budgetMeterLastFedAt — identical figures included', () => {
430
+ const el = element();
431
+ assert.is(el.budgetMeterLastFedAt, null, 'null before any feed');
432
+ assert.is(el.settingsBudgetUpdatedLabel, undefined, 'no label before any feed');
433
+
434
+ el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 10 });
435
+ const first = el.budgetMeterLastFedAt;
436
+ assert.ok(typeof first === 'number', 'stamped on feed');
437
+ assert.ok(el.settingsBudgetUpdatedLabel!.startsWith('Updated '), 'label renders');
438
+
439
+ // The store is idempotent by value, but a refresh that CONFIRMS the same
440
+ // dollars is still a refresh — the stamp must move even when the rows don't.
441
+ el.budgetMeterLastFedAt = first! - 5000; // age it so a same-ms re-feed still differs
442
+ el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 10 });
443
+ assert.ok(el.budgetMeterLastFedAt! > first! - 5000, 'identical re-feed still stamps');
444
+
445
+ // Clearing figures is not a refresh — the label describes shown figures.
446
+ const before = el.budgetMeterLastFedAt;
447
+ el.setVendorBudget('anthropic', null);
448
+ assert.is(el.budgetMeterLastFedAt, before, 'a clear does not stamp');
449
+ });
450
+
451
+ Suite('requestBudgetRefresh emits budget-refresh-requested, and settings open asks too', () => {
452
+ const el = element();
453
+ let fired = 0;
454
+ el.addEventListener('budget-refresh-requested', () => {
455
+ fired += 1;
456
+ });
457
+
458
+ el.requestBudgetRefresh();
459
+ assert.is(fired, 1, 'the button path emits');
460
+
461
+ // The element cannot fetch — the event is the whole contract. Hosts without
462
+ // a listener simply keep the per-turn cadence, so firing is unconditional.
463
+ //
464
+ // The modal stub is load-bearing: openSettingsModal's `show` closure
465
+ // re-queues itself via DOM.queueUpdate until `settingsModal` exists, and on
466
+ // an unconnected element that is never — an infinite queue that keeps the
467
+ // test runner alive (this file's documented hang class). `open: true` makes
468
+ // the first queued run return immediately.
469
+ (el as unknown as { settingsModal: { open: boolean; show(): void } }).settingsModal = {
470
+ open: true,
471
+ show() {},
472
+ };
473
+ el.openSettingsModal();
474
+ assert.is(fired, 2, 'opening settings requests fresh figures');
475
+ });
476
+
427
477
  Suite.run();
@@ -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'),
package/src/main/main.ts CHANGED
@@ -1023,9 +1023,52 @@ export class FoundationAiAssistant extends GenesisElement {
1023
1023
  figures:
1024
1024
  figures == null ? null : { budgetUsd: figures.budgetUsd, spentUsd: figures.spentUsd },
1025
1025
  });
1026
+ if (figures != null) this.budgetMeterLastFedAt = Date.now();
1026
1027
  return true;
1027
1028
  }
1028
1029
 
1030
+ /**
1031
+ * When budget figures last arrived — from the host's {@link FoundationAiAssistant.setVendorBudget}
1032
+ * or the wall latch's own feed — as epoch ms; `null` until the first feed.
1033
+ *
1034
+ * ELEMENT state, not slice state, on purpose: the reducer is idempotent by
1035
+ * value (an unchanged re-feed must not re-render the meter rows), but "when
1036
+ * was this fetched" must move on every feed, changed figures or not — a
1037
+ * refresh that confirms the same dollars is still a refresh. Only the
1038
+ * toolbar label observes this, so a stamp re-renders one span, not the rows.
1039
+ */
1040
+ @observable budgetMeterLastFedAt: number | null = null;
1041
+
1042
+ /** "Updated HH:MM:SS" for the meter toolbar, or `undefined` before any feed. */
1043
+ @volatile
1044
+ get settingsBudgetUpdatedLabel(): string | undefined {
1045
+ if (this.budgetMeterLastFedAt == null) return undefined;
1046
+ return `Updated ${new Date(this.budgetMeterLastFedAt).toLocaleTimeString()}`;
1047
+ }
1048
+
1049
+ /**
1050
+ * Ask the host to re-fetch budget figures now (GENC-1464 refresh affordance).
1051
+ *
1052
+ * The element cannot fetch — the meter is host-fed by design — so this emits
1053
+ * `budget-refresh-requested` and the host answers with the same fetch that
1054
+ * already feeds {@link FoundationAiAssistant.setVendorBudget} (Create's handler coalesces in-flight
1055
+ * fetches, so a spammed button costs one request). Fired by the meter's
1056
+ * refresh button and on settings-modal open, so the figures are fresh exactly
1057
+ * when someone is looking at them. Fires unconditionally: a host without a
1058
+ * listener simply keeps the per-turn cadence.
1059
+ */
1060
+ requestBudgetRefresh(): void {
1061
+ // Plain dispatchEvent, not FAST's $emit: $emit silently no-ops while the
1062
+ // element is disconnected, and this must stay observable in headless/test
1063
+ // harnesses that never connect (and in any odd pop-out lifecycle moment).
1064
+ // bubbles + composed, matching this class's other events (SessionCleared
1065
+ // et al): the element lives inside host shadow roots (Create nests it in
1066
+ // gc-assistant's), and a delegation-based host must still see the request.
1067
+ this.dispatchEvent(
1068
+ new CustomEvent('budget-refresh-requested', { bubbles: true, composed: true }),
1069
+ );
1070
+ }
1071
+
1029
1072
  /** Whether this vendor's wall came from the sweep alone — see the slice's `sweptVendors`. */
1030
1073
  private isVendorSwept(vendor: AIProviderType): boolean {
1031
1074
  return (this._sessionRef?.store.aiAssistant.sweptVendors ?? []).includes(vendor);
@@ -1248,6 +1291,7 @@ export class FoundationAiAssistant extends GenesisElement {
1248
1291
  vendor,
1249
1292
  figures: { budgetUsd: budget.budgetUsd, spentUsd: budget.spentUsd },
1250
1293
  });
1294
+ this.budgetMeterLastFedAt = Date.now();
1251
1295
  }
1252
1296
  if (!vendor || vendor === 'none') {
1253
1297
  if (!this.blocked) {
@@ -3789,6 +3833,9 @@ export class FoundationAiAssistant extends GenesisElement {
3789
3833
 
3790
3834
  openSettingsModal(): void {
3791
3835
  this.logMeta('panel.toggled', { panel: 'settings', open: true });
3836
+ // The meter refreshes per turn, which a user staring at the modal cannot
3837
+ // see — ask the host for fresh figures at the moment they start looking.
3838
+ this.requestBudgetRefresh();
3792
3839
  this.settingsModalTab = 'settings';
3793
3840
  this.settingsOpen = true;
3794
3841
  const show = (): void => {
@@ -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