@yeaft/webchat-agent 1.0.608 → 1.0.611
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/local-runtime/server/handlers/client-person.js +6 -1
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +171 -114
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/llm/adapter.js +1 -1
- package/yeaft/llm/anthropic.js +8 -4
- package/yeaft/llm/openai-responses.js +4 -2
- package/yeaft/llm/provider-state.js +13 -0
- package/yeaft/person/contracts.js +4 -1
- package/yeaft/person/inspection.js +1 -1
- package/yeaft/person/provider.js +3 -3
- package/yeaft/person/runtime.js +28 -7
- package/yeaft/person/service.js +5 -3
- package/yeaft/person/sqlite-repository.js +2 -0
- package/yeaft/person/sqlite-schema.js +2 -0
- package/yeaft/person/sqlite-store.js +76 -5
- package/yeaft/person/turn-diagnostics.js +89 -0
|
Binary file
|
package/package.json
CHANGED
package/yeaft/llm/adapter.js
CHANGED
|
@@ -46,7 +46,7 @@ import { utf8PrefixWithinBytes } from '../utf8.js';
|
|
|
46
46
|
* @typedef {{ type: 'tool_call', id: string, name: string, input: object }} ToolCallEvent
|
|
47
47
|
* @typedef {{ type: 'provider_activity' }} ProviderActivityEvent Content-free progress on a non-empty hidden delta; watchdog only, never UI/history.
|
|
48
48
|
* @typedef {{ type: 'provider_state', providerState: object, providerStateBytes: number }} ProviderStateEvent Internal only; never forward to UI/search.
|
|
49
|
-
* @typedef {{ type: 'usage', inputTokens: number, outputTokens: number, reasoningTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, cacheTokensAreIncludedInInput?: boolean }} UsageEvent
|
|
49
|
+
* @typedef {{ type: 'usage', inputTokens: number, outputTokens: number, reasoningTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, cacheTokensAreIncludedInInput?: boolean, reportedTokenFields?: string[], usageIncomplete?: boolean }} UsageEvent
|
|
50
50
|
* @typedef {{ type: 'stop', stopReason: 'end_turn' | 'tool_use' | 'max_tokens' }} StopEvent
|
|
51
51
|
* @typedef {{ type: 'error', error: Error, retryable: boolean }} ErrorEvent
|
|
52
52
|
*
|
package/yeaft/llm/anthropic.js
CHANGED
|
@@ -64,7 +64,7 @@ export function applyAnthropicThinking(body, model, effort, effortContext = {})
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
import { ProviderStateError, createProviderContext, createProviderState, replayProviderState, providerStateBytes, applyAnthropicCaching, reasoningUsage } from './provider-state.js';
|
|
67
|
+
import { ProviderStateError, createProviderContext, createProviderState, replayProviderState, providerStateBytes, applyAnthropicCaching, reasoningUsage, reportedUsageFields, hasUsageCounts } from './provider-state.js';
|
|
68
68
|
|
|
69
69
|
const DEFAULT_BASE_URL = 'https://api.anthropic.com';
|
|
70
70
|
const API_VERSION = '2023-06-01';
|
|
@@ -330,7 +330,7 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
330
330
|
const result = await response.json();
|
|
331
331
|
// Account for consumed tokens even when the completed response cannot
|
|
332
332
|
// safely publish tools. Budget consumers may abort on this usage event.
|
|
333
|
-
yield { type: 'usage', inputTokens: result.usage?.input_tokens || 0, outputTokens: result.usage?.output_tokens || 0,
|
|
333
|
+
yield { type: 'usage', reportedTokenFields: reportedUsageFields(result.usage, 'anthropic'), usageIncomplete: !hasUsageCounts(result.usage), inputTokens: result.usage?.input_tokens || 0, outputTokens: result.usage?.output_tokens || 0,
|
|
334
334
|
cacheReadTokens: result.usage?.cache_read_input_tokens || 0, cacheWriteTokens: result.usage?.cache_creation_input_tokens || 0,
|
|
335
335
|
...reasoningUsage(result.usage, 'anthropic') };
|
|
336
336
|
if (signal?.aborted) throw new LLMAbortError();
|
|
@@ -557,11 +557,13 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
557
557
|
}
|
|
558
558
|
yield {
|
|
559
559
|
type: 'usage',
|
|
560
|
+
reportedTokenFields: reportedUsageFields(event.usage, 'anthropic'),
|
|
561
|
+
usageIncomplete: !hasUsageCounts(event.usage, ['output_tokens']),
|
|
560
562
|
...reasoning,
|
|
561
563
|
inputTokens: 0, // Only in message_start
|
|
562
564
|
outputTokens,
|
|
563
565
|
};
|
|
564
|
-
}
|
|
566
|
+
} else yield { type: 'usage', reportedTokenFields: [], usageIncomplete: true };
|
|
565
567
|
if (signal?.aborted) throw new LLMAbortError();
|
|
566
568
|
const stopReason = event.delta?.stop_reason;
|
|
567
569
|
if (stopReason === 'max_tokens' && hasToolBlocks()) {
|
|
@@ -598,13 +600,15 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
598
600
|
cumulativeReasoningTokens = reasoning.reasoningTokens || 0;
|
|
599
601
|
yield {
|
|
600
602
|
type: 'usage',
|
|
603
|
+
reportedTokenFields: reportedUsageFields(event.message.usage, 'anthropic'),
|
|
604
|
+
usageIncomplete: !hasUsageCounts(event.message.usage),
|
|
601
605
|
...reasoning,
|
|
602
606
|
inputTokens: event.message.usage.input_tokens || 0,
|
|
603
607
|
outputTokens: cumulativeOutputTokens,
|
|
604
608
|
cacheReadTokens: event.message.usage.cache_read_input_tokens || 0,
|
|
605
609
|
cacheWriteTokens: event.message.usage.cache_creation_input_tokens || 0,
|
|
606
610
|
};
|
|
607
|
-
}
|
|
611
|
+
} else yield { type: 'usage', reportedTokenFields: [], usageIncomplete: true };
|
|
608
612
|
} else if (type === 'error') {
|
|
609
613
|
stateFailed = true;
|
|
610
614
|
yield {
|
|
@@ -52,7 +52,7 @@ import {
|
|
|
52
52
|
mapEffortToOpenAIReasoning,
|
|
53
53
|
} from '../models.js';
|
|
54
54
|
|
|
55
|
-
import { createProviderContext, createProviderState, replayProviderState, providerStateBytes, applyResponsesContinuity, reasoningUsage } from './provider-state.js';
|
|
55
|
+
import { createProviderContext, createProviderState, replayProviderState, providerStateBytes, applyResponsesContinuity, reasoningUsage, reportedUsageFields, hasUsageCounts } from './provider-state.js';
|
|
56
56
|
|
|
57
57
|
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
|
|
58
58
|
|
|
@@ -369,7 +369,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
369
369
|
if (state) yield { type: 'provider_state', providerState: state, providerStateBytes: providerStateBytes(state) };
|
|
370
370
|
}
|
|
371
371
|
const usage = result.usage || {};
|
|
372
|
-
yield { type: 'usage', inputTokens: usage.input_tokens || 0, outputTokens: usage.output_tokens || 0,
|
|
372
|
+
yield { type: 'usage', reportedTokenFields: reportedUsageFields(result.usage, 'openai-responses'), usageIncomplete: !hasUsageCounts(result.usage), inputTokens: usage.input_tokens || 0, outputTokens: usage.output_tokens || 0,
|
|
373
373
|
cacheReadTokens: usage.input_tokens_details?.cached_tokens || 0, cacheWriteTokens: 0,
|
|
374
374
|
cacheTokensAreIncludedInInput: true,
|
|
375
375
|
...reasoningUsage(usage, 'openai-responses') };
|
|
@@ -528,6 +528,8 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
528
528
|
const usage = respObj.usage || {};
|
|
529
529
|
yield {
|
|
530
530
|
type: 'usage',
|
|
531
|
+
reportedTokenFields: reportedUsageFields(respObj.usage, 'openai-responses'),
|
|
532
|
+
usageIncomplete: !hasUsageCounts(respObj.usage),
|
|
531
533
|
inputTokens: usage.input_tokens || 0,
|
|
532
534
|
outputTokens: usage.output_tokens || 0,
|
|
533
535
|
cacheReadTokens: usage.input_tokens_details?.cached_tokens || 0,
|
|
@@ -241,3 +241,16 @@ export function reasoningUsage(usage, protocol) {
|
|
|
241
241
|
const value = protocol === 'anthropic' ? usage?.output_tokens_details?.thinking_tokens : usage?.output_tokens_details?.reasoning_tokens;
|
|
242
242
|
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? { reasoningTokens: value } : {};
|
|
243
243
|
}
|
|
244
|
+
|
|
245
|
+
/** Which counters the provider actually reported, before legacy zero defaults. */
|
|
246
|
+
export function hasUsageCounts(usage, fields = ['input_tokens', 'output_tokens']) {
|
|
247
|
+
return fields.every(key => Number.isSafeInteger(usage?.[key]) && usage[key] >= 0);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function reportedUsageFields(usage, protocol) {
|
|
251
|
+
const counts = { inputTokens: usage?.input_tokens, outputTokens: usage?.output_tokens,
|
|
252
|
+
cacheReadTokens: protocol === 'anthropic' ? usage?.cache_read_input_tokens : usage?.input_tokens_details?.cached_tokens,
|
|
253
|
+
cacheWriteTokens: protocol === 'anthropic' ? usage?.cache_creation_input_tokens : undefined,
|
|
254
|
+
reasoningTokens: protocol === 'anthropic' ? usage?.output_tokens_details?.thinking_tokens : usage?.output_tokens_details?.reasoning_tokens };
|
|
255
|
+
return Object.keys(counts).filter(key => Number.isSafeInteger(counts[key]) && counts[key] >= 0);
|
|
256
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
// 只允许应用层显式产物;不要求或保存 provider 的隐藏推理。
|
|
4
|
-
export const LIMITS = Object.freeze({ inputBytes: 8192, outputBytes: 65536, contextBytes: 65536, calls: 16,
|
|
4
|
+
export const LIMITS = Object.freeze({ inputBytes: 8192, outputBytes: 65536, contextBytes: 65536, calls: 16, leaseMs: 15000 });
|
|
5
|
+
// Feedback cadence is not an execution deadline. Slow providers/tools remain cancellable.
|
|
6
|
+
export const FEEDBACK = Object.freeze({ minMs: 30000, maxMs: 60000 });
|
|
7
|
+
export const FEEDBACK_INSTRUCTIONS = "feedback describes user-visible communication, not a deadline. After about 30 seconds without a substantive reply, use reply for a concise, useful progress update at a natural execution boundary; aim for 30\u201360 seconds according to actual progress. Explain verified findings, limitations and what you are checking next, in the user's language. Do not repeat feedback.lastReply, invent progress, expose internal deliberation/decision/selfCheck, or claim an unexecuted tool succeeded. If no meaningful update exists, reply:null is appropriate; the host shows an honest waiting status around 60 seconds without interrupting the current request. With next non-null, a validated reply may be published as progress independently of cognitive state, and retained on cancellation/failure. Only next:null commits cognition and supplies the final reply. Do not call a tool or an extra model just to report progress. Each final reply should stand on its own and note unfinished work.";
|
|
5
8
|
const ERRORS = {
|
|
6
9
|
INVALID_REQUEST: 'Invalid digital person request.', NOT_CONFIGURED: 'Digital person storage configuration is missing.',
|
|
7
10
|
STORAGE_UNAVAILABLE: 'Digital person storage is unavailable. Check instance storage and database configuration.',
|
|
@@ -20,7 +20,7 @@ export function personName(value) {
|
|
|
20
20
|
const pick = (value, keys) => value == null ? null : Object.fromEntries(keys.filter(key => Object.hasOwn(value, key)).map(key => [key, value[key]]));
|
|
21
21
|
export const settingsView = value => pick(value, ['autonomyEnabled', 'modelCandidates', 'defaultModel']);
|
|
22
22
|
export function messageView(value) {
|
|
23
|
-
const result = pick(value, ['id', 'schemaVersion', 'revision', 'seq', 'episodeId', 'clientMessageId', 'role', 'text', 'createdAt']);
|
|
23
|
+
const result = pick(value, ['id', 'schemaVersion', 'revision', 'seq', 'episodeId', 'clientMessageId', 'callId', 'replyKind', 'role', 'text', 'createdAt']);
|
|
24
24
|
if (result && Array.isArray(value.attachments)) result.attachments = value.attachments.slice(0, 4)
|
|
25
25
|
.map(file => pick(file, ['id', 'name', 'mimeType', 'size', 'sha256', 'kind']));
|
|
26
26
|
return result;
|
package/yeaft/person/provider.js
CHANGED
|
@@ -6,6 +6,7 @@ import { normalizeKnownProviderForRuntime } from '../llm/known-providers.js';
|
|
|
6
6
|
import { normalizeEffort, resolveContextWindow, resolveMaxOutputTokens } from '../models.js';
|
|
7
7
|
import { utf8PrefixWithinBytes } from '../utf8.js';
|
|
8
8
|
import { bytes, digest, fail, LIMITS, PersonError } from './contracts.js';
|
|
9
|
+
import { addUsage } from './turn-diagnostics.js';
|
|
9
10
|
|
|
10
11
|
export const MODEL_LIMITS = Object.freeze({ candidates: 8, available: 100 });
|
|
11
12
|
export function validateModelCandidates(refs) {
|
|
@@ -92,7 +93,7 @@ export async function createPersonProvider({ yeaftDir, config: suppliedConfig, a
|
|
|
92
93
|
const explicitImages = m.supportsImages ?? rawModel?.supportsImages ?? rawProvider?.supportsImages;
|
|
93
94
|
const protocol = routedModel?.protocol || routedProvider?.protocol || inferProtocolFromModelId(m.id) || 'openai-responses';
|
|
94
95
|
const imageBudget = !routedProvider || explicitImages === false ? null : imageInputBudget(m.id, protocol);
|
|
95
|
-
return { id: m.ref || m.id, efforts, maxOutput, supportsImages: imageBudget !== null, imageBudget,
|
|
96
|
+
return { id: m.ref || m.id, efforts, maxOutput, usageCacheIncluded: protocol === 'anthropic' ? false : undefined, supportsImages: imageBudget !== null, imageBudget,
|
|
96
97
|
contextWindow: Math.floor(resolveContextWindow(m.id, { ...config, modelInfo: m })) };
|
|
97
98
|
}).filter(m => typeof m.id === 'string' && m.id.length <= 256 && m.contextWindow > m.maxOutput + 1024 && m.maxOutput >= 256);
|
|
98
99
|
safeModels.sort((a, b) => Number(b.id === agentDefaultModel) - Number(a.id === agentDefaultModel));
|
|
@@ -147,8 +148,7 @@ export async function collectOutput(adapter, params, onEffort) {
|
|
|
147
148
|
else if (event.type === 'error') fail('PROVIDER_FAILED');
|
|
148
149
|
else if (event.type === 'tool_call') fail('INVALID_PROPOSAL');
|
|
149
150
|
else if (event.type === 'usage') {
|
|
150
|
-
usage =
|
|
151
|
-
for (const key of ['inputTokens', 'outputTokens', 'reasoningTokens', 'cacheReadTokens', 'cacheWriteTokens']) if (Number.isFinite(event[key]) && event[key] >= 0) usage[key] = event[key];
|
|
151
|
+
usage = addUsage(usage, event, params.usageCacheIncluded);
|
|
152
152
|
}
|
|
153
153
|
// Hidden thinking, signatures, opaque provider state and raw HTTP exchanges are deliberately not archived.
|
|
154
154
|
}
|
package/yeaft/person/runtime.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { createPersonToolHost, createPersonNativeRegistry, isNativeTool, projectNativeResult } from './native-tools.js';
|
|
2
2
|
import { PersonTaskHost } from './task-host.js';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { utf8PrefixWithinBytes } from '../utf8.js';
|
|
4
5
|
import { CAPABILITY_MAP, CAPABILITY_LIMITS, foundationCapabilities, PersonCapabilities, catalogRevision as capabilityCatalogRevision } from './capabilities.js';
|
|
5
6
|
import { attachmentMetadata } from './attachments.js';
|
|
6
7
|
import { abortable, collectOutput } from './provider.js';
|
|
7
|
-
import { bytes, digest, fail, LIMITS, PersonError, PROPOSAL_INSTRUCTIONS, reportedLineage, safeError, validateProposal, validateSelection } from './contracts.js';
|
|
8
|
+
import { bytes, digest, fail, FEEDBACK, FEEDBACK_INSTRUCTIONS, LIMITS, PersonError, PROPOSAL_INSTRUCTIONS, reportedLineage, safeError, validateProposal, validateSelection } from './contracts.js';
|
|
8
9
|
|
|
9
10
|
const messageRef = m => `message:${m.id}:${m.revision}`;
|
|
10
11
|
const conceptRef = c => `concept:${c.id}:${c.revision}`;
|
|
11
12
|
|
|
12
13
|
/** Assemble bounded request copies. Omitting a record never deletes or truncates its durable original. */
|
|
13
|
-
export function assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, remainingCalls, taskEvidence, dependencyRefs = [], activeCapabilities = foundationCapabilities(), capabilityMap = CAPABILITY_MAP, attachments = [], environment }) {
|
|
14
|
+
export function assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, remainingCalls, taskEvidence, dependencyRefs = [], activeCapabilities = foundationCapabilities(), capabilityMap = CAPABILITY_MAP, attachments = [], environment, feedback }) {
|
|
14
15
|
const model = validateSelection(selection, provider.catalog);
|
|
15
16
|
// UTF-8 bytes is a conservative text-token bound; reserve explicit envelope/output overhead.
|
|
16
17
|
const images = attachments.filter(file => file.kind === 'image');
|
|
@@ -74,6 +75,16 @@ export function assembleContext({ snapshot, episode, provider, selection, previo
|
|
|
74
75
|
omittedCapabilities.push({ id: contract.id, reason: 'context-budget', inspect: 'catalog.view' });
|
|
75
76
|
}
|
|
76
77
|
}
|
|
78
|
+
// Feedback is a request-only hint, never grounds to fail the next call.
|
|
79
|
+
// Keep durable replies intact; avoid duplicating up to 8 KiB from previousProposal.
|
|
80
|
+
if (feedback) {
|
|
81
|
+
const preview = utf8PrefixWithinBytes(feedback.lastReply ?? '', 512).text;
|
|
82
|
+
context.feedback = { ...feedback, instructions: FEEDBACK_INSTRUCTIONS, lastReply: preview || null,
|
|
83
|
+
lastReplyTruncated: bytes(feedback.lastReply ?? '') > bytes(preview) };
|
|
84
|
+
if (!fits()) { context.feedback.lastReply = null; context.feedback.lastReplyTruncated = Boolean(feedback.lastReply); }
|
|
85
|
+
if (!fits()) delete context.feedback.instructions;
|
|
86
|
+
if (!fits()) delete context.feedback;
|
|
87
|
+
}
|
|
77
88
|
const add = (field, item, ref) => {
|
|
78
89
|
if (renderedRefs.has(ref)) return;
|
|
79
90
|
const inherited = sourceRefs.has(ref);
|
|
@@ -189,8 +200,8 @@ export class PersonRuntime {
|
|
|
189
200
|
isOwnerRunning(ownerId) { return this.pendingCancellations.has(ownerId) || [...this.running.values()].some(job => job.episode.ownerId === ownerId); }
|
|
190
201
|
async run(episode, controller) {
|
|
191
202
|
const { signal } = controller;
|
|
192
|
-
|
|
193
|
-
|
|
203
|
+
let lastReplyAt = new Date(episode.createdAt).getTime() || Date.now();
|
|
204
|
+
let lastReply = null;
|
|
194
205
|
let heartbeatRunning = false;
|
|
195
206
|
const heartbeat = setInterval(async () => {
|
|
196
207
|
if (heartbeatRunning || signal.aborted) return;
|
|
@@ -198,7 +209,7 @@ export class PersonRuntime {
|
|
|
198
209
|
try { await this.repository.heartbeat(episode); }
|
|
199
210
|
catch (error) { controller.abort(safeError(error)); }
|
|
200
211
|
finally { heartbeatRunning = false; }
|
|
201
|
-
}, Math.max(100, Math.floor(this.repository.leaseMs / 3)));
|
|
212
|
+
}, Math.max(100, Math.min(5000, Math.floor(this.repository.leaseMs / 3))));
|
|
202
213
|
heartbeat.unref?.();
|
|
203
214
|
try {
|
|
204
215
|
const provider = await abortable(this.getProvider(episode.modelCandidates ?? [], episode.defaultModel ?? null), signal);
|
|
@@ -232,7 +243,11 @@ export class PersonRuntime {
|
|
|
232
243
|
for (let index = 0; index < episode.budget.calls; index++) {
|
|
233
244
|
signal.throwIfAborted();
|
|
234
245
|
const callId = randomUUID();
|
|
235
|
-
const context = assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, dependencyRefs, remainingCalls: episode.budget.calls - index, taskEvidence: projectTaskEvidence(this.tasks()?.snapshot(episode.ownerId)), attachments, activeCapabilities: capabilities.context(), capabilityMap: capabilities.catalog(), environment: toolHost.environment
|
|
246
|
+
const context = assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, dependencyRefs, remainingCalls: episode.budget.calls - index, taskEvidence: projectTaskEvidence(this.tasks()?.snapshot(episode.ownerId)), attachments, activeCapabilities: capabilities.context(), capabilityMap: capabilities.catalog(), environment: toolHost.environment, feedback: {
|
|
247
|
+
minIntervalMs: FEEDBACK.minMs, maxIntervalMs: FEEDBACK.maxMs,
|
|
248
|
+
elapsedSinceReplyMs: Math.max(0, Date.now() - lastReplyAt),
|
|
249
|
+
due: Date.now() - lastReplyAt >= FEEDBACK.minMs, lastReply,
|
|
250
|
+
} });
|
|
236
251
|
capabilities.activate(context.activeCapabilities);
|
|
237
252
|
dependencyRefs = context.manifest.inputDependencyRefs;
|
|
238
253
|
for (const [id, concept] of context.concepts) readConcepts.set(id, concept);
|
|
@@ -259,6 +274,7 @@ export class PersonRuntime {
|
|
|
259
274
|
}) };
|
|
260
275
|
output = await collectOutput(observingAdapter, {
|
|
261
276
|
model: selection.model, effort: selection.effort ?? undefined, effortSource: 'auto',
|
|
277
|
+
usageCacheIncluded: provider.catalog.find(model => model.id === selection.model)?.usageCacheIncluded,
|
|
262
278
|
system: context.system, messages: context.messages, maxTokens: context.maxTokens, signal,
|
|
263
279
|
}, decision => {
|
|
264
280
|
effective.effort = decision.effective; effective.effortObserved = true;
|
|
@@ -288,6 +304,11 @@ export class PersonRuntime {
|
|
|
288
304
|
await this.repository.commit(episode, proposal, selection, callId, new Map(proposal.concepts.map(c => [c.id, c.epistemicState === 'reported' ? reportedLineage(c, readSources) : []])));
|
|
289
305
|
return;
|
|
290
306
|
}
|
|
307
|
+
if (proposal.reply) {
|
|
308
|
+
signal.throwIfAborted();
|
|
309
|
+
const published = await this.repository.publishProgress(episode, { callId, reply: proposal.reply });
|
|
310
|
+
if (published) { lastReply = published.text; lastReplyAt = new Date(published.createdAt).getTime(); }
|
|
311
|
+
}
|
|
291
312
|
if (index + 1 >= episode.budget.calls) {
|
|
292
313
|
await this.tasks()?.cancel({ ownerId: episode.ownerId, episodeId: episode.id, reason: 'CALL_BUDGET' });
|
|
293
314
|
await this.repository.finish(episode, 'budget_exhausted', 'CALL_BUDGET');
|
|
@@ -344,7 +365,7 @@ export class PersonRuntime {
|
|
|
344
365
|
// On authority failure no alternate memory store is used. Expired leases become durable interrupted records on next access.
|
|
345
366
|
await this.repository.finish(episode, status, safe.code).catch(() => {});
|
|
346
367
|
} finally {
|
|
347
|
-
|
|
368
|
+
clearInterval(heartbeat);
|
|
348
369
|
if (!signal.aborted) controller.abort(new PersonError('INTERRUPTED'));
|
|
349
370
|
}
|
|
350
371
|
}
|
package/yeaft/person/service.js
CHANGED
|
@@ -7,6 +7,7 @@ import { loadConfig } from '../config.js';
|
|
|
7
7
|
import { validateFiles } from './attachments.js';
|
|
8
8
|
import { createPersonProvider, resolveAgentDefaultModel, selectPersonModels, validateDefaultModel, validateModelCandidates } from './provider.js';
|
|
9
9
|
import { fail, identifier, LIMITS, object, page, personTaskRequest, safeError, text } from './contracts.js';
|
|
10
|
+
import { turnsPage } from './turn-diagnostics.js';
|
|
10
11
|
import { inspectRequest, personName, searchRequest } from './inspection.js';
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -21,9 +22,9 @@ export function createPersonService(options = {}) {
|
|
|
21
22
|
identifier(namespace);
|
|
22
23
|
const configured = typeof yeaftDir === 'string' && Boolean(yeaftDir.trim());
|
|
23
24
|
const calls = options.maxCalls ?? LIMITS.calls;
|
|
24
|
-
|
|
25
|
+
// Legacy timeoutMs is intentionally ignored: an episode has no wall-clock deadline.
|
|
25
26
|
const leaseMs = options.leaseMs ?? LIMITS.leaseMs;
|
|
26
|
-
if (!Number.isInteger(calls) || calls < 1 || calls > 32 ||
|
|
27
|
+
if (!Number.isInteger(calls) || calls < 1 || calls > 32 ||
|
|
27
28
|
!Number.isInteger(leaseMs) || leaseMs < 300 || leaseMs > 60000) fail('INVALID_REQUEST');
|
|
28
29
|
if (allowedModels != null && (!Array.isArray(allowedModels) || !allowedModels.length || allowedModels.some(m => typeof m !== 'string'))) fail('INVALID_REQUEST');
|
|
29
30
|
const repository = configured ? new SqlitePersonRepository({ yeaftDir, namespace, leaseMs }) : null;
|
|
@@ -42,7 +43,7 @@ export function createPersonService(options = {}) {
|
|
|
42
43
|
? memory.recall(ownerId, args, { signal }) : literalRecall(ownerId, args);
|
|
43
44
|
// Config/adapter are loaded per explicit episode, not a permanent stale cache.
|
|
44
45
|
const getProvider = (modelCandidates = [], defaultModel = null) => createPersonProvider({ yeaftDir, config, adapter, allowedModels, modelCandidates, defaultModel, effortEnabled: options.effortEnabled });
|
|
45
|
-
const runtime = new PersonRuntime({ repository, getProvider, budget: { calls
|
|
46
|
+
const runtime = new PersonRuntime({ repository, getProvider, budget: { calls }, workDir, yeaftDir, config, namespace });
|
|
46
47
|
let closed = false;
|
|
47
48
|
const requests = new Set();
|
|
48
49
|
async function handle({ ownerId, op, payload = {} } = {}) {
|
|
@@ -125,6 +126,7 @@ export function createPersonService(options = {}) {
|
|
|
125
126
|
}
|
|
126
127
|
case 'traces': await repository.recover(ownerId); return repository.list(ownerId, 'traces', page(payload));
|
|
127
128
|
case 'messages': return repository.list(ownerId, 'messages', page(payload));
|
|
129
|
+
case 'turns': return repository.turns(ownerId, turnsPage(payload));
|
|
128
130
|
// These reads deliberately bypass recover(), Recall and the runtime/provider.
|
|
129
131
|
case 'inspect': {
|
|
130
132
|
const request = inspectRequest(payload);
|
|
@@ -100,11 +100,13 @@ export class SqlitePersonRepository {
|
|
|
100
100
|
async episodeAttachments(episode) { return this.request('episodeAttachments', [episode]); }
|
|
101
101
|
async context(episode) { return this.request('context', [episode]); }
|
|
102
102
|
async recall(ownerId, options) { return this.request('recall', [ownerId, options]); }
|
|
103
|
+
async publishProgress(episode, data) { return this.request('publishProgress', [episode, data]); }
|
|
103
104
|
async commit(episode, proposal, selection, callId, reportedSources = new Map()) { return this.request('commit', [episode, proposal, selection, callId, reportedSources]); }
|
|
104
105
|
async finish(episode, status, code) { return this.request('finish', [episode, status, code]); }
|
|
105
106
|
async cancel(ownerId, episodeId) { return this.request('cancel', [ownerId, episodeId]); }
|
|
106
107
|
async settings(ownerId, settings, expectedControlVersion) { return this.request('settings', [ownerId, settings, expectedControlVersion]); }
|
|
107
108
|
async list(ownerId, collection, options, filter) { return this.request('list', [ownerId, collection, options, filter]); }
|
|
109
|
+
async turns(ownerId, options) { return this.request('turns', [ownerId, options]); }
|
|
108
110
|
async snapshot(ownerId) { return this.request('snapshot', [ownerId]); }
|
|
109
111
|
async inspect(ownerId, options, nativeToolIds) { return this.request('inspect', [ownerId, options, nativeToolIds]); }
|
|
110
112
|
async search(ownerId, options) { return this.request('search', [ownerId, options]); }
|
|
@@ -75,6 +75,8 @@ CREATE TABLE IF NOT EXISTS traces (
|
|
|
75
75
|
PRIMARY KEY(namespace, ownerId, personId, id), UNIQUE(namespace, ownerId, personId, seq),
|
|
76
76
|
FOREIGN KEY(namespace, ownerId, personId) REFERENCES persons(namespace, ownerId, personId)
|
|
77
77
|
) STRICT;
|
|
78
|
+
CREATE INDEX IF NOT EXISTS traces_turn_metadata ON traces(namespace, ownerId, personId,
|
|
79
|
+
json_extract(record, '$.episodeId'), seq);
|
|
78
80
|
CREATE INDEX IF NOT EXISTS traces_call_output ON traces(namespace, ownerId, personId,
|
|
79
81
|
json_extract(record, '$.episodeId'), json_extract(record, '$.callId'))
|
|
80
82
|
WHERE json_extract(record, '$.kind') = 'call_output';
|
|
@@ -3,12 +3,13 @@ import { mkdirSync } from 'node:fs';
|
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { isMainThread } from 'node:worker_threads';
|
|
6
|
-
import { admissionReceipt, bytes, digest, fail, identifier, LIMITS, PersonError, text } from './contracts.js';
|
|
6
|
+
import { admissionReceipt, bytes, digest, fail, FEEDBACK, identifier, LIMITS, PersonError, text } from './contracts.js';
|
|
7
7
|
import { CREATED_CAPABILITY_LIMITS, createdCapabilityRecord, validateCreatedCapability } from './created-capability-contract.js';
|
|
8
8
|
import { SCHEMA, TABLES } from './sqlite-schema.js';
|
|
9
9
|
import { attachmentMetadata, attachmentRequestHash, validateFiles } from './attachments.js';
|
|
10
10
|
import { capabilityExperienceView, recordCapabilityExperience } from './capability-experience.js';
|
|
11
11
|
import { conceptView, inspectionPage, inspectRequest, messageView, personName, searchRequest, settingsView, stateView } from './inspection.js';
|
|
12
|
+
import { TOKEN_FIELDS, turnsPage, turnView } from './turn-diagnostics.js';
|
|
12
13
|
import { inspectCapabilities } from './capabilities.js';
|
|
13
14
|
|
|
14
15
|
const SCOPE = 'namespace = ? AND ownerId = ? AND personId = ?';
|
|
@@ -44,12 +45,15 @@ const publicOutput = (output, failed) => {
|
|
|
44
45
|
if (usage) for (const key of ['inputTokens', 'outputTokens', 'reasoningTokens', 'cacheReadTokens', 'cacheWriteTokens']) {
|
|
45
46
|
if (Number.isFinite(output.usage[key]) && output.usage[key] >= 0) usage[key] = output.usage[key];
|
|
46
47
|
}
|
|
48
|
+
if (usage && output.usage.accountingVersion === 1) usage.accountingVersion = 1;
|
|
49
|
+
if (usage && output.usage.accountingIncomplete === true) usage.accountingIncomplete = true;
|
|
50
|
+
if (usage && typeof output.usage.cacheTokensAreIncludedInInput === 'boolean') usage.cacheTokensAreIncludedInInput = output.usage.cacheTokensAreIncludedInInput;
|
|
47
51
|
const stopReason = typeof output.stopReason === 'string' && bytes(output.stopReason) <= 128 ? output.stopReason : null;
|
|
48
52
|
return failed ? { text: output.text, retainedBytes, observedBytes, complete: false, accepted: false, availability: 'captured', usage, stopReason }
|
|
49
53
|
: { text: output.text, bytes: retainedBytes, complete: true, usage, stopReason };
|
|
50
54
|
};
|
|
51
|
-
const READS = new Set(['receipt', 'inspect', 'search', 'getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities', 'episodeAttachments']);
|
|
52
|
-
const WRITES = new Set(['open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
|
|
55
|
+
const READS = new Set(['receipt', 'turns', 'inspect', 'search', 'getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities', 'episodeAttachments']);
|
|
56
|
+
const WRITES = new Set(['open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', 'publishProgress', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
|
|
53
57
|
const memoryKind = kind => { if (!['messages', 'concepts'].includes(kind)) fail('INVALID_REQUEST'); return kind; };
|
|
54
58
|
const boundedLimit = (limit, max = 100) => { if (!Number.isSafeInteger(limit) || limit < 1 || limit > max) fail('INVALID_REQUEST'); return limit; };
|
|
55
59
|
const sequence = value => {
|
|
@@ -253,6 +257,17 @@ export class SqlitePersonStore {
|
|
|
253
257
|
own(episode) { const p = this.fenced(episode); if (!p) fail('STALE'); return p; }
|
|
254
258
|
heartbeat(episode) {
|
|
255
259
|
const p = this.own(episode); p.leaseUntil = new Date(this.now.getTime() + this.leaseMs); p.writeSerial++; this.put('persons', p);
|
|
260
|
+
const record = this.one('episodes', this.episodeScope(episode), ' AND id = ?', [episode.id]);
|
|
261
|
+
if (!record || record.status !== 'running') fail('STALE');
|
|
262
|
+
const lastAt = new Date(record.feedback?.at ?? record.lastReplyAt ?? record.createdAt).getTime();
|
|
263
|
+
// Heartbeats are at most 5s apart: publish the fallback between 55–60s.
|
|
264
|
+
// This updates one snapshot field, never adds messages or schedules cognition.
|
|
265
|
+
if (this.now.getTime() - lastAt >= FEEDBACK.maxMs - 5000 || record.feedback) {
|
|
266
|
+
const phase = record.openCall ? 'model' : record.openCapability ? 'capability' : 'preparing';
|
|
267
|
+
record.feedback = { at: this.now.getTime() - lastAt >= FEEDBACK.maxMs - 5000 ? this.now.toISOString() : record.feedback.at, phase };
|
|
268
|
+
if (phase === 'capability') record.feedback.capabilityId = record.openCapability.capability?.id;
|
|
269
|
+
this.put('episodes', record);
|
|
270
|
+
}
|
|
256
271
|
}
|
|
257
272
|
append(episode, kind, data) {
|
|
258
273
|
// Call proof/publication events are emitted only by their transactional methods.
|
|
@@ -362,6 +377,32 @@ export class SqlitePersonStore {
|
|
|
362
377
|
state: stateView(state), messages: this.rows('messages', scope, ' ORDER BY seq DESC LIMIT 12').reverse().map(messageView),
|
|
363
378
|
concepts: [...new Map([...focused, ...recent].map(c => [c.id, conceptView(c)])).values()] };
|
|
364
379
|
}
|
|
380
|
+
/** Publish only a validated, complete call's reply. This is communication, not
|
|
381
|
+
* a state/Concept commit. Fencing, dedupe and the memory journal share one transaction. */
|
|
382
|
+
publishProgress(episode, { callId, reply }) {
|
|
383
|
+
const p = this.own(episode), scope = this.episodeScope(episode);
|
|
384
|
+
identifier(callId); text(reply, 8192);
|
|
385
|
+
const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
|
|
386
|
+
if (!record || record.status !== 'running' || record.openCall || record.openCapability) fail('STALE');
|
|
387
|
+
const prior = this.rows('messages', scope, " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.replyKind') = 'progress'", [episode.id]);
|
|
388
|
+
const duplicate = prior.find(m => m.callId === callId);
|
|
389
|
+
if (duplicate) { if (duplicate.text !== reply) fail('INVALID_REQUEST'); return messageView(duplicate); }
|
|
390
|
+
const proof = this.one('traces', scope, " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') = 'call_output' AND json_extract(record, '$.callId') = ? AND json_extract(record, '$.output.complete') = 1", [episode.id, callId]);
|
|
391
|
+
const accepted = this.one('traces', scope, " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') = 'activity' AND json_extract(record, '$.callId') = ? AND json_extract(record, '$.disposition') = 'candidate'", [episode.id, callId]);
|
|
392
|
+
let proposal;
|
|
393
|
+
try { proposal = JSON.parse(proof?.output?.text); } catch { fail('INVALID_REQUEST'); }
|
|
394
|
+
if (!accepted || !proposal.next || proposal.reply !== reply) fail('INVALID_REQUEST');
|
|
395
|
+
// Provider latency counts too: a call started before 30s can finish after it.
|
|
396
|
+
if (this.now.getTime() - new Date(record.lastReplyAt ?? record.createdAt).getTime() < FEEDBACK.minMs ||
|
|
397
|
+
prior.some(m => m.text.trim() === reply.trim())) return null;
|
|
398
|
+
p.messageSeq++; p.writeSerial++; this.put('persons', p);
|
|
399
|
+
const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id,
|
|
400
|
+
callId, replyKind: 'progress', role: 'assistant', text: reply, createdAt: this.now });
|
|
401
|
+
this.put('messages', message, true); this.journal(scope, 'messages', message);
|
|
402
|
+
record.lastReplyAt = this.now.toISOString(); delete record.feedback; this.put('episodes', record);
|
|
403
|
+
this.trace(episode.ownerId, episode.id, 'progress_reply', { callId, messageId: message.id });
|
|
404
|
+
return messageView(message);
|
|
405
|
+
}
|
|
365
406
|
commit(episode, proposal, selection, callId, reportedSources = new Map()) {
|
|
366
407
|
const p = this.own(episode), scope = this.episodeScope(episode);
|
|
367
408
|
if (proposal.baseStateVersion !== episode.baseStateVersion) fail('STALE');
|
|
@@ -387,7 +428,7 @@ export class SqlitePersonStore {
|
|
|
387
428
|
this.put('state_commits', this.doc(scope, { id: randomUUID(), version: p.stateVersion, parentVersion: episode.baseStateVersion,
|
|
388
429
|
episodeId: episode.id, callId, conceptRevisions: revisions, state: stateView(state), createdAt: this.now }), true);
|
|
389
430
|
if (proposal.reply) {
|
|
390
|
-
const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id, role: 'assistant', text: proposal.reply, createdAt: this.now });
|
|
431
|
+
const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id, callId, replyKind: 'final', role: 'assistant', text: proposal.reply, createdAt: this.now });
|
|
391
432
|
this.put('messages', message, true); this.journal(scope, 'messages', message);
|
|
392
433
|
}
|
|
393
434
|
const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
|
|
@@ -445,6 +486,35 @@ export class SqlitePersonStore {
|
|
|
445
486
|
' AND (? IS NULL OR seq < ?) AND instr(text, ?) > 0 ORDER BY seq DESC LIMIT ?', [cursor, cursor, query.toLowerCase(), limit + 1]);
|
|
446
487
|
return inspectionPage(records, limit, 'seq', messageView);
|
|
447
488
|
}
|
|
489
|
+
turns(ownerId, options = {}) {
|
|
490
|
+
const { cursor, limit } = turnsPage(options);
|
|
491
|
+
const scope = this.scope(ownerId);
|
|
492
|
+
if (!this.sql(`SELECT 1 FROM persons WHERE ${SCOPE}`).get(...scopeValues(scope))) fail('NOT_OPEN');
|
|
493
|
+
const project = fields => `json_object(${Object.entries(fields).map(([key, path]) => `'${key}', json_extract(record, '$.${path}')`).join(', ')})`;
|
|
494
|
+
const episodes = this.sql(`SELECT ${project({ id: 'id', seq: 'inputWatermark', kind: 'kind', status: 'status', createdAt: 'createdAt', endedAt: 'endedAt', terminalCode: 'terminalCode', calls: 'budget.calls', timeoutMs: 'budget.timeoutMs' })} AS metadata
|
|
495
|
+
FROM episodes WHERE ${SCOPE} AND (? IS NULL OR inputWatermark < ?) ORDER BY inputWatermark DESC LIMIT ?`)
|
|
496
|
+
.all(...scopeValues(scope), cursor, cursor, limit + 1).map(row => JSON.parse(row.metadata));
|
|
497
|
+
const fields = { kind: 'kind', callId: 'callId', callIndex: 'callIndex', createdAt: 'createdAt',
|
|
498
|
+
requestedModel: 'requested.model', requestedEffort: 'requested.effort', effectiveEffort: 'effective.effort', effortObserved: 'effective.effortObserved',
|
|
499
|
+
selectionOrigin: 'selectionOrigin', reason: 'reason', code: 'code', terminalCode: 'terminalCode', outcome: 'outcome',
|
|
500
|
+
contextBytes: 'manifest.contextBytes', contextBudgetBytes: 'manifest.contextBudgetBytes', outputTokensReserved: 'manifest.outputTokensReserved' };
|
|
501
|
+
const usage = `json_object(${TOKEN_FIELDS.map(key => `'${key}', json_extract(record, '$.output.usage.${key}')`).join(', ')},
|
|
502
|
+
'accountingVersion', json_extract(record, '$.output.usage.accountingVersion'),
|
|
503
|
+
'accountingIncomplete', json(CASE json_extract(record, '$.output.usage.accountingIncomplete') WHEN 1 THEN 'true' ELSE 'false' END),
|
|
504
|
+
'cacheTokensAreIncludedInInput', json(CASE json_extract(record, '$.output.usage.cacheTokensAreIncludedInInput') WHEN 1 THEN 'true' WHEN 0 THEN 'false' ELSE 'null' END))`;
|
|
505
|
+
const metadata = `${project(fields).slice(0, -1)}, 'capabilityId', COALESCE(json_extract(record, '$.capability.id'), json_extract(record, '$.capabilityId')),
|
|
506
|
+
'usage', ${usage})`;
|
|
507
|
+
const items = episodes.slice(0, limit).map(episode => {
|
|
508
|
+
const events = this.sql(`SELECT ${metadata} AS metadata FROM traces WHERE ${SCOPE}
|
|
509
|
+
AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') IN
|
|
510
|
+
('call_started', 'call_output', 'call_failed', 'proposal_rejected', 'capability_started', 'capability_result', 'capability_failed', 'capability_finalized') ORDER BY seq ASC`)
|
|
511
|
+
.all(...scopeValues(scope), episode.id).map(row => {
|
|
512
|
+
const event = JSON.parse(row.metadata); event.effortObserved = event.effortObserved === 1; return event;
|
|
513
|
+
});
|
|
514
|
+
return turnView(episode, events);
|
|
515
|
+
});
|
|
516
|
+
return { items, nextCursor: episodes.length > limit ? items.at(-1).seq : null };
|
|
517
|
+
}
|
|
448
518
|
list(ownerId, collection, { cursor = null, limit = 20 } = {}, filter = {}) {
|
|
449
519
|
if (!['messages', 'traces'].includes(collection)) fail('INVALID_REQUEST');
|
|
450
520
|
this.getPerson(ownerId); boundedLimit(limit);
|
|
@@ -471,7 +541,8 @@ export class SqlitePersonStore {
|
|
|
471
541
|
const scope = this.scope(ownerId), p = this.getPerson(ownerId), state = this.one('states', scope);
|
|
472
542
|
const messages = this.rows('messages', scope, ' ORDER BY seq DESC LIMIT 21');
|
|
473
543
|
const episode = this.one('episodes', scope, ' ORDER BY inputWatermark DESC');
|
|
474
|
-
const latestEpisode = episode ? { id: episode.id, status: episode.status,
|
|
544
|
+
const latestEpisode = episode ? { id: episode.id, status: episode.status, createdAt: episode.createdAt,
|
|
545
|
+
...(episode.status === 'running' && episode.feedback ? { feedback: episode.feedback } : {}),
|
|
475
546
|
...(episode.terminalCode ? { terminalCode: episode.terminalCode } : {}), ...(episode.endedAt ? { endedAt: episode.endedAt } : {}) } : null;
|
|
476
547
|
return { latestEpisode, person: this.personView(p), state: stateView(state), concepts: this.focused(scope, state.focusConceptIds).slice(0, 12).map(conceptView),
|
|
477
548
|
messages: messages.slice(0, 20).reverse().map(messageView), nextMessagesCursor: messages.length > 20 ? String(messages[19].seq) : null,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { fail, object } from './contracts.js';
|
|
2
|
+
|
|
3
|
+
export const TOKEN_FIELDS = Object.freeze(['inputTokens', 'outputTokens', 'reasoningTokens', 'cacheReadTokens', 'cacheWriteTokens']);
|
|
4
|
+
const numeric = value => Number.isSafeInteger(value) && value >= 0;
|
|
5
|
+
const token = value => numeric(value) ? value : null;
|
|
6
|
+
const string = value => typeof value === 'string' ? value : null;
|
|
7
|
+
|
|
8
|
+
/** Turn cursor is an exclusive numeric episode watermark, never a trace offset. */
|
|
9
|
+
export function turnsPage(payload = {}) {
|
|
10
|
+
object(payload, ['cursor', 'limit'], []);
|
|
11
|
+
const { cursor = null, limit = 20 } = payload;
|
|
12
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 20 ||
|
|
13
|
+
(cursor !== null && (!Number.isSafeInteger(cursor) || cursor < 1))) fail('INVALID_REQUEST');
|
|
14
|
+
return { cursor, limit };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Adapter usage events are additive. Keep unknown fields unknown, including cache semantics. */
|
|
18
|
+
export function addUsage(usage, event, inclusion) {
|
|
19
|
+
const result = { ...usage, accountingVersion: 1 };
|
|
20
|
+
if (event.usageIncomplete === true) result.accountingIncomplete = true;
|
|
21
|
+
for (const key of TOKEN_FIELDS) if ((!Array.isArray(event.reportedTokenFields) || event.reportedTokenFields.includes(key)) && numeric(event[key])) result[key] = (result[key] ?? 0) + event[key];
|
|
22
|
+
const flag = typeof event.cacheTokensAreIncludedInInput === 'boolean' ? event.cacheTokensAreIncludedInInput : inclusion;
|
|
23
|
+
if (typeof flag === 'boolean') {
|
|
24
|
+
if (typeof result.cacheTokensAreIncludedInInput === 'boolean' && result.cacheTokensAreIncludedInInput !== flag) result.cacheTokensAreIncludedInInput = null;
|
|
25
|
+
else if (result.cacheTokensAreIncludedInInput !== null) result.cacheTokensAreIncludedInInput = flag;
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function callUsage(raw, completed) {
|
|
31
|
+
const usage = Object.fromEntries(TOKEN_FIELDS.map(key => [key, token(raw?.[key])]));
|
|
32
|
+
const reported = TOKEN_FIELDS.some(key => usage[key] !== null);
|
|
33
|
+
const caches = (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
|
|
34
|
+
// Historical traces dropped the flag. Nonzero cache counts without it are ambiguous.
|
|
35
|
+
usage.inputTotalTokens = raw?.accountingVersion !== 1 || raw.accountingIncomplete === true || usage.inputTokens === null || (caches > 0 && typeof raw?.cacheTokensAreIncludedInInput !== 'boolean') ? null
|
|
36
|
+
: usage.inputTokens + (raw?.cacheTokensAreIncludedInInput === false ? caches : 0);
|
|
37
|
+
usage.totalTokens = usage.inputTotalTokens === null || usage.outputTokens === null ? null : usage.inputTotalTokens + usage.outputTokens;
|
|
38
|
+
// Reasoning is a subset of output, not an additional billable output quantity.
|
|
39
|
+
return { ...usage, reportedCalls: Number(reported), missingCalls: Number(!reported), complete: completed && usage.totalTokens !== null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sumUsage(calls, terminal) {
|
|
43
|
+
const fields = [...TOKEN_FIELDS, 'inputTotalTokens', 'totalTokens'];
|
|
44
|
+
const usage = Object.fromEntries(fields.map(key => {
|
|
45
|
+
const known = calls.map(call => call.usage[key]).filter(value => value !== null);
|
|
46
|
+
// Raw metrics sum reported values even for partial calls. Exact totals require every call.
|
|
47
|
+
return [key, !calls.length ? 0 : (key.endsWith('TotalTokens') || key === 'totalTokens') && known.length !== calls.length ? null
|
|
48
|
+
: known.length ? known.reduce((sum, value) => sum + value, 0) : null];
|
|
49
|
+
}));
|
|
50
|
+
return { ...usage, reportedCalls: calls.reduce((sum, call) => sum + call.usage.reportedCalls, 0),
|
|
51
|
+
missingCalls: calls.reduce((sum, call) => sum + call.usage.missingCalls, 0), complete: terminal && calls.every(call => call.usage.complete) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Only metadata supplied by SQLite's explicit projection enters this view. */
|
|
55
|
+
export function turnView(episode, events) {
|
|
56
|
+
const calls = new Map();
|
|
57
|
+
for (const event of events) {
|
|
58
|
+
if (event.kind === 'call_started') {
|
|
59
|
+
calls.set(event.callId, { callId: event.callId, index: numeric(event.callIndex) ? event.callIndex + 1 : calls.size + 1,
|
|
60
|
+
status: 'running', requested: { model: string(event.requestedModel), effort: string(event.requestedEffort) },
|
|
61
|
+
// Native router dispatches the configured ref (only credential refresh can retry).
|
|
62
|
+
// It does not expose the response model: do not label configured intent as observed reality.
|
|
63
|
+
dispatched: { model: string(event.requestedModel) }, effective: { model: null, effort: null },
|
|
64
|
+
selectionOrigin: string(event.selectionOrigin), reason: string(event.reason), createdAt: event.createdAt, endedAt: null, code: null,
|
|
65
|
+
contextBytes: token(event.contextBytes), contextBudgetBytes: token(event.contextBudgetBytes), outputTokensReserved: token(event.outputTokensReserved),
|
|
66
|
+
usage: callUsage(null, false) });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const call = calls.get(event.callId);
|
|
70
|
+
if (!call) continue;
|
|
71
|
+
if (event.kind === 'call_output' || event.kind === 'call_failed') {
|
|
72
|
+
call.status = event.kind === 'call_output' ? 'completed' : ['CANCELLED', 'INTERRUPTED'].includes(event.code) ? event.code.toLowerCase() : 'failed';
|
|
73
|
+
call.endedAt = event.createdAt; call.code = string(event.code);
|
|
74
|
+
call.effective.effort = event.effortObserved === true ? string(event.effectiveEffort) : null;
|
|
75
|
+
call.usage = callUsage(event.usage, event.kind === 'call_output');
|
|
76
|
+
} else if (event.kind === 'proposal_rejected') {
|
|
77
|
+
call.status = 'rejected'; call.code = string(event.code);
|
|
78
|
+
} else if (event.kind === 'capability_started') {
|
|
79
|
+
call.capability = { id: string(event.capabilityId), status: 'running', code: null };
|
|
80
|
+
} else if (['capability_result', 'capability_failed', 'capability_finalized'].includes(event.kind)) {
|
|
81
|
+
call.capability = { id: string(event.capabilityId) ?? call.capability?.id ?? null,
|
|
82
|
+
status: (event.outcome ?? event.kind) === 'capability_failed' ? 'failed' : 'completed', code: string(event.code) ?? string(event.terminalCode) };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const items = [...calls.values()].sort((a, b) => a.index - b.index);
|
|
86
|
+
return { id: episode.id, seq: episode.seq, kind: episode.kind, status: episode.status, createdAt: episode.createdAt,
|
|
87
|
+
endedAt: episode.endedAt, terminalCode: episode.terminalCode, budget: { calls: token(episode.calls), timeoutMs: token(episode.timeoutMs) },
|
|
88
|
+
models: [...new Set(items.map(call => call.dispatched.model).filter(Boolean))], usage: sumUsage(items, episode.status !== 'running'), calls: items };
|
|
89
|
+
}
|