@agdf/mcp-server 0.20.2 → 0.31.0

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.
@@ -0,0 +1,306 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+
3
+ const writeTools = new Set(['agdf_project', 'agdf_run', 'agdf_prepare', 'agdf_record', 'agdf_approve', 'agdf_revise', 'agdf_closeout']);
4
+ const tools = new Set([...writeTools, 'agdf_inspect']);
5
+ const referencePattern = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/u;
6
+ const WHOLE_PAYLOAD = '/command/payload';
7
+ const referenceSchema = { type: 'object', additionalProperties: false, required: ['prepared_call_id', 'inputs'], properties: {
8
+ prepared_call_id: { type: 'string', pattern: '^[0-9a-f-]{36}$' },
9
+ inputs: { type: 'object', maxProperties: 16 },
10
+ } };
11
+ const inputNames = pointers => {
12
+ const parts = pointers.map(pointer => pointer.split('/').slice(3));
13
+ const leaves = parts.map(path => path.at(-1) || 'payload');
14
+ const names = parts.map((path, index) => {
15
+ const base = /^[a-z_]/u.test(leaves[index]) && leaves.filter(leaf => leaf === leaves[index]).length === 1
16
+ ? leaves[index] : path.join('_') || 'payload';
17
+ return /^[a-z_]/u.test(base) ? base : `field_${base}`;
18
+ });
19
+ // Flattened paths may collide with literal underscore field names. The
20
+ // deterministic suffix belongs to this form only, never to a binding or UUID.
21
+ const reserved = new Set(names), used = new Set();
22
+ return names.map(base => {
23
+ let name = base, index = 1;
24
+ if (names.filter(candidate => candidate === base).length > 1 || used.has(name)) {
25
+ do { name = `${base}_${index++}`; } while (reserved.has(name) || used.has(name));
26
+ }
27
+ used.add(name); return name;
28
+ });
29
+ };
30
+
31
+ // Argument storage, never permission or policy. Owned plugin publishers may
32
+ // retain exact templates across connections; ad-hoc servers stay connection-local. The original
33
+ // group, immutable binding, UUID and every Core validator still execute unchanged.
34
+ export function preparedTransportSchema(name, schema) {
35
+ if (!tools.has(name)) return schema;
36
+ const { properties, $defs, type, additionalProperties, ...guards } = schema;
37
+ return { type, additionalProperties, required: [], ...($defs ? { $defs } : {}),
38
+ properties: { ...properties, ...referenceSchema.properties,
39
+ ...(name === 'agdf_inspect' ? { inputs: { type: 'object', maxProperties: 0 } }
40
+ : name === 'agdf_prepare' ? { inputs: { ...referenceSchema.properties.inputs, description: 'Named required fields; ready: {}.' } } : {}) },
41
+ anyOf: [{ ...guards, not: { anyOf: [...(guards.not ? [guards.not] : []),
42
+ { required: ['prepared_call_id'] }, { required: ['inputs'] }] } },
43
+ { required: referenceSchema.required, maxProperties: 2 }],
44
+ };
45
+ }
46
+ export function createPreparedCallTransport({ now = Date.now, maxEntries = 256, lifetimeMs = 30 * 60_000, validateRead = () => false, store } = {}) {
47
+ if (typeof validateRead !== 'function') throw Error('prepared_read_validator_invalid');
48
+ if (store && (typeof store.load !== 'function' || typeof store.register !== 'function')) throw Error('prepared_call_store_unavailable');
49
+ const rows = new Map(), signatures = new Map(), expired = new Map();
50
+ let unavailableReason = 'prepared_call_unavailable';
51
+ const storeError = error => ['runtime_provenance_invalid', 'dispatch_busy', 'prepared_call_store_unavailable'].includes(error?.message)
52
+ ? error.message : 'prepared_call_store_unavailable';
53
+ const fail = code => { throw Object.assign(Error(code), { facts: { code, dispatch: 'not_dispatched', field: 'arguments' } }); };
54
+ function expire() {
55
+ for (const [id, row] of rows) if (now() - row.created >= lifetimeMs) {
56
+ rows.delete(id); signatures.delete(row.signature); expired.set(id, now());
57
+ }
58
+ // Tombstones distinguish observed expiry from a typo without keeping an
59
+ // unbounded connection history or retaining the expired raw arguments.
60
+ while (expired.size > maxEntries) expired.delete(expired.keys().next().value);
61
+ }
62
+ function referenceState(id) {
63
+ if (typeof id !== 'string' || !id) return 'reference_empty';
64
+ if (!referencePattern.test(id)) return 'reference_invalid';
65
+ expire();
66
+ if (store) {
67
+ try {
68
+ const retained = store.load(id);
69
+ if (retained.state === 'available') {
70
+ const parsed = JSON.parse(retained.value), signature = createHash('sha256').update(retained.value).digest('hex');
71
+ if (retained.signature !== signature || !Number.isSafeInteger(retained.created) || retained.created < 0
72
+ || Buffer.byteLength(retained.value) > 65536 || !validTemplate(parsed.call, parsed.required)) throw Error('runtime_provenance_invalid');
73
+ if (now() - retained.created >= lifetimeMs) return 'reference_expired';
74
+ if (!rows.has(id) && rows.size >= maxEntries) { unavailableReason = 'prepared_call_unavailable'; return 'reference_unavailable'; }
75
+ rows.set(id, { call: parsed.call, required: parsed.required, names: inputNames(parsed.required), signature, created: retained.created });
76
+ signatures.set(signature, id); return 'available';
77
+ }
78
+ if (retained.state === 'reference_expired') return 'reference_expired';
79
+ if (retained.state !== 'reference_unknown') throw Error('runtime_provenance_invalid');
80
+ return 'reference_unknown';
81
+ } catch (error) { unavailableReason = storeError(error); return 'reference_unavailable'; }
82
+ }
83
+ return rows.has(id) ? 'available' : expired.has(id) ? 'reference_expired' : 'reference_unknown';
84
+ }
85
+ const operation = call => call.tool === 'agdf_inspect'
86
+ ? { kind: 'inspect.read', read_only: true, operation: call.arguments.operation,
87
+ ...Object.fromEntries(['target_id', 'run_id', 'expected_revision_id',
88
+ 'planning', 'gate', 'presentation_id', 'module', 'cursor']
89
+ .filter(key => call.arguments[key] !== undefined).map(key => [key, structuredClone(call.arguments[key])])) }
90
+ : ({ kind: call.arguments.command?.kind ?? null,
91
+ ...Object.fromEntries(['operation_id', 'target_id', 'run_id', 'expected_revision_id']
92
+ .filter(key => call.arguments[key] !== undefined).map(key => [key, call.arguments[key]])),
93
+ ...Object.fromEntries(['gate', 'presentation_id'].filter(key => call.arguments.command?.payload?.[key] !== undefined)
94
+ .map(key => [key, call.arguments.command.payload[key]])) });
95
+ function validTemplate(call, required = []) {
96
+ if (!tools.has(call?.tool) || !call.arguments || typeof call.arguments !== 'object' || Array.isArray(call.arguments)
97
+ || !Array.isArray(required)) return false;
98
+ if (call.tool === 'agdf_inspect') {
99
+ if (required.length || typeof call.arguments.operation !== 'string' || !call.arguments.operation
100
+ || ['prepared_call_id', 'inputs', 'command'].some(key => Object.hasOwn(call.arguments, key))) return false;
101
+ // Only the owning server can attest the original read schema and tool's
102
+ // read-only declaration. This callback never fills missing selectors.
103
+ try { return validateRead(structuredClone(call)) === true; } catch { return false; }
104
+ }
105
+ if (!referencePattern.test(call.arguments.operation_id ?? '')
106
+ || typeof call.arguments.command?.kind !== 'string' || !call.arguments.command.kind || !Array.isArray(required)
107
+ || required.length > 16 || new Set(required).size !== required.length
108
+ // Core asks for the whole payload only when no single variant applies;
109
+ // it is then the one input, never combined with nested field pointers.
110
+ || required.includes(WHOLE_PAYLOAD) && required.length !== 1
111
+ || required.some(p => !/^\/command\/payload(?:\/(?:[a-z_]+|[0-9]+))*$/u.test(p)
112
+ || p.split('/').some(k => ['__proto__', 'prototype', 'constructor'].includes(k)))) return false;
113
+ return true;
114
+ }
115
+ function originalProjection(raw, fields, names = inputNames(fields)) {
116
+ return { required_inputs: names, input_fields: names.map(name => ({ name, required: true })), operation: operation(raw), recovery_only: {
117
+ original_call: structuredClone(raw), required_inputs: [...fields], authorizes: false,
118
+ input_fields: names.map((name, index) => ({ name, pointer: fields[index] })),
119
+ instruction: raw.tool === 'agdf_inspect'
120
+ ? 'Recovery only, never the normal call path. Across reconnect, preserve this exact original read and its target, Run, revision, cursor and original operation-status selection. Never infer a target, refresh a selected revision or replace the original status UUID.'
121
+ : 'Recovery only, never the normal call path. Preserve this exact original raw call and UUID across reconnect. For an uncertain dispatch, first observe the original operation and retain exactly the inputs already submitted; never refresh the revision, invent a UUID or change the payload to escape uncertainty.' } };
122
+ }
123
+ const unregisteredRecovery = (call, required = []) => validTemplate(call, required) ? originalProjection(call, required) : null;
124
+ function submit(call, required = []) {
125
+ if (!validTemplate(call, required)) return undefined;
126
+ const value = JSON.stringify({ call, required });
127
+ if (Buffer.byteLength(value) > 65536) return undefined;
128
+ expire();
129
+ const signature = createHash('sha256').update(value).digest('hex');
130
+ let id = signatures.get(signature);
131
+ if (!id) {
132
+ if (rows.size >= maxEntries) return undefined;
133
+ let created = now();
134
+ if (store) {
135
+ try {
136
+ const retained = store.register({ value, signature, created });
137
+ if (!retained) return undefined;
138
+ if (!referencePattern.test(retained.id ?? '') || !Number.isSafeInteger(retained.created) || retained.created < 0) throw Error('runtime_provenance_invalid');
139
+ id = retained.id; created = retained.created;
140
+ } catch (error) { unavailableReason = storeError(error); return undefined; }
141
+ } else id = randomUUID();
142
+ signatures.set(signature, id);
143
+ rows.set(id, { call: JSON.parse(value).call, required: [...required], names: inputNames(required), signature, created });
144
+ } else if (store && referenceState(id) !== 'available') {
145
+ return undefined;
146
+ }
147
+ return { tool: call.tool, arguments: { prepared_call_id: id, inputs: {} } };
148
+ }
149
+ function projectCall(call, required = []) {
150
+ unavailableReason = 'prepared_call_unavailable';
151
+ if (!tools.has(call?.tool)) return call;
152
+ const alreadyPrepared = Object.hasOwn(call.arguments ?? {}, 'prepared_call_id');
153
+ const saved = alreadyPrepared && referenceState(call.arguments.prepared_call_id) === 'available'
154
+ ? rows.get(call.arguments.prepared_call_id) : null;
155
+ if (alreadyPrepared && (!saved || saved.call.tool !== call.tool
156
+ || Object.keys(call.arguments).some(key => !['prepared_call_id', 'inputs'].includes(key))
157
+ || !call.arguments.inputs || typeof call.arguments.inputs !== 'object' || Array.isArray(call.arguments.inputs)
158
+ || call.tool === 'agdf_inspect' && Object.keys(call.arguments.inputs).length)) return null;
159
+ const raw = saved?.call ?? call, fields = saved?.required ?? required;
160
+ const prepared = alreadyPrepared ? { tool: call.tool, arguments: structuredClone(call.arguments) } : submit(raw, fields);
161
+ if (!prepared) return null;
162
+ const names = [...(saved?.names ?? rows.get(prepared.arguments.prepared_call_id).names)];
163
+ return { ...prepared, ...originalProjection(raw, fields, names) };
164
+ }
165
+ return {
166
+ // Transport availability only: never a gate, review or permission decision.
167
+ // An offered input form legitimately has no authored values yet.
168
+ isAvailable(call) {
169
+ const args = call?.arguments;
170
+ if (!tools.has(call?.tool) || !args || typeof args !== 'object'
171
+ || !Object.hasOwn(args, 'prepared_call_id') || !Object.hasOwn(args, 'inputs')
172
+ || !args.inputs || typeof args.inputs !== 'object' || Array.isArray(args.inputs)
173
+ || Object.keys(args).some(key => !['prepared_call_id', 'inputs'].includes(key))
174
+ || referenceState(args.prepared_call_id) !== 'available') return false;
175
+ return rows.get(args.prepared_call_id).call.tool === call.tool
176
+ && (call.tool !== 'agdf_inspect' || Object.keys(args.inputs).length === 0);
177
+ },
178
+ decorate: function projectResult(result) {
179
+ const value = structuredClone(result);
180
+ // Explicit terminal/uncertain recovery can be resumed on a new connection.
181
+ // Keep its read requests independent of shared normal-form objects.
182
+ if (value.recovery && typeof value.recovery === 'object') value.recovery = structuredClone(value.recovery);
183
+ if (value.next_read?.result) value.next_read.result = projectResult(value.next_read.result);
184
+ if (value.next_read?.tool === 'agdf_inspect' && value.next_read.arguments) {
185
+ // tool/arguments are the honest record of the adapter's actual read.
186
+ // A separate normal retry retains that exact read without retyping it.
187
+ const raw = { tool: value.next_read.tool, arguments: value.next_read.arguments };
188
+ const retry = projectCall(raw);
189
+ value.next_read.call = retry ? { tool: retry.tool, arguments: retry.arguments } : null;
190
+ const projection = retry ?? unregisteredRecovery(raw);
191
+ if (projection) for (const key of ['required_inputs', 'input_fields', 'operation', 'recovery_only'])
192
+ value.next_read[key] = projection[key];
193
+ if (!retry) value.next_read.transport_reason = unavailableReason;
194
+ }
195
+ for (const owner of [value, value.report, value.continuation, value.recovery, value.navigation,
196
+ value.family_result, value.presentation_delivery, value.next_action].filter(Boolean)) {
197
+ const unavailable = [];
198
+ const projectReadyCall = call => {
199
+ if (owner === value.recovery && call?.tool === 'agdf_inspect') return call;
200
+ const projected = projectCall(call);
201
+ if (!projected) {
202
+ const recovery = unregisteredRecovery(call);
203
+ if (recovery) unavailable.push({ ...recovery.recovery_only, operation: recovery.operation, transport_reason: unavailableReason });
204
+ }
205
+ return projected;
206
+ };
207
+ const projectHelpers = container => {
208
+ for (const key of ['resume_call', 'operation_status_call', 'contract_call', 'first_page_call', 'larger_source_call'])
209
+ if (container?.[key]) container[key] = projectReadyCall(container[key]);
210
+ };
211
+ for (const form of [owner.prepared_call, owner.material_call, owner.approval_call, ...owner.recording_calls ?? []].filter(Boolean)) {
212
+ if (owner === value.recovery && form.call?.tool === 'agdf_inspect') continue;
213
+ const raw = form.call, projected = projectCall(raw, form.required_inputs);
214
+ delete form.submit;
215
+ if (!tools.has(raw?.tool)) continue;
216
+ form.call = projected ? { tool: projected.tool, arguments: projected.arguments } : null;
217
+ if (projected) {
218
+ form.required_inputs = projected.required_inputs; form.input_fields = projected.input_fields;
219
+ form.operation = projected.operation; form.recovery_only = projected.recovery_only;
220
+ form.instruction = (raw.tool === 'agdf_inspect'
221
+ ? 'Use call.tool with call.arguments unchanged, including inputs:{}. The adapter retains the exact read target and selectors. Do not rebuild an envelope or fill any inputs. recovery_only retains the original read for reconnect; it grants no authority. '
222
+ : 'Use call.tool with call.arguments. Fill only call.arguments.inputs using the named required_inputs fields; keep its reference unchanged. The adapter maps those names to the exact stored command fields and retains binding and UUID. Do not construct a raw envelope or copy technical JSON paths. Keep authored text as string data using host-supported serialization. Check failure before reading returned fields. recovery_only is separate original-request recovery, never the normal write path. ') + (form.instruction ?? '');
223
+ } else {
224
+ const recovery = unregisteredRecovery(raw, form.required_inputs);
225
+ if (recovery) Object.assign(form, recovery);
226
+ form.state = 'transport_unavailable'; form.transport_reason = unavailableReason;
227
+ form.authorizes = false;
228
+ }
229
+ projectHelpers(form);
230
+ projectHelpers(form.content_contract);
231
+ }
232
+ if (owner.next_calls) owner.next_calls = owner.next_calls.map(projectReadyCall).filter(Boolean);
233
+ projectHelpers(owner);
234
+ projectHelpers(owner.content_contract);
235
+ if (unavailable.length) owner.recovery_only = { ...owner.recovery_only, authorizes: false,
236
+ unavailable_calls: [...(owner.recovery_only?.unavailable_calls ?? []), ...unavailable] };
237
+ for (const call of owner.next_calls ?? []) if (call?.arguments?.prepared_call_id) {
238
+ call.required_inputs = [];
239
+ call.submission_instruction = 'This call is complete. Forward arguments unchanged, including inputs:{}. No cloning helper, new reference, UUID or binding is needed. Core still checks current eligibility.';
240
+ }
241
+ }
242
+ // The normal action has exactly the same call shape as named forms and
243
+ // ready calls; its original raw request stays in the explicit recovery branch.
244
+ if (value.next_action?.call) {
245
+ const projected = projectCall(value.next_action.call, value.next_action.required_inputs);
246
+ if (projected) value.next_action = { ...value.next_action,
247
+ call: { tool: projected.tool, arguments: projected.arguments }, required_inputs: projected.required_inputs,
248
+ input_fields: projected.input_fields, operation: projected.operation, recovery_only: projected.recovery_only };
249
+ else if (tools.has(value.next_action.call?.tool))
250
+ value.next_action = { ...value.next_action, ...(unregisteredRecovery(value.next_action.call, value.next_action.required_inputs) ?? {}),
251
+ state: 'transport_unavailable', authorizes: false, transport_reason: unavailableReason, call: null };
252
+ }
253
+ return value;
254
+ },
255
+ validationHint(name, input) {
256
+ if (!tools.has(name) || !input || typeof input !== 'object') return '';
257
+ const id = input.prepared_call_id ?? input.submit_id;
258
+ const prefix = 'Normal call arguments contain only prepared_call_id and inputs. Copy the supplied call.arguments; never submit_id, a new ID or duplicated raw fields. ';
259
+ if (typeof id !== 'string') {
260
+ if (!Object.hasOwn(input, 'inputs')) return '';
261
+ const raw = Object.keys(input).some(key => !['inputs', 'prepared_call_id', 'submit_id'].includes(key));
262
+ return prefix + (raw ? 'A full raw call cannot contain inputs. ' : 'The prepared reference is missing. ')
263
+ + 'Fill only its named required_inputs fields. Raw requests belong only to the explicit recovery path. No operation was dispatched.';
264
+ }
265
+ const state = referenceState(id);
266
+ if (state === 'reference_unavailable') return 'The owned reference store is unavailable or could not establish its integrity. Preserve the original receipt and saved request; no operation was dispatched by this attempt.';
267
+ if (state !== 'available') return state === 'reference_expired'
268
+ ? 'The supplied reference was issued previously and its original lifetime has expired. Use saved original-request/UUID observation and recovery; never generate another binding or UUID. No operation was dispatched.'
269
+ : state === 'reference_empty' ? 'The prepared reference is empty. Copy the existing reference in the supplied call.arguments; never use an empty placeholder. No operation was dispatched.'
270
+ : state === 'reference_invalid' ? 'The prepared reference is malformed. Copy the supplied call.arguments unchanged; never retype or autocorrect a reference. No operation was dispatched.'
271
+ : 'The reference is unknown on this connection; this is not proof of expiry. Copy the exact supplied call.arguments or use saved original-request/UUID recovery. Never guess or autocorrect. No operation was dispatched.';
272
+ const row = rows.get(id);
273
+ if (row.call.tool !== name) return 'A prepared reference cannot cross permission groups. No operation was dispatched.';
274
+ const conflict = Object.entries(input).some(([key, value]) => !['prepared_call_id', 'submit_id', 'inputs'].includes(key)
275
+ && JSON.stringify(row.call.arguments[key]) !== JSON.stringify(value));
276
+ if (conflict) return 'Do not merge raw and prepared calls: their fixed fields conflict. Review the intended binding; neither binding was executed.';
277
+ return (prefix + (row.required.length
278
+ ? `Fill exactly these named inputs from actual author/review/reply data: ${JSON.stringify(row.names)}. Do not invent values or mix named and legacy pointer inputs.`
279
+ : `Exact ready call: ${JSON.stringify({ tool: name, arguments: { prepared_call_id: id, inputs: {} } })}`)).slice(0, 768);
280
+ },
281
+ resolve(name, input) {
282
+ if (!Object.hasOwn(input, 'prepared_call_id')) return input;
283
+ const state = referenceState(input.prepared_call_id);
284
+ if (state !== 'available') return fail(state === 'reference_expired' ? 'session_expired'
285
+ : state === 'reference_unknown' ? 'prepared_call_unknown'
286
+ : state === 'reference_unavailable' ? unavailableReason === 'prepared_call_unavailable' ? 'resource_limit' : unavailableReason
287
+ : 'command_schema_invalid');
288
+ const row = rows.get(input.prepared_call_id);
289
+ if (row.call.tool !== name || Object.keys(input).some(k => !['prepared_call_id', 'inputs'].includes(k))
290
+ || !input.inputs || typeof input.inputs !== 'object' || Array.isArray(input.inputs)
291
+ || Buffer.byteLength(JSON.stringify(input)) > 65536) return fail('command_schema_invalid');
292
+ const keys = Object.keys(input.inputs);
293
+ const named = keys.length === row.names.length && row.names.every(key => Object.hasOwn(input.inputs, key));
294
+ const legacy = keys.length === row.required.length && row.required.every(key => Object.hasOwn(input.inputs, key));
295
+ if (!named && !legacy) return fail('command_schema_invalid');
296
+ const args = structuredClone(row.call.arguments);
297
+ for (const [index, pointer] of row.required.entries()) {
298
+ const parts = pointer.slice(1).split('/'); let destination = args;
299
+ for (const part of parts.slice(0, -1)) destination = destination[part];
300
+ destination[parts.at(-1)] = structuredClone(input.inputs[named ? row.names[index] : pointer]);
301
+ }
302
+ return args;
303
+ },
304
+ close() { rows.clear(); signatures.clear(); expired.clear(); },
305
+ };
306
+ }
@@ -0,0 +1,62 @@
1
+ // Only a single, exact Core-supplied inspect call may be folded into a response.
2
+ // Dispatch can repair sealed relationships; no dispatch or command is auto-run.
3
+ const operations = new Set(['authoring-inputs', 'gate-check', 'operation-contract']);
4
+ // Only read results fold a follow-up read. A write result returns at once so a
5
+ // slow or large follow-up can never delay or enlarge the receipt of a committed
6
+ // write; its next read stays a normal supplied call.
7
+ const readableOutcome = new Set(['inspect_result', 'skill_continuation', 'intake_continuation', 'control_result']);
8
+
9
+ export function suppliedReadContinuation(result) {
10
+ if (!result || result.terminal === true || !readableOutcome.has(result.outcome)) return null;
11
+ if (result.outcome === 'inspect_result' && result.report?.available === false) return null;
12
+ // Source pages are consumed explicitly, in order. Folding page N+1 into page N
13
+ // would create two competing source locations and could lose the first form.
14
+ if (result.operation === 'authoring-inputs') return null;
15
+ // Bootstrap state and initialization have compact response contracts. Their
16
+ // supplied setup/Run operation-contract remains an explicit next action
17
+ // instead of duplicating the full command schema inside the bootstrap result.
18
+ if (result.operation === 'project-state' || result.binding?.kind === 'project.initialize') return null;
19
+ // These are authoritative normal next steps, not recovery or optional preview.
20
+ const lists = [result.next_calls, result.continuation?.next_calls,
21
+ result.report?.next_calls, result.family_result?.next_calls].filter(Array.isArray);
22
+ const candidates = new Map(lists.flat().filter(Boolean).map(call => [JSON.stringify([call.tool, call.arguments]), call]));
23
+ if (candidates.size !== 1) return null;
24
+ const call = [...candidates.values()][0];
25
+ if (call.tool !== 'agdf_inspect' || !operations.has(call.arguments?.operation)
26
+ || call.arguments?.planning && call.arguments?.run_id) return null;
27
+ return { tool: call.tool, arguments: structuredClone(call.arguments) };
28
+ }
29
+
30
+ export async function appendSafeReadContinuation(result, { execute, validate, allowed = true } = {}) {
31
+ let call, phase = 'selection';
32
+ // execute owns normal parsing, policy and result validation. A follow-up failure
33
+ // is separate from the original read result and never replaces it.
34
+ try {
35
+ call = allowed && suppliedReadContinuation(result);
36
+ if (!call) return result;
37
+ phase = 'validation';
38
+ if (!validate?.(call)) return result;
39
+ phase = 'execute';
40
+ const observed = await execute(call);
41
+ return { ...result, next_read: { ...call, result: observed, authorizes: false } };
42
+ } catch {
43
+ if (!call) return result;
44
+ return { ...result, next_read: { ...call, result: null, authorizes: false,
45
+ failure: { code: 'read_followup_unavailable', phase, original_effect_unchanged: true,
46
+ instruction: 'The original operation result and receipt remain authoritative. No write was repeated. Inspect the supplied read separately; never replay a successful write to obtain its next step.' } } };
47
+ }
48
+ }
49
+
50
+ // Core validates the original domain before this explicit MCP wire projection.
51
+ // Open result schemas already permit the additive fields; dispatch is closed.
52
+ export function nextActionOutputSchema(name, schema) {
53
+ if (name !== 'agdf_dispatch' || !schema) return schema;
54
+ return { ...schema, properties: { ...schema.properties,
55
+ next_action: { type: 'object', required: ['state', 'authorizes'],
56
+ properties: { state: { type: 'string' }, authorizes: { const: false } } },
57
+ next_read: { type: 'object', required: ['tool', 'arguments', 'result', 'authorizes'],
58
+ properties: { tool: { const: 'agdf_inspect' }, arguments: { type: 'object' },
59
+ result: { type: ['object', 'null'] }, authorizes: { const: false } } },
60
+ recovery_only: { type: 'object' },
61
+ } };
62
+ }