@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.
- package/README.md +8 -6
- package/dist/ensure-conforming-contract.d.ts +8 -6
- package/dist/ensure-conforming-contract.d.ts.map +1 -1
- package/dist/ensure-conforming-contract.js +7 -2
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/preserve-declared-members.d.ts +89 -0
- package/dist/preserve-declared-members.d.ts.map +1 -0
- package/dist/preserve-declared-members.js +427 -0
- package/dist/synthesize-contract.d.ts.map +1 -1
- package/dist/synthesize-contract.js +20 -16
- package/package.json +3 -3
- package/src/ensure-conforming-contract.ts +15 -8
- package/src/index.ts +5 -3
- package/src/preserve-declared-members.ts +521 -0
- package/src/synthesize-contract.ts +22 -16
- package/dist/contract-hash.d.ts +0 -54
- package/dist/contract-hash.d.ts.map +0 -1
- package/dist/contract-hash.js +0 -96
- package/dist/preserve-action-members.d.ts +0 -79
- package/dist/preserve-action-members.d.ts.map +0 -1
- package/dist/preserve-action-members.js +0 -199
- package/src/contract-hash.ts +0 -102
- package/src/preserve-action-members.ts +0 -224
|
@@ -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
|
+
}
|
|
@@ -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;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;
|
|
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"}
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
import { dataContractSchema, gadgetExportName, } from '@ggui-ai/protocol';
|
|
40
40
|
import { lintContract } from '@ggui-ai/protocol';
|
|
41
41
|
import { normalizeSchema } from './normalize-schema.js';
|
|
42
|
-
import {
|
|
42
|
+
import { findDroppedEntries, isOnSurvivingEntry, REPAIR_ENTRY_DROPPED, restoreDraftDeclaredMembers } from './preserve-declared-members.js';
|
|
43
43
|
import { draftSeedPropKeys, findDroppedSeedSurfaces, } from './preserve-seed-surfaces.js';
|
|
44
44
|
import { formatValidationFindings, validateActionsVsContext, validateContractCoherence, validateContractRedundancy, } from './contract-validators.js';
|
|
45
45
|
/**
|
|
@@ -155,7 +155,7 @@ CONCRETE PATTERNS
|
|
|
155
155
|
(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"
|
|
156
156
|
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.
|
|
157
157
|
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} } }
|
|
158
|
-
actionSpec: { toggleTodo: {label: "Toggle todo", schema: {type: "object", properties: {id: {type: "string"}}, required: ["id"]}
|
|
158
|
+
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"]}} }
|
|
159
159
|
|
|
160
160
|
(b) User-built local — "a todo list where I can add and remove items", "a shopping list", "a checklist I tick off"
|
|
161
161
|
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.
|
|
@@ -461,6 +461,7 @@ const REPAIR_PREAMBLE = `PATCH MODE — you are REPAIRING a contract the agent a
|
|
|
461
461
|
- PRESERVE every spec the agent declared — ESPECIALLY every propsSpec property (agent-owned render-time seed data the UI needs). Never drop it.
|
|
462
462
|
- 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.
|
|
463
463
|
- Keep the agent's names, shapes, and structure intact wherever the findings do not force a change.
|
|
464
|
+
- 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.
|
|
464
465
|
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.`;
|
|
465
466
|
/**
|
|
466
467
|
* Preservation-failure repair note (L1) — drives a corrective retry when
|
|
@@ -743,11 +744,12 @@ export async function synthesizeContract(deps, intent, options) {
|
|
|
743
744
|
}
|
|
744
745
|
// `buildContract` normalizes every emitted schema (invalid `type`
|
|
745
746
|
// spellings → canonical JSON Schema) before the gate sees it. On a
|
|
746
|
-
// repair-in-place it authors only
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
// carry is named rather than
|
|
750
|
-
|
|
747
|
+
// repair-in-place it authors only a few members per entry; the agent's
|
|
748
|
+
// DECLARED members (`oneShot` above all) are put back from the draft
|
|
749
|
+
// deterministically, on every spec, and so is any tool the repair
|
|
750
|
+
// dropped. Anything the merged tree cannot carry is named rather than
|
|
751
|
+
// lost (ggui#1421, ggui#1430).
|
|
752
|
+
const restored = restoreDraftDeclaredMembers(options?.draft, buildContract(parsed), underRepair);
|
|
751
753
|
const contract = restored.contract;
|
|
752
754
|
// Defensive gate: re-validate the assembled contract against the
|
|
753
755
|
// canonical schema. Catches LLM outputs that pass the loose tool
|
|
@@ -844,17 +846,19 @@ export async function synthesizeContract(deps, intent, options) {
|
|
|
844
846
|
};
|
|
845
847
|
}
|
|
846
848
|
/**
|
|
847
|
-
* What a repair could not keep, against the contract it finally
|
|
848
|
-
* returns: the member drops
|
|
849
|
-
* entry the
|
|
850
|
-
*
|
|
851
|
-
*
|
|
849
|
+
* What a repair could not keep, or put back, against the contract it finally
|
|
850
|
+
* returns: the accepted attempt's member drops and restored tools, minus
|
|
851
|
+
* those on an entry the final contract no longer carries (the entry finding
|
|
852
|
+
* names that loss once), plus one entry finding per draft entry, on every
|
|
853
|
+
* spec, that the final contract no longer carries under its key. Where the
|
|
854
|
+
* overlay itself named an entry it had to un-restore, its finding (which
|
|
855
|
+
* carries the gate's reason) stands for that entry.
|
|
852
856
|
*/
|
|
853
|
-
function droppedFor(
|
|
854
|
-
const
|
|
857
|
+
function droppedFor(overlayFindings, draft, finalContract, underRepair) {
|
|
858
|
+
const overlayEntryDrops = new Map(overlayFindings.filter((f) => f.code === REPAIR_ENTRY_DROPPED).map((f) => [f.path, f]));
|
|
855
859
|
return [
|
|
856
|
-
...
|
|
857
|
-
...
|
|
860
|
+
...overlayFindings.filter((f) => f.code !== REPAIR_ENTRY_DROPPED && isOnSurvivingEntry(f, finalContract)),
|
|
861
|
+
...findDroppedEntries(draft, finalContract, underRepair).map((f) => overlayEntryDrops.get(f.path) ?? f),
|
|
858
862
|
];
|
|
859
863
|
}
|
|
860
864
|
function parseToolInput(raw) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ggui-ai/negotiator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Contract-synthesis + match-judge engine for ggui's handshake. Synthesizes or repairs a conforming DataContract from an agent's draft, judges blueprint-match candidates for reuse, and validates contract structure + novelty — the primitives composed by decideHandshake in @ggui-ai/mcp-server-handlers. Deployment-agnostic: concrete embedding and vector-store bindings plug in via the storage interfaces from @ggui-ai/mcp-server-core.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"keywords": [
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
}
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@ggui-ai/mcp-server-core": "0.
|
|
51
|
-
"@ggui-ai/protocol": "0.
|
|
50
|
+
"@ggui-ai/mcp-server-core": "0.26.0",
|
|
51
|
+
"@ggui-ai/protocol": "0.26.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@types/node": "^24.0.0",
|
|
@@ -44,6 +44,7 @@ import type { LLMCaller } from './llm-caller.js';
|
|
|
44
44
|
import { synthesizeContract } from './synthesize-contract.js';
|
|
45
45
|
import { liftedRequiredNames, normalizeDraft } from './normalize-draft.js';
|
|
46
46
|
import { salvageConformingSubset } from './salvage-draft.js';
|
|
47
|
+
import { REPAIR_ENTRY_RESTORED } from './preserve-declared-members.js';
|
|
47
48
|
|
|
48
49
|
/**
|
|
49
50
|
* How a conforming contract was produced — finer-grained than `origin`,
|
|
@@ -78,12 +79,14 @@ export interface EnsureConformingAccepted {
|
|
|
78
79
|
* findings that rejected the agent's draft — so the agent-side model
|
|
79
80
|
* learns what it got wrong, even though we repaired it. On
|
|
80
81
|
* `llm-repair` they additionally carry one `REPAIR_MEMBER_DROPPED` per
|
|
81
|
-
* declared
|
|
82
|
-
* `REPAIR_ENTRY_DROPPED` per draft
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
82
|
+
* declared member the repair could not keep, on any spec, one
|
|
83
|
+
* `REPAIR_ENTRY_DROPPED` per draft entry it no longer carries under its
|
|
84
|
+
* key, and one `REPAIR_ENTRY_RESTORED` (`warn`) per draft tool the repair
|
|
85
|
+
* dropped and the overlay put back (ggui#1421, ggui#1430 — a repair
|
|
86
|
+
* preserves the draft's declarations, `oneShot` above all, and names any
|
|
87
|
+
* it must drop, each at its own path with the gate's reason in the
|
|
88
|
+
* message; a member under repair is named by the gate's own finding at
|
|
89
|
+
* that same path).
|
|
87
90
|
* The error findings are the union of what the gate refused on the
|
|
88
91
|
* raw draft and on its normalized form (the gate stops after the
|
|
89
92
|
* shape phase, so the raw lint alone can hide a semantic finding). On
|
|
@@ -223,7 +226,8 @@ export async function ensureConformingContract(
|
|
|
223
226
|
synth.contract !== null &&
|
|
224
227
|
lintContract(synth.contract).errors.length === 0
|
|
225
228
|
) {
|
|
226
|
-
const droppedPaths = synth.dropped.map((d) => d.path);
|
|
229
|
+
const droppedPaths = synth.dropped.filter((d) => d.code !== REPAIR_ENTRY_RESTORED).map((d) => d.path);
|
|
230
|
+
const restoredPaths = synth.dropped.filter((d) => d.code === REPAIR_ENTRY_RESTORED).map((d) => d.path);
|
|
227
231
|
return {
|
|
228
232
|
contract: synth.contract,
|
|
229
233
|
origin: 'synth',
|
|
@@ -232,7 +236,10 @@ export async function ensureConformingContract(
|
|
|
232
236
|
reasoning:
|
|
233
237
|
`repaired the agent draft to pass validateContract — ${synth.reason}` +
|
|
234
238
|
(droppedPaths.length > 0
|
|
235
|
-
? `; dropped ${droppedPaths.length} declared
|
|
239
|
+
? `; dropped ${droppedPaths.length} declared ${droppedPaths.length === 1 ? 'member/entry' : 'members/entries'} the repair could not keep (${droppedPaths.join(', ')}) — each is a finding`
|
|
240
|
+
: '') +
|
|
241
|
+
(restoredPaths.length > 0
|
|
242
|
+
? `; restored ${restoredPaths.length} declared ${restoredPaths.length === 1 ? 'tool' : 'tools'} the repair dropped (${restoredPaths.join(', ')}) — each is a finding`
|
|
236
243
|
: ''),
|
|
237
244
|
};
|
|
238
245
|
}
|
package/src/index.ts
CHANGED
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
* handshake always returns a valid contract;
|
|
9
9
|
* - judges blueprint-match candidates for reuse (`rerankCandidates`);
|
|
10
10
|
* - validates contract structure + novelty (`contract-validators`);
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
11
|
+
* - normalizes untrusted drafts (`normalizeDraft`).
|
|
12
|
+
*
|
|
13
|
+
* Contract identity is not this package's: the cache key is
|
|
14
|
+
* `blueprintKey(contract)` and the variant key is `variantKey(variance)`,
|
|
15
|
+
* both from `@ggui-ai/protocol/blueprint-key`.
|
|
13
16
|
*
|
|
14
17
|
* The HANDSHAKE DECISION itself (find-similar → reuse vs synth-create)
|
|
15
18
|
* lives in the shared `decideHandshake` core in
|
|
@@ -25,7 +28,6 @@
|
|
|
25
28
|
* consumers need. Each additive export carries semver weight.
|
|
26
29
|
*/
|
|
27
30
|
|
|
28
|
-
export { hashContract, buildVariant } from './contract-hash.js';
|
|
29
31
|
export type { LLMCaller, LLMCallerConfig, Metered, TokenUsage, ToolSchema } from './llm-caller.js';
|
|
30
32
|
export { llmRerankJudge, rerankCandidates, RERANK_SYSTEM_PROMPT } from './llm-rerank.js';
|
|
31
33
|
export type {
|