@ggui-ai/negotiator 0.24.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,89 @@
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
+ import { type DataContract, type SuggestionFinding } from '@ggui-ai/protocol';
47
+ /** A declared member the repair could not keep; path `<spec>.<name>.<member>`. */
48
+ export declare const REPAIR_MEMBER_DROPPED: "REPAIR_MEMBER_DROPPED";
49
+ /** A draft entry the proposal no longer carries under its key; path `<spec>.<name>`. */
50
+ export declare const REPAIR_ENTRY_DROPPED: "REPAIR_ENTRY_DROPPED";
51
+ /** A draft tool the repair dropped and the overlay restored; path `agentCapabilities.tools.<name>`. */
52
+ export declare const REPAIR_ENTRY_RESTORED: "REPAIR_ENTRY_RESTORED";
53
+ /**
54
+ * Overlay the draft's declarations onto the candidate: every declared member
55
+ * of every entry the candidate carries under the same key (keeping what the
56
+ * repair authors), and every draft tool the candidate dropped, whole. A
57
+ * member or tool whose path a draft finding names is under repair and stays
58
+ * out. Then lint the merged contract and un-restore, until the tree is
59
+ * stable, every restored member the gate refuses (at its own path, or for
60
+ * `nextStep` as `CTR_SCHEMA_INCOMPAT` at the sibling `.schema`) and every
61
+ * restored tool whose presence adds a gate error, naming each one, so the
62
+ * returned contract never fails the gate BECAUSE of the overlay. A malformed
63
+ * candidate entry is left un-overlaid for the loop's own gate to retry;
64
+ * nothing here throws on the model's answer. Entries the candidate moved or
65
+ * removed (other than tools) get nothing back here: see
66
+ * {@link findDroppedEntries}.
67
+ */
68
+ export declare function restoreDraftDeclaredMembers(draft: unknown, candidate: DataContract, underRepair: ReadonlySet<string>): {
69
+ readonly contract: DataContract;
70
+ readonly dropped: readonly SuggestionFinding[];
71
+ };
72
+ /**
73
+ * Whether `finding` sits on an entry `contract` still carries: at its exact
74
+ * entry path or one of its member paths, never by dotted prefix (an action
75
+ * named `a` is not a prefix of `a.b`). Used to keep a repair's member and
76
+ * restore findings only for entries the final proposal carries.
77
+ */
78
+ export declare function isOnSurvivingEntry(finding: SuggestionFinding, contract: DataContract): boolean;
79
+ /**
80
+ * One `REPAIR_ENTRY_DROPPED` per draft entry, on every spec, that the
81
+ * candidate no longer carries under its key: renamed, moved or removed by the
82
+ * repair, or pruned by the loop's own placement pass. Its members are not
83
+ * owed one by one. When the gate refused the NAME itself (`CTR_DUP_NAME` /
84
+ * `CTR_RESERVED_NAME` at `<spec>.<name>`, so the path is under repair) the
85
+ * message says so and does not send the agent back to a name the gate will
86
+ * refuse again. Empty when every draft entry survived.
87
+ */
88
+ export declare function findDroppedEntries(draft: unknown, candidate: DataContract, underRepair: ReadonlySet<string>): readonly SuggestionFinding[];
89
+ //# sourceMappingURL=preserve-declared-members.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preserve-declared-members.d.ts","sourceRoot":"","sources":["../src/preserve-declared-members.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,OAAO,EAWL,KAAK,YAAY,EAGjB,KAAK,iBAAiB,EACvB,MAAM,mBAAmB,CAAC;AAE3B,kFAAkF;AAClF,eAAO,MAAM,qBAAqB,EAAG,uBAAgC,CAAC;AACtE,wFAAwF;AACxF,eAAO,MAAM,oBAAoB,EAAG,sBAA+B,CAAC;AACpE,uGAAuG;AACvG,eAAO,MAAM,qBAAqB,EAAG,uBAAgC,CAAC;AA4VtE;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,2BAA2B,CACzC,KAAK,EAAE,OAAO,EACd,SAAS,EAAE,YAAY,EACvB,WAAW,EAAE,WAAW,CAAC,MAAM,CAAC,GAC/B;IAAE,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,SAAS,iBAAiB,EAAE,CAAA;CAAE,CA+CrF;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,iBAAiB,EAAE,QAAQ,EAAE,YAAY,GAAG,OAAO,CAE9F;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,OAAO,EACd,SAAS,EAAE,YAAY,EACvB,WAAW,EAAE,WAAW,CAAC,MAAM,CAAC,GAC/B,SAAS,iBAAiB,EAAE,CAa9B"}
@@ -0,0 +1,427 @@
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
+ import { actionEntrySchema, agentToolEntrySchema, contextEntrySchema, CTR_SCHEMA_INCOMPAT, lintContract, propEntrySchema, streamChannelEntrySchema, } from '@ggui-ai/protocol';
47
+ /** A declared member the repair could not keep; path `<spec>.<name>.<member>`. */
48
+ export const REPAIR_MEMBER_DROPPED = 'REPAIR_MEMBER_DROPPED';
49
+ /** A draft entry the proposal no longer carries under its key; path `<spec>.<name>`. */
50
+ export const REPAIR_ENTRY_DROPPED = 'REPAIR_ENTRY_DROPPED';
51
+ /** A draft tool the repair dropped and the overlay restored; path `agentCapabilities.tools.<name>`. */
52
+ export const REPAIR_ENTRY_RESTORED = 'REPAIR_ENTRY_RESTORED';
53
+ function isRecord(value) {
54
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
55
+ }
56
+ function recordAt(value, key) {
57
+ if (value === undefined)
58
+ return undefined;
59
+ const at = value[key];
60
+ return isRecord(at) ? at : undefined;
61
+ }
62
+ function memberDropped(prefix, name, member, reason) {
63
+ return {
64
+ code: REPAIR_MEMBER_DROPPED,
65
+ severity: 'error',
66
+ path: `${prefix}.${name}.${member}`,
67
+ message: `the repair could not keep the declared \`${member}\` on '${name}' (${prefix}): ${reason}`,
68
+ };
69
+ }
70
+ function gateReason(issue) {
71
+ return `refused by the gate as ${issue.code} at ${issue.path}: ${issue.message}`;
72
+ }
73
+ function specRunner(d) {
74
+ const topMembers = Object.keys(d.schema.shape).filter((k) => !d.authored.has(k) && k !== d.nested?.member);
75
+ const nestedMembers = d.nested === undefined ? [] : Object.keys(d.nested.schema.shape).filter((k) => !(d.nested?.authored.has(k) ?? false));
76
+ const nestedPrefix = d.nested?.member;
77
+ /** Every member path a finding can carry on an entry, exactly. */
78
+ const memberPaths = [
79
+ ...Object.keys(d.schema.shape),
80
+ ...(d.nested === undefined ? [] : Object.keys(d.nested.schema.shape).map((k) => `${d.nested?.member}.${k}`)),
81
+ ];
82
+ const entryPath = (name) => `${d.prefix}.${name}`;
83
+ /** `entry` without the member at `member` (`a` or `parent.a`), re-derived by the strict schema. */
84
+ const withoutMember = (entry, member) => {
85
+ const dot = member.indexOf('.');
86
+ if (dot === -1)
87
+ return d.schema.parse(Object.fromEntries(Object.entries(d.asRecord(entry)).filter(([k]) => k !== member)));
88
+ const parent = member.slice(0, dot);
89
+ const leaf = member.slice(dot + 1);
90
+ const copy = { ...d.asRecord(entry) };
91
+ const inner = recordAt(copy, parent);
92
+ if (inner !== undefined)
93
+ copy[parent] = Object.fromEntries(Object.entries(inner).filter(([k]) => k !== leaf));
94
+ return d.schema.parse(copy);
95
+ };
96
+ const withoutEntry = (contract, name) => {
97
+ const current = d.entries(contract) ?? {};
98
+ return d.withEntries(contract, Object.fromEntries(Object.entries(current).filter(([k]) => k !== name)));
99
+ };
100
+ /** Under repair at or under an entry: the entry path, a member path, or a path inside a member. */
101
+ const entryUnderRepair = (name, underRepair) => {
102
+ const base = entryPath(name);
103
+ if (underRepair.has(base))
104
+ return true;
105
+ for (const p of underRepair) {
106
+ for (const m of memberPaths)
107
+ if (p === `${base}.${m}` || p.startsWith(`${base}.${m}.`))
108
+ return true;
109
+ }
110
+ return false;
111
+ };
112
+ const sameIssue = (a, b) => a.code === b.code && a.path === b.path;
113
+ return {
114
+ prefix: d.prefix,
115
+ noun: d.noun,
116
+ overlay(draft, contract, underRepair) {
117
+ const state = { members: new Map(), entries: new Set() };
118
+ const dropped = [];
119
+ const draftEntries = d.draftEntries(draft);
120
+ if (draftEntries === undefined)
121
+ return { contract, state, dropped };
122
+ const current = d.entries(contract) ?? {};
123
+ const merged = { ...current };
124
+ for (const [name, entry] of Object.entries(current)) {
125
+ const draftEntry = recordAt(draftEntries, name);
126
+ if (draftEntry === undefined)
127
+ continue;
128
+ const next = { ...d.asRecord(entry) };
129
+ const put = new Set();
130
+ for (const member of topMembers) {
131
+ const value = draftEntry[member];
132
+ if (value === undefined || next[member] !== undefined || underRepair.has(`${entryPath(name)}.${member}`))
133
+ continue;
134
+ const parsed = d.schema.shape[member]?.safeParse(value);
135
+ if (parsed === undefined)
136
+ continue;
137
+ if (!parsed.success) {
138
+ dropped.push(memberDropped(d.prefix, name, member, parsed.error.issues[0]?.message ?? 'invalid'));
139
+ continue;
140
+ }
141
+ next[member] = parsed.data;
142
+ put.add(member);
143
+ }
144
+ if (d.nested !== undefined && nestedPrefix !== undefined) {
145
+ const draftInner = recordAt(draftEntry, nestedPrefix);
146
+ const inner = recordAt(next, nestedPrefix);
147
+ if (draftInner !== undefined && inner !== undefined) {
148
+ const nextInner = { ...inner };
149
+ for (const member of nestedMembers) {
150
+ const path = `${nestedPrefix}.${member}`;
151
+ const value = draftInner[member];
152
+ if (value === undefined || nextInner[member] !== undefined || underRepair.has(`${entryPath(name)}.${path}`))
153
+ continue;
154
+ const parsed = d.nested.schema.shape[member]?.safeParse(value);
155
+ if (parsed === undefined)
156
+ continue;
157
+ if (!parsed.success) {
158
+ dropped.push(memberDropped(d.prefix, name, path, parsed.error.issues[0]?.message ?? 'invalid'));
159
+ continue;
160
+ }
161
+ nextInner[member] = parsed.data;
162
+ put.add(path);
163
+ }
164
+ next[nestedPrefix] = nextInner;
165
+ }
166
+ }
167
+ if (put.size === 0)
168
+ continue;
169
+ const reparsed = d.schema.safeParse(next);
170
+ // The CANDIDATE's own authored members are not an entry yet: the loop's gate retries that.
171
+ if (!reparsed.success)
172
+ continue;
173
+ merged[name] = reparsed.data;
174
+ state.members.set(name, put);
175
+ }
176
+ if (d.restoresEntries) {
177
+ // Sorted, so the outcome depends on the draft's content, never on its key order.
178
+ for (const name of Object.keys(draftEntries).sort()) {
179
+ if (Object.hasOwn(current, name) || entryUnderRepair(name, underRepair))
180
+ continue;
181
+ const parsed = d.schema.safeParse(draftEntries[name]);
182
+ if (!parsed.success)
183
+ continue;
184
+ merged[name] = parsed.data;
185
+ state.entries.add(name);
186
+ }
187
+ }
188
+ if (state.members.size === 0 && state.entries.size === 0)
189
+ return { contract, state, dropped };
190
+ return { contract: d.withEntries(contract, merged), state, dropped };
191
+ },
192
+ unrestoreMembers(contract, state, errors) {
193
+ const dropped = [];
194
+ let next = contract;
195
+ for (const [name, members] of state.members) {
196
+ for (const member of [...members]) {
197
+ const sib = d.sibling?.(member);
198
+ const refusal = errors.find((issue) => issue.path === `${entryPath(name)}.${member}`) ??
199
+ (sib !== undefined ? errors.find((issue) => issue.code === sib.code && issue.path === `${entryPath(name)}.${sib.segment}`) : undefined);
200
+ if (refusal === undefined)
201
+ continue;
202
+ const entry = d.entries(next)?.[name];
203
+ if (entry === undefined)
204
+ continue;
205
+ next = d.withEntries(next, { ...(d.entries(next) ?? {}), [name]: withoutMember(entry, member) });
206
+ members.delete(member);
207
+ dropped.push(memberDropped(d.prefix, name, member, gateReason(refusal)));
208
+ }
209
+ if (members.size === 0)
210
+ state.members.delete(name);
211
+ }
212
+ return { contract: next, dropped };
213
+ },
214
+ unrestoreOneEntry(contract, state) {
215
+ if (state.entries.size === 0)
216
+ return undefined;
217
+ const withAll = lintContract(contract).errors;
218
+ // Sorted: when two restored tools fail only together, which one comes back out
219
+ // depends on their names, never on the draft's key order.
220
+ for (const name of [...state.entries].sort()) {
221
+ const without = withoutEntry(contract, name);
222
+ const base = lintContract(without).errors;
223
+ const added = withAll.find((issue) => !base.some((b) => sameIssue(b, issue)));
224
+ if (added === undefined)
225
+ continue;
226
+ state.entries.delete(name);
227
+ return {
228
+ contract: without,
229
+ dropped: [
230
+ {
231
+ code: REPAIR_ENTRY_DROPPED,
232
+ severity: 'error',
233
+ path: entryPath(name),
234
+ 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`,
235
+ },
236
+ ],
237
+ };
238
+ }
239
+ return undefined;
240
+ },
241
+ restoredFindings(state) {
242
+ return [...state.entries].sort().map((name) => ({
243
+ code: REPAIR_ENTRY_RESTORED,
244
+ severity: 'warn',
245
+ path: entryPath(name),
246
+ message: `the repair dropped the ${d.noun} '${name}' you declared; it is restored from your draft, because the toolbox is yours to declare`,
247
+ }));
248
+ },
249
+ droppedKeys(draft, contract) {
250
+ const draftEntries = d.draftEntries(draft);
251
+ if (draftEntries === undefined)
252
+ return [];
253
+ const kept = d.entries(contract) ?? {};
254
+ return Object.keys(draftEntries).filter((name) => !Object.hasOwn(kept, name));
255
+ },
256
+ onSurvivingEntry(path, contract) {
257
+ const kept = d.entries(contract);
258
+ if (kept === undefined)
259
+ return false;
260
+ return Object.keys(kept).some((name) => {
261
+ const base = entryPath(name);
262
+ return path === base || memberPaths.some((m) => path === `${base}.${m}`);
263
+ });
264
+ },
265
+ };
266
+ }
267
+ /** The five specs, in the order a finding list names them. */
268
+ const RUNNERS = [
269
+ specRunner({
270
+ prefix: 'actionSpec',
271
+ noun: 'action',
272
+ schema: actionEntrySchema,
273
+ authored: new Set(['label', 'schema']),
274
+ restoresEntries: false,
275
+ sibling: (member) => (member === 'nextStep' ? { segment: 'schema', code: CTR_SCHEMA_INCOMPAT } : undefined),
276
+ draftEntries: (draft) => recordAt(draft, 'actionSpec'),
277
+ asRecord: (entry) => ({ ...entry }),
278
+ entries: (contract) => contract.actionSpec,
279
+ withEntries: (contract, entries) => ({ ...contract, actionSpec: entries }),
280
+ }),
281
+ specRunner({
282
+ prefix: 'propsSpec',
283
+ noun: 'prop',
284
+ schema: propEntrySchema,
285
+ authored: new Set(['schema', 'required']),
286
+ restoresEntries: false,
287
+ draftEntries: (draft) => recordAt(recordAt(draft, 'propsSpec'), 'properties'),
288
+ asRecord: (entry) => ({ ...entry }),
289
+ entries: (contract) => contract.propsSpec?.properties,
290
+ withEntries: (contract, entries) => ({ ...contract, propsSpec: { ...contract.propsSpec, properties: entries } }),
291
+ }),
292
+ specRunner({
293
+ prefix: 'contextSpec',
294
+ noun: 'context slot',
295
+ schema: contextEntrySchema,
296
+ authored: new Set(['schema', 'default']),
297
+ restoresEntries: false,
298
+ draftEntries: (draft) => recordAt(draft, 'contextSpec'),
299
+ asRecord: (entry) => ({ ...entry }),
300
+ entries: (contract) => contract.contextSpec,
301
+ withEntries: (contract, entries) => ({ ...contract, contextSpec: entries }),
302
+ }),
303
+ specRunner({
304
+ prefix: 'streamSpec',
305
+ noun: 'stream channel',
306
+ schema: streamChannelEntrySchema,
307
+ authored: new Set(['schema', 'source']),
308
+ restoresEntries: false,
309
+ draftEntries: (draft) => recordAt(draft, 'streamSpec'),
310
+ asRecord: (entry) => ({ ...entry }),
311
+ entries: (contract) => contract.streamSpec,
312
+ withEntries: (contract, entries) => ({ ...contract, streamSpec: entries }),
313
+ }),
314
+ specRunner({
315
+ prefix: 'agentCapabilities.tools',
316
+ noun: 'tool',
317
+ schema: agentToolEntrySchema,
318
+ authored: new Set(['toolInfo', 'usage']),
319
+ nested: { member: 'toolInfo', schema: agentToolEntrySchema.shape.toolInfo, authored: new Set(['inputSchema', 'outputSchema']) },
320
+ restoresEntries: true,
321
+ draftEntries: (draft) => recordAt(recordAt(draft, 'agentCapabilities'), 'tools'),
322
+ asRecord: (entry) => ({ ...entry }),
323
+ entries: (contract) => contract.agentCapabilities?.tools,
324
+ withEntries: (contract, entries) => ({ ...contract, agentCapabilities: { ...contract.agentCapabilities, tools: entries } }),
325
+ }),
326
+ ];
327
+ /**
328
+ * Overlay the draft's declarations onto the candidate: every declared member
329
+ * of every entry the candidate carries under the same key (keeping what the
330
+ * repair authors), and every draft tool the candidate dropped, whole. A
331
+ * member or tool whose path a draft finding names is under repair and stays
332
+ * out. Then lint the merged contract and un-restore, until the tree is
333
+ * stable, every restored member the gate refuses (at its own path, or for
334
+ * `nextStep` as `CTR_SCHEMA_INCOMPAT` at the sibling `.schema`) and every
335
+ * restored tool whose presence adds a gate error, naming each one, so the
336
+ * returned contract never fails the gate BECAUSE of the overlay. A malformed
337
+ * candidate entry is left un-overlaid for the loop's own gate to retry;
338
+ * nothing here throws on the model's answer. Entries the candidate moved or
339
+ * removed (other than tools) get nothing back here: see
340
+ * {@link findDroppedEntries}.
341
+ */
342
+ export function restoreDraftDeclaredMembers(draft, candidate, underRepair) {
343
+ if (!isRecord(draft))
344
+ return { contract: candidate, dropped: [] };
345
+ let contract = candidate;
346
+ const dropped = [];
347
+ const states = [];
348
+ for (const runner of RUNNERS) {
349
+ const result = runner.overlay(draft, contract, underRepair);
350
+ contract = result.contract;
351
+ dropped.push(...result.dropped);
352
+ states.push(result.state);
353
+ }
354
+ const restoredCount = () => states.reduce((n, s) => n + s.entries.size + [...s.members.values()].reduce((m, set) => m + set.size, 0), 0);
355
+ if (restoredCount() === 0)
356
+ return { contract, dropped };
357
+ // Cross-reference and compatibility errors exist only on the MERGED tree.
358
+ // Each pass un-restores at least one member or entry, or stops, so the
359
+ // loop is bounded by what was restored.
360
+ for (let pass = 0, budget = restoredCount() + 1; pass < budget; pass += 1) {
361
+ const errors = lintContract(contract).errors;
362
+ let changed = false;
363
+ RUNNERS.forEach((runner, i) => {
364
+ const state = states[i];
365
+ if (state === undefined)
366
+ return;
367
+ const result = runner.unrestoreMembers(contract, state, errors);
368
+ if (result.dropped.length > 0)
369
+ changed = true;
370
+ contract = result.contract;
371
+ dropped.push(...result.dropped);
372
+ });
373
+ if (!changed) {
374
+ for (const [i, runner] of RUNNERS.entries()) {
375
+ const state = states[i];
376
+ if (state === undefined)
377
+ continue;
378
+ const result = runner.unrestoreOneEntry(contract, state);
379
+ if (result === undefined)
380
+ continue;
381
+ contract = result.contract;
382
+ dropped.push(...result.dropped);
383
+ changed = true;
384
+ break;
385
+ }
386
+ }
387
+ if (!changed || restoredCount() === 0)
388
+ break;
389
+ }
390
+ RUNNERS.forEach((runner, i) => {
391
+ const state = states[i];
392
+ if (state !== undefined)
393
+ dropped.push(...runner.restoredFindings(state));
394
+ });
395
+ return { contract, dropped };
396
+ }
397
+ /**
398
+ * Whether `finding` sits on an entry `contract` still carries: at its exact
399
+ * entry path or one of its member paths, never by dotted prefix (an action
400
+ * named `a` is not a prefix of `a.b`). Used to keep a repair's member and
401
+ * restore findings only for entries the final proposal carries.
402
+ */
403
+ export function isOnSurvivingEntry(finding, contract) {
404
+ return RUNNERS.some((runner) => runner.onSurvivingEntry(finding.path, contract));
405
+ }
406
+ /**
407
+ * One `REPAIR_ENTRY_DROPPED` per draft entry, on every spec, that the
408
+ * candidate no longer carries under its key: renamed, moved or removed by the
409
+ * repair, or pruned by the loop's own placement pass. Its members are not
410
+ * owed one by one. When the gate refused the NAME itself (`CTR_DUP_NAME` /
411
+ * `CTR_RESERVED_NAME` at `<spec>.<name>`, so the path is under repair) the
412
+ * message says so and does not send the agent back to a name the gate will
413
+ * refuse again. Empty when every draft entry survived.
414
+ */
415
+ export function findDroppedEntries(draft, candidate, underRepair) {
416
+ if (!isRecord(draft))
417
+ return [];
418
+ return RUNNERS.flatMap((runner) => runner.droppedKeys(draft, candidate).map((name) => {
419
+ const path = `${runner.prefix}.${name}`;
420
+ const message = underRepair.has(path)
421
+ ? `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`
422
+ : runner.prefix === 'actionSpec'
423
+ ? `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`
424
+ : `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`;
425
+ return { code: REPAIR_ENTRY_DROPPED, severity: 'error', path, message };
426
+ }));
427
+ }
@@ -106,7 +106,7 @@ export function formatReport(report) {
106
106
  lines.push('Failed predictions:');
107
107
  for (const o of failed) {
108
108
  lines.push(` [${o.pair.kind}] ${o.pair.id}: predicted=${o.predictedMatchId ?? 'null'}, gold=${o.pair.goldMatchId ?? 'null'}, conf=${o.decision.confidence.toFixed(2)}`);
109
- lines.push(` reason: ${o.decision.reason}`);
109
+ lines.push(` reason: ${o.decision.reason ?? '(none)'}`);
110
110
  }
111
111
  }
112
112
  return lines.join('\n');
@@ -37,6 +37,7 @@
37
37
  * contract-aware agents skip synthesis entirely.
38
38
  */
39
39
  import { type DataContract, type GadgetDescriptor } from '@ggui-ai/protocol';
40
+ import { type SuggestionFinding } from '@ggui-ai/protocol';
40
41
  import type { LLMCaller, ToolSchema } from './llm-caller.js';
41
42
  import { type ContractValidationFinding } from './contract-validators.js';
42
43
  /** Result of one synthesis attempt. */
@@ -62,6 +63,16 @@ export interface SynthesizeContractResult {
62
63
  * findings without re-running the detector.
63
64
  */
64
65
  readonly findings: readonly ContractValidationFinding[];
66
+ /**
67
+ * What a repair-in-place could NOT keep of the agent's draft
68
+ * (ggui#1421): one `REPAIR_MEMBER_DROPPED` per declared action-entry
69
+ * member the merged contract cannot carry, one `REPAIR_ENTRY_DROPPED`
70
+ * per draft action the contract no longer carries under its name.
71
+ * Empty on the cold path (no draft) and whenever every declaration
72
+ * survived. The caller surfaces these to the agent beside the gate's
73
+ * own findings, so a drop is never silent.
74
+ */
75
+ readonly dropped: readonly SuggestionFinding[];
65
76
  }
66
77
  /**
67
78
  * Tool schema the synthesizer's structured-output call uses. The
@@ -1 +1 @@
1
- {"version":3,"file":"synthesize-contract.d.ts","sourceRoot":"","sources":["../src/synthesize-contract.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,OAAO,EAIL,KAAK,YAAY,EACjB,KAAK,gBAAgB,EAEtB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAM7D,OAAO,EAKL,KAAK,yBAAyB,EAC/B,MAAM,0BAA0B,CAAC;AAsBlC,uCAAuC;AACvC,MAAM,WAAW,wBAAwB;IACvC,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAC;IACvC,wDAAwD;IACxD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,+CAA+C;IAC/C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;;;;OAMG;IACH,QAAQ,CAAC,QAAQ,EAAE,SAAS,yBAAyB,EAAE,CAAC;CACzD;AAuOD;;;;;GAKG;AACH,eAAO,MAAM,eAAe,EAAE,UA+I7B,CAAC;AAwRF;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAE;IAAE,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAA;CAAE,EACjC,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE;IACR;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;IAClD;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS;QAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;KAC1B,EAAE,CAAC;CACL,GACA,OAAO,CAAC,wBAAwB,CAAC,CAyMnC;AAmUD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,SAAS,gBAAgB,EAAE,GAAG,SAAS,GAC/C,MAAM,GAAG,SAAS,CA+CpB"}
1
+ {"version":3,"file":"synthesize-contract.d.ts","sourceRoot":"","sources":["../src/synthesize-contract.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,OAAO,EAIL,KAAK,YAAY,EACjB,KAAK,gBAAgB,EAEtB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAoC,KAAK,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAC7F,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAO7D,OAAO,EAKL,KAAK,yBAAyB,EAC/B,MAAM,0BAA0B,CAAC;AAsBlC,uCAAuC;AACvC,MAAM,WAAW,wBAAwB;IACvC,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAC;IACvC,wDAAwD;IACxD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,+CAA+C;IAC/C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;;;;OAMG;IACH,QAAQ,CAAC,QAAQ,EAAE,SAAS,yBAAyB,EAAE,CAAC;IACxD;;;;;;;;OAQG;IACH,QAAQ,CAAC,OAAO,EAAE,SAAS,iBAAiB,EAAE,CAAC;CAChD;AAuOD;;;;;GAKG;AACH,eAAO,MAAM,eAAe,EAAE,UA+I7B,CAAC;AAyRF;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAE;IAAE,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAA;CAAE,EACjC,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE;IACR;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;IAClD;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS;QAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;KAC1B,EAAE,CAAC;CACL,GACA,OAAO,CAAC,wBAAwB,CAAC,CAuNnC;AA2VD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,SAAS,gBAAgB,EAAE,GAAG,SAAS,GAC/C,MAAM,GAAG,SAAS,CA+CpB"}