@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.
- package/dist/ai-assistant.api.json +135 -0
- package/dist/ai-assistant.d.ts +75 -3
- package/dist/chat-driver.cjs +540 -117
- package/dist/chat-driver.cjs.map +4 -4
- package/dist/chat-driver.mjs +524 -116
- package/dist/chat-driver.mjs.map +4 -4
- package/dist/custom-elements.json +1822 -1483
- package/dist/dts/chat-driver-node.d.ts +5 -2
- package/dist/dts/chat-driver-node.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +20 -3
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts +2 -0
- package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts.map +1 -0
- package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts +2 -0
- package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts.map +1 -0
- package/dist/dts/config/config.d.ts +39 -2
- package/dist/dts/config/config.d.ts.map +1 -1
- package/dist/dts/config/define-stateful-agent.d.ts +15 -1
- package/dist/dts/config/define-stateful-agent.d.ts.map +1 -1
- package/dist/dts/main/main.template.d.ts.map +1 -1
- package/dist/dts/utils/strip-agent-handlers.d.ts +1 -1
- package/dist/dts/utils/sum-usage.d.ts +37 -4
- package/dist/dts/utils/sum-usage.d.ts.map +1 -1
- package/dist/dts/utils/usage-rows.d.ts +102 -0
- package/dist/dts/utils/usage-rows.d.ts.map +1 -0
- package/dist/dts/utils/usage-rows.test.d.ts +2 -0
- package/dist/dts/utils/usage-rows.test.d.ts.map +1 -0
- package/dist/esm/chat-driver-node.js +36 -1
- package/dist/esm/components/chat-driver/chat-driver.js +73 -10
- package/dist/esm/components/chat-driver/chat-driver.thinking-policy.test.js +137 -0
- package/dist/esm/components/chat-driver/chat-driver.trace-capture.test.js +200 -0
- package/dist/esm/config/define-stateful-agent.js +11 -0
- package/dist/esm/main/main.template.js +20 -1
- package/dist/esm/utils/strip-agent-handlers.js +1 -1
- package/dist/esm/utils/sum-usage.js +37 -4
- package/dist/esm/utils/usage-rows.js +90 -0
- package/dist/esm/utils/usage-rows.test.js +189 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +17 -17
- package/src/chat-driver-node.ts +58 -0
- package/src/components/chat-driver/chat-driver.thinking-policy.test.ts +185 -0
- package/src/components/chat-driver/chat-driver.trace-capture.test.ts +251 -0
- package/src/components/chat-driver/chat-driver.ts +90 -10
- package/src/config/config.ts +50 -1
- package/src/config/define-stateful-agent.ts +37 -0
- package/src/main/main.template.ts +19 -1
- package/src/utils/strip-agent-handlers.ts +1 -1
- package/src/utils/sum-usage.ts +37 -4
- package/src/utils/usage-rows.test.ts +237 -0
- 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-
|
|
1914
|
-
*
|
|
1915
|
-
* so parallel tool calls each capture their own
|
|
1916
|
-
|
|
1917
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/config/config.ts
CHANGED
|
@@ -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 {
|
|
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!
|
|
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
|