@ggui-ai/negotiator 0.9.0 → 0.11.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.
@@ -1 +1 @@
1
- {"version":3,"file":"contract-validators.d.ts","sourceRoot":"","sources":["../src/contract-validators.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAc,MAAM,mBAAmB,CAAC;AAElE,OAAO,KAAK,EACV,iBAAiB,EACjB,WAAW,EACZ,MAAM,0BAA0B,CAAC;AAMlC;;;;;;;;;GASG;AACH,MAAM,MAAM,6BAA6B,GACrC,kBAAkB,GAClB,aAAa,GACb,mCAAmC,GACnC,2BAA2B,GAC3B,6BAA6B,GAC7B,4BAA4B,GAC5B,8BAA8B,GAC9B,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;AAElB;;;;;;GAMG;AACH,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,IAAI,EAAE,6BAA6B,CAAC;IAC7C,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;IACpC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,QAAQ,EAAE,SAAS,yBAAyB,EAAE,CAAC;CACzD;AAED;;;;;GAKG;AACH,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,SAAS,EAAE,iBAAiB,CAAC;IACtC,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAC;IAClC;;sDAEkD;IAClD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,gCAAgC;IAC/C;;;;;;;OAOG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;CACnC;AAwJD;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,YAAY,GACrB,wBAAwB,CAkC1B;AAsCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,YAAY,GACrB,wBAAwB,CA8D1B;AAiCD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,YAAY,EACtB,MAAM,EAAE,MAAM,GACb,wBAAwB,CAoC1B;AAQD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,uBAAuB,CAC3C,QAAQ,EAAE,YAAY,EACtB,IAAI,EAAE,6BAA6B,EACnC,OAAO,GAAE,gCAAqC,GAC7C,OAAO,CAAC,wBAAwB,CAAC,CAkCnC;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,wBAAwB,GAC/B,MAAM,CAKR"}
1
+ {"version":3,"file":"contract-validators.d.ts","sourceRoot":"","sources":["../src/contract-validators.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAc,MAAM,mBAAmB,CAAC;AAElE,OAAO,KAAK,EACV,iBAAiB,EACjB,WAAW,EACZ,MAAM,0BAA0B,CAAC;AAMlC;;;;;;;;;GASG;AACH,MAAM,MAAM,6BAA6B,GACrC,kBAAkB,GAClB,aAAa,GACb,mCAAmC,GACnC,2BAA2B,GAC3B,6BAA6B,GAC7B,4BAA4B,GAC5B,8BAA8B,GAC9B,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;AAElB;;;;;;GAMG;AACH,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,IAAI,EAAE,6BAA6B,CAAC;IAC7C,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;IACpC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,QAAQ,EAAE,SAAS,yBAAyB,EAAE,CAAC;CACzD;AAED;;;;;GAKG;AACH,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,SAAS,EAAE,iBAAiB,CAAC;IACtC,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAC;IAClC;;sDAEkD;IAClD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,gCAAgC;IAC/C;;;;;;;OAOG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;CACnC;AA8JD;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,YAAY,GACrB,wBAAwB,CAkC1B;AAsCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,YAAY,GACrB,wBAAwB,CA8D1B;AAiCD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,YAAY,EACtB,MAAM,EAAE,MAAM,GACb,wBAAwB,CAoC1B;AAQD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,uBAAuB,CAC3C,QAAQ,EAAE,YAAY,EACtB,IAAI,EAAE,6BAA6B,EACnC,OAAO,GAAE,gCAAqC,GAC7C,OAAO,CAAC,wBAAwB,CAAC,CAkCnC;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,wBAAwB,GAC/B,MAAM,CAKR"}
@@ -163,7 +163,13 @@ function nameImpliesMutation(args) {
163
163
  function isEmptyPayloadSchema(schema) {
164
164
  if (schema === undefined)
165
165
  return true;
166
- if (schema.type !== 'object')
166
+ // `type` may be a draft-07 type ARRAY (`['object','null']`) since
167
+ // draft-2026-08-19 — treat a type set containing 'object' like the
168
+ // single-string form.
169
+ const declaresObject = Array.isArray(schema.type)
170
+ ? schema.type.includes('object')
171
+ : schema.type === 'object';
172
+ if (!declaresObject)
167
173
  return false;
168
174
  if (schema.properties === undefined)
169
175
  return true;
@@ -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
@@ -31,34 +35,56 @@
31
35
  */
32
36
  import { type DataContract, type GadgetDescriptor, type SuggestionFinding } from '@ggui-ai/protocol';
33
37
  import type { LLMCaller } from './llm-caller.js';
34
- export interface EnsureConformingResult {
38
+ /**
39
+ * How a conforming contract was produced — finer-grained than `origin`,
40
+ * for telemetry (the efficiency tiers):
41
+ * - `verbatim` — draft was clean; returned as-is (origin agent).
42
+ * - `normalized` — deterministic fix only, NO LLM (origin synth).
43
+ * - `llm-repair` — the bounded LLM repair loop ran (origin synth).
44
+ * - `salvaged-subset` — unrepairable within budget; the conforming
45
+ * SUBSET of the draft, refused entries dropped
46
+ * and reported (origin synth).
47
+ */
48
+ export type EnsureConformingMethod = 'verbatim' | 'normalized' | 'llm-repair' | 'salvaged-subset';
49
+ /** A conforming contract was produced (the common case). */
50
+ export interface EnsureConformingAccepted {
35
51
  /** A contract guaranteed to pass `lintContract` with zero errors. */
36
52
  readonly contract: DataContract;
37
53
  /**
38
54
  * - `'agent'` — the draft was already conforming; returned verbatim.
39
55
  * - `'synth'` — the draft had errors; this is the repaired result
40
- * (or the minimal-conforming fallback when repair was impossible).
56
+ * (or the salvaged subset when repair was impossible).
41
57
  */
42
58
  readonly origin: 'agent' | 'synth';
43
- /**
44
- * How the conforming contract was produced — finer-grained than
45
- * `origin`, for telemetry (the efficiency tiers):
46
- * - `verbatim` — draft was clean; returned as-is (origin agent).
47
- * - `normalized` — deterministic fix only, NO LLM (origin synth).
48
- * - `llm-repair` — the bounded LLM repair loop ran (origin synth).
49
- * - `fallback-empty`— unrepairable; minimal `{}` contract (origin synth).
50
- */
51
- readonly method: 'verbatim' | 'normalized' | 'llm-repair' | 'fallback-empty';
59
+ readonly method: EnsureConformingMethod;
52
60
  /**
53
61
  * Findings surfaced to the agent. On `origin: 'agent'`, any hygiene
54
62
  * warnings on the (valid) draft. On `origin: 'synth'`, the ERROR
55
63
  * findings that rejected the agent's draft — so the agent-side model
56
- * learns what it got wrong, even though we repaired it.
64
+ * learns what it got wrong, even though we repaired it. On
65
+ * `salvaged-subset` they include one finding per dropped entry.
57
66
  */
58
67
  readonly findings: readonly SuggestionFinding[];
59
68
  /** Operator- + LLM-readable explanation. */
60
69
  readonly reasoning: string;
61
70
  }
71
+ /**
72
+ * Nothing in the draft could be kept: repair failed AND no entry
73
+ * survives the gate. There is no contract to propose — the caller
74
+ * answers `action: 'declined'` with the findings and the agent fixes
75
+ * and re-handshakes. This is what replaced the empty-contract fallback:
76
+ * a hollow "success" was indistinguishable from a rejection and the
77
+ * observed recovery was a field-by-field bisect (ggui#523 item 3).
78
+ */
79
+ export interface EnsureConformingDeclined {
80
+ readonly contract: null;
81
+ readonly origin: 'agent';
82
+ readonly method: 'declined';
83
+ /** The ERROR findings that rejected the draft — every one of them. */
84
+ readonly findings: readonly SuggestionFinding[];
85
+ readonly reasoning: string;
86
+ }
87
+ export type EnsureConformingResult = EnsureConformingAccepted | EnsureConformingDeclined;
62
88
  export declare function ensureConformingContract(deps: {
63
89
  readonly llm: LLMCaller;
64
90
  }, args: {
@@ -1 +1 @@
1
- {"version":3,"file":"ensure-conforming-contract.d.ts","sourceRoot":"","sources":["../src/ensure-conforming-contract.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EACvB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAIjD,MAAM,WAAW,sBAAsB;IACrC,qEAAqE;IACrE,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;IAChC;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC;IACnC;;;;;;;OAOG;IACH,QAAQ,CAAC,MAAM,EAAE,UAAU,GAAG,YAAY,GAAG,YAAY,GAAG,gBAAgB,CAAC;IAC7E;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,EAAE,SAAS,iBAAiB,EAAE,CAAC;IAChD,4CAA4C;IAC5C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAKD,wBAAsB,wBAAwB,CAC5C,IAAI,EAAE;IAAE,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAA;CAAE,EACjC,IAAI,EAAE;IACJ,oEAAoE;IACpE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;CACnD,GACA,OAAO,CAAC,sBAAsB,CAAC,CA4FjC"}
1
+ {"version":3,"file":"ensure-conforming-contract.d.ts","sourceRoot":"","sources":["../src/ensure-conforming-contract.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EACvB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAKjD;;;;;;;;;GASG;AACH,MAAM,MAAM,sBAAsB,GAC9B,UAAU,GACV,YAAY,GACZ,YAAY,GACZ,iBAAiB,CAAC;AAEtB,4DAA4D;AAC5D,MAAM,WAAW,wBAAwB;IACvC,qEAAqE;IACrE,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;IAChC;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC;IACnC,QAAQ,CAAC,MAAM,EAAE,sBAAsB,CAAC;IACxC;;;;;;OAMG;IACH,QAAQ,CAAC,QAAQ,EAAE,SAAS,iBAAiB,EAAE,CAAC;IAChD,4CAA4C;IAC5C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAC5B,sEAAsE;IACtE,QAAQ,CAAC,QAAQ,EAAE,SAAS,iBAAiB,EAAE,CAAC;IAChD,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,MAAM,sBAAsB,GAAG,wBAAwB,GAAG,wBAAwB,CAAC;AAEzF,wBAAsB,wBAAwB,CAC5C,IAAI,EAAE;IAAE,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAA;CAAE,EACjC,IAAI,EAAE;IACJ,oEAAoE;IACpE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;CACnD,GACA,OAAO,CAAC,sBAAsB,CAAC,CAkHjC"}
@@ -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
@@ -32,8 +36,7 @@
32
36
  import { lintContract, dataContractSchema, } from '@ggui-ai/protocol';
33
37
  import { synthesizeContract } from './synthesize-contract.js';
34
38
  import { normalizeDraft } from './normalize-draft.js';
35
- /** Trivially-valid last-resort contract — all four specs omitted. */
36
- const EMPTY_CONTRACT = {};
39
+ import { salvageConformingSubset } from './salvage-draft.js';
37
40
  export async function ensureConformingContract(deps, args) {
38
41
  const lint = lintContract(args.draft);
39
42
  const warnFindings = lint.warnings.map((w) => ({
@@ -101,15 +104,34 @@ export async function ensureConformingContract(deps, args) {
101
104
  };
102
105
  }
103
106
  // Repair impossible (LLM down, provider can't synthesize, or the
104
- // repair budget exhausted). We still MUST return a conforming
105
- // contract — the handshake never hard-fails. Minimal conforming
106
- // contract + loud findings so the agent can re-issue a corrected
107
- // contract via ggui_render override if it needs the declared specs.
107
+ // repair budget exhausted). Keep what conforms: drop exactly the
108
+ // entries the gate refuses, report each drop, and propose the rest —
109
+ // the agent's own draft minus the parts the protocol rejected. Never
110
+ // an empty contract dressed as a proposal.
111
+ const salvaged = salvageConformingSubset(normalized);
112
+ if (salvaged !== null) {
113
+ const droppedPaths = salvaged.dropped.map((d) => d.path);
114
+ return {
115
+ contract: salvaged.contract,
116
+ origin: 'synth',
117
+ method: 'salvaged-subset',
118
+ findings: [...errorFindings, ...salvaged.dropped],
119
+ reasoning: `could not repair the agent draft within budget (${synth.reason}); ` +
120
+ `proposing the conforming SUBSET of your draft — dropped ${droppedPaths.length} ` +
121
+ `entr${droppedPaths.length === 1 ? 'y' : 'ies'} the protocol refused (${droppedPaths.join(', ')}). ` +
122
+ `Each drop is a finding: fix those entries and re-handshake, or render this subset ` +
123
+ `and re-declare them via ggui_render override.`,
124
+ };
125
+ }
126
+ // Nothing usable survives. Decline: there is no contract to propose,
127
+ // and the findings say exactly why.
108
128
  return {
109
- contract: EMPTY_CONTRACT,
110
- origin: 'synth',
111
- method: 'fallback-empty',
129
+ contract: null,
130
+ origin: 'agent',
131
+ method: 'declined',
112
132
  findings: errorFindings,
113
- 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`,
133
+ reasoning: `declined: could not repair the agent draft within budget (${synth.reason}) and no entry of it ` +
134
+ `passes the contract gate — nothing to propose. Fix the findings (every one names its path) ` +
135
+ `and re-handshake; do not render against this handshake.`,
114
136
  };
115
137
  }
package/dist/index.d.ts CHANGED
@@ -31,8 +31,9 @@ export type { RerankCandidate, RerankDecision, RerankQuery, } from './llm-rerank
31
31
  export { synthesizeContract } from './synthesize-contract.js';
32
32
  export type { SynthesizeContractResult } from './synthesize-contract.js';
33
33
  export { ensureConformingContract } from './ensure-conforming-contract.js';
34
- export type { EnsureConformingResult } from './ensure-conforming-contract.js';
34
+ export type { EnsureConformingAccepted, EnsureConformingDeclined, EnsureConformingMethod, EnsureConformingResult, } from './ensure-conforming-contract.js';
35
35
  export { normalizeDraft } from './normalize-draft.js';
36
+ export { salvageConformingSubset, declaresAnySurface, type SalvageResult, } from './salvage-draft.js';
36
37
  export { validateContractRedundancy, validateContractNovelty, formatValidationFindings, } from './contract-validators.js';
37
38
  export type { ContractValidationFinding, ContractValidationFindingKind, ContractValidationResult, ContractValidationNoveltyDeps, ContractValidationNoveltyOptions, } from './contract-validators.js';
38
39
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAChE,YAAY,EAAE,SAAS,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC9E,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACnD,YAAY,EACV,eAAe,EACf,cAAc,EACd,WAAW,GACZ,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,YAAY,EAAE,wBAAwB,EAAE,MAAM,0BAA0B,CAAC;AACzE,OAAO,EAAE,wBAAwB,EAAE,MAAM,iCAAiC,CAAC;AAC3E,YAAY,EAAE,sBAAsB,EAAE,MAAM,iCAAiC,CAAC;AAC9E,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EACL,0BAA0B,EAC1B,uBAAuB,EACvB,wBAAwB,GACzB,MAAM,0BAA0B,CAAC;AAClC,YAAY,EACV,yBAAyB,EACzB,6BAA6B,EAC7B,wBAAwB,EACxB,6BAA6B,EAC7B,gCAAgC,GACjC,MAAM,0BAA0B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAChE,YAAY,EAAE,SAAS,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC9E,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACnD,YAAY,EACV,eAAe,EACf,cAAc,EACd,WAAW,GACZ,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,YAAY,EAAE,wBAAwB,EAAE,MAAM,0BAA0B,CAAC;AACzE,OAAO,EAAE,wBAAwB,EAAE,MAAM,iCAAiC,CAAC;AAC3E,YAAY,EACV,wBAAwB,EACxB,wBAAwB,EACxB,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,iCAAiC,CAAC;AACzC,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAItD,OAAO,EACL,uBAAuB,EACvB,kBAAkB,EAClB,KAAK,aAAa,GACnB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,0BAA0B,EAC1B,uBAAuB,EACvB,wBAAwB,GACzB,MAAM,0BAA0B,CAAC;AAClC,YAAY,EACV,yBAAyB,EACzB,6BAA6B,EAC7B,wBAAwB,EACxB,6BAA6B,EAC7B,gCAAgC,GACjC,MAAM,0BAA0B,CAAC"}
package/dist/index.js CHANGED
@@ -29,4 +29,8 @@ export { rerankCandidates } from './llm-rerank.js';
29
29
  export { synthesizeContract } from './synthesize-contract.js';
30
30
  export { ensureConformingContract } from './ensure-conforming-contract.js';
31
31
  export { normalizeDraft } from './normalize-draft.js';
32
+ // The last-resort tier that replaced the empty-contract fallback (ggui#523
33
+ // item 3): keep the conforming subset of a draft, or decline. Shared with
34
+ // the handlers' no-LLM paths so every producer answers the same way.
35
+ export { salvageConformingSubset, declaresAnySurface, } from './salvage-draft.js';
32
36
  export { validateContractRedundancy, validateContractNovelty, formatValidationFindings, } from './contract-validators.js';
@@ -1 +1 @@
1
- {"version":3,"file":"normalize-schema.d.ts","sourceRoot":"","sources":["../src/normalize-schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AA6JH;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CA2BxD"}
1
+ {"version":3,"file":"normalize-schema.d.ts","sourceRoot":"","sources":["../src/normalize-schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AA+KH;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CA2BxD"}
@@ -109,34 +109,49 @@ function normalizeTypeValue(value, enumSibling) {
109
109
  if (lower.includes('|')) {
110
110
  // Pipe-union string (`"STRING|null"`). Some models emit the union
111
111
  // as one pipe-delimited string rather than the JSON Schema array
112
- // form; recover the first valid non-null member, mirroring the
113
- // array branch below (drops the nullable arm).
114
- for (const member of lower.split('|')) {
115
- const norm = normalizeTypeValue(member, enumSibling);
116
- if (norm !== undefined && norm !== 'null')
117
- return norm;
118
- }
119
- return undefined;
112
+ // form; recover it AS the draft-07 array form — since
113
+ // draft-2026-08-19 the protocol's JsonSchema.type admits type
114
+ // arrays, and the nullable arm is load-bearing (schema-precise
115
+ // render: dropping it narrows the contract and turns the agent's
116
+ // legal `null` into a contract_violation).
117
+ return normalizeTypeMembers(lower.split('|'), enumSibling);
120
118
  }
121
119
  // Unrecognized garbage — drop the constraint rather than guess.
122
120
  return undefined;
123
121
  }
124
122
  if (Array.isArray(value)) {
125
- // Union type (`["string", "null"]`). The protocol contract schema
126
- // accepts only a single string; recover the first valid non-null
127
- // member, dropping the nullable arm.
128
- for (const member of value) {
129
- if (typeof member === 'string') {
130
- const norm = normalizeTypeValue(member, enumSibling);
131
- if (norm !== undefined && norm !== 'null')
132
- return norm;
133
- }
134
- }
135
- return 'string';
123
+ // Draft-07 union type (`["string", "null"]`) — PRESERVED, with
124
+ // each member normalized individually (invalid members drop). The
125
+ // pre-draft-2026-08-19 behavior collapsed to the first non-null
126
+ // member; see the pipe-union note above for why that narrowing is
127
+ // now a contract-violation factory.
128
+ return normalizeTypeMembers(value, enumSibling) ?? 'string';
136
129
  }
137
130
  // `type` was a number / object / boolean — meaningless; drop it.
138
131
  return undefined;
139
132
  }
133
+ /**
134
+ * Normalize a list of candidate type members: each member runs through
135
+ * {@link normalizeTypeValue} (case folding, aliases, drop-words),
136
+ * survivors dedupe in order. Two-plus survivors → the draft-07 array
137
+ * form; exactly one → the plain string form; none → `undefined`.
138
+ */
139
+ function normalizeTypeMembers(members, enumSibling) {
140
+ const survivors = [];
141
+ for (const member of members) {
142
+ if (typeof member !== 'string')
143
+ continue;
144
+ const norm = normalizeTypeValue(member, enumSibling);
145
+ if (typeof norm === 'string' && !survivors.includes(norm)) {
146
+ survivors.push(norm);
147
+ }
148
+ }
149
+ if (survivors.length === 0)
150
+ return undefined;
151
+ if (survivors.length === 1)
152
+ return survivors[0];
153
+ return survivors;
154
+ }
140
155
  /** Normalize a map of name → schema (e.g. `properties`). */
141
156
  function normalizeSchemaMap(value) {
142
157
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
@@ -0,0 +1,64 @@
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 { type DataContract, type SuggestionFinding } from '@ggui-ai/protocol';
32
+ export interface SalvageResult {
33
+ /** A contract guaranteed to pass `lintContract` with zero errors, and to declare at least one surface. */
34
+ readonly contract: DataContract;
35
+ /**
36
+ * What was removed to get there — one finding per deleted key, in
37
+ * deletion order, carrying the gate's own code + message for that
38
+ * path. Surfaced to the agent verbatim.
39
+ */
40
+ readonly dropped: readonly SuggestionFinding[];
41
+ }
42
+ /** Does the contract declare at least one surface an agent could use? */
43
+ export declare function declaresAnySurface(contract: DataContract): boolean;
44
+ /**
45
+ * Where to cut for an offending path. Returns the key path to delete
46
+ * (as segments) or `null` when the error is structural.
47
+ *
48
+ * propsSpec.properties.<k>[.…] → try the sub-field, then the property
49
+ * actionSpec|streamSpec|contextSpec.<k>[.…] → sub-field, then the entry
50
+ * agentCapabilities.tools.<k>[.…] → sub-field, then the tool
51
+ * <spec>[.other] → the whole spec
52
+ * <unknown-top-level-key>[.…] → that key (retired fields, typos)
53
+ * <root> → structural, null
54
+ *
55
+ * `leafFirst` picks the deeper cut when one exists; the caller retries
56
+ * with `false` if that cut did not clear the entry.
57
+ */
58
+ export declare function cutFor(path: string, leafFirst: boolean): readonly string[] | null;
59
+ /**
60
+ * Keep the conforming subset of `draft` (already normalized by the
61
+ * caller, ideally). See the module docstring for the contract.
62
+ */
63
+ export declare function salvageConformingSubset(draft: unknown): SalvageResult | null;
64
+ //# sourceMappingURL=salvage-draft.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"salvage-draft.d.ts","sourceRoot":"","sources":["../src/salvage-draft.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,iBAAiB,EACvB,MAAM,mBAAmB,CAAC;AAkB3B,MAAM,WAAW,aAAa;IAC5B,0GAA0G;IAC1G,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;IAChC;;;;OAIG;IACH,QAAQ,CAAC,OAAO,EAAE,SAAS,iBAAiB,EAAE,CAAC;CAChD;AAMD,yEAAyE;AACzE,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,YAAY,GAAG,OAAO,CASlE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,SAAS,MAAM,EAAE,GAAG,IAAI,CA6BjF;AA8BD;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,OAAO,GAAG,aAAa,GAAG,IAAI,CA8C5E"}
@@ -0,0 +1,191 @@
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 { dataContractSchema, lintContract, } from '@ggui-ai/protocol';
32
+ /** The six top-level spec keys the DataContract declares. */
33
+ const SPEC_KEYS = new Set([
34
+ 'propsSpec',
35
+ 'actionSpec',
36
+ 'streamSpec',
37
+ 'contextSpec',
38
+ 'agentCapabilities',
39
+ 'clientCapabilities',
40
+ ]);
41
+ /** Spec maps whose direct children are the droppable entries. */
42
+ const ENTRY_MAP_SPECS = new Set(['actionSpec', 'streamSpec', 'contextSpec']);
43
+ /** Hard cap on gate iterations — every iteration deletes ≥1 key. */
44
+ const MAX_ROUNDS = 100;
45
+ function isRecord(value) {
46
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
47
+ }
48
+ /** Does the contract declare at least one surface an agent could use? */
49
+ export function declaresAnySurface(contract) {
50
+ const props = contract.propsSpec?.properties;
51
+ if (props !== undefined && Object.keys(props).length > 0)
52
+ return true;
53
+ if (contract.actionSpec !== undefined && Object.keys(contract.actionSpec).length > 0)
54
+ return true;
55
+ if (contract.streamSpec !== undefined && Object.keys(contract.streamSpec).length > 0)
56
+ return true;
57
+ if (contract.contextSpec !== undefined && Object.keys(contract.contextSpec).length > 0)
58
+ return true;
59
+ const tools = contract.agentCapabilities?.tools;
60
+ if (tools !== undefined && Object.keys(tools).length > 0)
61
+ return true;
62
+ return false;
63
+ }
64
+ /**
65
+ * Where to cut for an offending path. Returns the key path to delete
66
+ * (as segments) or `null` when the error is structural.
67
+ *
68
+ * propsSpec.properties.<k>[.…] → try the sub-field, then the property
69
+ * actionSpec|streamSpec|contextSpec.<k>[.…] → sub-field, then the entry
70
+ * agentCapabilities.tools.<k>[.…] → sub-field, then the tool
71
+ * <spec>[.other] → the whole spec
72
+ * <unknown-top-level-key>[.…] → that key (retired fields, typos)
73
+ * <root> → structural, null
74
+ *
75
+ * `leafFirst` picks the deeper cut when one exists; the caller retries
76
+ * with `false` if that cut did not clear the entry.
77
+ */
78
+ export function cutFor(path, leafFirst) {
79
+ if (path === '' || path === '<root>')
80
+ return null;
81
+ const seg = path.split('.');
82
+ const head = seg[0];
83
+ if (head === 'propsSpec') {
84
+ if (seg[1] === 'properties' && seg.length >= 3) {
85
+ const entry = seg.slice(0, 3);
86
+ return leafFirst && seg.length > 3 ? seg : entry;
87
+ }
88
+ return ['propsSpec'];
89
+ }
90
+ if (ENTRY_MAP_SPECS.has(head)) {
91
+ if (seg.length >= 2) {
92
+ const entry = seg.slice(0, 2);
93
+ return leafFirst && seg.length > 2 ? seg : entry;
94
+ }
95
+ return [head];
96
+ }
97
+ if (head === 'agentCapabilities') {
98
+ if (seg[1] === 'tools' && seg.length >= 3) {
99
+ const entry = seg.slice(0, 3);
100
+ return leafFirst && seg.length > 3 ? seg : entry;
101
+ }
102
+ return ['agentCapabilities'];
103
+ }
104
+ if (SPEC_KEYS.has(head))
105
+ return [head];
106
+ // Unknown / retired top-level field — the whole key goes.
107
+ return [head];
108
+ }
109
+ /** Delete `keyPath` from a structural copy of `root`; false if absent. */
110
+ function deleteAt(root, keyPath) {
111
+ let node = root;
112
+ for (let i = 0; i < keyPath.length - 1; i += 1) {
113
+ const next = node[keyPath[i]];
114
+ if (!isRecord(next))
115
+ return false;
116
+ // Copy-on-write down the path so the input is never mutated.
117
+ const copy = { ...next };
118
+ node[keyPath[i]] = copy;
119
+ node = copy;
120
+ }
121
+ const leaf = keyPath[keyPath.length - 1];
122
+ if (!(leaf in node))
123
+ return false;
124
+ delete node[leaf];
125
+ // A dropped prop must leave `propsSpec.required` too — a stale name
126
+ // there is its own gate error and would only cost another round.
127
+ if (keyPath.length === 3 && keyPath[0] === 'propsSpec' && keyPath[1] === 'properties') {
128
+ const ps = root['propsSpec'];
129
+ if (isRecord(ps) && Array.isArray(ps['required'])) {
130
+ root['propsSpec'] = {
131
+ ...ps,
132
+ required: ps['required'].filter((name) => name !== leaf),
133
+ };
134
+ }
135
+ }
136
+ return true;
137
+ }
138
+ /**
139
+ * Keep the conforming subset of `draft` (already normalized by the
140
+ * caller, ideally). See the module docstring for the contract.
141
+ */
142
+ export function salvageConformingSubset(draft) {
143
+ if (!isRecord(draft))
144
+ return null;
145
+ let working = { ...draft };
146
+ const dropped = [];
147
+ /** Cuts already tried at leaf depth for a path — the retry goes to the entry. */
148
+ const leafTried = new Set();
149
+ for (let round = 0; round < MAX_ROUNDS; round += 1) {
150
+ const lint = lintContract(working);
151
+ if (lint.errors.length === 0) {
152
+ // Shape passed ⇒ the strict parse cannot throw.
153
+ const contract = dataContractSchema.parse(working);
154
+ return declaresAnySurface(contract) ? { contract, dropped } : null;
155
+ }
156
+ let cutSomething = false;
157
+ for (const issue of lint.errors) {
158
+ const leafFirst = !leafTried.has(issue.path);
159
+ const cut = cutFor(issue.path, leafFirst);
160
+ if (cut === null)
161
+ return null; // structural — nothing to keep
162
+ const cutKey = cut.join('.');
163
+ if (leafFirst && cutKey === issue.path)
164
+ leafTried.add(issue.path);
165
+ const next = { ...working };
166
+ if (!deleteAt(next, cut)) {
167
+ // The path names something that is not there (a reference
168
+ // target, a computed check) — fall back to the entry cut once,
169
+ // then give up on this issue for the round.
170
+ if (leafFirst) {
171
+ leafTried.add(issue.path);
172
+ const entryCut = cutFor(issue.path, false);
173
+ if (entryCut !== null && deleteAt(next, entryCut)) {
174
+ working = next;
175
+ dropped.push({ code: issue.code, severity: 'error', path: entryCut.join('.'), message: issue.message });
176
+ cutSomething = true;
177
+ break;
178
+ }
179
+ }
180
+ continue;
181
+ }
182
+ working = next;
183
+ dropped.push({ code: issue.code, severity: 'error', path: cutKey, message: issue.message });
184
+ cutSomething = true;
185
+ break; // one cut per round — re-lint before the next decision
186
+ }
187
+ if (!cutSomething)
188
+ return null; // no cut could be applied — structural
189
+ }
190
+ return null;
191
+ }
@@ -31,9 +31,9 @@
31
31
  import { validatePropsData } from '@ggui-ai/protocol';
32
32
  /**
33
33
  * True when a contract declares none of the six spec surfaces — the
34
- * `EMPTY_CONTRACT` (`{}`) that `ensureConformingContract` returns when a
35
- * draft is unrepairable. Such a contract is structurally valid but
36
- * carries no wire at all.
34
+ * empty contract the bench substitutes for a DECLINE (and the `{}` the
35
+ * retired fallback used to return for an unrepairable draft). Such a
36
+ * contract is structurally valid but carries no wire at all.
37
37
  */
38
38
  function isEmptyContract(contract) {
39
39
  return (contract.propsSpec === undefined &&
@@ -25,19 +25,26 @@
25
25
  * deterministic scorer pinning lives in round-trip-score.test.ts.
26
26
  */
27
27
  import type { DataContract, SuggestionFinding } from '@ggui-ai/protocol';
28
+ import { type EnsureConformingMethod } from '../ensure-conforming-contract.js';
28
29
  import type { LLMCaller } from '../llm-caller.js';
29
30
  import { type ScoreResult } from './run-bench.js';
30
31
  import { type RoundTripScore } from './round-trip-score.js';
31
32
  import { type BenchEntry } from './corpus.js';
32
33
  export interface RepairBenchOutcome {
33
34
  readonly entry: BenchEntry;
34
- /** ensureConformingContract always returns a contract (possibly `{}`). */
35
- readonly contract: DataContract;
35
+ /**
36
+ * The conforming contract ensureConformingContract produced — or
37
+ * `null` when it DECLINED (nothing in the draft could be kept; ggui#523
38
+ * item 3 retired the empty-contract fallback). Scored as the empty
39
+ * contract so the tiers stay comparable across that change.
40
+ */
41
+ readonly contract: DataContract | null;
36
42
  /** `agent` = clean draft returned verbatim; `synth` = repaired in-place. */
37
43
  readonly origin: 'agent' | 'synth';
38
44
  /** How the contract was produced (the efficiency tier): verbatim /
39
- * normalized (deterministic, no LLM) / llm-repair / fallback-empty. */
40
- readonly method: 'verbatim' | 'normalized' | 'llm-repair' | 'fallback-empty';
45
+ * normalized (deterministic, no LLM) / llm-repair / salvaged-subset /
46
+ * declined. */
47
+ readonly method: EnsureConformingMethod | 'declined';
41
48
  /** Structural shape score (ride-along secondary signal). */
42
49
  readonly shape: ScoreResult;
43
50
  /** Round-trip usability — null when the entry declares no round-trip
@@ -1 +1 @@
1
- {"version":3,"file":"run-repair-bench.d.ts","sourceRoot":"","sources":["../../src/synth-bench/run-repair-bench.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"run-repair-bench.d.ts","sourceRoot":"","sources":["../../src/synth-bench/run-repair-bench.ts"],"names":[],"mappings":"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"}
@@ -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(' ');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/negotiator",
3
- "version": "0.9.0",
3
+ "version": "0.11.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.9.0",
51
- "@ggui-ai/protocol": "0.9.0"
50
+ "@ggui-ai/mcp-server-core": "0.11.0",
51
+ "@ggui-ai/protocol": "0.11.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
+ }
@@ -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 (
@@ -26,7 +26,10 @@
26
26
  */
27
27
 
28
28
  import type { DataContract, SuggestionFinding } from '@ggui-ai/protocol';
29
- import { ensureConformingContract } from '../ensure-conforming-contract.js';
29
+ import {
30
+ ensureConformingContract,
31
+ type EnsureConformingMethod,
32
+ } from '../ensure-conforming-contract.js';
30
33
  import type { LLMCaller } from '../llm-caller.js';
31
34
  import { scoreSynthesizedContract, type ScoreResult } from './run-bench.js';
32
35
  import {
@@ -37,13 +40,19 @@ import { REPAIR_CORPUS, type BenchEntry } from './corpus.js';
37
40
 
38
41
  export interface RepairBenchOutcome {
39
42
  readonly entry: BenchEntry;
40
- /** ensureConformingContract always returns a contract (possibly `{}`). */
41
- readonly contract: DataContract;
43
+ /**
44
+ * The conforming contract ensureConformingContract produced — or
45
+ * `null` when it DECLINED (nothing in the draft could be kept; ggui#523
46
+ * item 3 retired the empty-contract fallback). Scored as the empty
47
+ * contract so the tiers stay comparable across that change.
48
+ */
49
+ readonly contract: DataContract | null;
42
50
  /** `agent` = clean draft returned verbatim; `synth` = repaired in-place. */
43
51
  readonly origin: 'agent' | 'synth';
44
52
  /** How the contract was produced (the efficiency tier): verbatim /
45
- * normalized (deterministic, no LLM) / llm-repair / fallback-empty. */
46
- readonly method: 'verbatim' | 'normalized' | 'llm-repair' | 'fallback-empty';
53
+ * normalized (deterministic, no LLM) / llm-repair / salvaged-subset /
54
+ * declined. */
55
+ readonly method: EnsureConformingMethod | 'declined';
47
56
  /** Structural shape score (ride-along secondary signal). */
48
57
  readonly shape: ScoreResult;
49
58
  /** Round-trip usability — null when the entry declares no round-trip
@@ -114,7 +123,8 @@ export async function evaluateRepairCorpus(
114
123
  const startedAt = Date.now();
115
124
  // The real production create-path: lint the draft → verbatim if clean
116
125
  // (origin agent), repair-in-place otherwise (origin synth). NEVER
117
- // throws; an unrepairable draft yields the empty `{}` contract.
126
+ // throws; an unrepairable draft yields its conforming subset, or a
127
+ // decline (`contract: null`) when nothing survives.
118
128
  const result = await ensureConformingContract(
119
129
  { llm: deps.llm },
120
130
  {
@@ -126,10 +136,14 @@ export async function evaluateRepairCorpus(
126
136
  },
127
137
  );
128
138
  const latencyMs = Date.now() - startedAt;
129
- const shape = scoreSynthesizedContract(result.contract, entry.expected);
139
+ // A decline carries no contract; score it as the empty contract —
140
+ // exactly what the retired fallback returned — so pass rates before
141
+ // and after the posture change measure the same thing.
142
+ const scored: DataContract = result.contract ?? {};
143
+ const shape = scoreSynthesizedContract(scored, entry.expected);
130
144
  const roundTrip =
131
145
  entry.roundTrip !== undefined
132
- ? scoreContractRoundTrip(result.contract, entry.roundTrip)
146
+ ? scoreContractRoundTrip(scored, entry.roundTrip)
133
147
  : null;
134
148
  const outcome: RepairBenchOutcome = {
135
149
  entry,
@@ -215,7 +229,7 @@ export function formatRepairBenchReport(report: RepairBenchReport): string {
215
229
  for (const o of report.outcomes) {
216
230
  byMethod.set(o.method, (byMethod.get(o.method) ?? 0) + 1);
217
231
  }
218
- const methodStr = ['verbatim', 'normalized', 'llm-repair', 'fallback-empty']
232
+ const methodStr = ['verbatim', 'normalized', 'llm-repair', 'salvaged-subset', 'declined']
219
233
  .filter((m) => byMethod.has(m))
220
234
  .map((m) => `${m}×${byMethod.get(m)}`)
221
235
  .join(' ');