@yeaft/webchat-agent 1.0.504 → 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.
Files changed (100) hide show
  1. package/conversation.js +4 -2
  2. package/local-runtime/server/handlers/agent-conversation.js +11 -9
  3. package/local-runtime/server/handlers/client-conversation.js +28 -2
  4. package/local-runtime/version.json +1 -1
  5. package/local-runtime/web/app.bundle.js +99 -94
  6. package/local-runtime/web/app.bundle.js.gz +0 -0
  7. package/local-runtime/web/index.html +4 -3
  8. package/local-runtime/web/style.bundle.css +1 -1
  9. package/local-runtime/web/style.bundle.css.gz +0 -0
  10. package/local-runtime/web/vendor/katex/LICENSE +21 -0
  11. package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.ttf +0 -0
  12. package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff +0 -0
  13. package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  14. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  15. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  16. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  17. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  18. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  19. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  20. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  21. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  22. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  23. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  24. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  25. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  26. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.ttf +0 -0
  27. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.woff +0 -0
  28. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  29. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  30. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  31. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  32. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.ttf +0 -0
  33. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.woff +0 -0
  34. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  35. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.ttf +0 -0
  36. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.woff +0 -0
  37. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  38. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  39. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  40. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  41. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.ttf +0 -0
  42. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.woff +0 -0
  43. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  44. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  45. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  46. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  47. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  48. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  49. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  50. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  51. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  52. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  53. package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.ttf +0 -0
  54. package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.woff +0 -0
  55. package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  56. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.ttf +0 -0
  57. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff +0 -0
  58. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  59. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.ttf +0 -0
  60. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff +0 -0
  61. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  62. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.ttf +0 -0
  63. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff +0 -0
  64. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  65. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.ttf +0 -0
  66. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff +0 -0
  67. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  68. package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  69. package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  70. package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  71. package/local-runtime/web/vendor/katex/katex.min.css +1 -0
  72. package/local-runtime/web/vendor.bundle.js +1 -0
  73. package/local-runtime/web/vendor.bundle.js.gz +0 -0
  74. package/package.json +1 -1
  75. package/yeaft/cli.js +3 -0
  76. package/yeaft/config-api.js +16 -5
  77. package/yeaft/config.js +11 -2
  78. package/yeaft/conversation/history-index-worker.js +151 -0
  79. package/yeaft/conversation/history-index.js +96 -1
  80. package/yeaft/conversation/persist.js +21 -10
  81. package/yeaft/conversation/recall-relevance.js +103 -0
  82. package/yeaft/conversation/search.js +2 -1
  83. package/yeaft/debug-trace.js +15 -4
  84. package/yeaft/effort.js +159 -6
  85. package/yeaft/engine.js +180 -41
  86. package/yeaft/history-window.js +430 -5
  87. package/yeaft/llm/adapter.js +3 -2
  88. package/yeaft/llm/anthropic.js +97 -25
  89. package/yeaft/llm/openai-responses.js +71 -7
  90. package/yeaft/llm/provider-state.js +242 -0
  91. package/yeaft/llm/router.js +14 -2
  92. package/yeaft/llm/usage-accounting.js +3 -0
  93. package/yeaft/models.js +4 -1
  94. package/yeaft/snapshot-filter.js +1 -0
  95. package/yeaft/sub-agent/output-log.js +4 -0
  96. package/yeaft/sub-agent/prompt-queue.js +3 -0
  97. package/yeaft/sub-agent/runner.js +13 -2
  98. package/yeaft/tools/agent.js +3 -0
  99. package/yeaft/tools/send-message.js +2 -0
  100. package/yeaft/web-bridge.js +24 -17
@@ -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,
@@ -107,7 +107,7 @@ import { loadSessionConfig, normalizeSessionConfig, resolveSessionConfig, Sessio
107
107
  import { updateSessionConfig } from './sessions/session-crud.js';
108
108
  import { createCoordinator } from './sessions/coordinator.js';
109
109
  import { seedDefaultSession } from './sessions/seed-default.js';
110
- import { trimHistoryCacheForRuntime, trimSnapshotForBudget } from './history-window.js';
110
+ import { trimHistoryCacheForRuntime } from './history-window.js';
111
111
  import { persistYeaftAttachments, attachmentsForPersistence, persistedAttachmentPreviewPayload } from './attachments.js';
112
112
  import { normalizeSessionMessageQuote, sessionMessageQuotePrompt } from './session-message-quote.js';
113
113
  import { ConversationStore, parseSeqFromId, projectVisibleSessionMessages } from './conversation/persist.js';
@@ -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
 
@@ -2264,6 +2264,7 @@ async function routeEnvelopeToVpThread(sessionId, vpId, envelope) {
2264
2264
  related = false;
2265
2265
  } else {
2266
2266
  persistInboundMessageOnceByMsgId({
2267
+ envelope,
2267
2268
  msgId: envelope?.msg?.id,
2268
2269
  text,
2269
2270
  sessionId,
@@ -2364,6 +2365,7 @@ function ensureDriverRunning(sessionId, vpId, threadId = 'main') {
2364
2365
  const isInternal = injectedBy === 'route_forward' || injectedBy === 'task_result';
2365
2366
  const senderVpId = isInternal ? (meta.senderVpId || envelope?.msg?.from || null) : null;
2366
2367
  persistInboundMessageOnceByMsgId({
2368
+ envelope,
2367
2369
  msgId: envMsgId,
2368
2370
  text,
2369
2371
  sessionId,
@@ -5672,14 +5674,10 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
5672
5674
  markEngineTerminal,
5673
5675
  markTurnEnd,
5674
5676
  };
5675
- // Always trim the snapshot before passing to engine.query. This is a
5676
- // deterministic provider-request window; it never calls an LLM or
5677
- // changes the persisted transcript. See `trimSnapshotForBudget`.
5677
+ // The runtime cache is already bounded. Preserve its candidates here:
5678
+ // Engine allocates recent + related turns together at each provider boundary.
5678
5679
  const trimStart = perfNowMs();
5679
- const trimmedMessages = trimSnapshotForBudget(baseSnapshot, {
5680
- messageTokenBudget: session?.config?.messageTokenBudget,
5681
- language: session?.config?.language,
5682
- });
5680
+ const trimmedMessages = baseSnapshot;
5683
5681
  if (perfTraceId) {
5684
5682
  recordAgentPerfTrace(ctx.CONFIG, {
5685
5683
  traceId: perfTraceId,
@@ -5707,6 +5705,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
5707
5705
  // each write a copy of the user message, and history replay
5708
5706
  // would render the user's prompt N times.
5709
5707
  userAlreadyPersisted: true,
5708
+ currentUserMessage: inboundEnvelope?._persistedUserMessage || null,
5710
5709
  askUser: ({ question, options }, toolCall = null) => new Promise((resolve, reject) => {
5711
5710
  const requestId = `ask_${randomUUID()}`;
5712
5711
  const signal = vpAbort.signal;
@@ -6102,7 +6101,7 @@ function appendTurnToSessionHistory(sessionId, threadId, vpId, prompts, assistan
6102
6101
  * @returns {boolean} true if this call wrote the row, false if a prior
6103
6102
  * call already wrote it (dedup hit).
6104
6103
  */
6105
- function persistInboundMessageOnceByMsgId({ msgId, text, sessionId, threadId = 'main', role, speakerVpId, attachments, quote, internal = false, ts = null, clientMessageId = null }) {
6104
+ function persistInboundMessageOnceByMsgId({ envelope = null, msgId, text, sessionId, threadId = 'main', role, speakerVpId, attachments, quote, internal = false, ts = null, clientMessageId = null }) {
6106
6105
  if (!session?.conversationStore) return false;
6107
6106
  // No msgId means no dedup key — caller is responsible for guarding.
6108
6107
  // Both call sites already do (`if (envMsgId && text)` and
@@ -6111,8 +6110,11 @@ function persistInboundMessageOnceByMsgId({ msgId, text, sessionId, threadId = '
6111
6110
  // every call would mint a unique id and write a duplicate row, which
6112
6111
  // is the exact bug this helper exists to prevent.
6113
6112
  if (!msgId || typeof msgId !== 'string') return false;
6114
- const dedupKey = `${msgId}::${threadId || 'main'}`;
6115
- if (_persistedUserMsgIds.has(dedupKey)) return false;
6113
+ const dedupKey = `${sessionId || ''}::${msgId}::${threadId || 'main'}`;
6114
+ if (_persistedUserMsgIds.has(dedupKey)) {
6115
+ if (envelope) envelope._persistedUserMessage = _persistedInboundRows.get(dedupKey) || null;
6116
+ return false;
6117
+ }
6116
6118
  // Mark BEFORE the empty-text bail. If a later same-id call arrives
6117
6119
  // with non-empty text (e.g. a route_forward injection that the first
6118
6120
  // caller passed in with empty text), the Set must already remember
@@ -6131,6 +6133,7 @@ function persistInboundMessageOnceByMsgId({ msgId, text, sessionId, threadId = '
6131
6133
  const v = iter.next();
6132
6134
  if (v.done) break;
6133
6135
  _persistedUserMsgIds.delete(v.value);
6136
+ _persistedInboundRows.delete(v.value);
6134
6137
  }
6135
6138
  }
6136
6139
  try {
@@ -6170,7 +6173,9 @@ function persistInboundMessageOnceByMsgId({ msgId, text, sessionId, threadId = '
6170
6173
  if (ts && typeof ts === 'string') {
6171
6174
  record.time = ts;
6172
6175
  }
6173
- session.conversationStore.append(record);
6176
+ const persisted = session.conversationStore.append(record);
6177
+ _persistedInboundRows.set(dedupKey, persisted);
6178
+ if (envelope) envelope._persistedUserMessage = persisted;
6174
6179
  return true;
6175
6180
  } catch (err) {
6176
6181
  console.warn(
@@ -6186,6 +6191,7 @@ function persistInboundMessageOnceByMsgId({ msgId, text, sessionId, threadId = '
6186
6191
  * starts with no stale msg-ids.
6187
6192
  */
6188
6193
  const _persistedUserMsgIds = new Set();
6194
+ const _persistedInboundRows = new Map();
6189
6195
 
6190
6196
  /**
6191
6197
  * Abort every in-flight VP turn and clear all queued envelopes across
@@ -7734,6 +7740,7 @@ export async function resetYeaftSession() {
7734
7740
  // History-dedup cache is keyed by per-session coordinator msg ids;
7735
7741
  // a fresh session resets the id space, so clear the cache too.
7736
7742
  _persistedUserMsgIds.clear();
7743
+ _persistedInboundRows.clear();
7737
7744
  // vp-status: nuke the broker table too. Drivers above have just
7738
7745
  // been aborted, so any in-flight `settleIdle` from their outer
7739
7746
  // `finally` blocks is racing this reset. Clearing here makes the