@ggui-ai/negotiator 0.24.0 → 0.25.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 +6 -1
- package/dist/ensure-conforming-contract.d.ts +12 -1
- package/dist/ensure-conforming-contract.d.ts.map +1 -1
- package/dist/ensure-conforming-contract.js +40 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/llm-caller.d.ts +26 -0
- package/dist/llm-caller.d.ts.map +1 -1
- package/dist/llm-caller.js +9 -0
- package/dist/llm-rerank.d.ts +8 -9
- package/dist/llm-rerank.d.ts.map +1 -1
- package/dist/llm-rerank.js +14 -7
- package/dist/normalize-draft.d.ts +20 -0
- package/dist/normalize-draft.d.ts.map +1 -1
- package/dist/normalize-draft.js +63 -39
- package/dist/preserve-action-members.d.ts +79 -0
- package/dist/preserve-action-members.d.ts.map +1 -0
- package/dist/preserve-action-members.js +199 -0
- package/dist/rerank-eval/run-probe.js +1 -1
- package/dist/synthesize-contract.d.ts +11 -0
- package/dist/synthesize-contract.d.ts.map +1 -1
- package/dist/synthesize-contract.js +30 -2
- package/package.json +3 -3
- package/src/ensure-conforming-contract.ts +53 -7
- package/src/index.ts +2 -2
- package/src/llm-caller.ts +34 -0
- package/src/llm-rerank.ts +21 -18
- package/src/normalize-draft.ts +70 -39
- package/src/preserve-action-members.ts +224 -0
- package/src/rerank-eval/run-probe.ts +1 -1
- package/src/synthesize-contract.ts +47 -3
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action-entry member preservation — the deterministic backstop that
|
|
3
|
+
* keeps a repair FAITHFUL to what the agent DECLARED on its actions
|
|
4
|
+
* (ggui#1421).
|
|
5
|
+
*
|
|
6
|
+
* The repair tool authors exactly two members per action, `label` and
|
|
7
|
+
* `schema` (`SYNTHESIZE_TOOL`); every other member of an
|
|
8
|
+
* {@link ActionEntry} — `description`, `example`, `icon`, `confirm`,
|
|
9
|
+
* `oneShot`, `nextStep` — is the agent's declaration, and the whole
|
|
10
|
+
* one-shot line (ggui#1108 → ggui#1223: the runtime's second-dispatch
|
|
11
|
+
* guard, the server's spend record, ui-gen's `useActionSpent` binding)
|
|
12
|
+
* keys on `oneShot === true` being ON THE SERVED CONTRACT. A repair that
|
|
13
|
+
* re-emits an entry from the tool's answer alone serves a card with no
|
|
14
|
+
* guard, silently. So the overlay below puts the draft's declarations
|
|
15
|
+
* back on the repaired candidate, deterministically, and NAMES whatever
|
|
16
|
+
* it cannot keep:
|
|
17
|
+
*
|
|
18
|
+
* - `REPAIR_MEMBER_DROPPED` at `actionSpec.<name>.<member>` — a
|
|
19
|
+
* declared member the merged contract cannot carry (the gate refuses
|
|
20
|
+
* it there, e.g. a `nextStep` whose tool the repair did not
|
|
21
|
+
* re-declare, or a member that fails its own schema);
|
|
22
|
+
* - `REPAIR_ENTRY_DROPPED` at `actionSpec.<name>` — a draft action the
|
|
23
|
+
* candidate no longer carries under that name (renamed or removed),
|
|
24
|
+
* which takes every member with it.
|
|
25
|
+
*
|
|
26
|
+
* A member whose path a DRAFT finding already names is under repair: it
|
|
27
|
+
* is not restored, and its absence is named by that finding, at the same
|
|
28
|
+
* path. Every other loss is named HERE, at the member's or the entry's
|
|
29
|
+
* own path, with the gate's reason in the message — so the invariant an
|
|
30
|
+
* agent can rely on is one sentence: a declared member or action missing
|
|
31
|
+
* from the served proposal always has a finding at its own path. The
|
|
32
|
+
* `CTR_*` codes say what the gate refused; the `REPAIR_*` codes say what
|
|
33
|
+
* the served proposal does not carry of the declaration, and why. The
|
|
34
|
+
* codes are handshake-decision codes like `COVERAGE_GAP` — declared where
|
|
35
|
+
* they are emitted, read by the agent as strings in `validationFindings`.
|
|
36
|
+
* Model-independent: the overlay works whatever the repair LLM returns.
|
|
37
|
+
*/
|
|
38
|
+
import { actionEntrySchema, CTR_SCHEMA_INCOMPAT, lintContract, } from '@ggui-ai/protocol';
|
|
39
|
+
/** A declared action-entry member the repair could not keep; path `actionSpec.<name>.<member>`. */
|
|
40
|
+
export const REPAIR_MEMBER_DROPPED = 'REPAIR_MEMBER_DROPPED';
|
|
41
|
+
/** A draft action entry the repair no longer carries under its name; path `actionSpec.<name>`. */
|
|
42
|
+
export const REPAIR_ENTRY_DROPPED = 'REPAIR_ENTRY_DROPPED';
|
|
43
|
+
/**
|
|
44
|
+
* The members the agent declares — the protocol's action-entry schema
|
|
45
|
+
* minus the pair the repair tool authors. Derived, never listed, so a
|
|
46
|
+
* new schema member is preserved the day it lands.
|
|
47
|
+
*/
|
|
48
|
+
const DECLARED_MEMBERS = Object.keys(actionEntrySchema.shape).filter((k) => k !== 'label' && k !== 'schema');
|
|
49
|
+
function isRecord(value) {
|
|
50
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
/** The draft's `actionSpec` map when it is one; `undefined` otherwise (the draft is untrusted). */
|
|
53
|
+
function draftActionSpec(draft) {
|
|
54
|
+
if (!isRecord(draft))
|
|
55
|
+
return undefined;
|
|
56
|
+
const actionSpec = draft['actionSpec'];
|
|
57
|
+
return isRecord(actionSpec) ? actionSpec : undefined;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* `entry` without `member`. Removing an optional member from a parsed
|
|
61
|
+
* entry leaves a parsed entry, so the strict re-parse re-derives the
|
|
62
|
+
* typed shape without a cast.
|
|
63
|
+
*/
|
|
64
|
+
function withoutMember(entry, member) {
|
|
65
|
+
return actionEntrySchema.parse(Object.fromEntries(Object.entries(entry).filter(([key]) => key !== member)));
|
|
66
|
+
}
|
|
67
|
+
function memberDropped(name, member, reason) {
|
|
68
|
+
return {
|
|
69
|
+
code: REPAIR_MEMBER_DROPPED,
|
|
70
|
+
severity: 'error',
|
|
71
|
+
path: `actionSpec.${name}.${member}`,
|
|
72
|
+
message: `the repair could not keep the declared \`${member}\` on action '${name}': ${reason}`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Whether `finding` is a member drop on one of `entryNames` — matched by
|
|
77
|
+
* the exact `actionSpec.<name>.<member>` path per declared member, never
|
|
78
|
+
* by a dotted prefix (an action named `a` is not a prefix of `a.b`).
|
|
79
|
+
*/
|
|
80
|
+
export function isMemberDropOf(finding, entryNames) {
|
|
81
|
+
return entryNames.some((name) => DECLARED_MEMBERS.some((member) => finding.path === `actionSpec.${name}.${member}`));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Overlay the draft's declared members onto the candidate's action
|
|
85
|
+
* entries (same name), keeping the candidate's `label` and `schema`. A
|
|
86
|
+
* member whose exact path a draft finding names is under repair and
|
|
87
|
+
* stays out. Then lint the merged contract and un-restore, until the
|
|
88
|
+
* tree is stable, every restored member the gate refuses — at its own
|
|
89
|
+
* path, or (for `nextStep`) as `CTR_SCHEMA_INCOMPAT` at the sibling
|
|
90
|
+
* `.schema`, the path the compatibility check reports on — naming each
|
|
91
|
+
* one, so the returned contract never fails the gate BECAUSE of the
|
|
92
|
+
* overlay. A malformed candidate entry (no `label`) is left un-overlaid
|
|
93
|
+
* for the loop's own gate to retry; nothing here throws on the model's
|
|
94
|
+
* answer. Entries the candidate does not carry get nothing back here —
|
|
95
|
+
* see {@link findDroppedActionEntries}.
|
|
96
|
+
*/
|
|
97
|
+
export function restoreDraftActionMembers(draft, candidate, underRepair) {
|
|
98
|
+
const draftActions = draftActionSpec(draft);
|
|
99
|
+
if (draftActions === undefined || candidate.actionSpec === undefined) {
|
|
100
|
+
return { contract: candidate, dropped: [] };
|
|
101
|
+
}
|
|
102
|
+
const dropped = [];
|
|
103
|
+
/** Per entry, the members the overlay put back. */
|
|
104
|
+
const restored = new Map();
|
|
105
|
+
const merged = {};
|
|
106
|
+
for (const [name, entry] of Object.entries(candidate.actionSpec)) {
|
|
107
|
+
const draftEntry = Object.hasOwn(draftActions, name) ? draftActions[name] : undefined;
|
|
108
|
+
if (!isRecord(draftEntry)) {
|
|
109
|
+
merged[name] = entry;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const next = { ...entry };
|
|
113
|
+
const put = new Set();
|
|
114
|
+
for (const member of DECLARED_MEMBERS) {
|
|
115
|
+
const value = draftEntry[member];
|
|
116
|
+
if (value === undefined || entry[member] !== undefined || underRepair.has(`actionSpec.${name}.${member}`))
|
|
117
|
+
continue;
|
|
118
|
+
const parsed = actionEntrySchema.shape[member].safeParse(value);
|
|
119
|
+
if (!parsed.success) {
|
|
120
|
+
dropped.push(memberDropped(name, member, parsed.error.issues[0]?.message ?? 'invalid'));
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
next[member] = parsed.data;
|
|
124
|
+
put.add(member);
|
|
125
|
+
}
|
|
126
|
+
const reparsed = actionEntrySchema.safeParse(next);
|
|
127
|
+
if (put.size === 0 || !reparsed.success) {
|
|
128
|
+
// Nothing to put back, or the CANDIDATE's own label/schema are not
|
|
129
|
+
// an entry yet — the loop's gate retries that; not this overlay's.
|
|
130
|
+
merged[name] = entry;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
merged[name] = reparsed.data;
|
|
134
|
+
restored.set(name, put);
|
|
135
|
+
}
|
|
136
|
+
let actionSpec = merged;
|
|
137
|
+
if (restored.size === 0)
|
|
138
|
+
return { contract: { ...candidate, actionSpec }, dropped };
|
|
139
|
+
// Cross-reference and compatibility errors only exist on the MERGED
|
|
140
|
+
// tree. Un-restore exactly the members the gate names — at their own
|
|
141
|
+
// path, or a `nextStep` as `CTR_SCHEMA_INCOMPAT` at the sibling
|
|
142
|
+
// `.schema`, the path the compatibility check reports on — naming each
|
|
143
|
+
// at ITS OWN path with the gate's reason; re-lint, and stop when a pass
|
|
144
|
+
// changes nothing. Every pass removes at least one member, so the loop
|
|
145
|
+
// is bounded by what was restored.
|
|
146
|
+
for (let pass = 0; pass < DECLARED_MEMBERS.length * restored.size + 1; pass += 1) {
|
|
147
|
+
const errors = lintContract({ ...candidate, actionSpec }).errors;
|
|
148
|
+
let changed = false;
|
|
149
|
+
for (const [name, members] of restored) {
|
|
150
|
+
for (const member of [...members]) {
|
|
151
|
+
const refusal = errors.find((issue) => issue.path === `actionSpec.${name}.${member}`) ??
|
|
152
|
+
(member === 'nextStep'
|
|
153
|
+
? errors.find((issue) => issue.code === CTR_SCHEMA_INCOMPAT && issue.path === `actionSpec.${name}.schema`)
|
|
154
|
+
: undefined);
|
|
155
|
+
if (refusal === undefined)
|
|
156
|
+
continue;
|
|
157
|
+
const entry = actionSpec[name];
|
|
158
|
+
if (entry === undefined)
|
|
159
|
+
continue;
|
|
160
|
+
actionSpec = { ...actionSpec, [name]: withoutMember(entry, member) };
|
|
161
|
+
members.delete(member);
|
|
162
|
+
dropped.push(memberDropped(name, member, `refused by the gate as ${refusal.code} at ${refusal.path}: ${refusal.message}`));
|
|
163
|
+
changed = true;
|
|
164
|
+
}
|
|
165
|
+
if (members.size === 0)
|
|
166
|
+
restored.delete(name);
|
|
167
|
+
}
|
|
168
|
+
if (!changed || restored.size === 0)
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
return { contract: { ...candidate, actionSpec }, dropped };
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* One `REPAIR_ENTRY_DROPPED` per draft action entry the candidate no
|
|
175
|
+
* longer carries under that name — renamed or removed by the repair (or
|
|
176
|
+
* pruned by the loop's own placement pass) — always, because the members
|
|
177
|
+
* it carried (`oneShot` above all) went with it and nothing else names
|
|
178
|
+
* that. When the gate refused the NAME itself (`CTR_DUP_NAME` /
|
|
179
|
+
* `CTR_RESERVED_NAME` at `actionSpec.<name>`, so the path is under
|
|
180
|
+
* repair) the message says so and does not send the agent back to a
|
|
181
|
+
* name the gate will refuse again. Empty when every draft entry survived,
|
|
182
|
+
* and for drafts that declare no actions.
|
|
183
|
+
*/
|
|
184
|
+
export function findDroppedActionEntries(draft, candidate, underRepair) {
|
|
185
|
+
const draftActions = draftActionSpec(draft);
|
|
186
|
+
if (draftActions === undefined)
|
|
187
|
+
return [];
|
|
188
|
+
const kept = candidate.actionSpec ?? {};
|
|
189
|
+
return Object.keys(draftActions)
|
|
190
|
+
.filter((name) => !Object.hasOwn(kept, name))
|
|
191
|
+
.map((name) => ({
|
|
192
|
+
code: REPAIR_ENTRY_DROPPED,
|
|
193
|
+
severity: 'error',
|
|
194
|
+
path: `actionSpec.${name}`,
|
|
195
|
+
message: underRepair.has(`actionSpec.${name}`)
|
|
196
|
+
? `the gate refused the action name '${name}' (see the finding at this path) and the repair removed or renamed it; every member it carried, oneShot included, went with it — declare those members again under a name the gate accepts, via ggui_render override or a corrected re-handshake`
|
|
197
|
+
: `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`,
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
@@ -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;
|
|
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;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,CAsNnC;AAuVD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,SAAS,gBAAgB,EAAE,GAAG,SAAS,GAC/C,MAAM,GAAG,SAAS,CA+CpB"}
|
|
@@ -39,6 +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 { findDroppedActionEntries, isMemberDropOf, restoreDraftActionMembers } from './preserve-action-members.js';
|
|
42
43
|
import { draftSeedPropKeys, findDroppedSeedSurfaces, } from './preserve-seed-surfaces.js';
|
|
43
44
|
import { formatValidationFindings, validateActionsVsContext, validateContractCoherence, validateContractRedundancy, } from './contract-validators.js';
|
|
44
45
|
/**
|
|
@@ -674,6 +675,7 @@ export async function synthesizeContract(deps, intent, options) {
|
|
|
674
675
|
latencyMs: Date.now() - startedAt,
|
|
675
676
|
attempts: 0,
|
|
676
677
|
findings: [],
|
|
678
|
+
dropped: [],
|
|
677
679
|
};
|
|
678
680
|
}
|
|
679
681
|
const gadgetsSection = composeAvailableGadgetsSection(options?.appGadgets);
|
|
@@ -706,6 +708,10 @@ export async function synthesizeContract(deps, intent, options) {
|
|
|
706
708
|
// preservation never makes the result WORSE than the validity-only gate.
|
|
707
709
|
const draftSeedKeys = options?.draft !== undefined ? draftSeedPropKeys(options.draft) : [];
|
|
708
710
|
let lastValidContract = null;
|
|
711
|
+
/** The paths the draft's own findings name — members under repair are not restored. */
|
|
712
|
+
const underRepair = new Set((options?.draftFindings ?? []).map((f) => f.path));
|
|
713
|
+
/** The member drops of the attempt that produced `lastValidContract`. */
|
|
714
|
+
let lastValidDropped = [];
|
|
709
715
|
for (let attempt = 1; attempt <= MAX_SYNTH_ATTEMPTS; attempt++) {
|
|
710
716
|
const userPrompt = repairNote === undefined
|
|
711
717
|
? baseUserPrompt
|
|
@@ -736,8 +742,13 @@ export async function synthesizeContract(deps, intent, options) {
|
|
|
736
742
|
continue;
|
|
737
743
|
}
|
|
738
744
|
// `buildContract` normalizes every emitted schema (invalid `type`
|
|
739
|
-
// spellings → canonical JSON Schema) before the gate sees it.
|
|
740
|
-
|
|
745
|
+
// spellings → canonical JSON Schema) before the gate sees it. On a
|
|
746
|
+
// repair-in-place it authors only `{label, schema}` per action; the
|
|
747
|
+
// agent's DECLARED members (`oneShot` above all) are put back from
|
|
748
|
+
// the draft deterministically, and anything the merged tree cannot
|
|
749
|
+
// carry is named rather than lost (ggui#1421).
|
|
750
|
+
const restored = restoreDraftActionMembers(options?.draft, buildContract(parsed), underRepair);
|
|
751
|
+
const contract = restored.contract;
|
|
741
752
|
// Defensive gate: re-validate the assembled contract against the
|
|
742
753
|
// canonical schema. Catches LLM outputs that pass the loose tool
|
|
743
754
|
// input_schema but produce structurally invalid contracts. Without
|
|
@@ -797,6 +808,7 @@ export async function synthesizeContract(deps, intent, options) {
|
|
|
797
808
|
// Remember the best VALID candidate so an exhausted budget never
|
|
798
809
|
// returns WORSE than the validity-only gate did (a valid contract).
|
|
799
810
|
lastValidContract = validatedContract;
|
|
811
|
+
lastValidDropped = restored.dropped;
|
|
800
812
|
lastReason = `synthesize-preservation: candidate dropped agent-owned propsSpec seed surface(s) [${dropped.join(', ')}]`;
|
|
801
813
|
lastFindings = allFindings;
|
|
802
814
|
repairNote = buildPreservationRepairNote(validatedContract, dropped);
|
|
@@ -813,6 +825,7 @@ export async function synthesizeContract(deps, intent, options) {
|
|
|
813
825
|
latencyMs: Date.now() - startedAt,
|
|
814
826
|
attempts: attempt,
|
|
815
827
|
findings: allFindings,
|
|
828
|
+
dropped: droppedFor(restored.dropped, options?.draft, validatedContract, underRepair),
|
|
816
829
|
};
|
|
817
830
|
}
|
|
818
831
|
// Budget exhausted. If preservation retries never landed a contract
|
|
@@ -827,8 +840,23 @@ export async function synthesizeContract(deps, intent, options) {
|
|
|
827
840
|
latencyMs: Date.now() - startedAt,
|
|
828
841
|
attempts: MAX_SYNTH_ATTEMPTS,
|
|
829
842
|
findings: lastFindings,
|
|
843
|
+
dropped: lastValidContract !== null ? droppedFor(lastValidDropped, options?.draft, lastValidContract, underRepair) : [],
|
|
830
844
|
};
|
|
831
845
|
}
|
|
846
|
+
/**
|
|
847
|
+
* What a repair could not keep, against the contract it finally
|
|
848
|
+
* returns: the member drops of the accepted attempt, minus those on an
|
|
849
|
+
* entry the loop's own placement pass pruned afterwards (the entry
|
|
850
|
+
* finding names that loss once, with every member), plus one entry
|
|
851
|
+
* finding per draft action the final contract no longer carries.
|
|
852
|
+
*/
|
|
853
|
+
function droppedFor(memberDrops, draft, finalContract, underRepair) {
|
|
854
|
+
const survivingEntries = Object.keys(finalContract.actionSpec ?? {});
|
|
855
|
+
return [
|
|
856
|
+
...memberDrops.filter((f) => isMemberDropOf(f, survivingEntries)),
|
|
857
|
+
...findDroppedActionEntries(draft, finalContract, underRepair),
|
|
858
|
+
];
|
|
859
|
+
}
|
|
832
860
|
function parseToolInput(raw) {
|
|
833
861
|
if (typeof raw !== 'object' || raw === null)
|
|
834
862
|
return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ggui-ai/negotiator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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.25.0",
|
|
51
|
+
"@ggui-ai/protocol": "0.25.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@types/node": "^24.0.0",
|
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
} from '@ggui-ai/protocol';
|
|
43
43
|
import type { LLMCaller } from './llm-caller.js';
|
|
44
44
|
import { synthesizeContract } from './synthesize-contract.js';
|
|
45
|
-
import { normalizeDraft } from './normalize-draft.js';
|
|
45
|
+
import { liftedRequiredNames, normalizeDraft } from './normalize-draft.js';
|
|
46
46
|
import { salvageConformingSubset } from './salvage-draft.js';
|
|
47
47
|
|
|
48
48
|
/**
|
|
@@ -77,7 +77,18 @@ export interface EnsureConformingAccepted {
|
|
|
77
77
|
* warnings on the (valid) draft. On `origin: 'synth'`, the ERROR
|
|
78
78
|
* findings that rejected the agent's draft — so the agent-side model
|
|
79
79
|
* learns what it got wrong, even though we repaired it. On
|
|
80
|
-
* `
|
|
80
|
+
* `llm-repair` they additionally carry one `REPAIR_MEMBER_DROPPED` per
|
|
81
|
+
* declared action-entry member the repair could not keep and one
|
|
82
|
+
* `REPAIR_ENTRY_DROPPED` per draft action it no longer carries
|
|
83
|
+
* (ggui#1421 — a repair preserves the draft's declarations, `oneShot`
|
|
84
|
+
* above all, and names any it must drop, each at its own path with the
|
|
85
|
+
* gate's reason in the message; a member under repair is named by the
|
|
86
|
+
* gate's own finding at that same path).
|
|
87
|
+
* The error findings are the union of what the gate refused on the
|
|
88
|
+
* raw draft and on its normalized form (the gate stops after the
|
|
89
|
+
* shape phase, so the raw lint alone can hide a semantic finding). On
|
|
90
|
+
* `salvaged-subset` they include one finding per dropped entry, with
|
|
91
|
+
* the gate's own code at the cut path.
|
|
81
92
|
*/
|
|
82
93
|
readonly findings: readonly SuggestionFinding[];
|
|
83
94
|
/** Operator- + LLM-readable explanation. */
|
|
@@ -137,12 +148,25 @@ export async function ensureConformingContract(
|
|
|
137
148
|
};
|
|
138
149
|
}
|
|
139
150
|
|
|
151
|
+
// ggui#1454 — a wrapper-level `propsSpec.required: [...]` is refused by the
|
|
152
|
+
// gate as an unrecognized key and LIFTED by `normalizeDraft` onto the named
|
|
153
|
+
// entries (ggui#1432). The finding stays (the draft used a non-wire shape,
|
|
154
|
+
// and the agent should see that); its message says the key was honoured,
|
|
155
|
+
// so it reads as a repair, not a refusal to fix and re-handshake for.
|
|
156
|
+
const lifted = liftedRequiredNames(args.draft);
|
|
157
|
+
const liftNote =
|
|
158
|
+
lifted.length > 0
|
|
159
|
+
? ` — the wrapper-level \`required\` is not a wire key; it was lifted into ${lifted
|
|
160
|
+
.map((name) => `propsSpec.properties.${name}.required = true`)
|
|
161
|
+
.join(', ')} (repaired, not refused)`
|
|
162
|
+
: '';
|
|
140
163
|
const errorFindings: SuggestionFinding[] = lint.errors.map(
|
|
141
164
|
(e): SuggestionFinding => ({
|
|
142
165
|
code: e.code,
|
|
143
166
|
severity: 'error',
|
|
144
167
|
path: e.path,
|
|
145
|
-
message:
|
|
168
|
+
message:
|
|
169
|
+
e.code === 'CTR_SHAPE_UNRECOGNIZED_KEYS' && e.path === 'propsSpec' ? `${e.message}${liftNote}` : e.message,
|
|
146
170
|
}),
|
|
147
171
|
);
|
|
148
172
|
|
|
@@ -154,6 +178,23 @@ export async function ensureConformingContract(
|
|
|
154
178
|
// LLM call). Semantic deficiencies fall through to the repair loop.
|
|
155
179
|
const normalized = normalizeDraft(args.draft);
|
|
156
180
|
const normLint = lintContract(normalized);
|
|
181
|
+
// The gate stops after the shape phase, so the RAW draft's lint may
|
|
182
|
+
// name only its mechanical errors while the NORMALIZED draft's lint
|
|
183
|
+
// reaches the semantic ones (a dangling `nextStep`). Every path the
|
|
184
|
+
// gate refused on either tree is the agent's to see (ggui#1421): the
|
|
185
|
+
// union, raw first, one finding per (code, path).
|
|
186
|
+
const gateFindings: SuggestionFinding[] = [...errorFindings];
|
|
187
|
+
for (const e of normLint.errors) {
|
|
188
|
+
if (!gateFindings.some((f) => f.code === e.code && f.path === e.path)) {
|
|
189
|
+
gateFindings.push({ code: e.code, severity: 'error', path: e.path, message: e.message });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** `gateFindings` plus `more`, one finding per (code, path) — a drop the gate already named is not named twice. */
|
|
193
|
+
const withGateFindings = (more: readonly SuggestionFinding[]): SuggestionFinding[] => {
|
|
194
|
+
const out = [...gateFindings];
|
|
195
|
+
for (const f of more) if (!out.some((g) => g.code === f.code && g.path === f.path)) out.push(f);
|
|
196
|
+
return out;
|
|
197
|
+
};
|
|
157
198
|
if (normLint.errors.length === 0) {
|
|
158
199
|
return {
|
|
159
200
|
contract: dataContractSchema.parse(normalized),
|
|
@@ -182,12 +223,17 @@ export async function ensureConformingContract(
|
|
|
182
223
|
synth.contract !== null &&
|
|
183
224
|
lintContract(synth.contract).errors.length === 0
|
|
184
225
|
) {
|
|
226
|
+
const droppedPaths = synth.dropped.map((d) => d.path);
|
|
185
227
|
return {
|
|
186
228
|
contract: synth.contract,
|
|
187
229
|
origin: 'synth',
|
|
188
230
|
method: 'llm-repair',
|
|
189
|
-
findings:
|
|
190
|
-
reasoning:
|
|
231
|
+
findings: withGateFindings(synth.dropped),
|
|
232
|
+
reasoning:
|
|
233
|
+
`repaired the agent draft to pass validateContract — ${synth.reason}` +
|
|
234
|
+
(droppedPaths.length > 0
|
|
235
|
+
? `; dropped ${droppedPaths.length} declared action ${droppedPaths.length === 1 ? 'member/entry' : 'members/entries'} the repair could not keep (${droppedPaths.join(', ')}) — each is a finding`
|
|
236
|
+
: ''),
|
|
191
237
|
};
|
|
192
238
|
}
|
|
193
239
|
|
|
@@ -203,7 +249,7 @@ export async function ensureConformingContract(
|
|
|
203
249
|
contract: salvaged.contract,
|
|
204
250
|
origin: 'synth',
|
|
205
251
|
method: 'salvaged-subset',
|
|
206
|
-
findings:
|
|
252
|
+
findings: withGateFindings(salvaged.dropped),
|
|
207
253
|
reasoning:
|
|
208
254
|
`could not repair the agent draft within budget (${synth.reason}); ` +
|
|
209
255
|
`proposing the conforming SUBSET of your draft — dropped ${droppedPaths.length} ` +
|
|
@@ -219,7 +265,7 @@ export async function ensureConformingContract(
|
|
|
219
265
|
contract: null,
|
|
220
266
|
origin: 'agent',
|
|
221
267
|
method: 'declined',
|
|
222
|
-
findings:
|
|
268
|
+
findings: gateFindings,
|
|
223
269
|
reasoning:
|
|
224
270
|
`declined: could not repair the agent draft within budget (${synth.reason}) and no entry of it ` +
|
|
225
271
|
`passes the contract gate — nothing to propose. Fix the findings (every one names its path) ` +
|
package/src/index.ts
CHANGED
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
export { hashContract, buildVariant } from './contract-hash.js';
|
|
29
|
-
export type { LLMCaller, LLMCallerConfig, ToolSchema } from './llm-caller.js';
|
|
30
|
-
export { llmRerankJudge, rerankCandidates } from './llm-rerank.js';
|
|
29
|
+
export type { LLMCaller, LLMCallerConfig, Metered, TokenUsage, ToolSchema } from './llm-caller.js';
|
|
30
|
+
export { llmRerankJudge, rerankCandidates, RERANK_SYSTEM_PROMPT } from './llm-rerank.js';
|
|
31
31
|
export type {
|
|
32
32
|
RerankCandidate,
|
|
33
33
|
RerankDecision,
|
package/src/llm-caller.ts
CHANGED
|
@@ -35,6 +35,15 @@
|
|
|
35
35
|
* that can't produce tool input at all simply omit this method;
|
|
36
36
|
* consumers fall back to `call` + regex JSON extraction. Absence is
|
|
37
37
|
* not an error.
|
|
38
|
+
* - `callStructuredMetered?(...)` is OPTIONAL: `callStructured`'s contract
|
|
39
|
+
* (the tool input, or a throw), with the call's token usage beside it
|
|
40
|
+
* as the provider reported it. `usage` is ABSENT when the provider
|
|
41
|
+
* reported none, never zeros: a consumer reads absence as "unmetered".
|
|
42
|
+
* A consumer that has it prefers it to `callStructured`; an
|
|
43
|
+
* implementation that cannot read usage omits it, and every caller
|
|
44
|
+
* written without it keeps compiling. It is a method rather than a
|
|
45
|
+
* usage callback because judges run concurrently, and a result carries
|
|
46
|
+
* its own usage where a callback would need correlating to its call.
|
|
38
47
|
* - `ToolSchema.input_schema` follows the OpenAI tool-use JSON
|
|
39
48
|
* Schema convention. Implementations that use a different
|
|
40
49
|
* tool-use protocol (e.g., Anthropic's variant) MUST translate at
|
|
@@ -48,6 +57,18 @@ export interface ToolSchema {
|
|
|
48
57
|
input_schema: Record<string, unknown>;
|
|
49
58
|
}
|
|
50
59
|
|
|
60
|
+
/** Tokens one provider call consumed, as the provider reported them. */
|
|
61
|
+
export interface TokenUsage {
|
|
62
|
+
readonly input: number;
|
|
63
|
+
readonly output: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A call's result with the usage the provider reported for it; `usage` absent = unmetered. */
|
|
67
|
+
export interface Metered<T> {
|
|
68
|
+
readonly value: T;
|
|
69
|
+
readonly usage?: TokenUsage;
|
|
70
|
+
}
|
|
71
|
+
|
|
51
72
|
/** Chat-model dispatcher consumed by the negotiator's synthesis + judge primitives. */
|
|
52
73
|
export interface LLMCaller {
|
|
53
74
|
/**
|
|
@@ -74,6 +95,19 @@ export interface LLMCaller {
|
|
|
74
95
|
tool: ToolSchema,
|
|
75
96
|
maxTokens?: number,
|
|
76
97
|
): Promise<unknown>;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* {@link callStructured}, with the call's token usage beside the tool
|
|
101
|
+
* input (see the normative semantics above). Omit it when the provider's
|
|
102
|
+
* usage cannot be read; consumers then fall back to `callStructured` and
|
|
103
|
+
* treat the call as unmetered.
|
|
104
|
+
*/
|
|
105
|
+
callStructuredMetered?(
|
|
106
|
+
systemPrompt: string,
|
|
107
|
+
userMessage: string,
|
|
108
|
+
tool: ToolSchema,
|
|
109
|
+
maxTokens?: number,
|
|
110
|
+
): Promise<Metered<unknown>>;
|
|
77
111
|
}
|
|
78
112
|
|
|
79
113
|
/**
|
package/src/llm-rerank.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* realistic workloads observe 30-70%.
|
|
14
14
|
*/
|
|
15
15
|
import { MATCHED_INTENT_MAX_CHARS } from '@ggui-ai/protocol';
|
|
16
|
-
import type { LLMCaller, ToolSchema } from './llm-caller.js';
|
|
16
|
+
import type { LLMCaller, TokenUsage, ToolSchema } from './llm-caller.js';
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* One candidate blueprint for the LLM judge to consider.
|
|
@@ -70,12 +70,14 @@ export interface RerankDecision {
|
|
|
70
70
|
/** Wall-clock latency of the LLM call. */
|
|
71
71
|
readonly latencyMs: number;
|
|
72
72
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
73
|
+
* Tokens the decision cost, as the provider reported them — for the
|
|
74
|
+
* cache-trace sink and cost accounting. ABSENT means unmetered, never
|
|
75
|
+
* zero: the judge called a provider and has no count for the call (a
|
|
76
|
+
* caller without `callStructuredMetered`, a provider that reported no
|
|
77
|
+
* usage, or a call that threw). `{ input: 0, output: 0 }` is a true
|
|
78
|
+
* zero: the decision was made without a provider call (ggui#1418).
|
|
77
79
|
*/
|
|
78
|
-
readonly tokenCost
|
|
80
|
+
readonly tokenCost?: TokenUsage;
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
/** Query the user's request the judge is matching against. */
|
|
@@ -235,7 +237,8 @@ export async function rerankCandidates(
|
|
|
235
237
|
const userMessage = buildUserMessage(query, candidates);
|
|
236
238
|
const candidateIds = new Set(candidates.map((c) => c.id));
|
|
237
239
|
|
|
238
|
-
|
|
240
|
+
const { callStructured, callStructuredMetered } = deps.llm;
|
|
241
|
+
if (typeof callStructuredMetered !== 'function' && typeof callStructured !== 'function') {
|
|
239
242
|
return {
|
|
240
243
|
matchId: null,
|
|
241
244
|
confidence: 0,
|
|
@@ -246,14 +249,18 @@ export async function rerankCandidates(
|
|
|
246
249
|
};
|
|
247
250
|
}
|
|
248
251
|
|
|
252
|
+
// The metered method when the caller has it (its usage is the decision's
|
|
253
|
+
// cost), else the plain one (unmetered: no tokenCost).
|
|
249
254
|
let toolInput: unknown;
|
|
255
|
+
let usage: TokenUsage | undefined;
|
|
250
256
|
try {
|
|
251
|
-
|
|
252
|
-
RERANK_SYSTEM_PROMPT,
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
+
if (typeof callStructuredMetered === 'function') {
|
|
258
|
+
const metered = await callStructuredMetered.call(deps.llm, RERANK_SYSTEM_PROMPT, userMessage, RERANK_TOOL, 512);
|
|
259
|
+
toolInput = metered.value;
|
|
260
|
+
usage = metered.usage;
|
|
261
|
+
} else if (typeof callStructured === 'function') {
|
|
262
|
+
toolInput = await callStructured.call(deps.llm, RERANK_SYSTEM_PROMPT, userMessage, RERANK_TOOL, 512);
|
|
263
|
+
}
|
|
257
264
|
} catch (err) {
|
|
258
265
|
const message = err instanceof Error ? err.message : String(err);
|
|
259
266
|
return {
|
|
@@ -261,7 +268,6 @@ export async function rerankCandidates(
|
|
|
261
268
|
confidence: 0,
|
|
262
269
|
reason: `llm-rerank: callStructured threw — ${message}`,
|
|
263
270
|
latencyMs: Date.now() - startedAt,
|
|
264
|
-
tokenCost: { input: 0, output: 0 },
|
|
265
271
|
};
|
|
266
272
|
}
|
|
267
273
|
|
|
@@ -271,10 +277,7 @@ export async function rerankCandidates(
|
|
|
271
277
|
confidence: parsed.confidence,
|
|
272
278
|
reason: parsed.reason,
|
|
273
279
|
latencyMs: Date.now() - startedAt,
|
|
274
|
-
|
|
275
|
-
// we don't have today. Default to zero; the cost gate is measured
|
|
276
|
-
// out-of-band from billing data during the probe.
|
|
277
|
-
tokenCost: { input: 0, output: 0 },
|
|
280
|
+
...(usage !== undefined ? { tokenCost: usage } : {}),
|
|
278
281
|
};
|
|
279
282
|
}
|
|
280
283
|
|