@yeaft/webchat-agent 1.0.505 → 1.0.506

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.
@@ -0,0 +1,242 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export const MAX_PROVIDER_STATE_BYTES = 4 * 1024 * 1024;
4
+ const PROTOCOLS = new Set(['openai-responses', 'anthropic']);
5
+ const hash = value => `sha256:${createHash('sha256').update(JSON.stringify(value)).digest('hex')}`;
6
+ const clone = value => structuredClone(value);
7
+ const nonempty = value => typeof value === 'string' && value.length > 0;
8
+
9
+ export class ProviderStateError extends Error {
10
+ constructor(reason) {
11
+ super(`Provider continuation cannot be replayed safely: ${reason}`);
12
+ this.name = 'ProviderStateError';
13
+ this.code = 'PROVIDER_STATE_INVALID';
14
+ }
15
+ }
16
+
17
+ function stable(value) {
18
+ if (Array.isArray(value)) return value.map(stable);
19
+ if (!value || typeof value !== 'object') return value;
20
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]));
21
+ }
22
+
23
+ export function providerProjectionDigest(message) {
24
+ return hash(stable({ content: message.content || '', toolCalls: (message.toolCalls || []).map(tc => ({
25
+ id: tc.id, name: tc.name, input: tc.input ?? {},
26
+ })) }));
27
+ }
28
+
29
+ export function providerStateBytes(state) {
30
+ try { return Buffer.byteLength(JSON.stringify(state)); } catch { return Infinity; }
31
+ }
32
+
33
+ export function requestOwner(identity) {
34
+ const keys = ['instanceScope', 'ownerScope', 'sessionId', 'vpId', 'threadId'];
35
+ if (!identity || !keys.every(key => nonempty(identity[key]))) return null;
36
+ // Persist opaque scope fingerprints, never local absolute paths/usernames.
37
+ return Object.fromEntries(keys.map(key => [key, hash(identity[key])]));
38
+ }
39
+
40
+ /** Stable, content-free identity: no prompt, model effort, request id or token. */
41
+ export function createPromptCacheKey(identity, context) {
42
+ const owner = requestOwner(identity);
43
+ if (!owner || !validOrigin(context?.origin)) return null;
44
+ return `yeaft:${hash({ owner, origin: context.origin }).slice(7, 55)}`;
45
+ }
46
+
47
+ function validOrigin(origin) {
48
+ return origin && ['providerId', 'endpointFingerprint', 'model', 'credentialScopeId'].every(key => nonempty(origin[key]));
49
+ }
50
+
51
+ /** Endpoint recognition is exact, never inferred from a Claude/GPT model name.
52
+ * Unknown/translation endpoints opt in via the existing provider/model config.
53
+ */
54
+ export function createProviderContext({ protocol, baseUrl, providerId, credentialScopeId, staticApiKey, model, capabilities = {} }) {
55
+ let endpoint;
56
+ try {
57
+ const url = new URL(baseUrl || (protocol === 'anthropic' ? 'https://api.anthropic.com' : 'https://api.openai.com/v1'));
58
+ url.username = ''; url.password = ''; url.search = ''; url.hash = '';
59
+ endpoint = url.toString().replace(/\/+$/, '');
60
+ } catch { endpoint = ''; }
61
+ const native = protocol === 'anthropic' ? endpoint === 'https://api.anthropic.com'
62
+ : endpoint === 'https://api.openai.com/v1';
63
+ // Official static-key routes work with existing configs. Persist only a
64
+ // full cryptographic fingerprint, stable across restart and changed on key
65
+ // rotation. Dynamic credentials and unknown proxies still require an explicit
66
+ // account scope: an expiring access token is not a stable account identity.
67
+ if (!nonempty(credentialScopeId) && native && nonempty(staticApiKey)) {
68
+ credentialScopeId = hash(['static-api-key', staticApiKey]);
69
+ }
70
+ const enabled = key => capabilities.translation !== true && (capabilities[key] ?? native) === true;
71
+ return {
72
+ protocol,
73
+ origin: { providerId: providerId || protocol, endpointFingerprint: hash(endpoint), model, credentialScopeId },
74
+ capabilities: {
75
+ nativeReasoningState: enabled('nativeReasoningState'),
76
+ promptCaching: enabled('promptCaching'),
77
+ parallelToolCalls: enabled('parallelToolCalls'),
78
+ },
79
+ diagnostics: { capabilitySource: capabilities.translation ? 'translation-disabled' : native ? 'native-endpoint' : 'custom-endpoint-unverified' },
80
+ };
81
+ }
82
+
83
+ function projectItems(protocol, items) {
84
+ const projection = { content: '', toolCalls: [] };
85
+ for (const item of items) {
86
+ if (!item || typeof item !== 'object') throw new ProviderStateError('invalid item');
87
+ if (protocol === 'openai-responses') {
88
+ if (item.status && !['completed', 'incomplete'].includes(item.status)) throw new ProviderStateError('unfinished item');
89
+ if (item.type === 'reasoning') {
90
+ if (!nonempty(item.encrypted_content)) throw new ProviderStateError('reasoning missing encrypted content');
91
+ } else if (item.type === 'message' && item.role === 'assistant' && Array.isArray(item.content)) {
92
+ for (const part of item.content) {
93
+ if (part.type !== 'output_text' || typeof part.text !== 'string') throw new ProviderStateError('unsupported message part');
94
+ projection.content += part.text;
95
+ }
96
+ } else if (item.type === 'function_call' && nonempty(item.call_id) && nonempty(item.name) && typeof item.arguments === 'string') {
97
+ projection.toolCalls.push({ id: item.call_id, name: item.name, input: JSON.parse(item.arguments) });
98
+ } else throw new ProviderStateError('unsupported Responses item');
99
+ } else if (protocol === 'anthropic') {
100
+ if (item.type === 'thinking' && typeof item.thinking === 'string' && nonempty(item.signature)) continue;
101
+ if (item.type === 'redacted_thinking' && nonempty(item.data)) continue;
102
+ if (item.type === 'text' && typeof item.text === 'string') projection.content += item.text;
103
+ else if (item.type === 'tool_use' && nonempty(item.id) && nonempty(item.name) && item.input && typeof item.input === 'object') {
104
+ projection.toolCalls.push({ id: item.id, name: item.name, input: item.input });
105
+ } else throw new ProviderStateError('unsupported or unsigned Anthropic block');
106
+ }
107
+ }
108
+ return projection;
109
+ }
110
+
111
+ export function createProviderState({ context, identity, items, responseId }) {
112
+ if (!context?.capabilities?.nativeReasoningState || !validOrigin(context.origin) || !requestOwner(identity)) {
113
+ if (context?.protocol === 'anthropic' && Array.isArray(items)
114
+ && items.some(item => item?.type === 'tool_use')
115
+ && items.some(item => ['thinking', 'redacted_thinking'].includes(item?.type))) {
116
+ throw new ProviderStateError('signed tool turn requires nativeReasoningState, credentialScopeId and request identity; configure the verified native route and start a new context');
117
+ }
118
+ return null;
119
+ }
120
+ if (!Array.isArray(items) || !items.length) return null;
121
+ const state = { version: 1, protocol: context.protocol, origin: clone(context.origin), owner: requestOwner(identity),
122
+ ...(nonempty(responseId) ? { responseId } : {}), items: clone(items) };
123
+ if (!PROTOCOLS.has(state.protocol)) return null;
124
+ if (providerStateBytes(state) > MAX_PROVIDER_STATE_BYTES) throw new ProviderStateError('state exceeds byte budget');
125
+ try { state.projectionDigest = providerProjectionDigest(projectItems(state.protocol, state.items)); }
126
+ catch (error) {
127
+ if (state.protocol === 'anthropic' && items.some(item => item?.type === 'tool_use')
128
+ && items.some(item => ['thinking', 'redacted_thinking'].includes(item?.type))) throw error;
129
+ return null; // unsupported/partial optional Responses state is not replayable
130
+ }
131
+ // Replay checks the final envelope, including its projection digest.
132
+ if (providerStateBytes(state) > MAX_PROVIDER_STATE_BYTES) throw new ProviderStateError('state exceeds byte budget');
133
+ return state;
134
+ }
135
+
136
+ function signedToolState(state, message) {
137
+ return state?.protocol === 'anthropic' && message?.toolCalls?.length > 0
138
+ && (!Array.isArray(state.items) || state.items.some(item => ['thinking', 'redacted_thinking'].includes(item?.type)));
139
+ }
140
+
141
+ export function bindProviderState(state, message) {
142
+ if (!state) return null;
143
+ if (state.projectionDigest !== providerProjectionDigest(message)) {
144
+ if (signedToolState(state, message)) throw new ProviderStateError('assistant projection changed in signed tool turn');
145
+ return null;
146
+ }
147
+ return state;
148
+ }
149
+
150
+ /** Returns cloned native payload only after all fences. Never mutates history. */
151
+ export function replayProviderState(message, context, identity) {
152
+ const state = message?.providerState;
153
+ if (!state) return null;
154
+ let reason = '';
155
+ if (state.version !== 1 || !PROTOCOLS.has(state.protocol)) reason = 'unknown schema';
156
+ else if (!context?.capabilities?.nativeReasoningState || state.protocol !== context.protocol) reason = 'protocol/capability changed';
157
+ else if (!validOrigin(context.origin) || hash(state.origin) !== hash(context.origin)) reason = 'origin changed';
158
+ else if (!requestOwner(identity) || hash(state.owner) !== hash(requestOwner(identity))) reason = 'owner changed';
159
+ else if (providerStateBytes(state) > MAX_PROVIDER_STATE_BYTES) reason = 'state exceeds byte budget';
160
+ else {
161
+ try {
162
+ if (state.projectionDigest !== providerProjectionDigest(message)
163
+ || state.projectionDigest !== providerProjectionDigest(projectItems(state.protocol, state.items))) reason = 'projection changed';
164
+ } catch { reason = 'invalid native items'; }
165
+ }
166
+ if (reason) {
167
+ if (signedToolState(state, message)) throw new ProviderStateError(reason);
168
+ return null;
169
+ }
170
+ return clone(state.items);
171
+ }
172
+
173
+ /** Mandatory normalization AFTER extraBody; generated input/storage cannot be overridden. */
174
+ export function applyResponsesContinuity(body, { context, identity, input, onProviderDiagnostics }) {
175
+ body.input = input;
176
+ delete body.previous_response_id;
177
+ body.store = false;
178
+ if (context?.capabilities?.nativeReasoningState && validOrigin(context.origin) && requestOwner(identity)) {
179
+ body.include = [...new Set([...(Array.isArray(body.include) ? body.include.filter(v => typeof v === 'string') : []), 'reasoning.encrypted_content'])];
180
+ } else if (Array.isArray(body.include)) {
181
+ body.include = body.include.filter(item => item !== 'reasoning.encrypted_content');
182
+ if (!body.include.length) delete body.include;
183
+ }
184
+ delete body.prompt_cache_key;
185
+ const key = context?.capabilities?.promptCaching && createPromptCacheKey(identity, context);
186
+ if (key) body.prompt_cache_key = key;
187
+ if (context?.capabilities?.parallelToolCalls) {
188
+ // Capability permits parallel calls; it must not override an explicit opt-out.
189
+ if (body.parallel_tool_calls === undefined) body.parallel_tool_calls = true;
190
+ } else delete body.parallel_tool_calls;
191
+ onProviderDiagnostics?.({ ...context?.diagnostics, nativeReasoningState: Boolean(body.include?.includes('reasoning.encrypted_content')),
192
+ promptCaching: key ? 'sent-unverified' : 'not-sent' });
193
+ }
194
+
195
+ /** At most three cache breakpoints. Never modifies signed assistant blocks. */
196
+ export function applyAnthropicCaching(body, context, onProviderDiagnostics) {
197
+ // Own the policy after extraBody: do not mix automatic/long-TTL caching or
198
+ // inherit caller breakpoints on unknown translation endpoints.
199
+ delete body.cache_control;
200
+ const withoutCache = block => {
201
+ if (!block || typeof block !== 'object') return block;
202
+ const { cache_control, ...rest } = block;
203
+ return rest;
204
+ };
205
+ if (Array.isArray(body.system)) body.system = body.system.map(withoutCache);
206
+ if (Array.isArray(body.tools)) body.tools = body.tools.map(withoutCache);
207
+ if (Array.isArray(body.messages)) body.messages = body.messages.map(message => ({ ...message,
208
+ content: Array.isArray(message.content) ? message.content.map(block => {
209
+ // Signed native blocks are immutable (and never eligible for caching).
210
+ return ['thinking', 'redacted_thinking'].includes(block?.type) ? block : withoutCache(block);
211
+ }) : message.content,
212
+ }));
213
+ let sent = false;
214
+ if (context?.capabilities?.promptCaching && validOrigin(context.origin)) {
215
+ if (typeof body.system === 'string' && body.system) body.system = [{ type: 'text', text: body.system }];
216
+ if (Array.isArray(body.system) && body.system.length) {
217
+ body.system[body.system.length - 1] = { ...body.system.at(-1), cache_control: { type: 'ephemeral' } };
218
+ sent = true;
219
+ }
220
+ if (body.tools?.length) {
221
+ body.tools[body.tools.length - 1] = { ...body.tools.at(-1), cache_control: { type: 'ephemeral' } };
222
+ sent = true;
223
+ }
224
+ // Latest complete user/tool_result boundary, not every growing message.
225
+ for (let index = (body.messages?.length || 0) - 1; index >= 0; index--) {
226
+ const message = body.messages[index];
227
+ if (message.role !== 'user') continue;
228
+ if (typeof message.content === 'string' && message.content) message.content = [{ type: 'text', text: message.content }];
229
+ const last = Array.isArray(message.content) ? message.content.at(-1) : null;
230
+ if (!['text', 'tool_result'].includes(last?.type)) continue;
231
+ message.content[message.content.length - 1] = { ...last, cache_control: { type: 'ephemeral' } };
232
+ sent = true;
233
+ break;
234
+ }
235
+ }
236
+ onProviderDiagnostics?.({ ...context?.diagnostics, promptCaching: sent ? 'sent-unverified' : 'not-sent' });
237
+ }
238
+
239
+ export function reasoningUsage(usage, protocol) {
240
+ const value = protocol === 'anthropic' ? usage?.output_tokens_details?.thinking_tokens : usage?.output_tokens_details?.reasoning_tokens;
241
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? { reasoningTokens: value } : {};
242
+ }
@@ -30,6 +30,7 @@ import {
30
30
  isGitHubCopilotProvider,
31
31
  normalizeKnownProviderForRuntime,
32
32
  } from './known-providers.js';
33
+ import { createProviderContext } from './provider-state.js';
33
34
  import { pairSanitize } from '../pair-sanitize.js';
34
35
 
35
36
  /**
@@ -46,6 +47,7 @@ export function normalizeModelEntry(entry) {
46
47
  }
47
48
  if (entry && typeof entry === 'object' && typeof entry.id === 'string' && entry.id) {
48
49
  const out = { id: entry.id };
50
+ if (entry.capabilities && typeof entry.capabilities === 'object') out.capabilities = { ...entry.capabilities };
49
51
  if (typeof entry.protocol === 'string' && entry.protocol) {
50
52
  out.protocol = entry.protocol;
51
53
  }
@@ -643,6 +645,11 @@ export class AdapterRouter extends LLMAdapter {
643
645
  while (true) {
644
646
  const resolved = await this.#resolveAdapter(params.model, dispatchSnapshot);
645
647
  const provider = this.#getProviderForModel(params.model, dispatchSnapshot);
648
+ const providerContext = createProviderContext({ protocol: resolved.protocol, baseUrl: provider?.baseUrl,
649
+ providerId: provider?.name, credentialScopeId: provider?.credentialScopeId,
650
+ staticApiKey: provider?.credentialProvider ? undefined : provider?.apiKey, model: resolved.modelId,
651
+ capabilities: { ...provider?.capabilities, ...resolved.entry?.capabilities },
652
+ });
646
653
  const effortContext = {
647
654
  protocol: resolved.protocol,
648
655
  supportsEffort: resolved.entry?.supportsEffort,
@@ -653,7 +660,7 @@ export class AdapterRouter extends LLMAdapter {
653
660
  const filtered = filterEffortForModel({ ...params, model: resolved.modelId }, resolved);
654
661
  const sanitized = sanitizeMessagesForWire(filtered);
655
662
  try {
656
- yield* resolved.adapter.stream({ ...sanitized, model: resolved.modelId, effortContext, rawExchangeMaxBytes: params.rawExchangeMaxBytes });
663
+ yield* resolved.adapter.stream({ ...sanitized, model: resolved.modelId, effortContext, providerContext, rawExchangeMaxBytes: params.rawExchangeMaxBytes });
657
664
  return;
658
665
  } catch (err) {
659
666
  this.#annotateAuthError(err, provider, params.model);
@@ -676,6 +683,11 @@ export class AdapterRouter extends LLMAdapter {
676
683
  while (true) {
677
684
  const resolved = await this.#resolveAdapter(params.model, dispatchSnapshot);
678
685
  const provider = this.#getProviderForModel(params.model, dispatchSnapshot);
686
+ const providerContext = createProviderContext({ protocol: resolved.protocol, baseUrl: provider?.baseUrl,
687
+ providerId: provider?.name, credentialScopeId: provider?.credentialScopeId,
688
+ staticApiKey: provider?.credentialProvider ? undefined : provider?.apiKey, model: resolved.modelId,
689
+ capabilities: { ...provider?.capabilities, ...resolved.entry?.capabilities },
690
+ });
679
691
  const effortContext = {
680
692
  protocol: resolved.protocol,
681
693
  supportsEffort: resolved.entry?.supportsEffort,
@@ -686,7 +698,7 @@ export class AdapterRouter extends LLMAdapter {
686
698
  const filtered = filterEffortForModel({ ...params, model: resolved.modelId }, resolved);
687
699
  const sanitized = sanitizeMessagesForWire(filtered);
688
700
  try {
689
- return await resolved.adapter.call({ ...sanitized, model: resolved.modelId, effortContext });
701
+ return await resolved.adapter.call({ ...sanitized, model: resolved.modelId, effortContext, providerContext });
690
702
  } catch (err) {
691
703
  this.#annotateAuthError(err, provider, params.model);
692
704
  if (err?.statusCode !== 401 || refreshedCredential
@@ -30,6 +30,8 @@ export function normalizeTokenUsage(usage = {}) {
30
30
  outputTokens,
31
31
  cacheReadTokens,
32
32
  cacheWriteTokens,
33
+ ...(typeof usage.reasoningTokens === 'number' && Number.isFinite(usage.reasoningTokens) && usage.reasoningTokens >= 0
34
+ ? { reasoningTokens: usage.reasoningTokens } : {}),
33
35
  totalTokens: explicitTotal || inputTokens + outputTokens + cacheInputTokens,
34
36
  };
35
37
  }
@@ -41,6 +43,7 @@ function addUsage(total, usage) {
41
43
  total.cacheReadTokens += normalized.cacheReadTokens;
42
44
  total.cacheWriteTokens += normalized.cacheWriteTokens;
43
45
  total.totalTokens += normalized.totalTokens;
46
+ if (normalized.reasoningTokens !== undefined) total.reasoningTokens = (total.reasoningTokens || 0) + normalized.reasoningTokens;
44
47
  }
45
48
 
46
49
  /**
package/yeaft/models.js CHANGED
@@ -694,6 +694,7 @@ export function normalizeProviderModels(provider) {
694
694
  }
695
695
  if (entry && typeof entry === 'object' && typeof entry.id === 'string' && entry.id.trim()) {
696
696
  const norm = { id: entry.id.trim() };
697
+ if (entry.capabilities && typeof entry.capabilities === 'object') norm.capabilities = { ...entry.capabilities };
697
698
  const ctx = coercePositiveInt(entry.contextWindow);
698
699
  const max = coercePositiveInt(entry.maxOutput);
699
700
  if (ctx !== undefined) norm.contextWindow = ctx;
@@ -731,8 +732,10 @@ export function serializeModelForPersistence(entry) {
731
732
  const proto = typeof entry.protocol === 'string' && entry.protocol.trim()
732
733
  ? entry.protocol.trim()
733
734
  : undefined;
734
- if (ctx === undefined && max === undefined && proto === undefined) return entry.id;
735
+ const capabilities = entry.capabilities && typeof entry.capabilities === 'object' ? { ...entry.capabilities } : undefined;
736
+ if (ctx === undefined && max === undefined && proto === undefined && capabilities === undefined) return entry.id;
735
737
  const obj = { id: entry.id };
738
+ if (capabilities !== undefined) obj.capabilities = capabilities;
736
739
  if (ctx !== undefined) obj.contextWindow = ctx;
737
740
  if (max !== undefined) obj.maxOutput = max;
738
741
  if (proto !== undefined) obj.protocol = proto;
@@ -76,6 +76,7 @@ export function filterSnapshotForVp(snapshot, vpId) {
76
76
  const copy = { ...m };
77
77
  delete copy.toolCalls;
78
78
  delete copy.thinkingBlocks;
79
+ delete copy.providerState;
79
80
  out.push(copy);
80
81
  }
81
82
  continue;
@@ -36,6 +36,7 @@
36
36
  import fs from 'fs';
37
37
  import path from 'path';
38
38
  import os from 'os';
39
+ import { snapshotEffortDecision } from '../effort.js';
39
40
 
40
41
  const DEFAULT_DIR = path.join(os.homedir(), '.yeaft', 'sub-agents');
41
42
  const MAX_BYTES = 2 * 1024 * 1024; // 2 MiB before rotation
@@ -204,6 +205,9 @@ function serialize(evt) {
204
205
  ? evt.error
205
206
  : (evt.error.message || String(evt.error));
206
207
  }
208
+ if (evt.parentEffortDecision && (evt.type === 'sub_agent_spawned' || evt.type === 'sub_agent_effort_snapshot')) {
209
+ out.parentEffortDecision = snapshotEffortDecision(evt.parentEffortDecision);
210
+ }
207
211
  if (evt.status) out.status = evt.status;
208
212
  if (typeof evt.tokens === 'number') out.tokens = evt.tokens;
209
213
  return out;
@@ -1,3 +1,5 @@
1
+ import { snapshotEffortDecision } from '../effort.js';
2
+
1
3
  /**
2
4
  * Queue a sub-agent continuation with the Project context that is authoritative
3
5
  * for the parent turn which submitted it. Keeping the snapshot on the queue
@@ -12,6 +14,7 @@ export function enqueueSubAgentPrompt(agent, prompt, projectContext = {}) {
12
14
  if (!Array.isArray(agent.pendingPrompts)) agent.pendingPrompts = [];
13
15
  agent.pendingPrompts.push({
14
16
  prompt,
17
+ parentEffortDecision: snapshotEffortDecision(projectContext.parentEffortDecision),
15
18
  projectSessionIds: Array.isArray(projectContext.projectSessionIds)
16
19
  ? projectContext.projectSessionIds.slice()
17
20
  : [],
@@ -36,6 +36,7 @@
36
36
  */
37
37
 
38
38
  import { Engine } from '../engine.js';
39
+ import { snapshotEffortDecision } from '../effort.js';
39
40
  import { SubAgentToolRegistry, resolveSubAgentBudget, createExecutionStats } from './execution-control.js';
40
41
  import { getPersona } from '../personas.js';
41
42
  import { buildSpawnedPreamble } from './spawned-prompt.js';
@@ -150,6 +151,8 @@ export function startSubAgent(agent, deps = {}) {
150
151
  if (!agent || typeof agent !== 'object') return;
151
152
  if (agent.__driverStarted) return; // idempotent
152
153
  agent.__driverStarted = true;
154
+ // Re-freeze restored JSON snapshots; never consult live parent config here.
155
+ agent.parentEffortDecision = snapshotEffortDecision(agent.parentEffortDecision);
153
156
 
154
157
  let subEngine = null;
155
158
  let outputLog = null;
@@ -200,7 +203,7 @@ export function startSubAgent(agent, deps = {}) {
200
203
  if (agent.taskId && deps.taskManager && agent.parentSessionId) {
201
204
  try { deps.taskManager.setTaskLogPath(agent.parentSessionId, agent.taskId, agent.outputFile); } catch { /* ignore */ }
202
205
  }
203
- outputLog.write({ type: 'sub_agent_spawned', agentId: agent.id, agentName: agent.name, mission: agent.mission || agent.task || '' });
206
+ outputLog.write({ type: 'sub_agent_spawned', agentId: agent.id, agentName: agent.name, mission: agent.mission || agent.task || '', parentEffortDecision: agent.parentEffortDecision });
204
207
 
205
208
  // Compose the system-prompt-overlay we want injected.
206
209
  const preamble = buildSpawnedPreamble({
@@ -357,6 +360,7 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
357
360
  if (typeof entry === 'string') {
358
361
  return {
359
362
  prompt: entry,
363
+ parentEffortDecision: snapshotEffortDecision(agent.parentEffortDecision),
360
364
  projectSessionIds: Array.isArray(deps.projectSessionIds)
361
365
  ? deps.projectSessionIds.slice()
362
366
  : [],
@@ -371,6 +375,7 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
371
375
  if (!entry || typeof entry !== 'object' || typeof entry.prompt !== 'string') return null;
372
376
  return {
373
377
  prompt: entry.prompt,
378
+ parentEffortDecision: snapshotEffortDecision(entry.parentEffortDecision ?? agent.parentEffortDecision),
374
379
  projectSessionIds: Array.isArray(entry.projectSessionIds)
375
380
  ? entry.projectSessionIds.slice()
376
381
  : [],
@@ -389,6 +394,7 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
389
394
  if (agent.mission && !agent.__missionSeeded) {
390
395
  agent.pendingPrompts.push({
391
396
  prompt: agent.mission,
397
+ parentEffortDecision: agent.parentEffortDecision,
392
398
  projectSessionIds: Array.isArray(deps.projectSessionIds)
393
399
  ? deps.projectSessionIds.slice()
394
400
  : [],
@@ -454,13 +460,18 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
454
460
  const priorUsageTokens = agent.usage?.tokens || 0;
455
461
  let turnUsageTokens = 0;
456
462
  try {
463
+ agent.activeParentEffortDecision = queuedPrompt.parentEffortDecision;
464
+ emit({ type: 'sub_agent_effort_snapshot', parentEffortDecision: queuedPrompt.parentEffortDecision });
457
465
  const stream = subEngine.query({
458
466
  prompt: queuedPrompt.prompt,
459
467
  messages: agent.engineMessages,
460
468
  signal: agent.abortController?.signal,
461
- scenario: 'chat',
469
+ scenario: 'sub_agent',
470
+ isSubAgent: true,
471
+ parentEffortDecision: queuedPrompt.parentEffortDecision,
462
472
  vpPersona,
463
473
  sessionId: agent.parentSessionId || deps.parentSessionId || null,
474
+ threadId: agent.id,
464
475
  // SpawnAgent records the caller-provided cwd on the agent. Thread it
465
476
  // into the child Engine just like a parent query's workDir so child
466
477
  // file tools resolve relative paths in the requested workspace.
@@ -28,6 +28,7 @@ import { resolveSubAgentBudget } from '../sub-agent/execution-control.js';
28
28
  import { STATUS, isTerminalAgentStatus } from '../sub-agent/status.js';
29
29
  import { diagnoseAgentLiveness, makeLiveness } from '../sub-agent/liveness.js';
30
30
  import { TASK_RESULT_DELIVERY } from '../tasks/store.js';
31
+ import { captureParentEffortDecision } from '../effort.js';
31
32
 
32
33
  /** In-memory sub-agent registry. */
33
34
  const agents = new Map();
@@ -407,6 +408,8 @@ liveness,不要盲目循环。`
407
408
  expected_output: spec.expected_output,
408
409
  persona: spec.persona,
409
410
  personaData: persona || null,
411
+ // Capture at the tool boundary, before fire-and-forget startup can yield.
412
+ parentEffortDecision: captureParentEffortDecision(ctx),
410
413
  budget: spec.budget,
411
414
  cwd: cwd || ctx?.cwd || process.cwd(),
412
415
  status: STATUS.CREATED,
@@ -8,6 +8,7 @@
8
8
  import { defineTool } from './types.js';
9
9
  import { agentBelongsToCaller, getAgentRegistry } from './agent.js';
10
10
  import { enqueueSubAgentPrompt } from '../sub-agent/prompt-queue.js';
11
+ import { captureParentEffortDecision } from '../effort.js';
11
12
  import { isTerminalAgentStatus, isPromptableAgentStatus, STATUS, describeAgentStatus } from '../sub-agent/status.js';
12
13
 
13
14
  export default defineTool({
@@ -117,6 +118,7 @@ stale/stalled,否则必须使用更大的有界 timeout 再次调用 WaitAgent
117
118
  // Queue as a pending prompt the driver will pull. This wakes the
118
119
  // driver out of its idle wait and starts a new turn.
119
120
  enqueueSubAgentPrompt(agent, message, {
121
+ parentEffortDecision: captureParentEffortDecision(ctx),
120
122
  projectSessionIds: ctx?.parentEngineDeps?.projectSessionIds,
121
123
  projectLabel: ctx?.parentEngineDeps?.projectLabel,
122
124
  projectInstruction: ctx?.parentEngineDeps?.projectInstruction,
@@ -1442,14 +1442,13 @@ function projectPersistedToHistoryEntry(m, { includeReflections = false } = {})
1442
1442
  // in the runtime history owner so a restart does not create a tool arc with
1443
1443
  // its required thinking prefix missing. `filterSnapshotForVp` strips these
1444
1444
  // blocks from other VPs before any provider request.
1445
- if (Array.isArray(m.thinkingBlocks) && m.thinkingBlocks.length > 0) {
1445
+ if (m.providerState) entry.providerState = m.providerState;
1446
+ if (!m.providerState && Array.isArray(m.thinkingBlocks) && m.thinkingBlocks.length > 0) {
1446
1447
  const thinkingBlocks = m.thinkingBlocks
1447
1448
  .filter(tb => tb
1448
- && typeof tb.signature === 'string'
1449
- && tb.signature
1450
1449
  && (tb.redacted === true
1451
1450
  ? typeof tb.data === 'string'
1452
- : typeof tb.thinking === 'string'))
1451
+ : typeof tb.thinking === 'string' && typeof tb.signature === 'string' && tb.signature))
1453
1452
  .map(tb => tb.redacted === true
1454
1453
  ? { redacted: true, data: tb.data, signature: tb.signature }
1455
1454
  : { thinking: tb.thinking, signature: tb.signature });
@@ -1462,13 +1461,14 @@ function projectPersistedToHistoryEntry(m, { includeReflections = false } = {})
1462
1461
  if (Array.isArray(m.attachments) && m.attachments.length > 0) entry.attachments = m.attachments;
1463
1462
  if (m.quote && typeof m.quote === 'object') entry.quote = m.quote;
1464
1463
  if (Array.isArray(m.todos)) entry.todos = m.todos;
1465
- if ((entry.role === 'user' || entry.role === 'assistant') && !entry.content && !entry.attachments && !entry.images && !entry.toolCalls && !entry.thinkingBlocks && !entry.todos && !entry.askUserResults) return null;
1464
+ if ((entry.role === 'user' || entry.role === 'assistant') && !entry.content && !entry.attachments && !entry.images && !entry.toolCalls && !entry.thinkingBlocks && !entry.providerState && !entry.todos && !entry.askUserResults) return null;
1466
1465
  return entry;
1467
1466
  }
1468
1467
 
1469
1468
  function projectPersistedToVisibleHistoryEntry(m) {
1470
1469
  if (!isVisibleConversationRow(m)) return null;
1471
1470
  const entry = projectPersistedToHistoryEntry(m);
1471
+ if (entry) { delete entry.providerState; delete entry.thinkingBlocks; }
1472
1472
  return entry && (entry.role === 'user' || entry.role === 'assistant') ? entry : null;
1473
1473
  }
1474
1474