@yeaft/webchat-agent 1.0.629 → 1.0.632

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.
@@ -4,9 +4,13 @@ import { applyAnthropicThinking } from '../llm/anthropic.js';
4
4
  import { inferProtocolFromModelId, normalizeModelEntry } from '../llm/router.js';
5
5
  import { normalizeKnownProviderForRuntime } from '../llm/known-providers.js';
6
6
  import { normalizeEffort, resolveContextWindow, resolveMaxOutputTokens } from '../models.js';
7
+ import { bindProviderState, providerStateBytes, MAX_PROVIDER_STATE_BYTES } from '../llm/provider-state.js';
7
8
  import { utf8PrefixWithinBytes } from '../utf8.js';
8
9
  import { bytes, digest, fail, LIMITS, PersonError } from './contracts.js';
9
10
  import { addUsage } from './turn-diagnostics.js';
11
+ import { defaultPersonMinimumRequestBytes, PERSON_ENVELOPE_TOKENS_RESERVED } from './request-budget.js';
12
+
13
+ export const NATIVE_CONTEXT_UNAVAILABLE_REASON = 'The model context window cannot fit the native tool schemas, execution instructions and final-cognition reserve. Choose a model with a larger context window.';
10
14
 
11
15
  export const MODEL_LIMITS = Object.freeze({ candidates: 8, available: 100 });
12
16
  export function validateModelCandidates(refs) {
@@ -27,14 +31,26 @@ export function resolveAgentDefaultModel(config) {
27
31
  }
28
32
 
29
33
  /** Shared validation for settings, status and runtime; never broaden an explicit subset. */
30
- export function selectPersonModels({ availableModels, modelCandidates = [], defaultModel = null }) {
34
+ export function selectPersonModels({ availableModels, unavailableModels = [], modelCandidates = [], defaultModel = null }) {
31
35
  validateModelCandidates(modelCandidates);
32
36
  validateDefaultModel(defaultModel);
33
- if (modelCandidates.some(ref => !availableModels.some(model => model.id === ref))) fail('MODEL_SELECTION');
37
+ if (modelCandidates.some(ref => !availableModels.some(model => model.id === ref))) {
38
+ const error = new PersonError('MODEL_SELECTION');
39
+ if (modelCandidates.some(ref => unavailableModels.some(model => model.id === ref))) error.message = NATIVE_CONTEXT_UNAVAILABLE_REASON;
40
+ throw error;
41
+ }
34
42
  const catalog = modelCandidates.length ? availableModels.filter(model => modelCandidates.includes(model.id))
35
43
  : availableModels.slice(0, MODEL_LIMITS.candidates);
36
- if (defaultModel !== null && !catalog.some(model => model.id === defaultModel)) fail('MODEL_SELECTION');
37
- if (!catalog.length) fail('MODEL_UNAVAILABLE');
44
+ if (defaultModel !== null && !catalog.some(model => model.id === defaultModel)) {
45
+ const error = new PersonError('MODEL_SELECTION');
46
+ if (unavailableModels.some(model => model.id === defaultModel)) error.message = NATIVE_CONTEXT_UNAVAILABLE_REASON;
47
+ throw error;
48
+ }
49
+ if (!catalog.length) {
50
+ const error = new PersonError('MODEL_UNAVAILABLE');
51
+ if (unavailableModels.length) error.message = NATIVE_CONTEXT_UNAVAILABLE_REASON;
52
+ throw error;
53
+ }
38
54
  return { catalog, defaultSelection: { model: defaultModel ?? catalog[0].id, effort: null } };
39
55
  }
40
56
 
@@ -62,16 +78,18 @@ function imageInputBudget(modelId, protocol) {
62
78
  }
63
79
 
64
80
  /** Only configured native API models; no Session initialization or implicit credential fallback. */
65
- export async function createPersonProvider({ yeaftDir, config: suppliedConfig, adapter: suppliedAdapter, allowedModels, effortEnabled = process.env.YEAFT_THINKING_V1 === '1', modelCandidates = [], defaultModel = null }) {
81
+ export async function createPersonProvider({ yeaftDir, config: suppliedConfig, adapter: suppliedAdapter, allowedModels, effortEnabled = process.env.YEAFT_THINKING_V1 === '1', modelCandidates = [], defaultModel = null, minimumRequestBytes }) {
66
82
  const config = structuredClone(suppliedConfig || loadConfig({ dir: yeaftDir }));
67
83
  if (!config.providers?.length && !suppliedAdapter) fail('MODEL_UNAVAILABLE');
84
+ minimumRequestBytes ??= await defaultPersonMinimumRequestBytes(config);
85
+ if (!Number.isSafeInteger(minimumRequestBytes) || minimumRequestBytes < 1) fail('INVALID_REQUEST');
68
86
  const models = config.availableModels || [];
69
87
  const agentDefaultModel = resolveAgentDefaultModel(config);
70
88
  validateModelCandidates(modelCandidates);
71
89
  validateDefaultModel(defaultModel);
72
90
  const available = models.filter(m => !allowedModels || allowedModels.includes(m.ref || m.id));
73
91
  const routingProviders = (config.providers || []).map(normalizeKnownProviderForRuntime);
74
- const safeModels = available.map(m => {
92
+ const resolvedModels = available.map(m => {
75
93
  // Use the same model/config resolution as the native Engine, without a Person-only token ceiling.
76
94
  const maxOutput = Math.floor(resolveMaxOutputTokens(m.id, { ...config, modelInfo: m }));
77
95
  const effortContext = { ...m, thinkingProtocol: m.effortProtocol || m.thinkingProtocol };
@@ -96,16 +114,21 @@ export async function createPersonProvider({ yeaftDir, config: suppliedConfig, a
96
114
  const imageBudget = !routedProvider || explicitImages === false ? null : imageInputBudget(m.id, protocol);
97
115
  return { id: m.ref || m.id, efforts, maxOutput, usageCacheIncluded: protocol === 'anthropic' ? false : undefined, supportsImages: imageBudget !== null, imageBudget,
98
116
  contextWindow: Math.floor(resolveContextWindow(m.id, { ...config, modelInfo: m })) };
99
- }).filter(m => typeof m.id === 'string' && m.id.length <= 256 && m.contextWindow > m.maxOutput + 1024 && m.maxOutput >= 256);
117
+ }).filter(m => typeof m.id === 'string' && m.id.length <= 256 && Number.isSafeInteger(m.contextWindow) &&
118
+ Number.isSafeInteger(m.maxOutput) && m.contextWindow > m.maxOutput + PERSON_ENVELOPE_TOKENS_RESERVED && m.maxOutput >= 256);
119
+ const fitsNativeRequest = model => model.contextWindow - model.maxOutput - PERSON_ENVELOPE_TOKENS_RESERVED > minimumRequestBytes;
120
+ const unavailableModels = resolvedModels.filter(model => !fitsNativeRequest(model))
121
+ .slice(0, MODEL_LIMITS.available).map(model => ({ id: model.id, reason: NATIVE_CONTEXT_UNAVAILABLE_REASON }));
122
+ const safeModels = resolvedModels.filter(fitsNativeRequest);
100
123
  safeModels.sort((a, b) => Number(b.id === agentDefaultModel) - Number(a.id === agentDefaultModel));
101
124
  const seen = new Set();
102
125
  const uniqueModels = safeModels.filter(model => !seen.has(model.id) && seen.add(model.id));
103
126
  const availableModels = uniqueModels.slice(0, MODEL_LIMITS.available);
104
127
  // A UI catalog must not be the bounded episode choice set: model 9+ remains selectable.
105
128
  // Explicit lists are validated, never silently truncated or broadened on stale config.
106
- const { catalog, defaultSelection } = selectPersonModels({ availableModels, modelCandidates, defaultModel });
129
+ const { catalog, defaultSelection } = selectPersonModels({ availableModels, unavailableModels, modelCandidates, defaultModel });
107
130
  const adapter = suppliedAdapter || await createLLMAdapter(config);
108
- return { adapter, catalog, availableModels, availableModelsTruncated: uniqueModels.length > MODEL_LIMITS.available,
131
+ return { adapter, catalog, availableModels, unavailableModels, availableModelsTruncated: uniqueModels.length > MODEL_LIMITS.available,
109
132
  catalogRevision: digest(catalog), defaultSelection, agentDefaultModel, effortEnabled };
110
133
  }
111
134
 
@@ -123,14 +146,17 @@ export function abortable(promise, signal) {
123
146
  });
124
147
  }
125
148
 
126
- /** Consume only public text and usage. Bound accumulation before accepting any proposal. */
127
- export async function collectOutput(adapter, params, onEffort) {
128
- const iterator = adapter.stream({ ...params, tools: [], onEffortDecision: decision => onEffort({
149
+ /** Drain a complete native response before any tool dispatch. Archive public
150
+ * text/calls/usage only; fenced opaque continuation remains episode-local RAM. */
151
+ export async function collectOutput(adapter, params, onEffort = () => {}) {
152
+ const iterator = adapter.stream({ ...params, tools: params.tools ?? [], onEffortDecision: decision => onEffort({
129
153
  effective: typeof decision.effective === 'string' ? decision.effective : null,
130
154
  wireMode: typeof decision.wireMode === 'string' ? decision.wireMode : null,
131
155
  thinkingEnabled: decision.thinkingEnabled === true,
132
156
  }) })[Symbol.asyncIterator]();
133
157
  let output = '', size = 0, stopReason = null, usage = null;
158
+ const toolCalls = [], toolIds = new Set();
159
+ let providerState = null;
134
160
  try {
135
161
  while (true) {
136
162
  const item = await abortable(iterator.next(), params.signal);
@@ -145,22 +171,43 @@ export async function collectOutput(adapter, params, onEffort) {
145
171
  fail('OUTPUT_LIMIT');
146
172
  }
147
173
  output += event.text;
174
+ } else if (event.type === 'provider_state') {
175
+ if (providerStateBytes(event.providerState) > MAX_PROVIDER_STATE_BYTES) fail('CONTEXT_LIMIT');
176
+ providerState = event.providerState;
148
177
  } else if (event.type === 'stop') stopReason = event.stopReason;
149
- else if (event.type === 'error') fail('PROVIDER_FAILED');
150
- else if (event.type === 'tool_call') fail('INVALID_PROPOSAL');
178
+ else if (event.type === 'error') { if (event.error?.toolInputRejected === true) fail('INVALID_TOOL_INPUT'); fail('PROVIDER_FAILED'); }
179
+ else if (event.type === 'tool_call') {
180
+ if (!params.tools?.length || typeof event.id !== 'string' || !event.id || event.id.length > 256 || toolIds.has(event.id) || typeof event.name !== 'string' || event.name.length > 128) fail('INVALID_PROPOSAL');
181
+ // Unknown tools are rejected before the whole completed batch reaches
182
+ // dispatch, not fabricated as recoverable traces with no archive.
183
+ // Validation below waits for final usage/stop before rejecting the batch.
184
+ toolIds.add(event.id);
185
+ const malformed = event.malformed === true || Boolean(event.inputError) || !event.input || typeof event.input !== 'object' || Array.isArray(event.input);
186
+ const call = { id: event.id, name: event.name, input: malformed ? {} : event.input, ...(malformed ? { malformed: true } : {}) };
187
+ size += bytes(call);
188
+ if (size > LIMITS.outputBytes) fail('OUTPUT_LIMIT');
189
+ toolCalls.push(call);
190
+ }
151
191
  else if (event.type === 'usage') {
152
192
  usage = addUsage(usage, event, params.usageCacheIncluded);
153
193
  params.onUsage?.(usage);
154
194
  }
155
195
  // Hidden thinking, signatures, opaque provider state and raw HTTP exchanges are deliberately not archived.
156
196
  }
197
+ if (toolCalls.some(call => call.malformed || !params.tools.some(tool => tool.name === call.name))) fail('INVALID_TOOL_INPUT');
157
198
  if (stopReason === 'max_tokens') fail('OUTPUT_TRUNCATED');
158
- if (stopReason !== 'end_turn') fail('INVALID_PROPOSAL');
159
- return { text: output, bytes: size, usage, stopReason };
199
+ if (!['end_turn', 'tool_use'].includes(stopReason) || (stopReason === 'tool_use') !== Boolean(toolCalls.length)) fail('INVALID_PROPOSAL');
200
+ const result = { text: output, bytes: size, usage, stopReason, ...(toolCalls.length ? { toolCalls } : {}) };
201
+ const state = bindProviderState(providerState, { content: output, toolCalls });
202
+ // Wire continuation only; non-enumerable so repository payloads/public output
203
+ // can never archive hidden reasoning or provider signatures.
204
+ if (state) Object.defineProperty(result, 'continuationState', { value: state });
205
+ return result;
160
206
  } catch (error) {
161
- const safe = error instanceof PersonError ? error : new PersonError('PROVIDER_FAILED');
207
+ const safe = error instanceof PersonError ? error : new PersonError(error?.toolInputRejected === true ? 'INVALID_TOOL_INPUT' : 'PROVIDER_FAILED');
162
208
  // This prefix is explicitly incomplete/rejected, never a full accepted record.
163
- safe.partialOutput = { text: output, retainedBytes: bytes(output), observedBytes: size, complete: false, accepted: false, usage, stopReason };
209
+ safe.partialOutput = { text: output, retainedBytes: bytes(output) + toolCalls.reduce((total, call) => total + bytes(call), 0),
210
+ observedBytes: size, complete: false, accepted: false, usage, stopReason, ...(toolCalls.length ? { toolCalls } : {}) };
164
211
  throw safe;
165
212
  } finally {
166
213
  // Do not let a provider ignoring AbortSignal indefinitely block cancellation/close.
@@ -0,0 +1,26 @@
1
+ import { bytes, PROPOSAL_INSTRUCTIONS } from './contracts.js';
2
+
3
+ // Request reserves, not a minimum model window. Runtime still accounts for the
4
+ // actual Soul, state, scripts, attachments and execution transcript per dispatch.
5
+ export const PERSON_ENVELOPE_TOKENS_RESERVED = 1024;
6
+ export const PERSON_FINAL_CONTEXT_RESERVE_BYTES = 16384;
7
+ export const personFinalContextReserveBytes = executionInstructions =>
8
+ Math.max(0, bytes(PROPOSAL_INSTRUCTIONS) - bytes(executionInstructions)) + PERSON_FINAL_CONTEXT_RESERVE_BYTES;
9
+
10
+ /** Static lower bound for the complete native execution schemas and both phases.
11
+ * Schemas come from PersonCapabilities.executionTools(), never a copied list.
12
+ * The service injects this value so the provider has no static tool/runtime imports. */
13
+ export function personMinimumRequestBytes(toolSchemas, executionInstructions) {
14
+ return (toolSchemas.length ? bytes(toolSchemas) : 0) + bytes(executionInstructions) + 2 +
15
+ personFinalContextReserveBytes(executionInstructions);
16
+ }
17
+
18
+ /** Standalone provider callers use the same schemas. Lazy imports avoid the
19
+ * provider -> native tools -> Engine dependency cycle during module evaluation. */
20
+ export async function defaultPersonMinimumRequestBytes(config) {
21
+ const [{ PersonCapabilities }, { EXECUTION_INSTRUCTIONS }, { allowedNativeToolIds }] = await Promise.all([
22
+ import('./capabilities.js'), import('./execution-protocol.js'), import('./native-tools.js'),
23
+ ]);
24
+ const capabilities = new PersonCapabilities(null, null, { toolHost: { allowedToolIds: () => allowedNativeToolIds(config) } });
25
+ return personMinimumRequestBytes(capabilities.executionTools(), EXECUTION_INSTRUCTIONS);
26
+ }
@@ -4,18 +4,19 @@ import { randomUUID } from 'node:crypto';
4
4
  import { utf8PrefixWithinBytes } from '../utf8.js';
5
5
  import { CAPABILITY_MAP, capabilityContextBudget, foundationCapabilities, PersonCapabilities, catalogRevision as capabilityCatalogRevision } from './capabilities.js';
6
6
  import { attachmentMetadata } from './attachments.js';
7
- import { ExecutionProgress } from './execution-progress.js';
8
- import { personPresentation } from './presentation.js';
7
+ import { ExecutionTranscript, EXECUTION_INSTRUCTIONS, proposalDiagnostics } from './execution-protocol.js';
8
+ import { PERSON_ENVELOPE_TOKENS_RESERVED, personFinalContextReserveBytes } from './request-budget.js';
9
9
  import { abortable, collectOutput } from './provider.js';
10
+ import { validateCapabilityArguments, validateToolArguments } from './tool-arguments.js';
10
11
  import { callUsage, sumUsage } from './turn-diagnostics.js';
11
- import { bytes, digest, fail, FEEDBACK, FEEDBACK_INSTRUCTIONS, LIMITS, PersonError, PROPOSAL_INSTRUCTIONS, reportedLineage, safeError, validateProposal, validateSelection } from './contracts.js';
12
+ import { bytes, digest, fail, FEEDBACK_INSTRUCTIONS, LIMITS, PersonError, PROPOSAL_INSTRUCTIONS, reportedLineage, safeError, validateProposal, validateSelection } from './contracts.js';
12
13
 
13
14
  const messageRef = m => `message:${m.id}:${m.revision}`;
14
15
  const conceptRef = c => `concept:${c.id}:${c.revision}`;
15
- const ENVELOPE_TOKENS_RESERVED = 1024;
16
+ const ENVELOPE_TOKENS_RESERVED = PERSON_ENVELOPE_TOKENS_RESERVED;
16
17
 
17
18
  /** Assemble bounded request copies. Omitting a record never deletes or truncates its durable original. */
18
- export function assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, taskEvidence, episodeProgress = [], omittedEpisodeProgress = 0, episodeWork = null, executionPattern = null, dependencyRefs = [], activeCapabilities = foundationCapabilities(), capabilityMap = CAPABILITY_MAP, attachments = [], environment, feedback, outputRecovery }) {
19
+ export function assembleContext({ instructions = PROPOSAL_INSTRUCTIONS, toolSchemas = [], reservedContextBytes = 0, snapshot, episode, provider, selection, previous, capabilityResult, taskEvidence, episodeProgress = [], omittedEpisodeProgress = 0, episodeWork = null, executionPattern = null, dependencyRefs = [], activeCapabilities = foundationCapabilities(), capabilityMap = CAPABILITY_MAP, attachments = [], environment, feedback, outputRecovery }) {
19
20
  const model = validateSelection(selection, provider.catalog);
20
21
  // UTF-8 bytes is a conservative text-token bound; reserve explicit envelope/output overhead.
21
22
  const images = attachments.filter(file => file.kind === 'image');
@@ -28,10 +29,11 @@ export function assembleContext({ snapshot, episode, provider, selection, previo
28
29
  const imageLabelBytes = imageLabels.reduce((sum, label) => sum + bytes(label), 0);
29
30
  // This is a byte allowance derived from the selected model's token window,
30
31
  // not a token estimate or an independent fixed-size Person context limit.
31
- const contextCap = model.contextWindow - model.maxOutput - ENVELOPE_TOKENS_RESERVED - imageTokensReserved;
32
+ const requestCap = model.contextWindow - model.maxOutput - ENVELOPE_TOKENS_RESERVED - imageTokensReserved;
33
+ const contextCap = requestCap - (toolSchemas.length ? bytes(toolSchemas) : 0) - reservedContextBytes;
32
34
  if (!Number.isSafeInteger(model.contextWindow) || !Number.isSafeInteger(model.maxOutput) || model.maxOutput < 1 ||
33
35
  !Number.isSafeInteger(contextCap) || contextCap < 1) fail('CONTEXT_LIMIT');
34
- const system = `${snapshot.person.soul}\n\n${PROPOSAL_INSTRUCTIONS}`;
36
+ const system = `${snapshot.person.soul}\n\n${instructions}`;
35
37
  // Preserve the full proposal in durable traces, but do not duplicate optional
36
38
  // working notes inside the mandatory previous-proposal context. They have one
37
39
  // separately budgeted episodeWork slot below, including explicit null clearing.
@@ -96,7 +98,7 @@ export function assembleContext({ snapshot, episode, provider, selection, previo
96
98
  if (!fits()) delete context.outputRecovery;
97
99
  }
98
100
  context.outputInstructions = 'Fit complete JSON/tool args within budget.maxOutputTokens (reasoning included). Split big scripts or use SpawnAgent; long Shell work uses background:true and explicit result collection. For authorized edits, read relevant ranges, edit, inspect diff/checks. Missing broader validation need not block independently testable work. Recover omitted output via Execution.read or narrow reads, not reruns. Repeated evidence is not progress; diagnostics impose no quota/forced write. Optional work:null|{goal:string,findings:[{summary:string,sourceRefs:string[]}],nextStep:string,blocker:string}. Max 6 findings, summary<=600 UTF-8 bytes, 1..4 exact evidence refs each; other strings<=1000 bytes, total<=6144. Retain relevant findings, retract errors, integrate results before next action.';
99
- if (!fits()) delete context.outputInstructions;
101
+ if (instructions === EXECUTION_INSTRUCTIONS || !fits()) delete context.outputInstructions;
100
102
  // Feedback is a request-only hint, never grounds to fail the next call.
101
103
  // Keep durable replies intact; avoid duplicating up to 8 KiB from previousProposal.
102
104
  if (feedback) {
@@ -170,7 +172,7 @@ export function assembleContext({ snapshot, episode, provider, selection, previo
170
172
  manifest: { stateVersion: snapshot.state.version, sourceRefs: [...sourceRefs], renderedSourceRefs: [...renderedRefs], inputDependencyRefs: [...sourceRefs], omitted,
171
173
  boundedRecentWindow: { messages: 12, recentConcepts: 12, focusedConcepts: 12 },
172
174
  attachments: attachments.map(attachmentMetadata), imageTokensReserved, imageBudget: images.length ? imageBudget : null,
173
- contextBytes: bytes(system) + bytes(context) + imageLabelBytes, contextBudgetBytes: contextCap,
175
+ contextBytes: bytes(system) + bytes(context) + imageLabelBytes, contextBudgetBytes: requestCap, toolSchemaBytes: toolSchemas.length ? bytes(toolSchemas) : 0,
174
176
  contextWindowTokens: model.contextWindow, outputTokensReserved: model.maxOutput, envelopeTokensReserved: ENVELOPE_TOKENS_RESERVED,
175
177
  // Counts describe actual rendered sources, not the activity label or the
176
178
  // inherited bounded provenance read-set. Recall duplicates are counted in its page,
@@ -229,6 +231,10 @@ export function executionReceipt(callId, capability, result) {
229
231
  return receipt;
230
232
  }
231
233
 
234
+ const archiveExecutionMessages = messages => messages.map(({ providerState, ...message }) => Array.isArray(message.content)
235
+ ? { ...message, content: message.content.map(block => block.type === 'image'
236
+ ? { type: 'image', archived: false, reason: 'attachment-owned-by-episode' } : block) } : message);
237
+
232
238
  export class PersonRuntime {
233
239
  constructor({ repository, getProvider, workDir, yeaftDir, config, namespace = repository?.namespace ?? 'default' }) {
234
240
  this.repository = repository; this.getProvider = getProvider; this.budget = { calls: null };
@@ -366,8 +372,6 @@ export class PersonRuntime {
366
372
  loops: 0, model: null, capability: null, models: new Map(), currentCall: null,
367
373
  controlWrites: Promise.resolve(), pauseGate: null, releasePause: null }) {
368
374
  const { signal } = controller;
369
- let lastReplyAt = new Date(episode.createdAt).getTime() || Date.now();
370
- let lastReply = null;
371
375
  let heartbeatRunning = false;
372
376
  const heartbeat = setInterval(async () => {
373
377
  if (heartbeatRunning || signal.aborted) return;
@@ -402,57 +406,53 @@ export class PersonRuntime {
402
406
  selection: toolSelection, effortDecision: toolEffortDecision, parentToolRegistry: this.parentToolRegistry }),
403
407
  onResult: result => finalizeNativeResult(result) });
404
408
  const capabilities = new PersonCapabilities(this.repository, episode.ownerId, { experience: snapshot.capabilityExperience, triggerKind: episode.kind, created, episode, toolHost, workDir: this.toolOptions.workDir });
405
- let previous = null, capabilityResult = null, latestExecution = null, dependencyRefs = [], episodeWork = null;
406
- const executionProgress = new ExecutionProgress();
407
- const episodeProgress = [];
408
- let outputRecovery = null, outputRecoveryAttempted = false, omittedEpisodeProgress = 0;
409
- // Retain a bounded actual read window. Older provenance must be recalled again.
410
- // Candidate proposals never enter this read-set or establish new provenance.
411
- const readConcepts = new Map(), readSources = new Map();
412
- for (let index = 0; ; index++) {
413
- await this.boundary(job, 'preparing');
414
- const callId = randomUUID();
415
- const context = assembleContext({ snapshot, episode, provider, selection, previous, capabilityResult, episodeProgress, omittedEpisodeProgress, episodeWork, executionPattern: executionProgress.context(), dependencyRefs, taskEvidence: projectTaskEvidence(this.tasks()?.snapshot(episode.ownerId)), attachments, activeCapabilities: capabilities.context(), capabilityMap: capabilities.catalog(), environment: toolHost.environment, feedback: {
416
- minIntervalMs: FEEDBACK.minMs, maxIntervalMs: FEEDBACK.maxMs,
417
- elapsedSinceReplyMs: Math.max(0, Date.now() - lastReplyAt),
418
- due: Date.now() - lastReplyAt >= FEEDBACK.minMs, lastReply,
419
- }, outputRecovery });
420
- capabilities.activate(context.activeCapabilities);
421
- const workRefs = episodeWork?.findings.flatMap(finding => finding.sourceRefs) ?? [];
422
- dependencyRefs = [...new Set([...context.manifest.inputDependencyRefs.filter(ref => !workRefs.includes(ref)), ...workRefs.filter(ref => context.sourceRefs.has(ref))])].slice(-128);
423
- for (const [id, concept] of context.concepts) { readConcepts.delete(id); readConcepts.set(id, concept); }
424
- for (const [ref, source] of context.sources) { readSources.delete(ref); readSources.set(ref, source); }
425
- // Refresh existing reads above, then evict only historical entries. A
426
- // revision/source rendered in this request must remain valid even when
427
- // its insertion order overlaps a full inherited window.
428
- for (const ref of readSources.keys()) {
429
- if (readSources.size <= 256) break;
430
- if (!context.sources.has(ref)) readSources.delete(ref);
409
+ const initialTools = capabilities.executionTools();
410
+ const context = assembleContext({ instructions: EXECUTION_INSTRUCTIONS, toolSchemas: initialTools,
411
+ reservedContextBytes: personFinalContextReserveBytes(EXECUTION_INSTRUCTIONS), snapshot, episode, provider, selection,
412
+ attachments, activeCapabilities: [], capabilityMap: capabilities.catalog(), environment: toolHost.environment,
413
+ taskEvidence: projectTaskEvidence(this.tasks()?.snapshot(episode.ownerId)) });
414
+ const transcript = new ExecutionTranscript(context.messages);
415
+ const sourceRefs = new Set(context.sourceRefs), readConcepts = new Map(context.concepts), readSources = new Map(context.sources);
416
+ let phase = 'execution', corrections = 0, inputCorrections = 0, callIndex = 0;
417
+ const observe = result => {
418
+ if (result?.sourceRef) {
419
+ sourceRefs.add(result.sourceRef);
420
+ readSources.set(result.sourceRef, { kind: 'external-tool-observation', reportedSourceRefs: [] });
431
421
  }
432
- for (const id of readConcepts.keys()) {
433
- if (readConcepts.size <= 128) break;
434
- if (!context.concepts.has(id)) readConcepts.delete(id);
422
+ if (result?.kind === 'messages') for (const message of result.items) {
423
+ const ref = messageRef(message); sourceRefs.add(ref);
424
+ readSources.set(ref, { kind: 'message', role: message.role, reportedSourceRefs: message.role === 'user' ? [ref] : [] });
435
425
  }
436
- await this.repository.heartbeat(episode); // Revalidate distributed ownership before each dispatch.
426
+ if (result?.kind === 'concepts') for (const concept of result.items) {
427
+ const ref = conceptRef(concept); sourceRefs.add(ref); readConcepts.set(concept.id, concept);
428
+ readSources.set(ref, { kind: 'concept', epistemicState: concept.epistemicState,
429
+ reportedSourceRefs: concept.epistemicState === 'reported' ? concept.reportedSourceRefs ?? [] : [] });
430
+ }
431
+ };
432
+ for (;;) {
433
+ await this.boundary(job, 'preparing');
434
+ const callId = randomUUID();
435
+ const tools = phase === 'execution' ? capabilities.executionTools() : [];
436
+ const system = `${snapshot.person.soul}\n\n${phase === 'execution' ? EXECUTION_INSTRUCTIONS : PROPOSAL_INSTRUCTIONS}`;
437
+ const request = transcript.request({ system, tools, maxBytes: context.manifest.contextBudgetBytes });
438
+ await this.repository.heartbeat(episode);
437
439
  const requested = { model: selection.model, effort: selection.effort };
438
440
  const effective = { model: selection.model, effort: null, effortObserved: false };
439
441
  job.model = selection.model;
440
442
  await this.boundary(job, 'model', null, () => this.repository.startCall(episode, {
441
- callId, callIndex: index, requested, effective, selectionOrigin: selection.origin, reason: selection.reason,
442
- manifest: context.manifest, request: { system: context.system, messages: context.archiveMessages, maxTokens: context.maxTokens, tools: [] },
443
- capability: previous?.next?.capability ?? null,
443
+ callId, callIndex: callIndex++, requested, effective, selectionOrigin: selection.origin, reason: selection.reason,
444
+ manifest: { ...context.manifest, protocol: 'native-tool-loop', phase, sourceRefs: [...sourceRefs],
445
+ inputDependencyRefs: [...sourceRefs],
446
+ renderedSourceRefs: [...new Set([...context.manifest.renderedSourceRefs, ...request.renderedSourceRefs])],
447
+ contextSources: { ...context.manifest.contextSources,
448
+ recall: request.recallPages.at(-1) ?? { kind: null, count: 0 }, recallPages: request.recallPages },
449
+ contextBytes: request.contextBytes, omittedExecutionGroups: request.omittedExecutionGroups },
450
+ request: { system, messages: archiveExecutionMessages(request.messages), maxTokens: context.maxTokens, tools },
444
451
  }));
445
452
  let output, observedEffortDecision;
446
453
  try {
447
- signal.throwIfAborted();
448
- // collectOutput deliberately exposes only public effort diagnostics.
449
- // Preserve the complete wire decision privately for child inheritance
450
- // (including any inherited cap), without archiving provider internals.
451
454
  const observingAdapter = { stream: params => provider.adapter.stream({ ...params,
452
- onEffortDecision: decision => {
453
- observedEffortDecision = { ...decision };
454
- params.onEffortDecision?.(decision);
455
- },
455
+ onEffortDecision: decision => { observedEffortDecision = { ...decision }; params.onEffortDecision?.(decision); },
456
456
  }) };
457
457
  output = await this.boundary(job, 'model', null, () => {
458
458
  job.loops++;
@@ -460,10 +460,11 @@ export class PersonRuntime {
460
460
  calls: 0, completedCalls: 0, failedCalls: 0, elapsedMs: 0, usage: null });
461
461
  job.models.get(selection.model).calls++;
462
462
  job.currentCall = { model: selection.model, startedAt: Date.now(), usage: null };
463
- return collectOutput(observingAdapter, {
464
- model: selection.model, effort: selection.effort ?? undefined, effortSource: 'auto',
463
+ return collectOutput(observingAdapter, { model: selection.model, effort: selection.effort ?? undefined, effortSource: 'auto',
465
464
  usageCacheIncluded: provider.catalog.find(model => model.id === selection.model)?.usageCacheIncluded,
466
- system: context.system, messages: context.messages, maxTokens: context.maxTokens, signal,
465
+ system, messages: request.messages, tools, maxTokens: context.maxTokens, signal,
466
+ requestIdentity: { instanceScope: this.toolOptions.yeaftDir ?? this.namespace, ownerScope: episode.ownerId,
467
+ sessionId: `person:${snapshot.person.id}`, vpId: snapshot.person.id, threadId: episode.id },
467
468
  onUsage: usage => { if (job.currentCall) job.currentCall.usage = usage; },
468
469
  }, decision => {
469
470
  effective.effort = decision.effective; effective.effortObserved = true;
@@ -471,112 +472,124 @@ export class PersonRuntime {
471
472
  });
472
473
  });
473
474
  } catch (error) {
474
- const requestElapsedMs = this.finishUsage(job, false);
475
- const safe = safeError(error, 'PROVIDER_FAILED');
476
- const finalized = await this.repository.finalizeCall(episode, { callId, effective, requestElapsedMs, code: safe.code, output: error.partialOutput }).catch(() => false);
477
- // A truncated proposal has never passed validation or executed its tool.
478
- // Attempt one fresh, smaller proposal after truncation;
479
- // preserve completed tool evidence, never splice JSON or replay effects.
480
- if (safe.code === 'OUTPUT_TRUNCATED' && finalized && !signal.aborted &&
481
- !outputRecoveryAttempted) {
482
- outputRecoveryAttempted = true;
483
- outputRecovery = { code: safe.code, failedCallId: callId, rejectedBeforeExecution: true,
484
- instruction: 'The truncated response was rejected; no action from it executed. Produce a fresh smaller complete proposal within budget.maxOutputTokens. Split large code into small steps or delegate; do not repeat prior successful tools. Keep the same output allowance; recovery is attempted only once.' };
485
- selection = { ...selection, origin: 'output-recovery', reason: 'Replan once after output token truncation; no rejected action executed.' };
475
+ const requestElapsedMs = this.finishUsage(job, false), safe = safeError(error, 'PROVIDER_FAILED');
476
+ const partialOutput = error.partialOutput ? { ...error.partialOutput, protocol: phase === 'execution' ? 'native-tools' : 'cognition' } : undefined;
477
+ const finalized = await this.repository.finalizeCall(episode, { callId, effective, requestElapsedMs, code: safe.code, output: partialOutput }).catch(() => false);
478
+ // Collector has not dispatched anything from an incomplete response.
479
+ // Only typed malformed-input rejection or truncation is recoverable.
480
+ if (finalized && !signal.aborted && ['OUTPUT_TRUNCATED', 'INVALID_TOOL_INPUT'].includes(safe.code) && inputCorrections++ < 2) {
481
+ transcript.instruct(JSON.stringify({ code: safe.code, rejectedBeforeExecution: true,
482
+ instruction: phase === 'cognition'
483
+ ? 'Return smaller complete final cognitive JSON with next:null; tools remain disabled. Do not repeat prior completed operations.'
484
+ : 'No tools in that rejected response executed. Return smaller complete tool arguments or public result. Do not repeat prior completed operations.' }));
486
485
  continue;
487
486
  }
488
487
  throw safe;
489
488
  }
490
489
  const requestElapsedMs = this.finishUsage(job, true);
491
- // A narrow once-only finalizer can retain consumed output after cancellation, never state.
492
- // Complete public output remains durable even if proposal validation subsequently rejects it.
493
- if (!await this.repository.finalizeCall(episode, { callId, effective, output, requestElapsedMs })) fail('STALE');
494
- outputRecovery = null;
490
+ if (!await this.repository.finalizeCall(episode, { callId, effective,
491
+ output: { ...output, protocol: phase === 'execution' ? 'native-tools' : 'cognition' }, requestElapsedMs })) fail('STALE');
495
492
  await this.boundary(job, 'validating');
496
- let proposal;
497
- try {
498
- try { proposal = JSON.parse(output.text); } catch { fail('INVALID_PROPOSAL'); }
499
- validateProposal(proposal, { stateVersion: episode.baseStateVersion, sourceRefs: context.sourceRefs, concepts: readConcepts, sources: readSources, catalog: provider.catalog, dream: episode.kind === 'dream' });
500
- } catch (error) {
501
- const safe = safeError(error, 'INVALID_PROPOSAL');
502
- await this.repository.append(episode, 'proposal_rejected', { callId, code: safe.code });
503
- throw safe;
504
- }
505
- if (Object.hasOwn(proposal, 'work')) episodeWork = proposal.work;
506
- await this.repository.append(episode, 'activity', { callId, activity: proposal.activity, decision: proposal.decision, disposition: proposal.next ? 'candidate' : 'proposed-commit' });
507
- if (!proposal.next) {
508
- await this.boundary(job, 'committing', null, () => this.repository.commit(episode, proposal, selection, callId, new Map(proposal.concepts.map(c => [c.id, c.epistemicState === 'reported' ? reportedLineage(c, readSources) : []]))));
493
+ if (phase === 'cognition') {
494
+ let proposal;
495
+ try {
496
+ try { proposal = JSON.parse(output.text); } catch { fail('INVALID_PROPOSAL'); }
497
+ validateProposal(proposal, { stateVersion: episode.baseStateVersion, sourceRefs, concepts: readConcepts,
498
+ sources: readSources, catalog: provider.catalog, dream: episode.kind === 'dream' });
499
+ } catch (error) {
500
+ const safe = safeError(error, 'INVALID_PROPOSAL');
501
+ const diagnostics = proposalDiagnostics(proposal, { sourceRefs, concepts: readConcepts });
502
+ await this.repository.append(episode, 'proposal_rejected', { callId, code: safe.code, diagnostics });
503
+ if (safe.code !== 'INVALID_PROPOSAL' || corrections++ >= 2) throw safe;
504
+ transcript.response(output);
505
+ transcript.instruct(JSON.stringify({ phase: 'cognition-correction', diagnostics,
506
+ instruction: 'Correct only the final public cognitive JSON. next:null; tools remain disabled and completed operations must not replay. Do not invent refs or weaken reported lineage.' }));
507
+ continue;
508
+ }
509
+ await this.repository.append(episode, 'activity', { callId, activity: proposal.activity, decision: proposal.decision, disposition: 'proposed-commit' });
510
+ await this.boundary(job, 'committing', null, () => this.repository.commit(episode, proposal, selection, callId,
511
+ new Map(proposal.concepts.map(c => [c.id, c.epistemicState === 'reported' ? reportedLineage(c, readSources) : []]))));
509
512
  return;
510
513
  }
511
- let richSummary = null;
512
- if (proposal.next.capability?.id === 'PresentResponse') {
513
- // Suppress only a valid planned delivery; invalid rich input must not
514
- // consume an otherwise valid ordinary progress reply.
515
- try { richSummary = personPresentation(proposal.next.capability.args).summary; } catch {}
514
+ const group = transcript.response(output);
515
+ // Native public text is already drained and durably bound to this exact
516
+ // call. Publish useful progress at the existing cadence, without another
517
+ // model request or a cognitive proposal between tool steps.
518
+ let delivery = null;
519
+ if (output.text.trim() && this.repository.publishProgress) {
520
+ const final = !output.toolCalls?.length;
521
+ delivery = await this.boundary(job, 'validating', null, () => this.repository.publishProgress(episode, { callId, reply: output.text, final }));
516
522
  }
517
- if (proposal.reply && (richSummary === null || proposal.reply.trim() !== richSummary.trim())) {
518
- const published = await this.boundary(job, 'publishing', null, () => this.repository.publishProgress(episode, { callId, reply: proposal.reply }));
519
- if (published) { lastReply = published.text; lastReplyAt = new Date(published.createdAt).getTime(); }
523
+ if (!output.toolCalls?.length) {
524
+ phase = 'cognition';
525
+ transcript.instruct(JSON.stringify({ phase: 'final-cognition', baseStateVersion: episode.baseStateVersion,
526
+ ...(delivery ? { executionDelivery: { messageId: delivery.id, alreadyPublished: true } } : {}),
527
+ sourceRefs: [...sourceRefs], instruction: 'Execution has ended. Distill once into the final strict cognitive JSON; next:null. Preserve actual results and unfinished blockers. The native public result is already delivered when executionDelivery is present; use reply:null unless a substantive correction or additional delivery is needed. Tool observations never establish user-reported lineage.' }));
528
+ continue;
520
529
  }
521
- // The tool belongs to the response just received, not to next.model.
522
- // Retain requested intent separately from the provider's observed wire.
523
- toolSelection = { model: requested.model, effort: requested.effort };
524
- toolEffortDecision = { requested: requested.effort, effective: effective.effort,
525
- model: requested.model, source: 'auto', wireMode: effective.wireMode ?? 'omitted',
526
- thinkingEnabled: effective.thinkingEnabled === true, ...observedEffortDecision };
527
- previous = proposal;
528
- selection = { model: proposal.next.model, effort: proposal.next.effort, reason: proposal.next.reason, origin: 'person' };
529
- // The current result already has a complete mandatory slot. Move it to
530
- // bounded history only when advancing, including a cognition-only step.
531
- if (latestExecution) {
532
- episodeProgress.push(latestExecution);
533
- while (episodeProgress.length > 16 || bytes(episodeProgress) > 8192) { episodeProgress.shift(); omittedEpisodeProgress++; }
534
- }
535
- latestExecution = null;
536
- capabilityResult = null;
537
- if (proposal.next.capability) {
538
- const invocation = proposal.next.capability;
530
+ // Delegation inherits the producing request's observed, representable
531
+ // effort rather than an obsolete proposal.next selection. The child
532
+ // boundary still pins the admitted model and applies its own ceiling.
533
+ const modelEfforts = provider.catalog.find(model => model.id === requested.model)?.efforts ?? [];
534
+ toolSelection = { ...requested, effort: effective.effortObserved && modelEfforts.includes(effective.effort) ? effective.effort : requested.effort };
535
+ toolEffortDecision = { requested: requested.effort, effective: effective.effort, model: requested.model,
536
+ source: 'auto', wireMode: effective.wireMode ?? 'omitted', thinkingEnabled: effective.thinkingEnabled === true, ...observedEffortDecision };
537
+ for (let toolIndex = 0; toolIndex < output.toolCalls.length; toolIndex++) {
538
+ const toolCall = output.toolCalls[toolIndex], traceCallId = randomUUID();
539
+ let invocation, inputError = null;
540
+ try {
541
+ invocation = capabilities.invocation(toolCall.name, toolCall.input);
542
+ const schema = tools.find(tool => tool.name === toolCall.name)?.parameters;
543
+ if (!schema) fail('UNSUPPORTED');
544
+ try {
545
+ if (!toolCall.malformed) {
546
+ validateToolArguments(toolCall.input, schema);
547
+ validateCapabilityArguments(invocation);
548
+ }
549
+ } catch (error) {
550
+ const safe = safeError(error, 'INVALID_TOOL_INPUT');
551
+ if (safe.code !== 'INVALID_TOOL_INPUT') throw error;
552
+ inputError = safe;
553
+ }
554
+ }
555
+ catch (error) {
556
+ // Collector already rejected unknown names before any batch effect.
557
+ // A catalog/authority mismatch at this boundary is not retry-safe.
558
+ throw safeError(error, 'UNSUPPORTED');
559
+ }
539
560
  const manifest = capabilities.executionManifest(invocation.id);
540
- const execution = manifest ? { capabilityManifest: manifest } : {};
541
- await this.boundary(job, 'capability', invocation.id, () => this.repository.startCapability(episode, { callId, capability: invocation, ...execution, access: capabilities.active.get(invocation.id)?.access ?? 'catalog-read' }));
542
- capabilityResult = null;
543
- let finalized = false;
544
- const finalize = async (result, code) => {
545
- const accepted = await this.repository.finalizeCapability(episode, { callId, capability: invocation, result, code,
561
+ await this.boundary(job, 'capability', invocation.id, () => this.repository.startCapability(episode, {
562
+ callId: traceCallId, modelCallId: callId, toolUseId: toolCall.id, toolIndex, capability: invocation,
563
+ capabilityManifest: manifest, access: capabilities.active.get(invocation.id)?.access,
564
+ }));
565
+ let result, finalized = false;
566
+ const finalize = async (value, code) => {
567
+ const accepted = await this.repository.finalizeCapability(episode, { callId: traceCallId, capability: invocation, result: value, code,
546
568
  terminalCode: signal.aborted ? safeError(signal.reason, 'INTERRUPTED').code : null });
547
- finalized = true;
548
- return accepted;
549
- };
550
- finalizeNativeResult = async result => {
551
- capabilityResult = result;
552
- if (!await finalize(result) && !signal.aborted) fail('STALE');
569
+ finalized = true; return accepted;
553
570
  };
571
+ finalizeNativeResult = async value => { result = value; if (!await finalize(value) && !signal.aborted) fail('STALE'); };
554
572
  try {
555
573
  signal.throwIfAborted();
556
- capabilityResult = await this.boundary(job, 'capability', invocation.id, () => {
557
- // Native host and script execution join their actual work before close.
558
- const executionPromise = capabilities.execute(invocation, { signal, callId });
559
- return isNativeTool(invocation.id) || invocation.id === 'Output.publish' || invocation.id === 'PresentResponse' || invocation.id === 'Capability.create' || invocation.id.startsWith('Script.')
560
- ? executionPromise : abortable(executionPromise, signal);
561
- });
562
- if (!finalized && !await finalize(capabilityResult)) fail('STALE');
563
- if (invocation.id === 'PresentResponse' && capabilityResult?.published) {
564
- lastReply = capabilityResult.summary;
565
- lastReplyAt = new Date(capabilityResult.createdAt).getTime();
574
+ if (toolCall.malformed || inputError) {
575
+ result = { ok: false, code: 'INVALID_TOOL_INPUT', errorEffect: 'none', replaySafe: true, output: 'Malformed or schema-invalid provider tool arguments. Nothing executed; correct only the arguments.' };
576
+ if (inputCorrections++ >= 2) fail('INVALID_TOOL_INPUT');
577
+ } else {
578
+ result = await this.boundary(job, 'capability', invocation.id, () => capabilities.execute(invocation, { signal, callId: traceCallId }));
566
579
  }
580
+ if (!finalized && !await finalize(result)) fail('STALE');
567
581
  signal.throwIfAborted();
568
- if (capabilityResult?.terminal) fail('TOOL_EFFECT_UNCONFIRMED');
569
- // Discovery/method contracts are already available in capabilities;
570
- // keep result evidence, not another copy of those instructions.
571
- if (!invocation.id.startsWith('catalog.') && capabilityResult?.access !== 'method-only') {
572
- latestExecution = executionReceipt(callId, invocation.id, capabilityResult);
573
- executionProgress.observe(callId, invocation, capabilityResult);
574
- }
575
- // Raw tool result above remains durable; only this next-call copy is budgeted.
576
- if (isNativeTool(invocation.id)) capabilityResult = projectNativeResult(capabilityResult);
582
+ // A confirmed nonzero exit is useful failure evidence (for example a
583
+ // failing test), not proof of rollback or permission to replay it.
584
+ // Only unconfirmed process/effect termination blocks the next step.
585
+ if (result?.terminal) fail('TOOL_EFFECT_UNCONFIRMED');
586
+ const projection = transcript.result(group, toolCall.id, traceCallId, invocation, result);
587
+ // Recall pages carry structured message/concept reads; if projection
588
+ // cannot retain them it must not manufacture their provenance.
589
+ observe(projection);
577
590
  } catch (error) {
578
591
  const safe = safeError(error, 'UNSUPPORTED');
579
- if (!finalized) await finalize(capabilityResult ?? undefined, safe.code).catch(() => {});
592
+ if (!finalized) await finalize(result, safe.code).catch(() => {});
580
593
  throw safe;
581
594
  } finally { finalizeNativeResult = null; }
582
595
  }