@ggui-ai/negotiator 0.8.0 → 0.10.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/dist/contract-validators.d.ts.map +1 -1
- package/dist/contract-validators.js +7 -1
- package/dist/ensure-conforming-contract.d.ts +42 -16
- package/dist/ensure-conforming-contract.d.ts.map +1 -1
- package/dist/ensure-conforming-contract.js +36 -14
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/normalize-schema.d.ts.map +1 -1
- package/dist/normalize-schema.js +34 -19
- package/dist/salvage-draft.d.ts +64 -0
- package/dist/salvage-draft.d.ts.map +1 -0
- package/dist/salvage-draft.js +191 -0
- package/dist/synth-bench/corpus.js +1 -1
- package/dist/synth-bench/round-trip-score.js +3 -3
- package/dist/synth-bench/run-repair-bench.d.ts +11 -4
- package/dist/synth-bench/run-repair-bench.d.ts.map +1 -1
- package/dist/synth-bench/run-repair-bench.js +10 -5
- package/dist/synthesize-contract.js +4 -4
- package/package.json +3 -3
- package/src/contract-validators.ts +7 -1
- package/src/ensure-conforming-contract.ts +79 -26
- package/src/index.ts +14 -1
- package/src/normalize-schema.ts +36 -18
- package/src/salvage-draft.ts +204 -0
- package/src/synth-bench/corpus.ts +1 -1
- package/src/synth-bench/round-trip-score.ts +3 -3
- package/src/synth-bench/run-repair-bench.ts +23 -9
- package/src/synthesize-contract.ts +4 -4
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* Live LLM probe — opt-in CLI (run-repair-bench-cli.ts), NOT in CI. The
|
|
25
25
|
* deterministic scorer pinning lives in round-trip-score.test.ts.
|
|
26
26
|
*/
|
|
27
|
-
import { ensureConformingContract } from '../ensure-conforming-contract.js';
|
|
27
|
+
import { ensureConformingContract, } from '../ensure-conforming-contract.js';
|
|
28
28
|
import { scoreSynthesizedContract } from './run-bench.js';
|
|
29
29
|
import { scoreContractRoundTrip, } from './round-trip-score.js';
|
|
30
30
|
import { REPAIR_CORPUS } from './corpus.js';
|
|
@@ -48,7 +48,8 @@ export async function evaluateRepairCorpus(deps, options = {}, corpus = REPAIR_C
|
|
|
48
48
|
const startedAt = Date.now();
|
|
49
49
|
// The real production create-path: lint the draft → verbatim if clean
|
|
50
50
|
// (origin agent), repair-in-place otherwise (origin synth). NEVER
|
|
51
|
-
// throws; an unrepairable draft yields
|
|
51
|
+
// throws; an unrepairable draft yields its conforming subset, or a
|
|
52
|
+
// decline (`contract: null`) when nothing survives.
|
|
52
53
|
const result = await ensureConformingContract({ llm: deps.llm }, {
|
|
53
54
|
draft: entry.draft,
|
|
54
55
|
intent: entry.intent,
|
|
@@ -57,9 +58,13 @@ export async function evaluateRepairCorpus(deps, options = {}, corpus = REPAIR_C
|
|
|
57
58
|
: {}),
|
|
58
59
|
});
|
|
59
60
|
const latencyMs = Date.now() - startedAt;
|
|
60
|
-
|
|
61
|
+
// A decline carries no contract; score it as the empty contract —
|
|
62
|
+
// exactly what the retired fallback returned — so pass rates before
|
|
63
|
+
// and after the posture change measure the same thing.
|
|
64
|
+
const scored = result.contract ?? {};
|
|
65
|
+
const shape = scoreSynthesizedContract(scored, entry.expected);
|
|
61
66
|
const roundTrip = entry.roundTrip !== undefined
|
|
62
|
-
? scoreContractRoundTrip(
|
|
67
|
+
? scoreContractRoundTrip(scored, entry.roundTrip)
|
|
63
68
|
: null;
|
|
64
69
|
const outcome = {
|
|
65
70
|
entry,
|
|
@@ -134,7 +139,7 @@ export function formatRepairBenchReport(report) {
|
|
|
134
139
|
for (const o of report.outcomes) {
|
|
135
140
|
byMethod.set(o.method, (byMethod.get(o.method) ?? 0) + 1);
|
|
136
141
|
}
|
|
137
|
-
const methodStr = ['verbatim', 'normalized', 'llm-repair', '
|
|
142
|
+
const methodStr = ['verbatim', 'normalized', 'llm-repair', 'salvaged-subset', 'declined']
|
|
138
143
|
.filter((m) => byMethod.has(m))
|
|
139
144
|
.map((m) => `${m}×${byMethod.get(m)}`)
|
|
140
145
|
.join(' ');
|
|
@@ -77,7 +77,7 @@ A contract has FOUR specs that describe distinct directions on the wire between
|
|
|
77
77
|
THE FOUR-SPEC MODEL
|
|
78
78
|
|
|
79
79
|
propsSpec (agent → UI, render-time + agent-pushed refreshes)
|
|
80
|
-
Data the AGENT owns and supplies — the initial values at mount, AND every later refresh via ggui_update. NOT "static / never changes": propsSpec is the ONLY channel for agent-owned data, mutable or not. A weather card's city+temp (fixed) AND the items of the todo list the agent fetched and keeps in sync (mutable) BOTH live here — the agent seeds them at render and pushes each change with
|
|
80
|
+
Data the AGENT owns and supplies — the initial values at mount, AND every later refresh via ggui_amend (in-place repaint of the mounted card; ggui_update instead mints a NEW history card for milestones). NOT "static / never changes": propsSpec is the ONLY channel for agent-owned data, mutable or not. A weather card's city+temp (fixed) AND the items of the todo list the agent fetched and keeps in sync (mutable) BOTH live here — the agent seeds them at render and pushes each change with ggui_amend. Use whenever the agent is the SOURCE of what the UI shows: the intent names data the agent provides / fetches / owns ("my todos", "the cart", "this user's profile", "the directory contents") OR data the component cannot render without (city, temp). Omit only when the UI originates its own state with no agent-supplied contents (a counter starting at zero, a blank notepad, a list the USER builds locally).
|
|
81
81
|
|
|
82
82
|
streamSpec (agent → UI, live, append-only)
|
|
83
83
|
Channels where the agent pushes live data the UI displays as it arrives. Use ONLY when the intent describes ongoing agent-originated updates (a chat with messages, a live dashboard, a clock, a stock ticker, a notifications feed). Wrong instinct: do NOT use streamSpec for user-driven state, nor for a multi-step wizard / tutorial — its steps are a local stepper plus component-authored copy, not an agent-pushed feed.
|
|
@@ -152,7 +152,7 @@ CONCRETE PATTERNS
|
|
|
152
152
|
Todo list / collection — split on OWNERSHIP, not on mutability. Both kinds mutate; what differs is WHO supplies the items.
|
|
153
153
|
|
|
154
154
|
(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"
|
|
155
|
-
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
|
|
155
|
+
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.
|
|
156
156
|
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} } }
|
|
157
157
|
actionSpec: { toggleTodo: {label: "Toggle todo", schema: {type: "object", properties: {id: {type: "string"}}, required: ["id"]}, nextStep: "todo_toggle"}, addTodo: {label: "Add todo", schema: {type: "object", properties: {text: {type: "string"}}, required: ["text"]}, nextStep: "todo_add"} }
|
|
158
158
|
|
|
@@ -409,7 +409,7 @@ export const SYNTHESIZE_TOOL = {
|
|
|
409
409
|
description: 'Per-prop map: name → {schema, required?}. Declares the initial render data the agent passes at push time.',
|
|
410
410
|
},
|
|
411
411
|
},
|
|
412
|
-
description: 'Agent-OWNED data the UI displays — the initial values seeded at render, refreshed any time after via
|
|
412
|
+
description: 'Agent-OWNED data the UI displays — the initial values seeded at render, refreshed any time after via ggui_amend. NOT static-only: mutable collections the agent owns / fetched / keeps in sync (my todos, the cart, this thread\'s messages, a directory listing) go here too — propsSpec is the ONLY agent→client data channel. Use whenever the agent is the SOURCE of the displayed data (weather card → city/temp; profile → name/avatar; "my todos" → todos). Omit only when the UI originates its own state with no agent-supplied contents (a counter, a blank notepad, a list the user builds locally).',
|
|
413
413
|
},
|
|
414
414
|
reason: {
|
|
415
415
|
type: 'string',
|
|
@@ -477,7 +477,7 @@ function buildPreservationRepairNote(rejected, dropped) {
|
|
|
477
477
|
'',
|
|
478
478
|
`The agent's draft declared these on propsSpec (agent-owned seed data the UI renders): ${dropped.join(', ')}. Your contract no longer carries them as propsSpec properties — so the agent can no longer seed them at render. contextSpec has NO agent seed channel, so moving them there leaves the UI empty.`,
|
|
479
479
|
'',
|
|
480
|
-
`Re-emit the contract with ${dropped.join(', ')} restored as propsSpec properties (agent-owned, seeded at render and refreshed via
|
|
480
|
+
`Re-emit the contract with ${dropped.join(', ')} restored as propsSpec properties (agent-owned, seeded at render and refreshed via ggui_amend). Keep every other spec unchanged.`,
|
|
481
481
|
].join('\n');
|
|
482
482
|
}
|
|
483
483
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ggui-ai/negotiator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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.10.0",
|
|
51
|
+
"@ggui-ai/protocol": "0.10.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@types/node": "^24.0.0",
|
|
@@ -249,7 +249,13 @@ function nameImpliesMutation(args: {
|
|
|
249
249
|
*/
|
|
250
250
|
function isEmptyPayloadSchema(schema: JsonSchema | undefined): boolean {
|
|
251
251
|
if (schema === undefined) return true;
|
|
252
|
-
|
|
252
|
+
// `type` may be a draft-07 type ARRAY (`['object','null']`) since
|
|
253
|
+
// draft-2026-08-19 — treat a type set containing 'object' like the
|
|
254
|
+
// single-string form.
|
|
255
|
+
const declaresObject = Array.isArray(schema.type)
|
|
256
|
+
? schema.type.includes('object')
|
|
257
|
+
: schema.type === 'object';
|
|
258
|
+
if (!declaresObject) return false;
|
|
253
259
|
if (schema.properties === undefined) return true;
|
|
254
260
|
return Object.keys(schema.properties).length === 0;
|
|
255
261
|
}
|
|
@@ -13,10 +13,14 @@
|
|
|
13
13
|
* seeded with the draft + the deterministic
|
|
14
14
|
* findings, looping until the gate is green
|
|
15
15
|
* (origin: 'synth')
|
|
16
|
-
* - repair impossible →
|
|
17
|
-
* (LLM down / provider
|
|
18
|
-
* can't synth / budget
|
|
19
|
-
* exhausted)
|
|
16
|
+
* - repair impossible → the conforming SUBSET of the draft (every
|
|
17
|
+
* (LLM down / provider refused entry dropped and reported;
|
|
18
|
+
* can't synth / budget origin 'synth', method 'salvaged-subset')
|
|
19
|
+
* exhausted) — or, when nothing usable survives, a
|
|
20
|
+
* DECLINE (`contract: null`, method
|
|
21
|
+
* 'declined') with the findings. NEVER
|
|
22
|
+
* throws; NEVER an empty contract on the
|
|
23
|
+
* agent's behalf (ggui#523 item 3).
|
|
20
24
|
*
|
|
21
25
|
* Determinism lives in the GATE (`lintContract`), never in the repair.
|
|
22
26
|
* The repair LLM is non-deterministic, but the loop only exits when the
|
|
@@ -39,38 +43,65 @@ import {
|
|
|
39
43
|
import type { LLMCaller } from './llm-caller.js';
|
|
40
44
|
import { synthesizeContract } from './synthesize-contract.js';
|
|
41
45
|
import { normalizeDraft } from './normalize-draft.js';
|
|
46
|
+
import { salvageConformingSubset } from './salvage-draft.js';
|
|
42
47
|
|
|
43
|
-
|
|
48
|
+
/**
|
|
49
|
+
* How a conforming contract was produced — finer-grained than `origin`,
|
|
50
|
+
* for telemetry (the efficiency tiers):
|
|
51
|
+
* - `verbatim` — draft was clean; returned as-is (origin agent).
|
|
52
|
+
* - `normalized` — deterministic fix only, NO LLM (origin synth).
|
|
53
|
+
* - `llm-repair` — the bounded LLM repair loop ran (origin synth).
|
|
54
|
+
* - `salvaged-subset` — unrepairable within budget; the conforming
|
|
55
|
+
* SUBSET of the draft, refused entries dropped
|
|
56
|
+
* and reported (origin synth).
|
|
57
|
+
*/
|
|
58
|
+
export type EnsureConformingMethod =
|
|
59
|
+
| 'verbatim'
|
|
60
|
+
| 'normalized'
|
|
61
|
+
| 'llm-repair'
|
|
62
|
+
| 'salvaged-subset';
|
|
63
|
+
|
|
64
|
+
/** A conforming contract was produced (the common case). */
|
|
65
|
+
export interface EnsureConformingAccepted {
|
|
44
66
|
/** A contract guaranteed to pass `lintContract` with zero errors. */
|
|
45
67
|
readonly contract: DataContract;
|
|
46
68
|
/**
|
|
47
69
|
* - `'agent'` — the draft was already conforming; returned verbatim.
|
|
48
70
|
* - `'synth'` — the draft had errors; this is the repaired result
|
|
49
|
-
* (or the
|
|
71
|
+
* (or the salvaged subset when repair was impossible).
|
|
50
72
|
*/
|
|
51
73
|
readonly origin: 'agent' | 'synth';
|
|
52
|
-
|
|
53
|
-
* How the conforming contract was produced — finer-grained than
|
|
54
|
-
* `origin`, for telemetry (the efficiency tiers):
|
|
55
|
-
* - `verbatim` — draft was clean; returned as-is (origin agent).
|
|
56
|
-
* - `normalized` — deterministic fix only, NO LLM (origin synth).
|
|
57
|
-
* - `llm-repair` — the bounded LLM repair loop ran (origin synth).
|
|
58
|
-
* - `fallback-empty`— unrepairable; minimal `{}` contract (origin synth).
|
|
59
|
-
*/
|
|
60
|
-
readonly method: 'verbatim' | 'normalized' | 'llm-repair' | 'fallback-empty';
|
|
74
|
+
readonly method: EnsureConformingMethod;
|
|
61
75
|
/**
|
|
62
76
|
* Findings surfaced to the agent. On `origin: 'agent'`, any hygiene
|
|
63
77
|
* warnings on the (valid) draft. On `origin: 'synth'`, the ERROR
|
|
64
78
|
* findings that rejected the agent's draft — so the agent-side model
|
|
65
|
-
* learns what it got wrong, even though we repaired it.
|
|
79
|
+
* learns what it got wrong, even though we repaired it. On
|
|
80
|
+
* `salvaged-subset` they include one finding per dropped entry.
|
|
66
81
|
*/
|
|
67
82
|
readonly findings: readonly SuggestionFinding[];
|
|
68
83
|
/** Operator- + LLM-readable explanation. */
|
|
69
84
|
readonly reasoning: string;
|
|
70
85
|
}
|
|
71
86
|
|
|
72
|
-
/**
|
|
73
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Nothing in the draft could be kept: repair failed AND no entry
|
|
89
|
+
* survives the gate. There is no contract to propose — the caller
|
|
90
|
+
* answers `action: 'declined'` with the findings and the agent fixes
|
|
91
|
+
* and re-handshakes. This is what replaced the empty-contract fallback:
|
|
92
|
+
* a hollow "success" was indistinguishable from a rejection and the
|
|
93
|
+
* observed recovery was a field-by-field bisect (ggui#523 item 3).
|
|
94
|
+
*/
|
|
95
|
+
export interface EnsureConformingDeclined {
|
|
96
|
+
readonly contract: null;
|
|
97
|
+
readonly origin: 'agent';
|
|
98
|
+
readonly method: 'declined';
|
|
99
|
+
/** The ERROR findings that rejected the draft — every one of them. */
|
|
100
|
+
readonly findings: readonly SuggestionFinding[];
|
|
101
|
+
readonly reasoning: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type EnsureConformingResult = EnsureConformingAccepted | EnsureConformingDeclined;
|
|
74
105
|
|
|
75
106
|
export async function ensureConformingContract(
|
|
76
107
|
deps: { readonly llm: LLMCaller },
|
|
@@ -161,15 +192,37 @@ export async function ensureConformingContract(
|
|
|
161
192
|
}
|
|
162
193
|
|
|
163
194
|
// Repair impossible (LLM down, provider can't synthesize, or the
|
|
164
|
-
// repair budget exhausted).
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
195
|
+
// repair budget exhausted). Keep what conforms: drop exactly the
|
|
196
|
+
// entries the gate refuses, report each drop, and propose the rest —
|
|
197
|
+
// the agent's own draft minus the parts the protocol rejected. Never
|
|
198
|
+
// an empty contract dressed as a proposal.
|
|
199
|
+
const salvaged = salvageConformingSubset(normalized);
|
|
200
|
+
if (salvaged !== null) {
|
|
201
|
+
const droppedPaths = salvaged.dropped.map((d) => d.path);
|
|
202
|
+
return {
|
|
203
|
+
contract: salvaged.contract,
|
|
204
|
+
origin: 'synth',
|
|
205
|
+
method: 'salvaged-subset',
|
|
206
|
+
findings: [...errorFindings, ...salvaged.dropped],
|
|
207
|
+
reasoning:
|
|
208
|
+
`could not repair the agent draft within budget (${synth.reason}); ` +
|
|
209
|
+
`proposing the conforming SUBSET of your draft — dropped ${droppedPaths.length} ` +
|
|
210
|
+
`entr${droppedPaths.length === 1 ? 'y' : 'ies'} the protocol refused (${droppedPaths.join(', ')}). ` +
|
|
211
|
+
`Each drop is a finding: fix those entries and re-handshake, or render this subset ` +
|
|
212
|
+
`and re-declare them via ggui_render override.`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Nothing usable survives. Decline: there is no contract to propose,
|
|
217
|
+
// and the findings say exactly why.
|
|
168
218
|
return {
|
|
169
|
-
contract:
|
|
170
|
-
origin: '
|
|
171
|
-
method: '
|
|
219
|
+
contract: null,
|
|
220
|
+
origin: 'agent',
|
|
221
|
+
method: 'declined',
|
|
172
222
|
findings: errorFindings,
|
|
173
|
-
reasoning:
|
|
223
|
+
reasoning:
|
|
224
|
+
`declined: could not repair the agent draft within budget (${synth.reason}) and no entry of it ` +
|
|
225
|
+
`passes the contract gate — nothing to propose. Fix the findings (every one names its path) ` +
|
|
226
|
+
`and re-handshake; do not render against this handshake.`,
|
|
174
227
|
};
|
|
175
228
|
}
|
package/src/index.ts
CHANGED
|
@@ -36,8 +36,21 @@ export type {
|
|
|
36
36
|
export { synthesizeContract } from './synthesize-contract.js';
|
|
37
37
|
export type { SynthesizeContractResult } from './synthesize-contract.js';
|
|
38
38
|
export { ensureConformingContract } from './ensure-conforming-contract.js';
|
|
39
|
-
export type {
|
|
39
|
+
export type {
|
|
40
|
+
EnsureConformingAccepted,
|
|
41
|
+
EnsureConformingDeclined,
|
|
42
|
+
EnsureConformingMethod,
|
|
43
|
+
EnsureConformingResult,
|
|
44
|
+
} from './ensure-conforming-contract.js';
|
|
40
45
|
export { normalizeDraft } from './normalize-draft.js';
|
|
46
|
+
// The last-resort tier that replaced the empty-contract fallback (ggui#523
|
|
47
|
+
// item 3): keep the conforming subset of a draft, or decline. Shared with
|
|
48
|
+
// the handlers' no-LLM paths so every producer answers the same way.
|
|
49
|
+
export {
|
|
50
|
+
salvageConformingSubset,
|
|
51
|
+
declaresAnySurface,
|
|
52
|
+
type SalvageResult,
|
|
53
|
+
} from './salvage-draft.js';
|
|
41
54
|
export {
|
|
42
55
|
validateContractRedundancy,
|
|
43
56
|
validateContractNovelty,
|
package/src/normalize-schema.ts
CHANGED
|
@@ -102,7 +102,7 @@ function inferEnumBaseType(enumSibling: unknown): string {
|
|
|
102
102
|
function normalizeTypeValue(
|
|
103
103
|
value: unknown,
|
|
104
104
|
enumSibling: unknown,
|
|
105
|
-
): string | undefined {
|
|
105
|
+
): string | string[] | undefined {
|
|
106
106
|
if (typeof value === 'string') {
|
|
107
107
|
const lower = value.toLowerCase();
|
|
108
108
|
if (VALID_TYPES.has(lower)) return lower;
|
|
@@ -113,33 +113,51 @@ function normalizeTypeValue(
|
|
|
113
113
|
if (lower.includes('|')) {
|
|
114
114
|
// Pipe-union string (`"STRING|null"`). Some models emit the union
|
|
115
115
|
// as one pipe-delimited string rather than the JSON Schema array
|
|
116
|
-
// form; recover
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return undefined;
|
|
116
|
+
// form; recover it AS the draft-07 array form — since
|
|
117
|
+
// draft-2026-08-19 the protocol's JsonSchema.type admits type
|
|
118
|
+
// arrays, and the nullable arm is load-bearing (schema-precise
|
|
119
|
+
// render: dropping it narrows the contract and turns the agent's
|
|
120
|
+
// legal `null` into a contract_violation).
|
|
121
|
+
return normalizeTypeMembers(lower.split('|'), enumSibling);
|
|
123
122
|
}
|
|
124
123
|
// Unrecognized garbage — drop the constraint rather than guess.
|
|
125
124
|
return undefined;
|
|
126
125
|
}
|
|
127
126
|
if (Array.isArray(value)) {
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
for
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (norm !== undefined && norm !== 'null') return norm;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
return 'string';
|
|
127
|
+
// Draft-07 union type (`["string", "null"]`) — PRESERVED, with
|
|
128
|
+
// each member normalized individually (invalid members drop). The
|
|
129
|
+
// pre-draft-2026-08-19 behavior collapsed to the first non-null
|
|
130
|
+
// member; see the pipe-union note above for why that narrowing is
|
|
131
|
+
// now a contract-violation factory.
|
|
132
|
+
return normalizeTypeMembers(value, enumSibling) ?? 'string';
|
|
138
133
|
}
|
|
139
134
|
// `type` was a number / object / boolean — meaningless; drop it.
|
|
140
135
|
return undefined;
|
|
141
136
|
}
|
|
142
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Normalize a list of candidate type members: each member runs through
|
|
140
|
+
* {@link normalizeTypeValue} (case folding, aliases, drop-words),
|
|
141
|
+
* survivors dedupe in order. Two-plus survivors → the draft-07 array
|
|
142
|
+
* form; exactly one → the plain string form; none → `undefined`.
|
|
143
|
+
*/
|
|
144
|
+
function normalizeTypeMembers(
|
|
145
|
+
members: readonly unknown[],
|
|
146
|
+
enumSibling: unknown,
|
|
147
|
+
): string | string[] | undefined {
|
|
148
|
+
const survivors: string[] = [];
|
|
149
|
+
for (const member of members) {
|
|
150
|
+
if (typeof member !== 'string') continue;
|
|
151
|
+
const norm = normalizeTypeValue(member, enumSibling);
|
|
152
|
+
if (typeof norm === 'string' && !survivors.includes(norm)) {
|
|
153
|
+
survivors.push(norm);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (survivors.length === 0) return undefined;
|
|
157
|
+
if (survivors.length === 1) return survivors[0];
|
|
158
|
+
return survivors;
|
|
159
|
+
}
|
|
160
|
+
|
|
143
161
|
/** Normalize a map of name → schema (e.g. `properties`). */
|
|
144
162
|
function normalizeSchemaMap(value: unknown): unknown {
|
|
145
163
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `salvageConformingSubset` — the last-resort tier that replaces the
|
|
3
|
+
* empty-contract fallback (ggui#523 item 3, "make `{}` impossible").
|
|
4
|
+
*
|
|
5
|
+
* When a draft cannot be made to conform any other way (no LLM bound,
|
|
6
|
+
* provider down, repair budget exhausted), the negotiator used to hand
|
|
7
|
+
* back the trivially-conforming `{}` with the findings attached — a
|
|
8
|
+
* response the agent cannot tell apart from success: `action: create`,
|
|
9
|
+
* a handshakeId, a `nextStep`, and a contract that declares NOTHING. The
|
|
10
|
+
* paired render then paints a hollow shell, and the observed recovery
|
|
11
|
+
* is a field-by-field bisect (No Silent Block, applied to contracts).
|
|
12
|
+
*
|
|
13
|
+
* This tier instead keeps what DOES conform. It deletes exactly the
|
|
14
|
+
* entries the deterministic gate names — one offending property,
|
|
15
|
+
* action, stream, context slot, or tool at a time (a bad sub-field
|
|
16
|
+
* first, the whole entry only if that was not enough) — re-lints, and
|
|
17
|
+
* repeats until the gate is green. The result is the agent's own draft
|
|
18
|
+
* minus the parts the protocol refused, with every drop reported as a
|
|
19
|
+
* finding, so the agent sees exactly what to fix in one read.
|
|
20
|
+
*
|
|
21
|
+
* It returns `null` — "nothing salvageable" — when what survives
|
|
22
|
+
* declares no surface at all (no props, actions, streams, context
|
|
23
|
+
* slots, or tools), or when an error is structural (the root is not an
|
|
24
|
+
* object). `null` is the DECLINE signal: the caller answers
|
|
25
|
+
* `action: 'declined'` with the findings, never a proposal. Between the
|
|
26
|
+
* two, no path produces an empty contract on the agent's behalf.
|
|
27
|
+
*
|
|
28
|
+
* Deterministic and pure: no LLM, no mutation of the input, no throw.
|
|
29
|
+
* Bounded: every iteration removes at least one key or returns.
|
|
30
|
+
*/
|
|
31
|
+
import {
|
|
32
|
+
dataContractSchema,
|
|
33
|
+
lintContract,
|
|
34
|
+
type DataContract,
|
|
35
|
+
type SuggestionFinding,
|
|
36
|
+
} from '@ggui-ai/protocol';
|
|
37
|
+
|
|
38
|
+
/** The six top-level spec keys the DataContract declares. */
|
|
39
|
+
const SPEC_KEYS = new Set([
|
|
40
|
+
'propsSpec',
|
|
41
|
+
'actionSpec',
|
|
42
|
+
'streamSpec',
|
|
43
|
+
'contextSpec',
|
|
44
|
+
'agentCapabilities',
|
|
45
|
+
'clientCapabilities',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/** Spec maps whose direct children are the droppable entries. */
|
|
49
|
+
const ENTRY_MAP_SPECS = new Set(['actionSpec', 'streamSpec', 'contextSpec']);
|
|
50
|
+
|
|
51
|
+
/** Hard cap on gate iterations — every iteration deletes ≥1 key. */
|
|
52
|
+
const MAX_ROUNDS = 100;
|
|
53
|
+
|
|
54
|
+
export interface SalvageResult {
|
|
55
|
+
/** A contract guaranteed to pass `lintContract` with zero errors, and to declare at least one surface. */
|
|
56
|
+
readonly contract: DataContract;
|
|
57
|
+
/**
|
|
58
|
+
* What was removed to get there — one finding per deleted key, in
|
|
59
|
+
* deletion order, carrying the gate's own code + message for that
|
|
60
|
+
* path. Surfaced to the agent verbatim.
|
|
61
|
+
*/
|
|
62
|
+
readonly dropped: readonly SuggestionFinding[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
66
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Does the contract declare at least one surface an agent could use? */
|
|
70
|
+
export function declaresAnySurface(contract: DataContract): boolean {
|
|
71
|
+
const props = contract.propsSpec?.properties;
|
|
72
|
+
if (props !== undefined && Object.keys(props).length > 0) return true;
|
|
73
|
+
if (contract.actionSpec !== undefined && Object.keys(contract.actionSpec).length > 0) return true;
|
|
74
|
+
if (contract.streamSpec !== undefined && Object.keys(contract.streamSpec).length > 0) return true;
|
|
75
|
+
if (contract.contextSpec !== undefined && Object.keys(contract.contextSpec).length > 0) return true;
|
|
76
|
+
const tools = contract.agentCapabilities?.tools;
|
|
77
|
+
if (tools !== undefined && Object.keys(tools).length > 0) return true;
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Where to cut for an offending path. Returns the key path to delete
|
|
83
|
+
* (as segments) or `null` when the error is structural.
|
|
84
|
+
*
|
|
85
|
+
* propsSpec.properties.<k>[.…] → try the sub-field, then the property
|
|
86
|
+
* actionSpec|streamSpec|contextSpec.<k>[.…] → sub-field, then the entry
|
|
87
|
+
* agentCapabilities.tools.<k>[.…] → sub-field, then the tool
|
|
88
|
+
* <spec>[.other] → the whole spec
|
|
89
|
+
* <unknown-top-level-key>[.…] → that key (retired fields, typos)
|
|
90
|
+
* <root> → structural, null
|
|
91
|
+
*
|
|
92
|
+
* `leafFirst` picks the deeper cut when one exists; the caller retries
|
|
93
|
+
* with `false` if that cut did not clear the entry.
|
|
94
|
+
*/
|
|
95
|
+
export function cutFor(path: string, leafFirst: boolean): readonly string[] | null {
|
|
96
|
+
if (path === '' || path === '<root>') return null;
|
|
97
|
+
const seg = path.split('.');
|
|
98
|
+
const head = seg[0]!;
|
|
99
|
+
|
|
100
|
+
if (head === 'propsSpec') {
|
|
101
|
+
if (seg[1] === 'properties' && seg.length >= 3) {
|
|
102
|
+
const entry = seg.slice(0, 3);
|
|
103
|
+
return leafFirst && seg.length > 3 ? seg : entry;
|
|
104
|
+
}
|
|
105
|
+
return ['propsSpec'];
|
|
106
|
+
}
|
|
107
|
+
if (ENTRY_MAP_SPECS.has(head)) {
|
|
108
|
+
if (seg.length >= 2) {
|
|
109
|
+
const entry = seg.slice(0, 2);
|
|
110
|
+
return leafFirst && seg.length > 2 ? seg : entry;
|
|
111
|
+
}
|
|
112
|
+
return [head];
|
|
113
|
+
}
|
|
114
|
+
if (head === 'agentCapabilities') {
|
|
115
|
+
if (seg[1] === 'tools' && seg.length >= 3) {
|
|
116
|
+
const entry = seg.slice(0, 3);
|
|
117
|
+
return leafFirst && seg.length > 3 ? seg : entry;
|
|
118
|
+
}
|
|
119
|
+
return ['agentCapabilities'];
|
|
120
|
+
}
|
|
121
|
+
if (SPEC_KEYS.has(head)) return [head];
|
|
122
|
+
// Unknown / retired top-level field — the whole key goes.
|
|
123
|
+
return [head];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Delete `keyPath` from a structural copy of `root`; false if absent. */
|
|
127
|
+
function deleteAt(root: Record<string, unknown>, keyPath: readonly string[]): boolean {
|
|
128
|
+
let node: Record<string, unknown> = root;
|
|
129
|
+
for (let i = 0; i < keyPath.length - 1; i += 1) {
|
|
130
|
+
const next = node[keyPath[i]!];
|
|
131
|
+
if (!isRecord(next)) return false;
|
|
132
|
+
// Copy-on-write down the path so the input is never mutated.
|
|
133
|
+
const copy: Record<string, unknown> = { ...next };
|
|
134
|
+
node[keyPath[i]!] = copy;
|
|
135
|
+
node = copy;
|
|
136
|
+
}
|
|
137
|
+
const leaf = keyPath[keyPath.length - 1]!;
|
|
138
|
+
if (!(leaf in node)) return false;
|
|
139
|
+
delete node[leaf];
|
|
140
|
+
// A dropped prop must leave `propsSpec.required` too — a stale name
|
|
141
|
+
// there is its own gate error and would only cost another round.
|
|
142
|
+
if (keyPath.length === 3 && keyPath[0] === 'propsSpec' && keyPath[1] === 'properties') {
|
|
143
|
+
const ps = root['propsSpec'];
|
|
144
|
+
if (isRecord(ps) && Array.isArray(ps['required'])) {
|
|
145
|
+
root['propsSpec'] = {
|
|
146
|
+
...ps,
|
|
147
|
+
required: ps['required'].filter((name) => name !== leaf),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Keep the conforming subset of `draft` (already normalized by the
|
|
156
|
+
* caller, ideally). See the module docstring for the contract.
|
|
157
|
+
*/
|
|
158
|
+
export function salvageConformingSubset(draft: unknown): SalvageResult | null {
|
|
159
|
+
if (!isRecord(draft)) return null;
|
|
160
|
+
let working: Record<string, unknown> = { ...draft };
|
|
161
|
+
const dropped: SuggestionFinding[] = [];
|
|
162
|
+
/** Cuts already tried at leaf depth for a path — the retry goes to the entry. */
|
|
163
|
+
const leafTried = new Set<string>();
|
|
164
|
+
|
|
165
|
+
for (let round = 0; round < MAX_ROUNDS; round += 1) {
|
|
166
|
+
const lint = lintContract(working);
|
|
167
|
+
if (lint.errors.length === 0) {
|
|
168
|
+
// Shape passed ⇒ the strict parse cannot throw.
|
|
169
|
+
const contract = dataContractSchema.parse(working);
|
|
170
|
+
return declaresAnySurface(contract) ? { contract, dropped } : null;
|
|
171
|
+
}
|
|
172
|
+
let cutSomething = false;
|
|
173
|
+
for (const issue of lint.errors) {
|
|
174
|
+
const leafFirst = !leafTried.has(issue.path);
|
|
175
|
+
const cut = cutFor(issue.path, leafFirst);
|
|
176
|
+
if (cut === null) return null; // structural — nothing to keep
|
|
177
|
+
const cutKey = cut.join('.');
|
|
178
|
+
if (leafFirst && cutKey === issue.path) leafTried.add(issue.path);
|
|
179
|
+
const next: Record<string, unknown> = { ...working };
|
|
180
|
+
if (!deleteAt(next, cut)) {
|
|
181
|
+
// The path names something that is not there (a reference
|
|
182
|
+
// target, a computed check) — fall back to the entry cut once,
|
|
183
|
+
// then give up on this issue for the round.
|
|
184
|
+
if (leafFirst) {
|
|
185
|
+
leafTried.add(issue.path);
|
|
186
|
+
const entryCut = cutFor(issue.path, false);
|
|
187
|
+
if (entryCut !== null && deleteAt(next, entryCut)) {
|
|
188
|
+
working = next;
|
|
189
|
+
dropped.push({ code: issue.code, severity: 'error', path: entryCut.join('.'), message: issue.message });
|
|
190
|
+
cutSomething = true;
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
working = next;
|
|
197
|
+
dropped.push({ code: issue.code, severity: 'error', path: cutKey, message: issue.message });
|
|
198
|
+
cutSomething = true;
|
|
199
|
+
break; // one cut per round — re-lint before the next decision
|
|
200
|
+
}
|
|
201
|
+
if (!cutSomething) return null; // no cut could be applied — structural
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
@@ -451,7 +451,7 @@ export const BENCH_CORPUS: readonly BenchEntry[] = [
|
|
|
451
451
|
'remove',
|
|
452
452
|
],
|
|
453
453
|
notes:
|
|
454
|
-
'agent-backed/persisted = the agent OWNS the items → todos seed on propsSpec (refreshed via
|
|
454
|
+
'agent-backed/persisted = the agent OWNS the items → todos seed on propsSpec (refreshed via ggui_amend); add/delete/toggle are discrete events on actionSpec. contextSpec has no agent-push channel, so an agent-owned persisted list there cannot round-trip — this is the round-trip-correct shape, aligned with list-message-thread / list-file-browser (both props-bearing agent-supplied collections).',
|
|
455
455
|
},
|
|
456
456
|
},
|
|
457
457
|
{
|
|
@@ -86,9 +86,9 @@ export interface RoundTripScore {
|
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
88
|
* True when a contract declares none of the six spec surfaces — the
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* carries no wire at all.
|
|
89
|
+
* empty contract the bench substitutes for a DECLINE (and the `{}` the
|
|
90
|
+
* retired fallback used to return for an unrepairable draft). Such a
|
|
91
|
+
* contract is structurally valid but carries no wire at all.
|
|
92
92
|
*/
|
|
93
93
|
function isEmptyContract(contract: DataContract): boolean {
|
|
94
94
|
return (
|