@ggui-ai/negotiator 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,7 +35,12 @@ pnpm add @ggui-ai/negotiator
35
35
  - **`ensureConformingContract(...)`** — the create-path guarantee: validates
36
36
  an untrusted draft and, on errors, deterministically normalizes or
37
37
  LLM-repairs it so the handshake always returns a contract that passes the
38
- backstop. Never throws.
38
+ backstop. Never throws. A repair keeps every member the draft declared on
39
+ its actions (`oneShot`, `confirm`, `nextStep`, `description`, `example`,
40
+ `icon`) and names anything it cannot keep at its own path — the LLM
41
+ repair as `REPAIR_MEMBER_DROPPED` / `REPAIR_ENTRY_DROPPED` with the
42
+ gate's reason in the message, the salvaged subset with the gate's code
43
+ at the cut path — so a declaration is never lost silently.
39
44
  - **`rerankCandidates(...)`** — LLM judge that re-ranks blueprint-match
40
45
  retrieval candidates (the semantic-match decision used by
41
46
  `decideHandshake`).
@@ -62,7 +62,18 @@ export interface EnsureConformingAccepted {
62
62
  * warnings on the (valid) draft. On `origin: 'synth'`, the ERROR
63
63
  * findings that rejected the agent's draft — so the agent-side model
64
64
  * learns what it got wrong, even though we repaired it. On
65
- * `salvaged-subset` they include one finding per dropped entry.
65
+ * `llm-repair` they additionally carry one `REPAIR_MEMBER_DROPPED` per
66
+ * declared action-entry member the repair could not keep and one
67
+ * `REPAIR_ENTRY_DROPPED` per draft action it no longer carries
68
+ * (ggui#1421 — a repair preserves the draft's declarations, `oneShot`
69
+ * above all, and names any it must drop, each at its own path with the
70
+ * gate's reason in the message; a member under repair is named by the
71
+ * gate's own finding at that same path).
72
+ * The error findings are the union of what the gate refused on the
73
+ * raw draft and on its normalized form (the gate stops after the
74
+ * shape phase, so the raw lint alone can hide a semantic finding). On
75
+ * `salvaged-subset` they include one finding per dropped entry, with
76
+ * the gate's own code at the cut path.
66
77
  */
67
78
  readonly findings: readonly SuggestionFinding[];
68
79
  /** Operator- + LLM-readable explanation. */
@@ -1 +1 @@
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"}
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;;;;;;;;;;;;;;;;;OAiBG;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,CAqJjC"}
@@ -35,7 +35,7 @@
35
35
  */
36
36
  import { lintContract, dataContractSchema, } from '@ggui-ai/protocol';
37
37
  import { synthesizeContract } from './synthesize-contract.js';
38
- import { normalizeDraft } from './normalize-draft.js';
38
+ import { liftedRequiredNames, normalizeDraft } from './normalize-draft.js';
39
39
  import { salvageConformingSubset } from './salvage-draft.js';
40
40
  export async function ensureConformingContract(deps, args) {
41
41
  const lint = lintContract(args.draft);
@@ -58,11 +58,22 @@ export async function ensureConformingContract(deps, args) {
58
58
  reasoning: 'agent draft passed validateContract; accepted verbatim (origin: agent)',
59
59
  };
60
60
  }
61
+ // ggui#1454 — a wrapper-level `propsSpec.required: [...]` is refused by the
62
+ // gate as an unrecognized key and LIFTED by `normalizeDraft` onto the named
63
+ // entries (ggui#1432). The finding stays (the draft used a non-wire shape,
64
+ // and the agent should see that); its message says the key was honoured,
65
+ // so it reads as a repair, not a refusal to fix and re-handshake for.
66
+ const lifted = liftedRequiredNames(args.draft);
67
+ const liftNote = lifted.length > 0
68
+ ? ` — the wrapper-level \`required\` is not a wire key; it was lifted into ${lifted
69
+ .map((name) => `propsSpec.properties.${name}.required = true`)
70
+ .join(', ')} (repaired, not refused)`
71
+ : '';
61
72
  const errorFindings = lint.errors.map((e) => ({
62
73
  code: e.code,
63
74
  severity: 'error',
64
75
  path: e.path,
65
- message: e.message,
76
+ message: e.code === 'CTR_SHAPE_UNRECOGNIZED_KEYS' && e.path === 'propsSpec' ? `${e.message}${liftNote}` : e.message,
66
77
  }));
67
78
  // L3 — deterministic normalization tier. Most agent malformations are
68
79
  // mechanical (stray illegal wrapper keys, non-canonical schema types).
@@ -72,6 +83,25 @@ export async function ensureConformingContract(deps, args) {
72
83
  // LLM call). Semantic deficiencies fall through to the repair loop.
73
84
  const normalized = normalizeDraft(args.draft);
74
85
  const normLint = lintContract(normalized);
86
+ // The gate stops after the shape phase, so the RAW draft's lint may
87
+ // name only its mechanical errors while the NORMALIZED draft's lint
88
+ // reaches the semantic ones (a dangling `nextStep`). Every path the
89
+ // gate refused on either tree is the agent's to see (ggui#1421): the
90
+ // union, raw first, one finding per (code, path).
91
+ const gateFindings = [...errorFindings];
92
+ for (const e of normLint.errors) {
93
+ if (!gateFindings.some((f) => f.code === e.code && f.path === e.path)) {
94
+ gateFindings.push({ code: e.code, severity: 'error', path: e.path, message: e.message });
95
+ }
96
+ }
97
+ /** `gateFindings` plus `more`, one finding per (code, path) — a drop the gate already named is not named twice. */
98
+ const withGateFindings = (more) => {
99
+ const out = [...gateFindings];
100
+ for (const f of more)
101
+ if (!out.some((g) => g.code === f.code && g.path === f.path))
102
+ out.push(f);
103
+ return out;
104
+ };
75
105
  if (normLint.errors.length === 0) {
76
106
  return {
77
107
  contract: dataContractSchema.parse(normalized),
@@ -95,12 +125,16 @@ export async function ensureConformingContract(deps, args) {
95
125
  });
96
126
  if (synth.contract !== null &&
97
127
  lintContract(synth.contract).errors.length === 0) {
128
+ const droppedPaths = synth.dropped.map((d) => d.path);
98
129
  return {
99
130
  contract: synth.contract,
100
131
  origin: 'synth',
101
132
  method: 'llm-repair',
102
- findings: errorFindings,
103
- reasoning: `repaired the agent draft to pass validateContract — ${synth.reason}`,
133
+ findings: withGateFindings(synth.dropped),
134
+ reasoning: `repaired the agent draft to pass validateContract — ${synth.reason}` +
135
+ (droppedPaths.length > 0
136
+ ? `; dropped ${droppedPaths.length} declared action ${droppedPaths.length === 1 ? 'member/entry' : 'members/entries'} the repair could not keep (${droppedPaths.join(', ')}) — each is a finding`
137
+ : ''),
104
138
  };
105
139
  }
106
140
  // Repair impossible (LLM down, provider can't synthesize, or the
@@ -115,7 +149,7 @@ export async function ensureConformingContract(deps, args) {
115
149
  contract: salvaged.contract,
116
150
  origin: 'synth',
117
151
  method: 'salvaged-subset',
118
- findings: [...errorFindings, ...salvaged.dropped],
152
+ findings: withGateFindings(salvaged.dropped),
119
153
  reasoning: `could not repair the agent draft within budget (${synth.reason}); ` +
120
154
  `proposing the conforming SUBSET of your draft — dropped ${droppedPaths.length} ` +
121
155
  `entr${droppedPaths.length === 1 ? 'y' : 'ies'} the protocol refused (${droppedPaths.join(', ')}). ` +
@@ -129,7 +163,7 @@ export async function ensureConformingContract(deps, args) {
129
163
  contract: null,
130
164
  origin: 'agent',
131
165
  method: 'declined',
132
- findings: errorFindings,
166
+ findings: gateFindings,
133
167
  reasoning: `declined: could not repair the agent draft within budget (${synth.reason}) and no entry of it ` +
134
168
  `passes the contract gate — nothing to propose. Fix the findings (every one names its path) ` +
135
169
  `and re-handshake; do not render against this handshake.`,
package/dist/index.d.ts CHANGED
@@ -25,9 +25,9 @@
25
25
  * consumers need. Each additive export carries semver weight.
26
26
  */
27
27
  export { hashContract, buildVariant } from './contract-hash.js';
28
- export type { LLMCaller, LLMCallerConfig, ToolSchema } from './llm-caller.js';
29
- export { rerankCandidates } from './llm-rerank.js';
30
- export type { RerankCandidate, RerankDecision, RerankQuery, } from './llm-rerank.js';
28
+ export type { LLMCaller, LLMCallerConfig, Metered, TokenUsage, ToolSchema } from './llm-caller.js';
29
+ export { llmRerankJudge, rerankCandidates, RERANK_SYSTEM_PROMPT } from './llm-rerank.js';
30
+ export type { RerankCandidate, RerankDecision, RerankJudge, RerankQuery, } from './llm-rerank.js';
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';
@@ -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,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"}
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,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AACnG,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AACzF,YAAY,EACV,eAAe,EACf,cAAc,EACd,WAAW,EACX,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
@@ -25,7 +25,7 @@
25
25
  * consumers need. Each additive export carries semver weight.
26
26
  */
27
27
  export { hashContract, buildVariant } from './contract-hash.js';
28
- export { rerankCandidates } from './llm-rerank.js';
28
+ export { llmRerankJudge, rerankCandidates, RERANK_SYSTEM_PROMPT } 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';
@@ -35,6 +35,15 @@
35
35
  * that can't produce tool input at all simply omit this method;
36
36
  * consumers fall back to `call` + regex JSON extraction. Absence is
37
37
  * not an error.
38
+ * - `callStructuredMetered?(...)` is OPTIONAL: `callStructured`'s contract
39
+ * (the tool input, or a throw), with the call's token usage beside it
40
+ * as the provider reported it. `usage` is ABSENT when the provider
41
+ * reported none, never zeros: a consumer reads absence as "unmetered".
42
+ * A consumer that has it prefers it to `callStructured`; an
43
+ * implementation that cannot read usage omits it, and every caller
44
+ * written without it keeps compiling. It is a method rather than a
45
+ * usage callback because judges run concurrently, and a result carries
46
+ * its own usage where a callback would need correlating to its call.
38
47
  * - `ToolSchema.input_schema` follows the OpenAI tool-use JSON
39
48
  * Schema convention. Implementations that use a different
40
49
  * tool-use protocol (e.g., Anthropic's variant) MUST translate at
@@ -46,6 +55,16 @@ export interface ToolSchema {
46
55
  description: string;
47
56
  input_schema: Record<string, unknown>;
48
57
  }
58
+ /** Tokens one provider call consumed, as the provider reported them. */
59
+ export interface TokenUsage {
60
+ readonly input: number;
61
+ readonly output: number;
62
+ }
63
+ /** A call's result with the usage the provider reported for it; `usage` absent = unmetered. */
64
+ export interface Metered<T> {
65
+ readonly value: T;
66
+ readonly usage?: TokenUsage;
67
+ }
49
68
  /** Chat-model dispatcher consumed by the negotiator's synthesis + judge primitives. */
50
69
  export interface LLMCaller {
51
70
  /**
@@ -62,6 +81,13 @@ export interface LLMCaller {
62
81
  * extraction on the text path.
63
82
  */
64
83
  callStructured?(systemPrompt: string, userMessage: string, tool: ToolSchema, maxTokens?: number): Promise<unknown>;
84
+ /**
85
+ * {@link callStructured}, with the call's token usage beside the tool
86
+ * input (see the normative semantics above). Omit it when the provider's
87
+ * usage cannot be read; consumers then fall back to `callStructured` and
88
+ * treat the call as unmetered.
89
+ */
90
+ callStructuredMetered?(systemPrompt: string, userMessage: string, tool: ToolSchema, maxTokens?: number): Promise<Metered<unknown>>;
65
91
  }
66
92
  /**
67
93
  * Provider + model selector for factory-style LLM caller
@@ -1 +1 @@
1
- {"version":3,"file":"llm-caller.d.ts","sourceRoot":"","sources":["../src/llm-caller.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAEH,6DAA6D;AAC7D,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACvC;AAED,uFAAuF;AACvF,MAAM,WAAW,SAAS;IACxB;;;OAGG;IACH,IAAI,CACF,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,MAAM,EACnB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,CAAC,CAAC;IAEnB;;;;;;;OAOG;IACH,cAAc,CAAC,CACb,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,UAAU,EAChB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,OAAO,CAAC,CAAC;CACrB;AAED;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,WAAW,GAAG,QAAQ,GAAG,QAAQ,GAAG,YAAY,GAAG,SAAS,CAAC;IACvE,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB"}
1
+ {"version":3,"file":"llm-caller.d.ts","sourceRoot":"","sources":["../src/llm-caller.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AAEH,6DAA6D;AAC7D,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACvC;AAED,wEAAwE;AACxE,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,+FAA+F;AAC/F,MAAM,WAAW,OAAO,CAAC,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC;IAClB,QAAQ,CAAC,KAAK,CAAC,EAAE,UAAU,CAAC;CAC7B;AAED,uFAAuF;AACvF,MAAM,WAAW,SAAS;IACxB;;;OAGG;IACH,IAAI,CACF,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,MAAM,EACnB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,CAAC,CAAC;IAEnB;;;;;;;OAOG;IACH,cAAc,CAAC,CACb,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,UAAU,EAChB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,OAAO,CAAC,CAAC;IAEpB;;;;;OAKG;IACH,qBAAqB,CAAC,CACpB,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,UAAU,EAChB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC;CAC9B;AAED;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,WAAW,GAAG,QAAQ,GAAG,QAAQ,GAAG,YAAY,GAAG,SAAS,CAAC;IACvE,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB"}
@@ -35,6 +35,15 @@
35
35
  * that can't produce tool input at all simply omit this method;
36
36
  * consumers fall back to `call` + regex JSON extraction. Absence is
37
37
  * not an error.
38
+ * - `callStructuredMetered?(...)` is OPTIONAL: `callStructured`'s contract
39
+ * (the tool input, or a throw), with the call's token usage beside it
40
+ * as the provider reported it. `usage` is ABSENT when the provider
41
+ * reported none, never zeros: a consumer reads absence as "unmetered".
42
+ * A consumer that has it prefers it to `callStructured`; an
43
+ * implementation that cannot read usage omits it, and every caller
44
+ * written without it keeps compiling. It is a method rather than a
45
+ * usage callback because judges run concurrently, and a result carries
46
+ * its own usage where a callback would need correlating to its call.
38
47
  * - `ToolSchema.input_schema` follows the OpenAI tool-use JSON
39
48
  * Schema convention. Implementations that use a different
40
49
  * tool-use protocol (e.g., Anthropic's variant) MUST translate at
@@ -1,18 +1,4 @@
1
- /**
2
- * LLM rerank — Tier-2 precision oracle for the blueprint registry.
3
- *
4
- * Given a user's UI request (intent + contract structure) and a set
5
- * of candidate cached blueprints retrieved by RAG, ask a fast LLM
6
- * (Haiku 4.5) which candidate (if any) matches. Returns a structured
7
- * decision so the caller can branch deterministically.
8
- *
9
- * This module is the precision half of the blueprint-first
10
- * architecture: RAG retrieval is high-recall but low-precision (bge-
11
- * small confuses topic-similar but UI-divergent prompts); the LLM
12
- * judge restores precision. Combined break-even hit rate is ~10%;
13
- * realistic workloads observe 30-70%.
14
- */
15
- import type { LLMCaller, ToolSchema } from './llm-caller.js';
1
+ import type { LLMCaller, TokenUsage, ToolSchema } from './llm-caller.js';
16
2
  /**
17
3
  * One candidate blueprint for the LLM judge to consider.
18
4
  *
@@ -58,23 +44,23 @@ export interface RerankDecision {
58
44
  */
59
45
  readonly confidence: number;
60
46
  /**
61
- * Free-text reason from the judge. Surface in trace logs so
47
+ * Free-text reason from the judge, when it gives one — a judge that
48
+ * decides without prose sends none (ggui#1235). Surface in trace logs so
62
49
  * operators can debug "why didn't this hit." Truncate at the
63
50
  * persistence boundary if cardinality is a concern.
64
51
  */
65
- readonly reason: string;
52
+ readonly reason?: string;
66
53
  /** Wall-clock latency of the LLM call. */
67
54
  readonly latencyMs: number;
68
55
  /**
69
- * Token cost of the call — for the cache-trace sink and cost
70
- * accounting. Implementations that can't surface token counts may
71
- * report `{input: 0, output: 0}` and the cost-per-call gate will
72
- * have to be measured externally.
56
+ * Tokens the decision cost, as the provider reported them — for the
57
+ * cache-trace sink and cost accounting. ABSENT means unmetered, never
58
+ * zero: the judge called a provider and has no count for the call (a
59
+ * caller without `callStructuredMetered`, a provider that reported no
60
+ * usage, or a call that threw). `{ input: 0, output: 0 }` is a true
61
+ * zero: the decision was made without a provider call (ggui#1418).
73
62
  */
74
- readonly tokenCost: {
75
- readonly input: number;
76
- readonly output: number;
77
- };
63
+ readonly tokenCost?: TokenUsage;
78
64
  }
79
65
  /** Query the user's request the judge is matching against. */
80
66
  export interface RerankQuery {
@@ -98,5 +84,20 @@ declare const RERANK_TOOL: ToolSchema;
98
84
  export declare function rerankCandidates(deps: {
99
85
  readonly llm: LLMCaller;
100
86
  }, query: RerankQuery, candidates: readonly RerankCandidate[]): Promise<RerankDecision>;
87
+ /**
88
+ * The judge seam (ggui#1235): one function from a query and its candidates
89
+ * to a {@link RerankDecision}. The matcher takes a judge together with the
90
+ * confidence threshold it was measured on (the pair), so a judge on
91
+ * another scale never meets a cut calibrated for a different one.
92
+ * `matchId: null` is a judge's only decline; `confidence` is the judge's
93
+ * own, compared by the caller against the pair's threshold.
94
+ */
95
+ export type RerankJudge = (query: RerankQuery, candidates: readonly RerankCandidate[]) => Promise<RerankDecision>;
96
+ /**
97
+ * Today's judge as a {@link RerankJudge}: {@link rerankCandidates} bound to
98
+ * an {@link LLMCaller} — the same prompt, the same tool, the same decision,
99
+ * so a caller that injects nothing else behaves exactly as before.
100
+ */
101
+ export declare function llmRerankJudge(llm: LLMCaller): RerankJudge;
101
102
  export { RERANK_SYSTEM_PROMPT, RERANK_TOOL };
102
103
  //# sourceMappingURL=llm-rerank.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"llm-rerank.d.ts","sourceRoot":"","sources":["../src/llm-rerank.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7D;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC9B,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,gEAAgE;IAChE,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B;;;;;OAKG;IACH,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAC;IACvC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,0CAA0C;AAC1C,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC;;;;;;;;OAQG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,0CAA0C;IAC1C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CACzE;AAED,8DAA8D;AAC9D,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;CAClC;AAED,QAAA,MAAM,oBAAoB,03DAQ6U,CAAC;AAExW,QAAA,MAAM,WAAW,EAAE,UA4BlB,CAAC;AA8EF;;;;;;;;;;;GAWG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE;IAAE,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAA;CAAE,EACjC,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,SAAS,eAAe,EAAE,GACrC,OAAO,CAAC,cAAc,CAAC,CAwDzB;AAGD,OAAO,EAAE,oBAAoB,EAAE,WAAW,EAAE,CAAC"}
1
+ {"version":3,"file":"llm-rerank.d.ts","sourceRoot":"","sources":["../src/llm-rerank.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAEzE;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC9B,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,gEAAgE;IAChE,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B;;;;;OAKG;IACH,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAC;IACvC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,0CAA0C;AAC1C,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC;;;;;;;;OAQG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,0CAA0C;IAC1C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,UAAU,CAAC;CACjC;AAED,8DAA8D;AAC9D,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;CAClC;AAED,QAAA,MAAM,oBAAoB,03DAQ6U,CAAC;AAExW,QAAA,MAAM,WAAW,EAAE,UA4BlB,CAAC;AAkFF;;;;;;;;;;;GAWG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE;IAAE,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAA;CAAE,EACjC,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,SAAS,eAAe,EAAE,GACrC,OAAO,CAAC,cAAc,CAAC,CAyDzB;AAGD;;;;;;;GAOG;AACH,MAAM,MAAM,WAAW,GAAG,CACxB,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,SAAS,eAAe,EAAE,KACnC,OAAO,CAAC,cAAc,CAAC,CAAC;AAE7B;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,SAAS,GAAG,WAAW,CAE1D;AAED,OAAO,EAAE,oBAAoB,EAAE,WAAW,EAAE,CAAC"}
@@ -1,3 +1,18 @@
1
+ /**
2
+ * LLM rerank — Tier-2 precision oracle for the blueprint registry.
3
+ *
4
+ * Given a user's UI request (intent + contract structure) and a set
5
+ * of candidate cached blueprints retrieved by RAG, ask a fast LLM
6
+ * (Haiku 4.5) which candidate (if any) matches. Returns a structured
7
+ * decision so the caller can branch deterministically.
8
+ *
9
+ * This module is the precision half of the blueprint-first
10
+ * architecture: RAG retrieval is high-recall but low-precision (bge-
11
+ * small confuses topic-similar but UI-divergent prompts); the LLM
12
+ * judge restores precision. Combined break-even hit rate is ~10%;
13
+ * realistic workloads observe 30-70%.
14
+ */
15
+ import { MATCHED_INTENT_MAX_CHARS } from '@ggui-ai/protocol';
1
16
  const RERANK_SYSTEM_PROMPT = `You match user UI requests against previously-generated UI blueprints. Each blueprint was produced for a past request and stored. Decide whether any candidate belongs to the SAME FAMILY as the current request — i.e. it would serve as a reasonable starting point that the requester can refine, not a pixel-exact replica.
2
17
 
3
18
  MATCH means the candidate is the same intended user task AND the same broad UI shape (component types, layout pattern). A candidate still MATCHES when the current request adds or omits fields, slots, or actions relative to the cached blueprint — a superset, a subset, or an overlapping wire surface all still match. Added or omitted fields/slots/actions DO NOT block a match and are NOT yours to judge: those wire-surface deltas are reconciled and reported to the agent separately, after you decide. Judge similarity of task and shape, never coverage of fields.
@@ -42,7 +57,11 @@ function buildUserMessage(query, candidates) {
42
57
  for (const c of candidates) {
43
58
  lines.push('---');
44
59
  lines.push(` id: ${c.id}`);
45
- lines.push(` intent: ${truncate(c.cachedIntent, 280)}`);
60
+ // The cap is the protocol's `MATCHED_INTENT_MAX_CHARS`: the same
61
+ // string the judge reads here is what a judged hit hands back to the
62
+ // agent as `blueprintMeta.matchedIntent` (ggui#1336), so the two cuts
63
+ // are one constant, not two literals that happen to agree.
64
+ lines.push(` intent: ${truncate(c.cachedIntent, MATCHED_INTENT_MAX_CHARS)}`);
46
65
  lines.push(` contract: ${c.cachedContractSummary}`);
47
66
  if (typeof c.cosine === 'number') {
48
67
  lines.push(` cosine: ${c.cosine.toFixed(3)}`);
@@ -118,7 +137,8 @@ export async function rerankCandidates(deps, query, candidates) {
118
137
  }
119
138
  const userMessage = buildUserMessage(query, candidates);
120
139
  const candidateIds = new Set(candidates.map((c) => c.id));
121
- if (typeof deps.llm.callStructured !== 'function') {
140
+ const { callStructured, callStructuredMetered } = deps.llm;
141
+ if (typeof callStructuredMetered !== 'function' && typeof callStructured !== 'function') {
122
142
  return {
123
143
  matchId: null,
124
144
  confidence: 0,
@@ -127,9 +147,19 @@ export async function rerankCandidates(deps, query, candidates) {
127
147
  tokenCost: { input: 0, output: 0 },
128
148
  };
129
149
  }
150
+ // The metered method when the caller has it (its usage is the decision's
151
+ // cost), else the plain one (unmetered: no tokenCost).
130
152
  let toolInput;
153
+ let usage;
131
154
  try {
132
- toolInput = await deps.llm.callStructured(RERANK_SYSTEM_PROMPT, userMessage, RERANK_TOOL, 512);
155
+ if (typeof callStructuredMetered === 'function') {
156
+ const metered = await callStructuredMetered.call(deps.llm, RERANK_SYSTEM_PROMPT, userMessage, RERANK_TOOL, 512);
157
+ toolInput = metered.value;
158
+ usage = metered.usage;
159
+ }
160
+ else if (typeof callStructured === 'function') {
161
+ toolInput = await callStructured.call(deps.llm, RERANK_SYSTEM_PROMPT, userMessage, RERANK_TOOL, 512);
162
+ }
133
163
  }
134
164
  catch (err) {
135
165
  const message = err instanceof Error ? err.message : String(err);
@@ -138,7 +168,6 @@ export async function rerankCandidates(deps, query, candidates) {
138
168
  confidence: 0,
139
169
  reason: `llm-rerank: callStructured threw — ${message}`,
140
170
  latencyMs: Date.now() - startedAt,
141
- tokenCost: { input: 0, output: 0 },
142
171
  };
143
172
  }
144
173
  const parsed = parseToolInput(toolInput, candidateIds);
@@ -147,11 +176,15 @@ export async function rerankCandidates(deps, query, candidates) {
147
176
  confidence: parsed.confidence,
148
177
  reason: parsed.reason,
149
178
  latencyMs: Date.now() - startedAt,
150
- // Token cost surfacing requires LLMCaller-level instrumentation
151
- // we don't have today. Default to zero; the cost gate is measured
152
- // out-of-band from billing data during the probe.
153
- tokenCost: { input: 0, output: 0 },
179
+ ...(usage !== undefined ? { tokenCost: usage } : {}),
154
180
  };
155
181
  }
156
- // Re-exports for the eval harness — keep public surface explicit.
182
+ /**
183
+ * Today's judge as a {@link RerankJudge}: {@link rerankCandidates} bound to
184
+ * an {@link LLMCaller} — the same prompt, the same tool, the same decision,
185
+ * so a caller that injects nothing else behaves exactly as before.
186
+ */
187
+ export function llmRerankJudge(llm) {
188
+ return (query, candidates) => rerankCandidates({ llm }, query, candidates);
189
+ }
157
190
  export { RERANK_SYSTEM_PROMPT, RERANK_TOOL };
@@ -11,6 +11,12 @@
11
11
  *
12
12
  * This pass fixes the mechanical classes deterministically, preserving
13
13
  * the agent's intent exactly:
14
+ * - lifts a wrapper-level `propsSpec.required: [...]` (JSON Schema's
15
+ * spelling of the same declaration) into each listed entry's
16
+ * `required: true` before the key goes — the agent said which props
17
+ * are required, and the served contract keeps saying it (ggui#1432);
18
+ * an entry's own explicit `required` wins, and a name with no entry
19
+ * lifts nothing;
14
20
  * - strips keys the protocol's `.strict()` spec schemas would reject,
15
21
  * keeping only the allowed keys at each wrapper / entry level;
16
22
  * - canonicalizes every inner JSON Schema via {@link normalizeSchema}
@@ -29,5 +35,19 @@
29
35
  * (the top-level DataContract schema is `.passthrough()`); only the
30
36
  * `.strict()` spec wrappers and entries are cleaned.
31
37
  */
38
+ /**
39
+ * The prop names a wrapper-level `propsSpec.required: [...]` will be
40
+ * lifted onto by {@link normalizeDraft} (ggui#1432): each listed name that
41
+ * has an entry and whose entry declares no `required` of its own, in the
42
+ * wrapper's order and de-duplicated. A ghost name (no entry) and an entry
43
+ * with its own word are skipped, exactly as the lift skips them. Empty when
44
+ * nothing lifts.
45
+ *
46
+ * One source of truth for the lift and for the finding that reports it
47
+ * (ggui#1454): the gate's `CTR_SHAPE_UNRECOGNIZED_KEYS` at `propsSpec` is
48
+ * a repair here, not a refusal, and its message says which entries gained
49
+ * `required: true`.
50
+ */
51
+ export declare function liftedRequiredNames(draft: unknown): readonly string[];
32
52
  export declare function normalizeDraft(draft: unknown): unknown;
33
53
  //# sourceMappingURL=normalize-draft.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"normalize-draft.d.ts","sourceRoot":"","sources":["../src/normalize-draft.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AA2GH;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CA4CtD"}
1
+ {"version":3,"file":"normalize-draft.d.ts","sourceRoot":"","sources":["../src/normalize-draft.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAgGH;;;;;;GAMG;AACH;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,SAAS,MAAM,EAAE,CAUrE;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAuDtD"}
@@ -11,6 +11,12 @@
11
11
  *
12
12
  * This pass fixes the mechanical classes deterministically, preserving
13
13
  * the agent's intent exactly:
14
+ * - lifts a wrapper-level `propsSpec.required: [...]` (JSON Schema's
15
+ * spelling of the same declaration) into each listed entry's
16
+ * `required: true` before the key goes — the agent said which props
17
+ * are required, and the served contract keeps saying it (ggui#1432);
18
+ * an entry's own explicit `required` wins, and a name with no entry
19
+ * lifts nothing;
14
20
  * - strips keys the protocol's `.strict()` spec schemas would reject,
15
21
  * keeping only the allowed keys at each wrapper / entry level;
16
22
  * - canonicalizes every inner JSON Schema via {@link normalizeSchema}
@@ -22,43 +28,23 @@
22
28
  * cross-refs) are deliberately out of scope — those still go to the
23
29
  * repair loop, where reasoning earns its keep.
24
30
  */
31
+ import { actionEntrySchema, agentToolEntrySchema, contextEntrySchema, propEntrySchema, propsSpecSchema, streamChannelEntrySchema, } from '@ggui-ai/protocol';
25
32
  import { normalizeSchema } from './normalize-schema.js';
26
- // Allowed-key sets mirror the protocol `.strict()` schemas
27
- // (schemas/data-contract.ts). Stripping anything outside these is safe:
28
- // the strict schema would reject it as CTR_SHAPE_UNRECOGNIZED_KEYS.
29
- const PROPS_WRAPPER_KEYS = new Set(['description', 'properties']);
30
- const PROP_ENTRY_KEYS = new Set([
31
- 'description',
32
- 'schema',
33
- 'required',
34
- 'default',
35
- 'example',
36
- 'sourceTool',
37
- ]);
38
- const CONTEXT_ENTRY_KEYS = new Set([
39
- 'description',
40
- 'schema',
41
- 'default',
42
- 'debounceMs',
43
- 'example',
44
- ]);
45
- const ACTION_ENTRY_KEYS = new Set([
46
- 'description',
47
- 'label',
48
- 'schema',
49
- 'example',
50
- 'icon',
51
- 'confirm',
52
- 'nextStep',
53
- ]);
54
- const STREAM_ENTRY_KEYS = new Set(['description', 'schema', 'source']);
55
- const AGENT_TOOL_KEYS = new Set(['serverInfo', 'toolInfo', 'usage', 'example']);
33
+ // Allowed-key sets are DERIVED from the protocol's `.strict()` spec
34
+ // schemas (schemas/data-contract.ts) — never hand-copied. A hand-copied
35
+ // mirror drifts the day the schema grows: `oneShot` joined
36
+ // `actionEntrySchema` on 2026-09-15 (ggui#1108) and a stale mirror here
37
+ // stripped it from every repaired draft for eleven days (ggui#1421).
38
+ // Stripping anything outside these sets is safe: the strict schema
39
+ // would reject it as CTR_SHAPE_UNRECOGNIZED_KEYS.
40
+ const PROPS_WRAPPER_KEYS = new Set(Object.keys(propsSpecSchema.shape));
41
+ const PROP_ENTRY_KEYS = new Set(Object.keys(propEntrySchema.shape));
42
+ const CONTEXT_ENTRY_KEYS = new Set(Object.keys(contextEntrySchema.shape));
43
+ const ACTION_ENTRY_KEYS = new Set(Object.keys(actionEntrySchema.shape));
44
+ const STREAM_ENTRY_KEYS = new Set(Object.keys(streamChannelEntrySchema.shape));
45
+ const AGENT_TOOL_KEYS = new Set(Object.keys(agentToolEntrySchema.shape));
56
46
  /** Inner keys of an {@link AgentToolEntry.toolInfo} (the MCP descriptor). */
57
- const AGENT_TOOL_INFO_KEYS = new Set([
58
- 'inputSchema',
59
- 'description',
60
- 'outputSchema',
61
- ]);
47
+ const AGENT_TOOL_INFO_KEYS = new Set(Object.keys(agentToolEntrySchema.shape.toolInfo.shape));
62
48
  function isRecord(value) {
63
49
  return typeof value === 'object' && value !== null && !Array.isArray(value);
64
50
  }
@@ -121,21 +107,59 @@ function cleanAgentToolMap(map) {
121
107
  * (the top-level DataContract schema is `.passthrough()`); only the
122
108
  * `.strict()` spec wrappers and entries are cleaned.
123
109
  */
110
+ /**
111
+ * The prop names a wrapper-level `propsSpec.required: [...]` will be
112
+ * lifted onto by {@link normalizeDraft} (ggui#1432): each listed name that
113
+ * has an entry and whose entry declares no `required` of its own, in the
114
+ * wrapper's order and de-duplicated. A ghost name (no entry) and an entry
115
+ * with its own word are skipped, exactly as the lift skips them. Empty when
116
+ * nothing lifts.
117
+ *
118
+ * One source of truth for the lift and for the finding that reports it
119
+ * (ggui#1454): the gate's `CTR_SHAPE_UNRECOGNIZED_KEYS` at `propsSpec` is
120
+ * a repair here, not a refusal, and its message says which entries gained
121
+ * `required: true`.
122
+ */
123
+ export function liftedRequiredNames(draft) {
124
+ if (!isRecord(draft) || !isRecord(draft['propsSpec']))
125
+ return [];
126
+ const wrapper = draft['propsSpec'];
127
+ if (!Array.isArray(wrapper['required']) || !isRecord(wrapper['properties']))
128
+ return [];
129
+ const entries = wrapper['properties'];
130
+ // De-duplicated: a name the wrapper lists twice lifts once and is reported once.
131
+ return [...new Set(wrapper['required'].filter((name) => typeof name === 'string'))].filter((name) => {
132
+ const entry = entries[name];
133
+ return isRecord(entry) && entry['required'] === undefined;
134
+ });
135
+ }
124
136
  export function normalizeDraft(draft) {
125
137
  if (!isRecord(draft))
126
138
  return draft;
127
139
  const out = { ...draft };
128
140
  // propsSpec wrapper: keep {description, properties}; clean each PropEntry.
141
+ // A wrapper-level `required: [...]` is the agent's declaration in JSON
142
+ // Schema's spelling — lift it into the listed entries before the key
143
+ // goes, so the served contract still says which props are required.
129
144
  if (isRecord(out['propsSpec'])) {
145
+ const wrapper = out['propsSpec'];
130
146
  const ps = {};
131
- for (const [key, value] of Object.entries(out['propsSpec'])) {
147
+ for (const [key, value] of Object.entries(wrapper)) {
132
148
  if (PROPS_WRAPPER_KEYS.has(key))
133
149
  ps[key] = value;
134
150
  }
135
151
  if (isRecord(ps['properties'])) {
136
- ps['properties'] = cleanEntryMap(ps['properties'], PROP_ENTRY_KEYS, [
137
- 'schema',
138
- ]);
152
+ const cleaned = cleanEntryMap(ps['properties'], PROP_ENTRY_KEYS, ['schema']);
153
+ // The same selection `liftedRequiredNames` reports (one source of truth);
154
+ // `cleanEntryMap` keeps an entry's own `required`, so the test is the same on
155
+ // the cleaned map as on the raw one.
156
+ for (const name of liftedRequiredNames(draft)) {
157
+ const entry = cleaned[name];
158
+ if (!isRecord(entry))
159
+ continue;
160
+ cleaned[name] = { ...entry, required: true };
161
+ }
162
+ ps['properties'] = cleaned;
139
163
  }
140
164
  out['propsSpec'] = ps;
141
165
  }