@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.
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +69 -69
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +1 -1
- package/package.json +1 -1
- package/yeaft/llm/adapter.js +15 -0
- package/yeaft/llm/anthropic.js +49 -16
- package/yeaft/llm/openai-responses.js +179 -61
- package/yeaft/person/capabilities.js +61 -5
- package/yeaft/person/contracts.js +7 -13
- package/yeaft/person/execution-evidence.js +14 -0
- package/yeaft/person/execution-protocol.js +288 -0
- package/yeaft/person/local-memory-worker.js +23 -13
- package/yeaft/person/native-tools.js +17 -10
- package/yeaft/person/provider.js +65 -18
- package/yeaft/person/request-budget.js +26 -0
- package/yeaft/person/runtime.js +155 -142
- package/yeaft/person/service.js +17 -6
- package/yeaft/person/sqlite-repository.js +3 -0
- package/yeaft/person/sqlite-store.js +113 -38
- package/yeaft/person/tool-arguments.js +76 -0
- package/yeaft/person/turn-diagnostics.js +23 -7
- package/yeaft/tools/bash.js +20 -17
- package/yeaft/tools/file-edit.js +4 -1
package/yeaft/person/service.js
CHANGED
|
@@ -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) =>
|
|
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 {
|
|
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 :
|
|
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: '
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
341
|
-
if (
|
|
342
|
-
|
|
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',
|
|
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
|
|
399
|
-
|
|
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.
|
|
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')
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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
|
|
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',
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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:
|
|
630
|
-
usageScope:
|
|
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 })
|
|
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 (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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);
|
package/yeaft/tools/bash.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
'
|
|
245
|
-
'
|
|
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.
|
|
253
|
-
|
|
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 ||
|
|
263
|
+
if (result.exitCode !== 0 || result.timedOut || !terminationConfirmed) {
|
|
262
264
|
const failureType = result.timedOut
|
|
263
|
-
? (
|
|
264
|
-
:
|
|
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${
|
|
268
|
-
: `Command exited with code ${
|
|
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
|
|
273
|
-
status: `Exit code: ${result.
|
|
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
|
|
279
|
+
terminationConfirmed,
|
|
277
280
|
terminationError: result.terminationError || null,
|
|
278
281
|
cwd,
|
|
279
282
|
output: output || '(no output)',
|
package/yeaft/tools/file-edit.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|