@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.
- package/conversation.js +4 -2
- package/local-runtime/server/handlers/agent-conversation.js +11 -9
- package/local-runtime/server/handlers/client-conversation.js +28 -2
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +68 -68
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +1 -1
- package/package.json +1 -1
- package/yeaft/cli.js +3 -0
- package/yeaft/conversation/persist.js +15 -9
- package/yeaft/conversation/search.js +2 -1
- package/yeaft/effort.js +159 -6
- package/yeaft/engine.js +94 -28
- package/yeaft/history-window.js +60 -5
- package/yeaft/llm/adapter.js +3 -2
- package/yeaft/llm/anthropic.js +97 -25
- package/yeaft/llm/openai-responses.js +71 -7
- package/yeaft/llm/provider-state.js +242 -0
- package/yeaft/llm/router.js +14 -2
- package/yeaft/llm/usage-accounting.js +3 -0
- package/yeaft/models.js +4 -1
- package/yeaft/snapshot-filter.js +1 -0
- package/yeaft/sub-agent/output-log.js +4 -0
- package/yeaft/sub-agent/prompt-queue.js +3 -0
- package/yeaft/sub-agent/runner.js +13 -2
- package/yeaft/tools/agent.js +3 -0
- package/yeaft/tools/send-message.js +2 -0
- package/yeaft/web-bridge.js +5 -5
|
@@ -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
|
+
}
|
package/yeaft/llm/router.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/yeaft/snapshot-filter.js
CHANGED
|
@@ -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: '
|
|
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.
|
package/yeaft/tools/agent.js
CHANGED
|
@@ -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,
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -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 (
|
|
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
|
|