@yeaft/webchat-agent 1.0.629 → 1.0.631

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.
@@ -2,6 +2,9 @@ import { SqlitePersonRepository } from './sqlite-repository.js';
2
2
  import { LocalPersonMemory } from './local-memory.js';
3
3
  import { ensurePersonStorage } from './sqlite-storage.js';
4
4
  import { PersonRuntime } from './runtime.js';
5
+ import { PersonCapabilities } from './capabilities.js';
6
+ import { EXECUTION_INSTRUCTIONS } from './execution-protocol.js';
7
+ import { personMinimumRequestBytes } from './request-budget.js';
5
8
  import { allowedNativeToolIds } from './native-tools.js';
6
9
  import { loadConfig } from '../config.js';
7
10
  import { validateFiles } from './attachments.js';
@@ -41,7 +44,13 @@ export function createPersonService(options = {}) {
41
44
  if (memory) repository.recall = (ownerId, args = {}, { signal } = {}) => args.query?.trim()
42
45
  ? memory.recall(ownerId, args, { signal }) : literalRecall(ownerId, args);
43
46
  // Config/adapter are loaded per explicit episode, not a permanent stale cache.
44
- const getProvider = (modelCandidates = [], defaultModel = null) => createPersonProvider({ yeaftDir, config, adapter, allowedModels, modelCandidates, defaultModel, effortEnabled: options.effortEnabled });
47
+ const getProvider = (modelCandidates = [], defaultModel = null) => {
48
+ const nativeConfig = config ?? loadConfig({ dir: yeaftDir });
49
+ const capabilities = new PersonCapabilities(null, null, { toolHost: { allowedToolIds: () => allowedNativeToolIds(nativeConfig) } });
50
+ const minimumRequestBytes = personMinimumRequestBytes(capabilities.executionTools(), EXECUTION_INSTRUCTIONS);
51
+ return createPersonProvider({ yeaftDir, config: nativeConfig, adapter, allowedModels, modelCandidates, defaultModel,
52
+ effortEnabled: options.effortEnabled, minimumRequestBytes });
53
+ };
45
54
  const runtime = new PersonRuntime({ repository, getProvider, budget: { calls: null }, workDir, yeaftDir, config, namespace });
46
55
  let closed = false;
47
56
  const requests = new Set();
@@ -68,18 +77,19 @@ export function createPersonService(options = {}) {
68
77
  } catch (error) { if (error.code !== 'NOT_OPEN') throw error; }
69
78
  try {
70
79
  const provider = await getProvider();
80
+ let modelReason = null;
71
81
  try {
72
- const selected = selectPersonModels({ availableModels: provider.availableModels,
82
+ const selected = selectPersonModels({ availableModels: provider.availableModels, unavailableModels: provider.unavailableModels,
73
83
  modelCandidates: status.modelCandidates, defaultModel: status.defaultModel });
74
84
  status.modelReady = true;
75
85
  status.effectiveModelCandidates = selected.catalog.map(model => model.id);
76
86
  status.effectiveDefaultModel = selected.defaultSelection.model;
77
- } catch { /* Stale settings remain visible, but do not grant fallback candidates. */ }
87
+ } catch (error) { modelReason = safeError(error, 'MODEL_SELECTION').message; /* No fallback candidates for stale settings. */ }
78
88
  return { ...status, agentDefaultModel: provider.agentDefaultModel,
79
- reason: status.modelReady ? null : 'Saved model settings are unavailable; choose models or reset to Agent defaults.',
89
+ reason: status.modelReady ? null : `${modelReason} Choose models or reset to Agent defaults.`,
80
90
  storageReady: true, models: provider.availableModels, availableModels: provider.availableModels,
81
91
  availableModelsTruncated: provider.availableModelsTruncated, autonomySupported: false };
82
- } catch { return { ...status, reason: 'No permitted native model is configured for digital person.', storageReady: true, models: [], availableModels: [] }; }
92
+ } catch (error) { return { ...status, reason: safeError(error, 'MODEL_UNAVAILABLE').message, storageReady: true, models: [], availableModels: [] }; }
83
93
  }
84
94
  if (!configured) fail('NOT_CONFIGURED');
85
95
  switch (op) {
@@ -188,7 +198,8 @@ export function createPersonService(options = {}) {
188
198
  // An automatic reset must also work when no model is currently available.
189
199
  if (merged.modelCandidates.length || merged.defaultModel !== null) {
190
200
  const provider = await getProvider();
191
- selectPersonModels({ availableModels: provider.availableModels, modelCandidates: merged.modelCandidates, defaultModel: merged.defaultModel });
201
+ selectPersonModels({ availableModels: provider.availableModels, unavailableModels: provider.unavailableModels,
202
+ modelCandidates: merged.modelCandidates, defaultModel: merged.defaultModel });
192
203
  }
193
204
  // Fence the merge base across concurrent services; retry from fresh settings.
194
205
  expectedControlVersion = prior.controlVersion;
@@ -95,6 +95,9 @@ export class SqlitePersonRepository {
95
95
  async append(episode, kind, data) { return this.request('append', [episode, kind, data]); }
96
96
  async startCall(episode, data) { return this.request('startCall', [episode, data]); }
97
97
  async finalizeCall(episode, data) { return this.request('finalizeCall', [episode, data]); }
98
+ /** Native tools have an independent trace callId, with modelCallId/toolUseId/
99
+ * toolIndex bound by storage to the complete public provider batch. Legacy
100
+ * scalar invocations keep their original shared callId. */
98
101
  async startCapability(episode, data) { return this.request('startCapability', [episode, data]); }
99
102
  async finalizeCapability(episode, data) { return this.request('finalizeCapability', [episode, data]); }
100
103
  async executionRead(episode, args, { signal } = {}) {
@@ -3,6 +3,7 @@ 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 { isDeepStrictEqual } from 'node:util';
6
7
  import { admissionReceipt, bytes, digest, fail, FEEDBACK, identifier, LIMITS, PersonError, text } from './contracts.js';
7
8
  import { CREATED_CAPABILITY_LIMITS, createdCapabilityRecord, validateCreatedCapability } from './created-capability-contract.js';
8
9
  import { SCHEMA, TABLES } from './sqlite-schema.js';
@@ -12,7 +13,7 @@ import { chronologicalCursor, conceptView, inspectionCursor, inspectionPage, ins
12
13
  import { TOKEN_FIELDS, turnsPage, turnView } from './turn-diagnostics.js';
13
14
  import { personPresentation } from './presentation.js';
14
15
  import { inspectCapabilities } from './capabilities.js';
15
- import { executionEvidencePage, executionReadRequest } from './execution-evidence.js';
16
+ import { executionEvidencePage, executionReadRequest, nativeInvocationMatches } from './execution-evidence.js';
16
17
  import { OUTPUT_LIMITS, outputArgs, outputMime, outputReadRequest, outputsPage, outputUrl, outputView } from './outputs.js';
17
18
 
18
19
  const SCOPE = 'namespace = ? AND ownerId = ? AND personId = ?';
@@ -42,7 +43,30 @@ const unavailableOutput = () => ({ text: '', retainedBytes: 0, observedBytes: nu
42
43
  const publicOutput = (output, failed) => {
43
44
  if (!output) return unavailableOutput();
44
45
  if (typeof output.text !== 'string' || bytes(output.text) > LIMITS.outputBytes) fail('OUTPUT_LIMIT');
45
- const retainedBytes = bytes(output.text), observedBytes = output.observedBytes ?? output.bytes ?? retainedBytes;
46
+ if (output.protocol !== undefined && !['native-tools', 'cognition'].includes(output.protocol)) fail('INVALID_REQUEST');
47
+ // A native batch cannot be relabeled as an unmarked legacy scalar proof.
48
+ if (output.toolCalls?.length && output.protocol !== 'native-tools') fail('INVALID_REQUEST');
49
+ const toolCalls = [];
50
+ let retainedBytes = bytes(output.text), toolCallsTruncated = false;
51
+ if (output.toolCalls !== undefined) {
52
+ if (!Array.isArray(output.toolCalls)) fail('INVALID_REQUEST');
53
+ const ids = new Set();
54
+ for (const item of output.toolCalls) {
55
+ if (!item || typeof item.id !== 'string' || !item.id || bytes(item.id) > 1024 ||
56
+ typeof item.name !== 'string' || !item.name || bytes(item.name) > 512 || ids.has(item.id) ||
57
+ !item.input || typeof item.input !== 'object' || Array.isArray(item.input)) fail('INVALID_REQUEST');
58
+ ids.add(item.id);
59
+ const input = JSON.parse(JSON.stringify(item.input));
60
+ if (!isDeepStrictEqual(input, item.input)) fail('INVALID_REQUEST');
61
+ const call = { id: item.id, name: item.name, input, ...(item.malformed === true ? { malformed: true } : {}) };
62
+ if (retainedBytes + bytes(call) > LIMITS.outputBytes) {
63
+ if (!failed) fail('OUTPUT_LIMIT');
64
+ toolCallsTruncated = true; break;
65
+ }
66
+ retainedBytes += bytes(call); toolCalls.push(call);
67
+ }
68
+ }
69
+ const observedBytes = output.observedBytes ?? output.bytes ?? retainedBytes;
46
70
  if (!Number.isSafeInteger(observedBytes) || observedBytes < retainedBytes) fail('INVALID_REQUEST');
47
71
  const usage = output.usage ? {} : null;
48
72
  if (usage) for (const key of ['inputTokens', 'outputTokens', 'reasoningTokens', 'cacheReadTokens', 'cacheWriteTokens']) {
@@ -52,8 +76,10 @@ const publicOutput = (output, failed) => {
52
76
  if (usage && output.usage.accountingIncomplete === true) usage.accountingIncomplete = true;
53
77
  if (usage && typeof output.usage.cacheTokensAreIncludedInInput === 'boolean') usage.cacheTokensAreIncludedInInput = output.usage.cacheTokensAreIncludedInInput;
54
78
  const stopReason = typeof output.stopReason === 'string' && bytes(output.stopReason) <= 128 ? output.stopReason : null;
55
- return failed ? { text: output.text, retainedBytes, observedBytes, complete: false, accepted: false, availability: 'captured', usage, stopReason }
56
- : { text: output.text, bytes: retainedBytes, complete: true, usage, stopReason };
79
+ const publicFields = { ...(output.protocol === 'native-tools' || output.protocol === 'cognition' ? { protocol: output.protocol } : {}),
80
+ ...(output.toolCalls !== undefined ? { toolCalls } : {}), ...(toolCallsTruncated ? { toolCallsTruncated: true } : {}) };
81
+ return failed ? { text: output.text, retainedBytes, observedBytes, complete: false, accepted: false, availability: 'captured', usage, stopReason, ...publicFields }
82
+ : { text: output.text, bytes: retainedBytes, complete: true, usage, stopReason, ...publicFields };
57
83
  };
58
84
  const objectOutput = (record, data) => {
59
85
  if (!record || !['file', 'link'].includes(record.kind)) fail('INVALID_REQUEST');
@@ -304,9 +330,11 @@ export class SqlitePersonStore {
304
330
  return this.trace(episode.ownerId, episode.id, kind, data);
305
331
  }
306
332
  startCall(episode, data) {
333
+ identifier(data.callId);
307
334
  const p = this.own(episode), scope = this.episodeScope(episode);
308
335
  const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
309
- if (!record || record.status !== 'running' || record.openCall || record.openCapability) fail('STALE');
336
+ if (!record || record.status !== 'running' || record.openCall || record.openCapability ||
337
+ this.one('traces', scope, " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.callId') = ? AND json_extract(record, '$.kind') IN ('call_started', 'capability_started')", [episode.id, data.callId])) fail('STALE');
310
338
  p.writeSerial++; this.put('persons', p);
311
339
  record.openCall = { callId: data.callId, requested: data.requested, effective: data.effective, ...(data.manifest ? { manifest: data.manifest } : {}) };
312
340
  this.put('episodes', record);
@@ -318,7 +346,8 @@ export class SqlitePersonStore {
318
346
  const live = record.status === 'running' && Boolean(this.fenced(episode));
319
347
  const draining = record.status === 'cancelled' && record.callFinalizeUntil > this.now;
320
348
  if (!live && !draining) return false;
321
- const failed = Boolean(code || !live || !output), terminalCode = !live ? 'CANCELLED' : code || 'INTERRUPTED';
349
+ const failed = Boolean(code || !live || !output || output.complete === false || output.accepted === false || output.toolCallsTruncated === true),
350
+ terminalCode = !live ? 'CANCELLED' : code || 'INTERRUPTED';
322
351
  const call = record.openCall;
323
352
  const safeEffective = { model: call.requested.model, effort: typeof effective?.effort === 'string' && effective.effort.length <= 128 ? effective.effort : null,
324
353
  effortObserved: effective?.effortObserved === true };
@@ -332,17 +361,42 @@ export class SqlitePersonStore {
332
361
  });
333
362
  return live;
334
363
  }
364
+ callProof(scope, episodeId, callId) {
365
+ return 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", [episodeId, callId]);
366
+ }
367
+ /** Recheck the original transactional slot and its request proof at publication.
368
+ * Legacy scalar APIs remain supported only for legacy complete call outputs. */
369
+ capabilityProof(scope, episodeId, started) {
370
+ if (!started || started.invocationHash !== digest(started.capability)) return null;
371
+ const proof = this.callProof(scope, episodeId, started.modelCallId ?? started.callId);
372
+ if (!proof) return null;
373
+ const original = this.one('traces', scope, " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') = 'capability_started' AND json_extract(record, '$.callId') = ? ORDER BY seq ASC", [episodeId, started.callId]);
374
+ if (!original || !isDeepStrictEqual(original.capability, started.capability) ||
375
+ ['modelCallId', 'toolUseId', 'toolIndex'].some(key => original[key] !== started[key])) return null;
376
+ if (proof.output.protocol === 'native-tools') return nativeInvocationMatches(proof.output, started, { executable: true }) ? proof : null;
377
+ return started.modelCallId === undefined && started.toolUseId === undefined && started.toolIndex === undefined && !proof.output.protocol ? proof : null;
378
+ }
335
379
  startCapability(episode, data) {
380
+ identifier(data.callId);
381
+ if (data.modelCallId !== undefined) identifier(data.modelCallId);
382
+ if (!data.capability || typeof data.capability.id !== 'string' || !data.capability.args || typeof data.capability.args !== 'object') fail('INVALID_REQUEST');
336
383
  const p = this.own(episode), scope = this.episodeScope(episode);
337
384
  const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
338
- 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, data.callId]);
385
+ const proof = this.callProof(scope, episode.id, data.modelCallId ?? data.callId);
339
386
  if (!record || record.status !== 'running' || record.openCall || record.openCapability || !proof) fail('STALE');
340
- const prior = this.one('traces', scope, " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') = 'capability_started' AND json_extract(record, '$.callId') = ?", [episode.id, data.callId]);
341
- if (prior) fail('STALE');
342
- record.openCapability = { ...data, invocationHash: digest(data.capability) };
387
+ const native = proof.output.protocol === 'native-tools';
388
+ if (native ? !data.modelCallId || data.callId === data.modelCallId || !nativeInvocationMatches(proof.output, data)
389
+ : proof.output.protocol || data.modelCallId !== undefined || data.toolUseId !== undefined || data.toolIndex !== undefined) fail('STALE');
390
+ const prior = this.one('traces', scope, " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') = 'capability_started' AND (json_extract(record, '$.callId') = ? OR (? = 1 AND json_extract(record, '$.modelCallId') = ? AND (json_extract(record, '$.toolUseId') = ? OR json_extract(record, '$.toolIndex') = ?)))",
391
+ [episode.id, data.callId, Number(native), data.modelCallId ?? null, data.toolUseId ?? null, data.toolIndex ?? null]);
392
+ if (prior || (native && this.one('traces', scope, " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') = 'call_started' AND json_extract(record, '$.callId') = ?", [episode.id, data.callId]))) fail('STALE');
393
+ const started = { callId: data.callId, capability: data.capability,
394
+ ...(native ? { modelCallId: data.modelCallId, toolUseId: data.toolUseId, toolIndex: data.toolIndex } : {}),
395
+ ...(data.capabilityManifest ? { capabilityManifest: data.capabilityManifest } : {}), ...(data.access ? { access: data.access } : {}) };
396
+ record.openCapability = { ...started, invocationHash: digest(started.capability) };
343
397
  this.put('episodes', record);
344
398
  p.writeSerial++; this.put('persons', p);
345
- return this.trace(episode.ownerId, episode.id, 'capability_started', data);
399
+ return this.trace(episode.ownerId, episode.id, 'capability_started', started);
346
400
  }
347
401
  /** Original started invocation only; consuming this slot never restores cognitive authority. */
348
402
  finalizeCapability(episode, { callId, capability, result, code = null, terminalCode = null }) {
@@ -356,7 +410,7 @@ export class SqlitePersonStore {
356
410
  const started = record.openCapability;
357
411
  const failed = Boolean(code || result?.ok === false);
358
412
  const outcome = failed ? 'capability_failed' : 'capability_result';
359
- const data = { callId, ...(started.capabilityManifest ? { capabilityManifest: started.capabilityManifest } : {}),
413
+ const data = { callId, ...(started.modelCallId !== undefined ? { modelCallId: started.modelCallId, toolUseId: started.toolUseId, toolIndex: started.toolIndex } : {}), ...(started.capabilityManifest ? { capabilityManifest: started.capabilityManifest } : {}),
360
414
  ...(failed ? { capabilityId: started.capability.id, code: code || result.code } : { capability: started.capability }),
361
415
  ...(result !== undefined ? { result } : {}) };
362
416
  delete record.openCapability; this.put('episodes', record);
@@ -395,8 +449,10 @@ export class SqlitePersonStore {
395
449
  this.own(episode);
396
450
  const scope = this.episodeScope(episode), active = this.one('episodes', scope, ' AND id = ?', [episode.id]);
397
451
  if (!active || active.status !== 'running' || active.epoch !== episode.epoch || active.workerId !== episode.workerId) fail('STALE');
398
- 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]);
399
- if (!proof || active.openCall?.callId === callId) fail('INVALID_REQUEST');
452
+ const started = active.openCapability;
453
+ const proof = started?.callId === callId ? this.capabilityProof(scope, episode.id, started) : this.callProof(scope, episode.id, callId);
454
+ if (!proof || active.openCall || (started ? started.callId !== callId || started.capability.id !== 'Capability.create' ||
455
+ !isDeepStrictEqual(started.capability.args, definition) : proof.output.protocol)) fail('INVALID_REQUEST');
400
456
  const current = this.one('created_capabilities', scope, ' AND id = ?', [definition.id]);
401
457
  if ((current?.version ?? 0) !== definition.expectedVersion) fail('STALE');
402
458
  if (definition.expectedVersion >= CREATED_CAPABILITY_LIMITS.versions || (!current &&
@@ -437,8 +493,7 @@ export class SqlitePersonStore {
437
493
  if (!message) fail('STORAGE_UNAVAILABLE');
438
494
  return messageView(message);
439
495
  }
440
- const proof = this.one('traces', scope,
441
- " 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]);
496
+ const proof = this.capabilityProof(scope, episode.id, record?.openCapability);
442
497
  if (!record || record.status !== 'running' || record.openCall || !proof || record.openCapability?.callId !== callId ||
443
498
  record.openCapability.invocationHash !== invocationHash) fail('STALE');
444
499
  // Repeating an identical delivery in this episode adds no new visible answer.
@@ -463,25 +518,35 @@ export class SqlitePersonStore {
463
518
  }
464
519
  /** Publish only a validated, complete call's reply. This is communication, not
465
520
  * a state/Concept commit. Fencing, dedupe and the memory journal share one transaction. */
466
- publishProgress(episode, { callId, reply }) {
521
+ publishProgress(episode, { callId, reply, final = false }) {
467
522
  const p = this.own(episode), scope = this.episodeScope(episode);
468
523
  identifier(callId); text(reply, LIMITS.outputBytes);
524
+ if (typeof final !== 'boolean') fail('INVALID_REQUEST');
469
525
  const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
470
526
  if (!record || record.status !== 'running' || record.openCall || record.openCapability) fail('STALE');
471
- const prior = this.rows('messages', scope, " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.replyKind') = 'progress'", [episode.id]);
527
+ const prior = this.rows('messages', scope, " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.replyKind') IN ('progress', 'execution-final')", [episode.id]);
472
528
  const duplicate = prior.find(m => m.callId === callId);
473
529
  if (duplicate) { if (duplicate.text !== reply) fail('INVALID_REQUEST'); return messageView(duplicate); }
474
530
  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]);
475
- 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]);
476
- let proposal;
477
- try { proposal = JSON.parse(proof?.output?.text); } catch { fail('INVALID_REQUEST'); }
478
- if (!accepted || !proposal.next || proposal.reply !== reply) fail('INVALID_REQUEST');
531
+ if (proof?.output?.protocol === 'native-tools') {
532
+ if (proof.output.text !== reply || (final && (proof.output.stopReason !== 'end_turn' || proof.output.toolCalls?.length))) fail('INVALID_REQUEST');
533
+ } else {
534
+ if (final) fail('INVALID_REQUEST');
535
+ 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]);
536
+ let proposal;
537
+ try { proposal = JSON.parse(proof?.output?.text); } catch { fail('INVALID_REQUEST'); }
538
+ if (!accepted || !proposal.next || proposal.reply !== reply) fail('INVALID_REQUEST');
539
+ }
540
+ // A final duplicate is already delivered. Preserve its identity so final
541
+ // cognition does not interpret dedupe as a missing public result.
542
+ const sameReply = prior.find(m => m.text.trim() === reply.trim());
543
+ if (final && sameReply) return messageView(sameReply);
479
544
  // Provider latency counts too: a call started before 30s can finish after it.
480
- if (this.now.getTime() - new Date(record.lastReplyAt ?? record.createdAt).getTime() < FEEDBACK.minMs ||
481
- prior.some(m => m.text.trim() === reply.trim()) || this.repeatsPresentation(scope, episode.id, reply)) return null;
545
+ if ((!final && this.now.getTime() - new Date(record.lastReplyAt ?? record.createdAt).getTime() < FEEDBACK.minMs) ||
546
+ sameReply || this.repeatsPresentation(scope, episode.id, reply)) return null;
482
547
  p.messageSeq++; p.writeSerial++; this.put('persons', p);
483
548
  const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id,
484
- callId, replyKind: 'progress', role: 'assistant', text: reply, createdAt: this.now });
549
+ callId, replyKind: final ? 'execution-final' : 'progress', role: 'assistant', text: reply, createdAt: this.now });
485
550
  this.put('messages', message, true); this.journal(scope, 'messages', message);
486
551
  record.lastReplyAt = this.now.toISOString(); delete record.feedback; this.put('episodes', record);
487
552
  this.trace(episode.ownerId, episode.id, 'progress_reply', { callId, messageId: message.id });
@@ -490,7 +555,10 @@ export class SqlitePersonStore {
490
555
  commit(episode, proposal, selection, callId, reportedSources = new Map()) {
491
556
  const p = this.own(episode), scope = this.episodeScope(episode);
492
557
  if (proposal.baseStateVersion !== episode.baseStateVersion) fail('STALE');
493
- const publishReply = proposal.reply && !this.repeatsPresentation(scope, episode.id, proposal.reply);
558
+ const repeatsExecutionReply = proposal.reply && this.rows('messages', scope,
559
+ " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.replyKind') IN ('progress', 'execution-final')", [episode.id])
560
+ .some(message => message.text.trim() === proposal.reply.trim());
561
+ const publishReply = proposal.reply && !repeatsExecutionReply && !this.repeatsPresentation(scope, episode.id, proposal.reply);
494
562
  p.stateVersion++; p.writeSerial++; if (publishReply) p.messageSeq++;
495
563
  p.activeEpisodeId = null; p.leaseOwner = null; p.leaseUntil = new Date(0);
496
564
  this.put('persons', p);
@@ -593,7 +661,8 @@ export class SqlitePersonStore {
593
661
  const episodes = this.sql(`SELECT ${project({ id: 'id', seq: 'inputWatermark', kind: 'kind', status: 'status', createdAt: 'createdAt', endedAt: 'endedAt', terminalCode: 'terminalCode', controlStatus: 'executionControl.status', calls: 'budget.calls', timeoutMs: 'budget.timeoutMs' })} AS metadata
594
662
  FROM episodes WHERE ${SCOPE} AND (? IS NULL OR inputWatermark < ?) ORDER BY inputWatermark DESC LIMIT ?`)
595
663
  .all(...scopeValues(scope), cursor, cursor, limit + 1).map(row => JSON.parse(row.metadata));
596
- const fields = { kind: 'kind', callId: 'callId', callIndex: 'callIndex', createdAt: 'createdAt',
664
+ const fields = { kind: 'kind', callId: 'callId', modelCallId: 'modelCallId', toolUseId: 'toolUseId', toolIndex: 'toolIndex',
665
+ protocol: 'output.protocol', callIndex: 'callIndex', createdAt: 'createdAt',
597
666
  requestedModel: 'requested.model', requestedEffort: 'requested.effort', effectiveEffort: 'effective.effort', effortObserved: 'effective.effortObserved',
598
667
  selectionOrigin: 'selectionOrigin', reason: 'reason', code: 'code', terminalCode: 'terminalCode', outcome: 'outcome',
599
668
  contextBytes: 'manifest.contextBytes', contextBudgetBytes: 'manifest.contextBudgetBytes', outputTokensReserved: 'manifest.outputTokensReserved' };
@@ -618,16 +687,21 @@ export class SqlitePersonStore {
618
687
  AND json_extract(record, '$.kind') = 'call_started' ORDER BY seq DESC LIMIT 33`)
619
688
  .all(...scopeValues(scope), episode.id);
620
689
  const oldest = starts[Math.min(starts.length, 32) - 1]?.seq ?? 0;
621
- const events = this.sql(`SELECT ${metadata} AS metadata FROM traces WHERE ${SCOPE} AND seq >= ?
622
- AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') IN
623
- ('call_started', 'call_output', 'call_failed', 'proposal_rejected', 'capability_started', 'capability_result', 'capability_failed', 'capability_finalized') ORDER BY seq DESC LIMIT 257`)
624
- .all(...scopeValues(scope), oldest, episode.id).map(row => {
625
- const event = JSON.parse(row.metadata); event.effortObserved = event.effortObserved === 1; return event;
626
- });
627
- return { ...turnView(episode, events.slice(0, 256).reverse()),
690
+ // Tool fan-out must not evict the parent model lifecycle/usage. Both
691
+ // projections remain bounded, and a shortened detail window is explicit.
692
+ const eventWindow = kinds => this.sql(`SELECT seq, ${metadata} AS metadata FROM traces WHERE ${SCOPE} AND seq >= ?
693
+ AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') IN (${kinds}) ORDER BY seq DESC LIMIT 257`)
694
+ .all(...scopeValues(scope), oldest, episode.id);
695
+ const modelEvents = eventWindow("'call_started', 'call_output', 'call_failed', 'proposal_rejected'");
696
+ const toolEvents = eventWindow("'capability_started', 'capability_result', 'capability_failed', 'capability_finalized'");
697
+ const events = [...modelEvents.slice(0, 256), ...toolEvents.slice(0, 256)].sort((a, b) => a.seq - b.seq).map(row => {
698
+ const event = JSON.parse(row.metadata); event.effortObserved = event.effortObserved === 1; return event;
699
+ });
700
+ const callsTruncated = starts.length > 32 || modelEvents.length > 256;
701
+ return { ...turnView(episode, events),
628
702
  loopCount: starts[0]?.callIndex != null ? starts[0].callIndex + 1 : starts.length > 32 ? null : starts.length,
629
- callsTruncated: starts.length > 32 || events.length > 256, callWindowLimit: 32,
630
- usageScope: starts.length > 32 || events.length > 256 ? 'latest-call-window' : 'episode' };
703
+ callsTruncated, callWindowLimit: 32, detailsTruncated: toolEvents.length > 256 || modelEvents.length > 256, detailWindowLimit: 256,
704
+ usageScope: callsTruncated ? 'latest-call-window' : 'episode' };
631
705
  });
632
706
  return { items, nextCursor: episodes.length > limit ? items.at(-1).seq : null };
633
707
  }
@@ -655,8 +729,9 @@ export class SqlitePersonStore {
655
729
  checkOutputPublication(episode, { callId, args }) {
656
730
  outputArgs(args); this.own(episode);
657
731
  const scope = this.episodeScope(episode), active = this.one('episodes', scope, ' AND id = ?', [episode.id]);
658
- if (!active || active.status !== 'running' || active.openCapability?.callId !== callId ||
659
- active.openCapability.invocationHash !== digest({ id: 'Output.publish', args })) fail('STALE');
732
+ if (!active || active.status !== 'running' || active.openCall || active.openCapability?.callId !== callId ||
733
+ active.openCapability.invocationHash !== digest({ id: 'Output.publish', args }) ||
734
+ !this.capabilityProof(scope, episode.id, active.openCapability)) fail('STALE');
660
735
  return true;
661
736
  }
662
737
  saveOutput(episode, { callId, args, record, data }) {
@@ -0,0 +1,76 @@
1
+ import { fail, identifier, object, page, PersonError, text } from './contracts.js';
2
+ import { validateCreatedDefinition } from './created-capability-contract.js';
3
+ import { executionReadRequest } from './execution-evidence.js';
4
+ import { outputArgs } from './outputs.js';
5
+ import { personPresentation } from './presentation.js';
6
+ import { scriptInput } from './script-executor.js';
7
+
8
+ /** Pre-dispatch schema check, deliberately separate from execution: rejection
9
+ * here proves that no host effect has begun and can safely become tool feedback.
10
+ * Runtime/IO exceptions after this boundary must never be classified as args. */
11
+ export function validateToolArguments(value, schema) {
12
+ const matches = (input, rule) => {
13
+ if (rule === false) return false;
14
+ if (!rule || typeof rule !== 'object') return true;
15
+ if (rule.allOf && !rule.allOf.every(item => matches(input, item))) return false;
16
+ if (rule.not && matches(input, rule.not)) return false;
17
+ if (rule.anyOf && !rule.anyOf.some(item => matches(input, item))) return false;
18
+ if (rule.oneOf && rule.oneOf.filter(item => matches(input, item)).length !== 1) return false;
19
+ if (rule.enum && !rule.enum.some(item => JSON.stringify(item) === JSON.stringify(input))) return false;
20
+ if (Object.hasOwn(rule, 'const') && JSON.stringify(rule.const) !== JSON.stringify(input)) return false;
21
+ const type = input === null ? 'null' : Array.isArray(input) ? 'array' : typeof input;
22
+ if (rule.type && !(Array.isArray(rule.type) ? rule.type : [rule.type]).some(wanted => wanted === type || (wanted === 'integer' && Number.isInteger(input)))) return false;
23
+ if (typeof input === 'string') {
24
+ if (rule.minLength != null && [...input].length < rule.minLength) return false;
25
+ if (rule.maxLength != null && [...input].length > rule.maxLength) return false;
26
+ if (rule.pattern && !new RegExp(rule.pattern, 'u').test(input)) return false;
27
+ }
28
+ if (typeof input === 'number' && (!Number.isFinite(input) || (rule.minimum != null && input < rule.minimum) || (rule.maximum != null && input > rule.maximum))) return false;
29
+ if (Array.isArray(input)) {
30
+ if (rule.minItems != null && input.length < rule.minItems) return false;
31
+ if (rule.maxItems != null && input.length > rule.maxItems) return false;
32
+ if (rule.items && !input.every(item => matches(item, rule.items))) return false;
33
+ } else if (input && typeof input === 'object') {
34
+ if ((rule.required ?? []).some(key => !Object.hasOwn(input, key))) return false;
35
+ for (const [key, item] of Object.entries(input)) {
36
+ const known = Object.hasOwn(rule.properties ?? {}, key);
37
+ if (!known && rule.additionalProperties === false) return false;
38
+ if (known && !matches(item, rule.properties[key])) return false;
39
+ if (!known && typeof rule.additionalProperties === 'object' && !matches(item, rule.additionalProperties)) return false;
40
+ }
41
+ }
42
+ return true;
43
+ };
44
+ if (!matches(value, schema)) fail('INVALID_TOOL_INPUT');
45
+ }
46
+
47
+ /** Pure argument checks that JSON Schema cannot fully describe (UTF-8 budgets,
48
+ * URLs and dependent fields). No repository, host dispatch or guest execution.
49
+ * Only this boundary translates known validation errors to retry-safe feedback;
50
+ * callers must not wrap capability execution in the same catch. */
51
+ export function validateCapabilityArguments({ id, args }) {
52
+ try {
53
+ if (id === 'Capability.create') validateCreatedDefinition(args);
54
+ else if (id === 'Execution.read') executionReadRequest(args);
55
+ else if (id === 'Output.publish') outputArgs(args);
56
+ else if (id === 'PresentResponse') personPresentation(args);
57
+ else if (id.startsWith('Script.')) {
58
+ object(args, ['input']); scriptInput(args.input);
59
+ } else if (id === 'catalog.view') {
60
+ object(args, ['id']); identifier(args.id);
61
+ } else if (id === 'catalog.search' || id === 'Recall') {
62
+ object(args, id === 'Recall' ? ['kind', 'query', 'cursor', 'limit'] : ['query', 'cursor', 'limit'], id === 'Recall' ? ['kind'] : []);
63
+ text(args.query ?? '', 200, true);
64
+ const limit = args.limit ?? 3;
65
+ if (!Number.isInteger(limit) || limit < 1 || limit > 5) fail('INVALID_REQUEST');
66
+ if (id === 'Recall' && !['messages', 'concepts'].includes(args.kind)) fail('INVALID_REQUEST');
67
+ if (args.cursor != null) {
68
+ if (id === 'Recall' && args.kind === 'messages' && !/^lm:[a-f0-9]{32}:\d{1,6}$/.test(args.cursor)) page({ cursor: args.cursor, limit });
69
+ else identifier(args.cursor);
70
+ }
71
+ }
72
+ } catch (error) {
73
+ if (error instanceof PersonError && error.code === 'INVALID_REQUEST') fail('INVALID_TOOL_INPUT');
74
+ throw error;
75
+ }
76
+ }
@@ -77,14 +77,15 @@ export function turnView(episode, events) {
77
77
  contextBytes: token(event.contextBytes), contextBudgetBytes: token(event.contextBudgetBytes), contextWindowTokens: token(event.contextWindowTokens),
78
78
  outputTokensReserved: token(event.outputTokensReserved), imageTokensReserved: token(event.imageTokensReserved), envelopeTokensReserved: token(event.envelopeTokensReserved),
79
79
  contextSources: contextSources(event),
80
- usage: callUsage(null, false) });
80
+ usage: callUsage(null, false), capabilities: [] });
81
81
  continue;
82
82
  }
83
- const call = calls.get(event.callId);
83
+ const call = calls.get(event.modelCallId ?? event.callId);
84
84
  if (!call) continue;
85
85
  if (event.kind === 'call_output' || event.kind === 'call_failed') {
86
86
  call.status = event.kind === 'call_output' ? 'completed' : ['CANCELLED', 'INTERRUPTED'].includes(event.code) ? event.code.toLowerCase() : 'failed';
87
87
  call.endedAt = event.createdAt;
88
+ if (['native-tools', 'cognition'].includes(event.protocol)) call.protocol = event.protocol;
88
89
  // Legacy call lifecycle timestamps include queue/pause time. Do not label
89
90
  // them as provider duration when no actual dispatch measurement was stored.
90
91
  call.elapsedMs = token(event.requestElapsedMs);
@@ -93,11 +94,26 @@ export function turnView(episode, events) {
93
94
  call.usage = callUsage(event.usage, event.kind === 'call_output');
94
95
  } else if (event.kind === 'proposal_rejected') {
95
96
  call.status = 'rejected'; call.code = string(event.code);
96
- } else if (event.kind === 'capability_started') {
97
- call.capability = { id: string(event.capabilityId), status: 'running', code: null };
98
- } else if (['capability_result', 'capability_failed', 'capability_finalized'].includes(event.kind)) {
99
- call.capability = { id: string(event.capabilityId) ?? call.capability?.id ?? null,
100
- status: (event.outcome ?? event.kind) === 'capability_failed' ? 'failed' : 'completed', code: string(event.code) ?? string(event.terminalCode) };
97
+ } else if (['capability_started', 'capability_result', 'capability_failed', 'capability_finalized'].includes(event.kind)) {
98
+ let capability = call.capabilities.find(item => item.callId === event.callId);
99
+ if (!capability) {
100
+ capability = { callId: event.callId, ...(event.modelCallId ? { modelCallId: event.modelCallId,
101
+ toolUseId: string(event.toolUseId), toolIndex: token(event.toolIndex) } : {}), id: string(event.capabilityId),
102
+ status: 'running', code: null, createdAt: event.kind === 'capability_started' ? event.createdAt : null, endedAt: null };
103
+ call.capabilities.push(capability);
104
+ }
105
+ if (event.kind !== 'capability_started') {
106
+ capability.id = string(event.capabilityId) ?? capability.id;
107
+ capability.status = (event.outcome ?? event.kind) === 'capability_failed' ? 'failed' : 'completed';
108
+ capability.code = string(event.code) ?? string(event.terminalCode);
109
+ capability.endedAt = event.createdAt;
110
+ if (event.kind === 'capability_finalized') Object.assign(capability, { afterTerminal: true, accepted: false,
111
+ terminalCode: string(event.terminalCode) });
112
+ }
113
+ // Compatibility for scalar readers. Native one-to-many consumers use the
114
+ // complete array; never silently select one member of a multi-tool call.
115
+ if (call.capabilities.length === 1) call.capability = { id: capability.id, status: capability.status, code: capability.code };
116
+ else delete call.capability;
101
117
  }
102
118
  }
103
119
  const items = [...calls.values()].sort((a, b) => a.index - b.index);
@@ -238,42 +238,45 @@ Guidelines:
238
238
  if (result.stderr) parts.push(`STDERR:\n${result.stderr}`);
239
239
  if (result.truncated) parts.push('[Captured output truncated; command exit/timeout status is reported separately. Use a background task for a durable log.]');
240
240
  if (result.timedOut) parts.push(`\n(Command timed out after ${timeout}ms)`);
241
- if (result.terminationError) {
241
+ // runProcess resolves after close/owned cleanup, or reports an explicit
242
+ // terminationError. A missing exit code is not confirmed exit evidence.
243
+ const terminationConfirmed = !result.terminationError && Number.isInteger(result.exitCode);
244
+ if (!terminationConfirmed) {
242
245
  parts.push([
243
- `WARNING: ${result.terminationError}`,
244
- 'The command timed out, but process-tree termination could not be confirmed. The command may still be running.',
245
- 'Decide whether to inspect or stop it, retry, or use background=true.',
246
+ `WARNING: ${result.terminationError || 'No confirmed process exit code was captured.'}`,
247
+ 'Process-tree termination could not be confirmed. The command may still be running.',
248
+ 'Inspect or stop it before retrying or launching another command; use background=true only after resolving its lifecycle.',
246
249
  ].join('\n'));
247
250
  }
248
- if (result.timedOut) {
251
+ if (result.timedOut || !terminationConfirmed) {
249
252
  ctx?.requestToolBatchBarrier?.({
250
- kind: 'owned_timeout',
253
+ kind: result.timedOut ? 'owned_timeout' : 'exit_unconfirmed',
251
254
  message: [
252
- result.terminationError
253
- ? 'The foreground Bash command timed out and process-tree termination could not be confirmed.'
254
- : 'The foreground Bash command timed out.',
255
+ result.timedOut ? 'The foreground Bash command timed out.' : 'The foreground Bash command did not report a confirmed exit.',
256
+ ...(!terminationConfirmed ? ['Process-tree termination could not be confirmed.'] : []),
255
257
  'Return this result to the model before executing another tool call from the same batch.',
256
258
  ].join(' '),
257
259
  });
258
260
  }
259
261
 
260
262
  const output = parts.join('\n');
261
- if (result.exitCode !== 0 || result.timedOut || result.terminationError) {
263
+ if (result.exitCode !== 0 || result.timedOut || !terminationConfirmed) {
262
264
  const failureType = result.timedOut
263
- ? (result.terminationError ? 'timeout_unconfirmed' : 'timeout_confirmed')
264
- : result.terminationError || result.exitCode === null ? 'exit_unconfirmed' : 'exit_nonzero';
265
+ ? (terminationConfirmed ? 'timeout_confirmed' : 'timeout_unconfirmed')
266
+ : terminationConfirmed ? 'exit_nonzero' : 'exit_unconfirmed';
267
+ const exitCode = Number.isInteger(result.exitCode) ? result.exitCode : null;
265
268
  return JSON.stringify({
266
269
  error: result.timedOut
267
- ? `Command timed out after ${timeout}ms${result.terminationError ? '; process-tree termination was not confirmed' : ''}.`
268
- : `Command exited with code ${result.exitCode}.`,
270
+ ? `Command timed out after ${timeout}ms${terminationConfirmed ? '' : '; process-tree termination was not confirmed'}.`
271
+ : terminationConfirmed ? `Command exited with code ${exitCode}.` : 'Command termination was not confirmed; the command may still be running.',
269
272
  code: `bash_${failureType}`,
270
273
  errorEffect: 'unknown',
271
274
  failureType,
272
- exitCode: Number.isInteger(result.exitCode) ? result.exitCode : null,
273
- status: `Exit code: ${result.exitCode}`,
275
+ exitCode,
276
+ status: `Exit code: ${exitCode}${result.timedOut ? '; timed out' : ''}; termination ${terminationConfirmed ? 'confirmed' : 'unconfirmed'}`,
274
277
  workingDirectory: `Working directory: ${cwd}`,
275
278
  timedOut: result.timedOut === true,
276
- terminationConfirmed: result.timedOut ? !result.terminationError : null,
279
+ terminationConfirmed,
277
280
  terminationError: result.terminationError || null,
278
281
  cwd,
279
282
  output: output || '(no output)',
@@ -48,6 +48,7 @@ Guidelines:
48
48
  },
49
49
  old_string: {
50
50
  type: 'string',
51
+ minLength: 1,
51
52
  description: {
52
53
  en: 'The exact text to find and replace',
53
54
  zh: '要查找并替换的精确文本',
@@ -76,7 +77,9 @@ Guidelines:
76
77
  async execute(input, ctx) {
77
78
  const { file_path, old_string, new_string, replace_all = false } = input;
78
79
  if (!file_path) return JSON.stringify({ errorEffect: 'none', error: 'file_path is required' });
79
- if (old_string === undefined) return JSON.stringify({ errorEffect: 'none', error: 'old_string is required' });
80
+ // Guard even callers that bypass schema validation, before any filesystem IO.
81
+ // An empty needle never advances the synchronous occurrence-counting loop.
82
+ if (typeof old_string !== 'string' || old_string.length === 0) return JSON.stringify({ errorEffect: 'none', error: 'old_string must be non-empty text' });
80
83
  if (new_string === undefined) return JSON.stringify({ errorEffect: 'none', error: 'new_string is required' });
81
84
  if (old_string === new_string) return JSON.stringify({ errorEffect: 'none', error: 'old_string and new_string are identical' });
82
85