@ggui-ai/negotiator 0.24.0 → 0.26.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
@@ -1,7 +1,7 @@
1
1
  # @ggui-ai/negotiator
2
2
 
3
3
  Contract-synthesis + match-judge engine for
4
- [ggui](https://github.com/ggui-ai/ggui)'s handshake.
4
+ [GGUI](https://github.com/ggui-ai/ggui)'s handshake.
5
5
 
6
6
  Given an agent's draft contract + intent, this package synthesizes (or
7
7
  repairs) a conforming `DataContract`, judges blueprint-match candidates for
@@ -35,7 +35,14 @@ 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
+ each entry it keeps under the same key, on every spec (an action's
40
+ `oneShot` and `nextStep`, a stream's `mode` and `replay`, a tool's
41
+ `toolInfo.description`, …), puts back any tool the repair dropped
42
+ (`REPAIR_ENTRY_RESTORED`), and names anything it cannot keep at its own
43
+ path — the LLM repair as `REPAIR_MEMBER_DROPPED` / `REPAIR_ENTRY_DROPPED`
44
+ with the gate's reason in the message, the salvaged subset with the
45
+ gate's code at the cut path — so a declaration is never lost silently.
39
46
  - **`rerankCandidates(...)`** — LLM judge that re-ranks blueprint-match
40
47
  retrieval candidates (the semantic-match decision used by
41
48
  `decideHandshake`).
@@ -62,7 +62,20 @@ 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 member the repair could not keep, on any spec, one
67
+ * `REPAIR_ENTRY_DROPPED` per draft entry it no longer carries under its
68
+ * key, and one `REPAIR_ENTRY_RESTORED` (`warn`) per draft tool the repair
69
+ * dropped and the overlay put back (ggui#1421, ggui#1430 — a repair
70
+ * preserves the draft's declarations, `oneShot` above all, and names any
71
+ * it must drop, each at its own path with the gate's reason in the
72
+ * message; a member under repair is named by the gate's own finding at
73
+ * that same path).
74
+ * The error findings are the union of what the gate refused on the
75
+ * raw draft and on its normalized form (the gate stops after the
76
+ * shape phase, so the raw lint alone can hide a semantic finding). On
77
+ * `salvaged-subset` they include one finding per dropped entry, with
78
+ * the gate's own code at the cut path.
66
79
  */
67
80
  readonly findings: readonly SuggestionFinding[];
68
81
  /** 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;AAMjD;;;;;;;;;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;;;;;;;;;;;;;;;;;;;OAmBG;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,CAyJjC"}
@@ -35,8 +35,9 @@
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
+ import { REPAIR_ENTRY_RESTORED } from './preserve-declared-members.js';
40
41
  export async function ensureConformingContract(deps, args) {
41
42
  const lint = lintContract(args.draft);
42
43
  const warnFindings = lint.warnings.map((w) => ({
@@ -58,11 +59,22 @@ export async function ensureConformingContract(deps, args) {
58
59
  reasoning: 'agent draft passed validateContract; accepted verbatim (origin: agent)',
59
60
  };
60
61
  }
62
+ // ggui#1454 — a wrapper-level `propsSpec.required: [...]` is refused by the
63
+ // gate as an unrecognized key and LIFTED by `normalizeDraft` onto the named
64
+ // entries (ggui#1432). The finding stays (the draft used a non-wire shape,
65
+ // and the agent should see that); its message says the key was honoured,
66
+ // so it reads as a repair, not a refusal to fix and re-handshake for.
67
+ const lifted = liftedRequiredNames(args.draft);
68
+ const liftNote = lifted.length > 0
69
+ ? ` — the wrapper-level \`required\` is not a wire key; it was lifted into ${lifted
70
+ .map((name) => `propsSpec.properties.${name}.required = true`)
71
+ .join(', ')} (repaired, not refused)`
72
+ : '';
61
73
  const errorFindings = lint.errors.map((e) => ({
62
74
  code: e.code,
63
75
  severity: 'error',
64
76
  path: e.path,
65
- message: e.message,
77
+ message: e.code === 'CTR_SHAPE_UNRECOGNIZED_KEYS' && e.path === 'propsSpec' ? `${e.message}${liftNote}` : e.message,
66
78
  }));
67
79
  // L3 — deterministic normalization tier. Most agent malformations are
68
80
  // mechanical (stray illegal wrapper keys, non-canonical schema types).
@@ -72,6 +84,25 @@ export async function ensureConformingContract(deps, args) {
72
84
  // LLM call). Semantic deficiencies fall through to the repair loop.
73
85
  const normalized = normalizeDraft(args.draft);
74
86
  const normLint = lintContract(normalized);
87
+ // The gate stops after the shape phase, so the RAW draft's lint may
88
+ // name only its mechanical errors while the NORMALIZED draft's lint
89
+ // reaches the semantic ones (a dangling `nextStep`). Every path the
90
+ // gate refused on either tree is the agent's to see (ggui#1421): the
91
+ // union, raw first, one finding per (code, path).
92
+ const gateFindings = [...errorFindings];
93
+ for (const e of normLint.errors) {
94
+ if (!gateFindings.some((f) => f.code === e.code && f.path === e.path)) {
95
+ gateFindings.push({ code: e.code, severity: 'error', path: e.path, message: e.message });
96
+ }
97
+ }
98
+ /** `gateFindings` plus `more`, one finding per (code, path) — a drop the gate already named is not named twice. */
99
+ const withGateFindings = (more) => {
100
+ const out = [...gateFindings];
101
+ for (const f of more)
102
+ if (!out.some((g) => g.code === f.code && g.path === f.path))
103
+ out.push(f);
104
+ return out;
105
+ };
75
106
  if (normLint.errors.length === 0) {
76
107
  return {
77
108
  contract: dataContractSchema.parse(normalized),
@@ -95,12 +126,20 @@ export async function ensureConformingContract(deps, args) {
95
126
  });
96
127
  if (synth.contract !== null &&
97
128
  lintContract(synth.contract).errors.length === 0) {
129
+ const droppedPaths = synth.dropped.filter((d) => d.code !== REPAIR_ENTRY_RESTORED).map((d) => d.path);
130
+ const restoredPaths = synth.dropped.filter((d) => d.code === REPAIR_ENTRY_RESTORED).map((d) => d.path);
98
131
  return {
99
132
  contract: synth.contract,
100
133
  origin: 'synth',
101
134
  method: 'llm-repair',
102
- findings: errorFindings,
103
- reasoning: `repaired the agent draft to pass validateContract — ${synth.reason}`,
135
+ findings: withGateFindings(synth.dropped),
136
+ reasoning: `repaired the agent draft to pass validateContract — ${synth.reason}` +
137
+ (droppedPaths.length > 0
138
+ ? `; dropped ${droppedPaths.length} declared ${droppedPaths.length === 1 ? 'member/entry' : 'members/entries'} the repair could not keep (${droppedPaths.join(', ')}) — each is a finding`
139
+ : '') +
140
+ (restoredPaths.length > 0
141
+ ? `; restored ${restoredPaths.length} declared ${restoredPaths.length === 1 ? 'tool' : 'tools'} the repair dropped (${restoredPaths.join(', ')}) — each is a finding`
142
+ : ''),
104
143
  };
105
144
  }
106
145
  // Repair impossible (LLM down, provider can't synthesize, or the
@@ -115,7 +154,7 @@ export async function ensureConformingContract(deps, args) {
115
154
  contract: salvaged.contract,
116
155
  origin: 'synth',
117
156
  method: 'salvaged-subset',
118
- findings: [...errorFindings, ...salvaged.dropped],
157
+ findings: withGateFindings(salvaged.dropped),
119
158
  reasoning: `could not repair the agent draft within budget (${synth.reason}); ` +
120
159
  `proposing the conforming SUBSET of your draft — dropped ${droppedPaths.length} ` +
121
160
  `entr${droppedPaths.length === 1 ? 'y' : 'ies'} the protocol refused (${droppedPaths.join(', ')}). ` +
@@ -129,7 +168,7 @@ export async function ensureConformingContract(deps, args) {
129
168
  contract: null,
130
169
  origin: 'agent',
131
170
  method: 'declined',
132
- findings: errorFindings,
171
+ findings: gateFindings,
133
172
  reasoning: `declined: could not repair the agent draft within budget (${synth.reason}) and no entry of it ` +
134
173
  `passes the contract gate — nothing to propose. Fix the findings (every one names its path) ` +
135
174
  `and re-handshake; do not render against this handshake.`,
package/dist/index.d.ts CHANGED
@@ -8,8 +8,11 @@
8
8
  * handshake always returns a valid contract;
9
9
  * - judges blueprint-match candidates for reuse (`rerankCandidates`);
10
10
  * - validates contract structure + novelty (`contract-validators`);
11
- * - hashes contracts into identity + variant keys (`hashContract`,
12
- * `buildVariant`) and normalizes untrusted drafts (`normalizeDraft`).
11
+ * - normalizes untrusted drafts (`normalizeDraft`).
12
+ *
13
+ * Contract identity is not this package's: the cache key is
14
+ * `blueprintKey(contract)` and the variant key is `variantKey(variance)`,
15
+ * both from `@ggui-ai/protocol/blueprint-key`.
13
16
  *
14
17
  * The HANDSHAKE DECISION itself (find-similar → reuse vs synth-create)
15
18
  * lives in the shared `decideHandshake` core in
@@ -24,9 +27,8 @@
24
27
  * This barrel stays narrow — it exports only the minimum surface
25
28
  * consumers need. Each additive export carries semver weight.
26
29
  */
27
- export { hashContract, buildVariant } from './contract-hash.js';
28
- export type { LLMCaller, LLMCallerConfig, ToolSchema } from './llm-caller.js';
29
- export { llmRerankJudge, rerankCandidates } from './llm-rerank.js';
30
+ export type { LLMCaller, LLMCallerConfig, Metered, TokenUsage, ToolSchema } from './llm-caller.js';
31
+ export { llmRerankJudge, rerankCandidates, RERANK_SYSTEM_PROMPT } from './llm-rerank.js';
30
32
  export type { RerankCandidate, RerankDecision, RerankJudge, RerankQuery, } from './llm-rerank.js';
31
33
  export { synthesizeContract } from './synthesize-contract.js';
32
34
  export type { SynthesizeContractResult } from './synthesize-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,cAAc,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACnE,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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,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
@@ -8,8 +8,11 @@
8
8
  * handshake always returns a valid contract;
9
9
  * - judges blueprint-match candidates for reuse (`rerankCandidates`);
10
10
  * - validates contract structure + novelty (`contract-validators`);
11
- * - hashes contracts into identity + variant keys (`hashContract`,
12
- * `buildVariant`) and normalizes untrusted drafts (`normalizeDraft`).
11
+ * - normalizes untrusted drafts (`normalizeDraft`).
12
+ *
13
+ * Contract identity is not this package's: the cache key is
14
+ * `blueprintKey(contract)` and the variant key is `variantKey(variance)`,
15
+ * both from `@ggui-ai/protocol/blueprint-key`.
13
16
  *
14
17
  * The HANDSHAKE DECISION itself (find-similar → reuse vs synth-create)
15
18
  * lives in the shared `decideHandshake` core in
@@ -24,8 +27,7 @@
24
27
  * This barrel stays narrow — it exports only the minimum surface
25
28
  * consumers need. Each additive export carries semver weight.
26
29
  */
27
- export { hashContract, buildVariant } from './contract-hash.js';
28
- export { llmRerankJudge, rerankCandidates } from './llm-rerank.js';
30
+ export { llmRerankJudge, rerankCandidates, RERANK_SYSTEM_PROMPT } from './llm-rerank.js';
29
31
  export { synthesizeContract } from './synthesize-contract.js';
30
32
  export { ensureConformingContract } from './ensure-conforming-contract.js';
31
33
  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,4 +1,4 @@
1
- import type { LLMCaller, ToolSchema } from './llm-caller.js';
1
+ import type { LLMCaller, TokenUsage, ToolSchema } from './llm-caller.js';
2
2
  /**
3
3
  * One candidate blueprint for the LLM judge to consider.
4
4
  *
@@ -53,15 +53,14 @@ export interface RerankDecision {
53
53
  /** Wall-clock latency of the LLM call. */
54
54
  readonly latencyMs: number;
55
55
  /**
56
- * Token cost of the call — for the cache-trace sink and cost
57
- * accounting. Implementations that can't surface token counts may
58
- * report `{input: 0, output: 0}` and the cost-per-call gate will
59
- * 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).
60
62
  */
61
- readonly tokenCost: {
62
- readonly input: number;
63
- readonly output: number;
64
- };
63
+ readonly tokenCost?: TokenUsage;
65
64
  }
66
65
  /** Query the user's request the judge is matching against. */
67
66
  export interface RerankQuery {
@@ -1 +1 @@
1
- {"version":3,"file":"llm-rerank.d.ts","sourceRoot":"","sources":["../src/llm-rerank.ts"],"names":[],"mappings":"AAeA,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;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,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;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,CAwDzB;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
+ {"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"}
@@ -137,7 +137,8 @@ export async function rerankCandidates(deps, query, candidates) {
137
137
  }
138
138
  const userMessage = buildUserMessage(query, candidates);
139
139
  const candidateIds = new Set(candidates.map((c) => c.id));
140
- if (typeof deps.llm.callStructured !== 'function') {
140
+ const { callStructured, callStructuredMetered } = deps.llm;
141
+ if (typeof callStructuredMetered !== 'function' && typeof callStructured !== 'function') {
141
142
  return {
142
143
  matchId: null,
143
144
  confidence: 0,
@@ -146,9 +147,19 @@ export async function rerankCandidates(deps, query, candidates) {
146
147
  tokenCost: { input: 0, output: 0 },
147
148
  };
148
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).
149
152
  let toolInput;
153
+ let usage;
150
154
  try {
151
- 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
+ }
152
163
  }
153
164
  catch (err) {
154
165
  const message = err instanceof Error ? err.message : String(err);
@@ -157,7 +168,6 @@ export async function rerankCandidates(deps, query, candidates) {
157
168
  confidence: 0,
158
169
  reason: `llm-rerank: callStructured threw — ${message}`,
159
170
  latencyMs: Date.now() - startedAt,
160
- tokenCost: { input: 0, output: 0 },
161
171
  };
162
172
  }
163
173
  const parsed = parseToolInput(toolInput, candidateIds);
@@ -166,10 +176,7 @@ export async function rerankCandidates(deps, query, candidates) {
166
176
  confidence: parsed.confidence,
167
177
  reason: parsed.reason,
168
178
  latencyMs: Date.now() - startedAt,
169
- // Token cost surfacing requires LLMCaller-level instrumentation
170
- // we don't have today. Default to zero; the cost gate is measured
171
- // out-of-band from billing data during the probe.
172
- tokenCost: { input: 0, output: 0 },
179
+ ...(usage !== undefined ? { tokenCost: usage } : {}),
173
180
  };
174
181
  }
175
182
  /**
@@ -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
  }