@ggui-ai/negotiator 0.25.0 → 0.26.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,521 @@
1
+ /**
2
+ * Declared-member preservation: the deterministic backstop that keeps a
3
+ * repair FAITHFUL to what the agent DECLARED (ggui#1421 for actions,
4
+ * ggui#1430 for every spec).
5
+ *
6
+ * The repair tool authors only a few members per entry:
7
+ * - `label` / `schema` on an action;
8
+ * - `schema` / `required` on a prop;
9
+ * - `schema` / `default` on a context slot;
10
+ * - `schema` / `source` on a stream channel;
11
+ * - `toolInfo.inputSchema` / `toolInfo.outputSchema` / `usage` on a tool.
12
+ * Every other member is the agent's declaration: an action's `oneShot` (the
13
+ * one-shot line keys on it being ON THE SERVED CONTRACT, ggui#1108 →
14
+ * ggui#1223), a stream's `mode` / `replay` / `complete`, a prop's
15
+ * `default`, a tool's `toolInfo.description`. A repair that re-emits an entry
16
+ * from the tool's answer alone drops them silently. So the overlay below puts
17
+ * the draft's declarations back on the repaired candidate, under the SAME
18
+ * KEY, and names whatever it cannot keep.
19
+ *
20
+ * The toolbox is the agent's too. A repair that drops a tool entry the draft
21
+ * declared leaves every `nextStep` naming it dangling, so a draft tool the
22
+ * candidate lacks is restored WHOLE. Only tools: a repair may legitimately
23
+ * move or remove an action, prop, context or stream entry.
24
+ *
25
+ * Findings, each at its own path:
26
+ * - `REPAIR_MEMBER_DROPPED` at `<spec>.<name>.<member>`: a declared
27
+ * member the merged contract cannot carry (it fails its own schema, or
28
+ * the gate refuses it there or, for `nextStep`, as `CTR_SCHEMA_INCOMPAT`
29
+ * at the sibling `.schema`);
30
+ * - `REPAIR_ENTRY_DROPPED` at `<spec>.<name>`: a draft action, prop,
31
+ * context, stream or tool entry the proposal no longer carries under that
32
+ * key (renamed, moved or removed), named ONCE; its members are not owed
33
+ * one by one. A restored tool the gate refuses is named so, with the
34
+ * gate's reason;
35
+ * - `REPAIR_ENTRY_RESTORED` at `agentCapabilities.tools.<name>`
36
+ * (severity `warn`): a draft tool the repair dropped and the overlay put
37
+ * back, so a tool coming back is never silent.
38
+ *
39
+ * A member whose path a DRAFT finding already names is under repair: it is
40
+ * not restored, and its absence is named by that finding. A draft tool with a
41
+ * finding at or under its path is not restored. Paths follow the gate's
42
+ * convention (`propsSpec.<name>`, not `propsSpec.properties.<name>`).
43
+ * Model-independent: the overlay works whatever the repair LLM returns, and
44
+ * it never makes the proposal fail the gate BECAUSE of what it put back.
45
+ */
46
+
47
+ import {
48
+ actionEntrySchema,
49
+ agentToolEntrySchema,
50
+ contextEntrySchema,
51
+ CTR_SCHEMA_INCOMPAT,
52
+ lintContract,
53
+ propEntrySchema,
54
+ streamChannelEntrySchema,
55
+ type ActionEntry,
56
+ type AgentToolEntry,
57
+ type ContextEntry,
58
+ type DataContract,
59
+ type PropEntry,
60
+ type StreamChannelEntry,
61
+ type SuggestionFinding,
62
+ } from '@ggui-ai/protocol';
63
+
64
+ /** A declared member the repair could not keep; path `<spec>.<name>.<member>`. */
65
+ export const REPAIR_MEMBER_DROPPED = 'REPAIR_MEMBER_DROPPED' as const;
66
+ /** A draft entry the proposal no longer carries under its key; path `<spec>.<name>`. */
67
+ export const REPAIR_ENTRY_DROPPED = 'REPAIR_ENTRY_DROPPED' as const;
68
+ /** A draft tool the repair dropped and the overlay restored; path `agentCapabilities.tools.<name>`. */
69
+ export const REPAIR_ENTRY_RESTORED = 'REPAIR_ENTRY_RESTORED' as const;
70
+
71
+ /** One gate error, as `lintContract` reports it. */
72
+ type GateIssue = ReturnType<typeof lintContract>['errors'][number];
73
+
74
+ /**
75
+ * What the overlay needs of an entry schema, structurally: the protocol's zod
76
+ * schemas satisfy it, and this package takes no zod dependency of its own.
77
+ */
78
+ interface Parser<T> {
79
+ safeParse(
80
+ value: unknown,
81
+ ):
82
+ | { readonly success: true; readonly data: T }
83
+ | { readonly success: false; readonly error: { readonly issues: readonly { readonly message: string }[] } };
84
+ parse(value: unknown): T;
85
+ }
86
+ interface ObjectParser<T> extends Parser<T> {
87
+ readonly shape: Readonly<Record<string, Parser<unknown>>>;
88
+ }
89
+
90
+ /** One spec the overlay covers. `E` is the protocol's entry type. */
91
+ interface SpecDescriptor<E> {
92
+ /** The gate's path prefix for this spec's entries. */
93
+ readonly prefix: string;
94
+ /** What an entry is called in a message. */
95
+ readonly noun: string;
96
+ readonly schema: ObjectParser<E>;
97
+ /** Top-level members the repair authors: never restored. */
98
+ readonly authored: ReadonlySet<string>;
99
+ /** A nested object whose members are declared too, minus the ones the repair authors in it. */
100
+ readonly nested?: { readonly member: string; readonly schema: ObjectParser<unknown>; readonly authored: ReadonlySet<string> };
101
+ /** Whether a draft entry the candidate lacks is restored whole (the toolbox only). */
102
+ readonly restoresEntries: boolean;
103
+ /** For a member the gate reports at a SIBLING path: that path's last segment and the code it reports. */
104
+ readonly sibling?: (member: string) => { readonly segment: string; readonly code: string } | undefined;
105
+ draftEntries(draft: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> | undefined;
106
+ /** The entry's members as a plain record (a spread at the concrete type). */
107
+ asRecord(entry: E): Readonly<Record<string, unknown>>;
108
+ entries(contract: DataContract): Readonly<Record<string, E>> | undefined;
109
+ withEntries(contract: DataContract, entries: Readonly<Record<string, E>>): DataContract;
110
+ }
111
+
112
+ function isRecord(value: unknown): value is Record<string, unknown> {
113
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
114
+ }
115
+
116
+ function recordAt(value: Readonly<Record<string, unknown>> | undefined, key: string): Readonly<Record<string, unknown>> | undefined {
117
+ if (value === undefined) return undefined;
118
+ const at = value[key];
119
+ return isRecord(at) ? at : undefined;
120
+ }
121
+
122
+ function memberDropped(prefix: string, name: string, member: string, reason: string): SuggestionFinding {
123
+ return {
124
+ code: REPAIR_MEMBER_DROPPED,
125
+ severity: 'error',
126
+ path: `${prefix}.${name}.${member}`,
127
+ message: `the repair could not keep the declared \`${member}\` on '${name}' (${prefix}): ${reason}`,
128
+ };
129
+ }
130
+
131
+ function gateReason(issue: GateIssue): string {
132
+ return `refused by the gate as ${issue.code} at ${issue.path}: ${issue.message}`;
133
+ }
134
+
135
+ /** Per spec, what the overlay put back: members per entry key, and whole entries. */
136
+ interface RestoreState {
137
+ readonly members: Map<string, Set<string>>;
138
+ readonly entries: Set<string>;
139
+ }
140
+
141
+ /** The descriptor's behaviour behind one non-generic face, so the five specs sit in one list. */
142
+ interface SpecRunner {
143
+ readonly prefix: string;
144
+ overlay(
145
+ draft: Readonly<Record<string, unknown>>,
146
+ contract: DataContract,
147
+ underRepair: ReadonlySet<string>,
148
+ ): { readonly contract: DataContract; readonly state: RestoreState; readonly dropped: readonly SuggestionFinding[] };
149
+ /** Un-restore the restored members the gate refuses at their own (or sibling) path. */
150
+ unrestoreMembers(
151
+ contract: DataContract,
152
+ state: RestoreState,
153
+ errors: readonly GateIssue[],
154
+ ): { readonly contract: DataContract; readonly dropped: readonly SuggestionFinding[] };
155
+ /** Un-restore ONE restored entry whose presence adds a gate error the tree does not have without it. */
156
+ unrestoreOneEntry(
157
+ contract: DataContract,
158
+ state: RestoreState,
159
+ ): { readonly contract: DataContract; readonly dropped: readonly SuggestionFinding[] } | undefined;
160
+ /** The `REPAIR_ENTRY_RESTORED` findings for the entries still restored. */
161
+ restoredFindings(state: RestoreState): readonly SuggestionFinding[];
162
+ /** The draft's entry keys the contract no longer carries. */
163
+ droppedKeys(draft: Readonly<Record<string, unknown>>, contract: DataContract): readonly string[];
164
+ readonly noun: string;
165
+ /** Whether `path` is this spec's entry path, or one of its member paths, for an entry `contract` carries. */
166
+ onSurvivingEntry(path: string, contract: DataContract): boolean;
167
+ }
168
+
169
+ function specRunner<E>(d: SpecDescriptor<E>): SpecRunner {
170
+ const topMembers = Object.keys(d.schema.shape).filter((k) => !d.authored.has(k) && k !== d.nested?.member);
171
+ const nestedMembers = d.nested === undefined ? [] : Object.keys(d.nested.schema.shape).filter((k) => !(d.nested?.authored.has(k) ?? false));
172
+ const nestedPrefix = d.nested?.member;
173
+ /** Every member path a finding can carry on an entry, exactly. */
174
+ const memberPaths = [
175
+ ...Object.keys(d.schema.shape),
176
+ ...(d.nested === undefined ? [] : Object.keys(d.nested.schema.shape).map((k) => `${d.nested?.member}.${k}`)),
177
+ ];
178
+ const entryPath = (name: string): string => `${d.prefix}.${name}`;
179
+
180
+ /** `entry` without the member at `member` (`a` or `parent.a`), re-derived by the strict schema. */
181
+ const withoutMember = (entry: E, member: string): E => {
182
+ const dot = member.indexOf('.');
183
+ if (dot === -1) return d.schema.parse(Object.fromEntries(Object.entries(d.asRecord(entry)).filter(([k]) => k !== member)));
184
+ const parent = member.slice(0, dot);
185
+ const leaf = member.slice(dot + 1);
186
+ const copy: Record<string, unknown> = { ...d.asRecord(entry) };
187
+ const inner = recordAt(copy, parent);
188
+ if (inner !== undefined) copy[parent] = Object.fromEntries(Object.entries(inner).filter(([k]) => k !== leaf));
189
+ return d.schema.parse(copy);
190
+ };
191
+
192
+ const withoutEntry = (contract: DataContract, name: string): DataContract => {
193
+ const current = d.entries(contract) ?? {};
194
+ return d.withEntries(contract, Object.fromEntries(Object.entries(current).filter(([k]) => k !== name)));
195
+ };
196
+
197
+ /** Under repair at or under an entry: the entry path, a member path, or a path inside a member. */
198
+ const entryUnderRepair = (name: string, underRepair: ReadonlySet<string>): boolean => {
199
+ const base = entryPath(name);
200
+ if (underRepair.has(base)) return true;
201
+ for (const p of underRepair) {
202
+ for (const m of memberPaths) if (p === `${base}.${m}` || p.startsWith(`${base}.${m}.`)) return true;
203
+ }
204
+ return false;
205
+ };
206
+
207
+ const sameIssue = (a: GateIssue, b: GateIssue): boolean => a.code === b.code && a.path === b.path;
208
+
209
+ return {
210
+ prefix: d.prefix,
211
+ noun: d.noun,
212
+
213
+ overlay(draft, contract, underRepair) {
214
+ const state: RestoreState = { members: new Map(), entries: new Set() };
215
+ const dropped: SuggestionFinding[] = [];
216
+ const draftEntries = d.draftEntries(draft);
217
+ if (draftEntries === undefined) return { contract, state, dropped };
218
+ const current = d.entries(contract) ?? {};
219
+ const merged: Record<string, E> = { ...current };
220
+
221
+ for (const [name, entry] of Object.entries(current)) {
222
+ const draftEntry = recordAt(draftEntries, name);
223
+ if (draftEntry === undefined) continue;
224
+ const next: Record<string, unknown> = { ...d.asRecord(entry) };
225
+ const put = new Set<string>();
226
+ for (const member of topMembers) {
227
+ const value = draftEntry[member];
228
+ if (value === undefined || next[member] !== undefined || underRepair.has(`${entryPath(name)}.${member}`)) continue;
229
+ const parsed = d.schema.shape[member]?.safeParse(value);
230
+ if (parsed === undefined) continue;
231
+ if (!parsed.success) {
232
+ dropped.push(memberDropped(d.prefix, name, member, parsed.error.issues[0]?.message ?? 'invalid'));
233
+ continue;
234
+ }
235
+ next[member] = parsed.data;
236
+ put.add(member);
237
+ }
238
+ if (d.nested !== undefined && nestedPrefix !== undefined) {
239
+ const draftInner = recordAt(draftEntry, nestedPrefix);
240
+ const inner = recordAt(next, nestedPrefix);
241
+ if (draftInner !== undefined && inner !== undefined) {
242
+ const nextInner: Record<string, unknown> = { ...inner };
243
+ for (const member of nestedMembers) {
244
+ const path = `${nestedPrefix}.${member}`;
245
+ const value = draftInner[member];
246
+ if (value === undefined || nextInner[member] !== undefined || underRepair.has(`${entryPath(name)}.${path}`)) continue;
247
+ const parsed = d.nested.schema.shape[member]?.safeParse(value);
248
+ if (parsed === undefined) continue;
249
+ if (!parsed.success) {
250
+ dropped.push(memberDropped(d.prefix, name, path, parsed.error.issues[0]?.message ?? 'invalid'));
251
+ continue;
252
+ }
253
+ nextInner[member] = parsed.data;
254
+ put.add(path);
255
+ }
256
+ next[nestedPrefix] = nextInner;
257
+ }
258
+ }
259
+ if (put.size === 0) continue;
260
+ const reparsed = d.schema.safeParse(next);
261
+ // The CANDIDATE's own authored members are not an entry yet: the loop's gate retries that.
262
+ if (!reparsed.success) continue;
263
+ merged[name] = reparsed.data;
264
+ state.members.set(name, put);
265
+ }
266
+
267
+ if (d.restoresEntries) {
268
+ // Sorted, so the outcome depends on the draft's content, never on its key order.
269
+ for (const name of Object.keys(draftEntries).sort()) {
270
+ if (Object.hasOwn(current, name) || entryUnderRepair(name, underRepair)) continue;
271
+ const parsed = d.schema.safeParse(draftEntries[name]);
272
+ if (!parsed.success) continue;
273
+ merged[name] = parsed.data;
274
+ state.entries.add(name);
275
+ }
276
+ }
277
+
278
+ if (state.members.size === 0 && state.entries.size === 0) return { contract, state, dropped };
279
+ return { contract: d.withEntries(contract, merged), state, dropped };
280
+ },
281
+
282
+ unrestoreMembers(contract, state, errors) {
283
+ const dropped: SuggestionFinding[] = [];
284
+ let next = contract;
285
+ for (const [name, members] of state.members) {
286
+ for (const member of [...members]) {
287
+ const sib = d.sibling?.(member);
288
+ const refusal =
289
+ errors.find((issue) => issue.path === `${entryPath(name)}.${member}`) ??
290
+ (sib !== undefined ? errors.find((issue) => issue.code === sib.code && issue.path === `${entryPath(name)}.${sib.segment}`) : undefined);
291
+ if (refusal === undefined) continue;
292
+ const entry = d.entries(next)?.[name];
293
+ if (entry === undefined) continue;
294
+ next = d.withEntries(next, { ...(d.entries(next) ?? {}), [name]: withoutMember(entry, member) });
295
+ members.delete(member);
296
+ dropped.push(memberDropped(d.prefix, name, member, gateReason(refusal)));
297
+ }
298
+ if (members.size === 0) state.members.delete(name);
299
+ }
300
+ return { contract: next, dropped };
301
+ },
302
+
303
+ unrestoreOneEntry(contract, state) {
304
+ if (state.entries.size === 0) return undefined;
305
+ const withAll = lintContract(contract).errors;
306
+ // Sorted: when two restored tools fail only together, which one comes back out
307
+ // depends on their names, never on the draft's key order.
308
+ for (const name of [...state.entries].sort()) {
309
+ const without = withoutEntry(contract, name);
310
+ const base = lintContract(without).errors;
311
+ const added = withAll.find((issue) => !base.some((b) => sameIssue(b, issue)));
312
+ if (added === undefined) continue;
313
+ state.entries.delete(name);
314
+ return {
315
+ contract: without,
316
+ dropped: [
317
+ {
318
+ code: REPAIR_ENTRY_DROPPED,
319
+ severity: 'error',
320
+ path: entryPath(name),
321
+ message: `the repair dropped the ${d.noun} '${name}' you declared, and restoring it from your draft would fail the gate (${gateReason(added)}); declare it again in a form the gate accepts, via ggui_render override or a corrected re-handshake`,
322
+ },
323
+ ],
324
+ };
325
+ }
326
+ return undefined;
327
+ },
328
+
329
+ restoredFindings(state) {
330
+ return [...state.entries].sort().map((name): SuggestionFinding => ({
331
+ code: REPAIR_ENTRY_RESTORED,
332
+ severity: 'warn',
333
+ path: entryPath(name),
334
+ message: `the repair dropped the ${d.noun} '${name}' you declared; it is restored from your draft, because the toolbox is yours to declare`,
335
+ }));
336
+ },
337
+
338
+ droppedKeys(draft, contract) {
339
+ const draftEntries = d.draftEntries(draft);
340
+ if (draftEntries === undefined) return [];
341
+ const kept = d.entries(contract) ?? {};
342
+ return Object.keys(draftEntries).filter((name) => !Object.hasOwn(kept, name));
343
+ },
344
+
345
+ onSurvivingEntry(path, contract) {
346
+ const kept = d.entries(contract);
347
+ if (kept === undefined) return false;
348
+ return Object.keys(kept).some((name) => {
349
+ const base = entryPath(name);
350
+ return path === base || memberPaths.some((m) => path === `${base}.${m}`);
351
+ });
352
+ },
353
+ };
354
+ }
355
+
356
+ /** The five specs, in the order a finding list names them. */
357
+ const RUNNERS: readonly SpecRunner[] = [
358
+ specRunner<ActionEntry>({
359
+ prefix: 'actionSpec',
360
+ noun: 'action',
361
+ schema: actionEntrySchema,
362
+ authored: new Set(['label', 'schema']),
363
+ restoresEntries: false,
364
+ sibling: (member) => (member === 'nextStep' ? { segment: 'schema', code: CTR_SCHEMA_INCOMPAT } : undefined),
365
+ draftEntries: (draft) => recordAt(draft, 'actionSpec'),
366
+ asRecord: (entry) => ({ ...entry }),
367
+ entries: (contract) => contract.actionSpec,
368
+ withEntries: (contract, entries) => ({ ...contract, actionSpec: entries }),
369
+ }),
370
+ specRunner<PropEntry>({
371
+ prefix: 'propsSpec',
372
+ noun: 'prop',
373
+ schema: propEntrySchema,
374
+ authored: new Set(['schema', 'required']),
375
+ restoresEntries: false,
376
+ draftEntries: (draft) => recordAt(recordAt(draft, 'propsSpec'), 'properties'),
377
+ asRecord: (entry) => ({ ...entry }),
378
+ entries: (contract) => contract.propsSpec?.properties,
379
+ withEntries: (contract, entries) => ({ ...contract, propsSpec: { ...contract.propsSpec, properties: entries } }),
380
+ }),
381
+ specRunner<ContextEntry>({
382
+ prefix: 'contextSpec',
383
+ noun: 'context slot',
384
+ schema: contextEntrySchema,
385
+ authored: new Set(['schema', 'default']),
386
+ restoresEntries: false,
387
+ draftEntries: (draft) => recordAt(draft, 'contextSpec'),
388
+ asRecord: (entry) => ({ ...entry }),
389
+ entries: (contract) => contract.contextSpec,
390
+ withEntries: (contract, entries) => ({ ...contract, contextSpec: entries }),
391
+ }),
392
+ specRunner<StreamChannelEntry>({
393
+ prefix: 'streamSpec',
394
+ noun: 'stream channel',
395
+ schema: streamChannelEntrySchema,
396
+ authored: new Set(['schema', 'source']),
397
+ restoresEntries: false,
398
+ draftEntries: (draft) => recordAt(draft, 'streamSpec'),
399
+ asRecord: (entry) => ({ ...entry }),
400
+ entries: (contract) => contract.streamSpec,
401
+ withEntries: (contract, entries) => ({ ...contract, streamSpec: entries }),
402
+ }),
403
+ specRunner<AgentToolEntry>({
404
+ prefix: 'agentCapabilities.tools',
405
+ noun: 'tool',
406
+ schema: agentToolEntrySchema,
407
+ authored: new Set(['toolInfo', 'usage']),
408
+ nested: { member: 'toolInfo', schema: agentToolEntrySchema.shape.toolInfo, authored: new Set(['inputSchema', 'outputSchema']) },
409
+ restoresEntries: true,
410
+ draftEntries: (draft) => recordAt(recordAt(draft, 'agentCapabilities'), 'tools'),
411
+ asRecord: (entry) => ({ ...entry }),
412
+ entries: (contract) => contract.agentCapabilities?.tools,
413
+ withEntries: (contract, entries) => ({ ...contract, agentCapabilities: { ...contract.agentCapabilities, tools: entries } }),
414
+ }),
415
+ ];
416
+
417
+ /**
418
+ * Overlay the draft's declarations onto the candidate: every declared member
419
+ * of every entry the candidate carries under the same key (keeping what the
420
+ * repair authors), and every draft tool the candidate dropped, whole. A
421
+ * member or tool whose path a draft finding names is under repair and stays
422
+ * out. Then lint the merged contract and un-restore, until the tree is
423
+ * stable, every restored member the gate refuses (at its own path, or for
424
+ * `nextStep` as `CTR_SCHEMA_INCOMPAT` at the sibling `.schema`) and every
425
+ * restored tool whose presence adds a gate error, naming each one, so the
426
+ * returned contract never fails the gate BECAUSE of the overlay. A malformed
427
+ * candidate entry is left un-overlaid for the loop's own gate to retry;
428
+ * nothing here throws on the model's answer. Entries the candidate moved or
429
+ * removed (other than tools) get nothing back here: see
430
+ * {@link findDroppedEntries}.
431
+ */
432
+ export function restoreDraftDeclaredMembers(
433
+ draft: unknown,
434
+ candidate: DataContract,
435
+ underRepair: ReadonlySet<string>,
436
+ ): { readonly contract: DataContract; readonly dropped: readonly SuggestionFinding[] } {
437
+ if (!isRecord(draft)) return { contract: candidate, dropped: [] };
438
+ let contract = candidate;
439
+ const dropped: SuggestionFinding[] = [];
440
+ const states: RestoreState[] = [];
441
+ for (const runner of RUNNERS) {
442
+ const result = runner.overlay(draft, contract, underRepair);
443
+ contract = result.contract;
444
+ dropped.push(...result.dropped);
445
+ states.push(result.state);
446
+ }
447
+ const restoredCount = (): number => states.reduce((n, s) => n + s.entries.size + [...s.members.values()].reduce((m, set) => m + set.size, 0), 0);
448
+ if (restoredCount() === 0) return { contract, dropped };
449
+
450
+ // Cross-reference and compatibility errors exist only on the MERGED tree.
451
+ // Each pass un-restores at least one member or entry, or stops, so the
452
+ // loop is bounded by what was restored.
453
+ for (let pass = 0, budget = restoredCount() + 1; pass < budget; pass += 1) {
454
+ const errors = lintContract(contract).errors;
455
+ let changed = false;
456
+ RUNNERS.forEach((runner, i) => {
457
+ const state = states[i];
458
+ if (state === undefined) return;
459
+ const result = runner.unrestoreMembers(contract, state, errors);
460
+ if (result.dropped.length > 0) changed = true;
461
+ contract = result.contract;
462
+ dropped.push(...result.dropped);
463
+ });
464
+ if (!changed) {
465
+ for (const [i, runner] of RUNNERS.entries()) {
466
+ const state = states[i];
467
+ if (state === undefined) continue;
468
+ const result = runner.unrestoreOneEntry(contract, state);
469
+ if (result === undefined) continue;
470
+ contract = result.contract;
471
+ dropped.push(...result.dropped);
472
+ changed = true;
473
+ break;
474
+ }
475
+ }
476
+ if (!changed || restoredCount() === 0) break;
477
+ }
478
+ RUNNERS.forEach((runner, i) => {
479
+ const state = states[i];
480
+ if (state !== undefined) dropped.push(...runner.restoredFindings(state));
481
+ });
482
+ return { contract, dropped };
483
+ }
484
+
485
+ /**
486
+ * Whether `finding` sits on an entry `contract` still carries: at its exact
487
+ * entry path or one of its member paths, never by dotted prefix (an action
488
+ * named `a` is not a prefix of `a.b`). Used to keep a repair's member and
489
+ * restore findings only for entries the final proposal carries.
490
+ */
491
+ export function isOnSurvivingEntry(finding: SuggestionFinding, contract: DataContract): boolean {
492
+ return RUNNERS.some((runner) => runner.onSurvivingEntry(finding.path, contract));
493
+ }
494
+
495
+ /**
496
+ * One `REPAIR_ENTRY_DROPPED` per draft entry, on every spec, that the
497
+ * candidate no longer carries under its key: renamed, moved or removed by the
498
+ * repair, or pruned by the loop's own placement pass. Its members are not
499
+ * owed one by one. When the gate refused the NAME itself (`CTR_DUP_NAME` /
500
+ * `CTR_RESERVED_NAME` at `<spec>.<name>`, so the path is under repair) the
501
+ * message says so and does not send the agent back to a name the gate will
502
+ * refuse again. Empty when every draft entry survived.
503
+ */
504
+ export function findDroppedEntries(
505
+ draft: unknown,
506
+ candidate: DataContract,
507
+ underRepair: ReadonlySet<string>,
508
+ ): readonly SuggestionFinding[] {
509
+ if (!isRecord(draft)) return [];
510
+ return RUNNERS.flatMap((runner) =>
511
+ runner.droppedKeys(draft, candidate).map((name): SuggestionFinding => {
512
+ const path = `${runner.prefix}.${name}`;
513
+ const message = underRepair.has(path)
514
+ ? `the gate refused the ${runner.noun} name '${name}' (see the finding at this path) and the repair removed or renamed it; every member it carried went with it — declare those members again under a name the gate accepts, via ggui_render override or a corrected re-handshake`
515
+ : runner.prefix === 'actionSpec'
516
+ ? `the repair no longer carries the action '${name}' you declared (renamed or removed); every member it carried, oneShot included, went with it — re-declare it via ggui_render override or a corrected re-handshake`
517
+ : `the repair no longer carries the ${runner.noun} '${name}' you declared under that key (renamed, moved or removed); every member it carried went with it — re-declare it via ggui_render override or a corrected re-handshake`;
518
+ return { code: REPAIR_ENTRY_DROPPED, severity: 'error', path, message };
519
+ }),
520
+ );
521
+ }
@@ -47,7 +47,7 @@ import {
47
47
  import { lintContract, type ContractIssue, type SuggestionFinding } from '@ggui-ai/protocol';
48
48
  import type { LLMCaller, ToolSchema } from './llm-caller.js';
49
49
  import { normalizeSchema } from './normalize-schema.js';
50
- import { findDroppedActionEntries, isMemberDropOf, restoreDraftActionMembers } from './preserve-action-members.js';
50
+ import { findDroppedEntries, isOnSurvivingEntry, REPAIR_ENTRY_DROPPED, restoreDraftDeclaredMembers } from './preserve-declared-members.js';
51
51
  import {
52
52
  draftSeedPropKeys,
53
53
  findDroppedSeedSurfaces,
@@ -211,7 +211,7 @@ CONCRETE PATTERNS
211
211
  (a) Agent-owned — "show my todos", "an agent-backed todo list that persists across sessions", "render my cart", "the messages in this thread", "the directory contents"
212
212
  The AGENT owns the items: it fetched / persists / keeps them in sync. The collection is the agent's data → it goes on PROPSSPEC, seeded at render and refreshed via ggui_amend after each change. This is the ONLY shape that round-trips — contextSpec has no agent-push channel, so an agent-owned list placed there can never be seeded or updated (the UI renders empty). add / delete / toggle are discrete events the agent must witness to persist → declare them on actionSpec (with a matching agentCapabilities tool for each nextStep). Mutability is fine: ggui_amend is exactly how the agent pushes the change.
213
213
  propsSpec: { properties: { todos: {schema: {type: "array", items: {type: "object", properties: {id: {type: "string"}, text: {type: "string"}, done: {type: "boolean"}}, required: ["id", "text", "done"]}}, required: true} } }
214
- actionSpec: { toggleTodo: {label: "Toggle todo", schema: {type: "object", properties: {id: {type: "string"}}, required: ["id"]}, nextStep: "todo_toggle"}, addTodo: {label: "Add todo", schema: {type: "object", properties: {text: {type: "string"}}, required: ["text"]}, nextStep: "todo_add"} }
214
+ actionSpec: { toggleTodo: {label: "Toggle todo", schema: {type: "object", properties: {id: {type: "string"}}, required: ["id"]}}, addTodo: {label: "Add todo", schema: {type: "object", properties: {text: {type: "string"}}, required: ["text"]}} }
215
215
 
216
216
  (b) User-built local — "a todo list where I can add and remove items", "a shopping list", "a checklist I tick off"
217
217
  No agent-owned source: the USER assembles the list in the UI and the agent merely observes it. The items are client-originated state → a CONTEXTSPEC slot; OMIT actionSpec (the slot mirror IS the wire — the agent already sees every change). Only when the intent says the agent must persist / sync each change does it become the agent-owned case (a) above.
@@ -576,6 +576,7 @@ const REPAIR_PREAMBLE = `PATCH MODE — you are REPAIRING a contract the agent a
576
576
  - PRESERVE every spec the agent declared — ESPECIALLY every propsSpec property (agent-owned render-time seed data the UI needs). Never drop it.
577
577
  - Do NOT move data between specs (e.g. propsSpec → contextSpec) unless a finding explicitly requires it. A collection the agent supplies on propsSpec STAYS on propsSpec.
578
578
  - Keep the agent's names, shapes, and structure intact wherever the findings do not force a change.
579
+ - KEEP the agent's TOOLBOX: every agentCapabilities.tools entry the draft declares, and every actionSpec[X].nextStep that names one of them. The rules below about tools (declare them only behind a streamSpec source; emit no nextStep) govern only what YOU synthesize from scratch — they never remove a tool or a nextStep the agent declared.
579
580
  The four-spec model and placement rules below still hold — but in PATCH MODE they are guardrails for the fix, not a license to re-author.`;
580
581
 
581
582
  /**
@@ -933,11 +934,12 @@ export async function synthesizeContract(
933
934
 
934
935
  // `buildContract` normalizes every emitted schema (invalid `type`
935
936
  // spellings → canonical JSON Schema) before the gate sees it. On a
936
- // repair-in-place it authors only `{label, schema}` per action; the
937
- // agent's DECLARED members (`oneShot` above all) are put back from
938
- // the draft deterministically, and anything the merged tree cannot
939
- // carry is named rather than lost (ggui#1421).
940
- const restored = restoreDraftActionMembers(options?.draft, buildContract(parsed), underRepair);
937
+ // repair-in-place it authors only a few members per entry; the agent's
938
+ // DECLARED members (`oneShot` above all) are put back from the draft
939
+ // deterministically, on every spec, and so is any tool the repair
940
+ // dropped. Anything the merged tree cannot carry is named rather than
941
+ // lost (ggui#1421, ggui#1430).
942
+ const restored = restoreDraftDeclaredMembers(options?.draft, buildContract(parsed), underRepair);
941
943
  const contract = restored.contract;
942
944
 
943
945
  // Defensive gate: re-validate the assembled contract against the
@@ -1055,22 +1057,26 @@ export async function synthesizeContract(
1055
1057
  }
1056
1058
 
1057
1059
  /**
1058
- * What a repair could not keep, against the contract it finally
1059
- * returns: the member drops of the accepted attempt, minus those on an
1060
- * entry the loop's own placement pass pruned afterwards (the entry
1061
- * finding names that loss once, with every member), plus one entry
1062
- * finding per draft action the final contract no longer carries.
1060
+ * What a repair could not keep, or put back, against the contract it finally
1061
+ * returns: the accepted attempt's member drops and restored tools, minus
1062
+ * those on an entry the final contract no longer carries (the entry finding
1063
+ * names that loss once), plus one entry finding per draft entry, on every
1064
+ * spec, that the final contract no longer carries under its key. Where the
1065
+ * overlay itself named an entry it had to un-restore, its finding (which
1066
+ * carries the gate's reason) stands for that entry.
1063
1067
  */
1064
1068
  function droppedFor(
1065
- memberDrops: readonly SuggestionFinding[],
1069
+ overlayFindings: readonly SuggestionFinding[],
1066
1070
  draft: unknown,
1067
1071
  finalContract: DataContract,
1068
1072
  underRepair: ReadonlySet<string>,
1069
1073
  ): readonly SuggestionFinding[] {
1070
- const survivingEntries = Object.keys(finalContract.actionSpec ?? {});
1074
+ const overlayEntryDrops = new Map(
1075
+ overlayFindings.filter((f) => f.code === REPAIR_ENTRY_DROPPED).map((f) => [f.path, f] as const),
1076
+ );
1071
1077
  return [
1072
- ...memberDrops.filter((f) => isMemberDropOf(f, survivingEntries)),
1073
- ...findDroppedActionEntries(draft, finalContract, underRepair),
1078
+ ...overlayFindings.filter((f) => f.code !== REPAIR_ENTRY_DROPPED && isOnSurvivingEntry(f, finalContract)),
1079
+ ...findDroppedEntries(draft, finalContract, underRepair).map((f) => overlayEntryDrops.get(f.path) ?? f),
1074
1080
  ];
1075
1081
  }
1076
1082
 
@@ -1,54 +0,0 @@
1
- /**
2
- * Contract Hashing — deterministic cache key from data contract.
3
- *
4
- * Uses RFC 8785 (JSON Canonicalization Scheme) for deterministic
5
- * serialization, then SHA-256 for the hash.
6
- *
7
- * What's in the hash: intent only (the semantic identity).
8
- * What's NOT in the hash: props, actions, stream (including per-channel source), adaptations.
9
- *
10
- * Why intent-only: The LLM generates slightly different prop descriptions
11
- * each call, but the INTENT is deterministic (provided by the agent or
12
- * generated once). Same intent = same component = same cached code.
13
- * Props are passed at render time, not baked into the component.
14
- *
15
- * The `interaction` field was removed from `DataContract` in pre-launch
16
- * cleanup (the four-spec surface — props/action/context/stream —
17
- * describes the wire exhaustively); this function used to factor it in
18
- * but no longer does. Old goldens were rotated when the field went away.
19
- */
20
- import type { DataContract } from '@ggui-ai/protocol';
21
- /**
22
- * Hash a data contract to a deterministic cache key.
23
- *
24
- * Hashes intent only — the semantic identity of the UI. Props,
25
- * actions, stream specs are for code generation, not caching.
26
- *
27
- * `intent` is passed separately because `DataContract` itself does
28
- * not carry an `intent` field — the outer pipeline owns intent
29
- * (`story.intent` on `ggui_render`, the operator prompt for harness
30
- * benchmarks). Threading intent into the hash keeps cache identity
31
- * stable across negotiations: different intents produce different
32
- * generated code (labels, copy, layout) even when the wire surface is
33
- * identical.
34
- *
35
- * The `contract` argument is retained on the signature for forward
36
- * compatibility — future cache-key fields (e.g., a structural
37
- * fingerprint of the four specs) can be folded in without breaking
38
- * call sites — but is not consulted by the current implementation.
39
- *
40
- * @param contract - The data contract from negotiation (unused today)
41
- * @param intent - The outer pipeline's intent (story.intent on
42
- * ggui_render). Empty/falsy values are dropped from the hash input.
43
- * @returns Contract hash prefixed with `ch_` (e.g., `ch_a3f8b2c1e9d04567`)
44
- */
45
- export declare function hashContract(_contract: DataContract, intent: string): string;
46
- /**
47
- * Build a variant key from shell type and device category.
48
- *
49
- * @param shellType - Shell type (default: 'universal')
50
- * @param deviceCategory - Device category (default: 'universal')
51
- * @returns Variant string (e.g., 'fullscreen:mobile', 'universal:universal')
52
- */
53
- export declare function buildVariant(shellType?: string, deviceCategory?: string): string;
54
- //# sourceMappingURL=contract-hash.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"contract-hash.d.ts","sourceRoot":"","sources":["../src/contract-hash.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAiCtD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,YAAY,CAC1B,SAAS,EAAE,YAAY,EACvB,MAAM,EAAE,MAAM,GACb,MAAM,CAMR;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,SAAS,SAAc,EACvB,cAAc,SAAc,GAC3B,MAAM,CAER"}