@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.
@@ -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 the empty `{}` contract.
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
- const shape = scoreSynthesizedContract(result.contract, entry.expected);
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(result.contract, entry.roundTrip)
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', 'fallback-empty']
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 ggui_update. 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).
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 ggui_update 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_update is exactly how the agent pushes the change.
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 ggui_update. 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).',
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 ggui_update). Keep every other spec unchanged.`,
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.8.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.8.0",
51
- "@ggui-ai/protocol": "0.8.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
- if (schema.type !== 'object') return false;
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 → minimal conforming contract (`{}`) + loud
17
- * (LLM down / provider error findings; STILL origin 'synth';
18
- * can't synth / budget NEVER throws.
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
- export interface EnsureConformingResult {
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 minimal-conforming fallback when repair was impossible).
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
- /** Trivially-valid last-resort contract — all four specs omitted. */
73
- const EMPTY_CONTRACT: DataContract = {};
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). We still MUST return a conforming
165
- // contract — the handshake never hard-fails. Minimal conforming
166
- // contract + loud findings so the agent can re-issue a corrected
167
- // contract via ggui_render override if it needs the declared specs.
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: EMPTY_CONTRACT,
170
- origin: 'synth',
171
- method: 'fallback-empty',
219
+ contract: null,
220
+ origin: 'agent',
221
+ method: 'declined',
172
222
  findings: errorFindings,
173
- reasoning: `could not repair the agent draft within budget (${synth.reason}); returning a minimal conforming contract — re-issue a corrected contract via ggui_render override if you need the declared specs`,
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 { EnsureConformingResult } from './ensure-conforming-contract.js';
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,
@@ -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 the first valid non-null member, mirroring the
117
- // array branch below (drops the nullable arm).
118
- for (const member of lower.split('|')) {
119
- const norm = normalizeTypeValue(member, enumSibling);
120
- if (norm !== undefined && norm !== 'null') return norm;
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
- // Union type (`["string", "null"]`). The protocol contract schema
129
- // accepts only a single string; recover the first valid non-null
130
- // member, dropping the nullable arm.
131
- for (const member of value) {
132
- if (typeof member === 'string') {
133
- const norm = normalizeTypeValue(member, enumSibling);
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 ggui_update); 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).',
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
- * `EMPTY_CONTRACT` (`{}`) that `ensureConformingContract` returns when a
90
- * draft is unrepairable. Such a contract is structurally valid but
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 (