@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 +9 -2
- package/dist/ensure-conforming-contract.d.ts +14 -1
- package/dist/ensure-conforming-contract.d.ts.map +1 -1
- package/dist/ensure-conforming-contract.js +45 -6
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/llm-caller.d.ts +26 -0
- package/dist/llm-caller.d.ts.map +1 -1
- package/dist/llm-caller.js +9 -0
- package/dist/llm-rerank.d.ts +8 -9
- package/dist/llm-rerank.d.ts.map +1 -1
- package/dist/llm-rerank.js +14 -7
- package/dist/normalize-draft.d.ts +20 -0
- package/dist/normalize-draft.d.ts.map +1 -1
- package/dist/normalize-draft.js +63 -39
- package/dist/preserve-declared-members.d.ts +89 -0
- package/dist/preserve-declared-members.d.ts.map +1 -0
- package/dist/preserve-declared-members.js +427 -0
- package/dist/rerank-eval/run-probe.js +1 -1
- package/dist/synthesize-contract.d.ts +11 -0
- package/dist/synthesize-contract.d.ts.map +1 -1
- package/dist/synthesize-contract.js +35 -3
- package/package.json +3 -3
- package/src/ensure-conforming-contract.ts +60 -7
- package/src/index.ts +7 -5
- package/src/llm-caller.ts +34 -0
- package/src/llm-rerank.ts +21 -18
- package/src/normalize-draft.ts +70 -39
- package/src/preserve-declared-members.ts +521 -0
- package/src/rerank-eval/run-probe.ts +1 -1
- package/src/synthesize-contract.ts +54 -4
- package/dist/contract-hash.d.ts +0 -54
- package/dist/contract-hash.d.ts.map +0 -1
- package/dist/contract-hash.js +0 -96
- package/src/contract-hash.ts +0 -102
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
import { dataContractSchema, gadgetExportName, } from '@ggui-ai/protocol';
|
|
40
40
|
import { lintContract } from '@ggui-ai/protocol';
|
|
41
41
|
import { normalizeSchema } from './normalize-schema.js';
|
|
42
|
+
import { findDroppedEntries, isOnSurvivingEntry, REPAIR_ENTRY_DROPPED, restoreDraftDeclaredMembers } from './preserve-declared-members.js';
|
|
42
43
|
import { draftSeedPropKeys, findDroppedSeedSurfaces, } from './preserve-seed-surfaces.js';
|
|
43
44
|
import { formatValidationFindings, validateActionsVsContext, validateContractCoherence, validateContractRedundancy, } from './contract-validators.js';
|
|
44
45
|
/**
|
|
@@ -154,7 +155,7 @@ CONCRETE PATTERNS
|
|
|
154
155
|
(a) Agent-owned — "show my todos", "an agent-backed todo list that persists across sessions", "render my cart", "the messages in this thread", "the directory contents"
|
|
155
156
|
The AGENT owns the items: it fetched / persists / keeps them in sync. The collection is the agent's data → it goes on PROPSSPEC, seeded at render and refreshed via ggui_amend after each change. This is the ONLY shape that round-trips — contextSpec has no agent-push channel, so an agent-owned list placed there can never be seeded or updated (the UI renders empty). add / delete / toggle are discrete events the agent must witness to persist → declare them on actionSpec (with a matching agentCapabilities tool for each nextStep). Mutability is fine: ggui_amend is exactly how the agent pushes the change.
|
|
156
157
|
propsSpec: { properties: { todos: {schema: {type: "array", items: {type: "object", properties: {id: {type: "string"}, text: {type: "string"}, done: {type: "boolean"}}, required: ["id", "text", "done"]}}, required: true} } }
|
|
157
|
-
actionSpec: { toggleTodo: {label: "Toggle todo", schema: {type: "object", properties: {id: {type: "string"}}, required: ["id"]}
|
|
158
|
+
actionSpec: { toggleTodo: {label: "Toggle todo", schema: {type: "object", properties: {id: {type: "string"}}, required: ["id"]}}, addTodo: {label: "Add todo", schema: {type: "object", properties: {text: {type: "string"}}, required: ["text"]}} }
|
|
158
159
|
|
|
159
160
|
(b) User-built local — "a todo list where I can add and remove items", "a shopping list", "a checklist I tick off"
|
|
160
161
|
No agent-owned source: the USER assembles the list in the UI and the agent merely observes it. The items are client-originated state → a CONTEXTSPEC slot; OMIT actionSpec (the slot mirror IS the wire — the agent already sees every change). Only when the intent says the agent must persist / sync each change does it become the agent-owned case (a) above.
|
|
@@ -460,6 +461,7 @@ const REPAIR_PREAMBLE = `PATCH MODE — you are REPAIRING a contract the agent a
|
|
|
460
461
|
- PRESERVE every spec the agent declared — ESPECIALLY every propsSpec property (agent-owned render-time seed data the UI needs). Never drop it.
|
|
461
462
|
- Do NOT move data between specs (e.g. propsSpec → contextSpec) unless a finding explicitly requires it. A collection the agent supplies on propsSpec STAYS on propsSpec.
|
|
462
463
|
- Keep the agent's names, shapes, and structure intact wherever the findings do not force a change.
|
|
464
|
+
- KEEP the agent's TOOLBOX: every agentCapabilities.tools entry the draft declares, and every actionSpec[X].nextStep that names one of them. The rules below about tools (declare them only behind a streamSpec source; emit no nextStep) govern only what YOU synthesize from scratch — they never remove a tool or a nextStep the agent declared.
|
|
463
465
|
The four-spec model and placement rules below still hold — but in PATCH MODE they are guardrails for the fix, not a license to re-author.`;
|
|
464
466
|
/**
|
|
465
467
|
* Preservation-failure repair note (L1) — drives a corrective retry when
|
|
@@ -674,6 +676,7 @@ export async function synthesizeContract(deps, intent, options) {
|
|
|
674
676
|
latencyMs: Date.now() - startedAt,
|
|
675
677
|
attempts: 0,
|
|
676
678
|
findings: [],
|
|
679
|
+
dropped: [],
|
|
677
680
|
};
|
|
678
681
|
}
|
|
679
682
|
const gadgetsSection = composeAvailableGadgetsSection(options?.appGadgets);
|
|
@@ -706,6 +709,10 @@ export async function synthesizeContract(deps, intent, options) {
|
|
|
706
709
|
// preservation never makes the result WORSE than the validity-only gate.
|
|
707
710
|
const draftSeedKeys = options?.draft !== undefined ? draftSeedPropKeys(options.draft) : [];
|
|
708
711
|
let lastValidContract = null;
|
|
712
|
+
/** The paths the draft's own findings name — members under repair are not restored. */
|
|
713
|
+
const underRepair = new Set((options?.draftFindings ?? []).map((f) => f.path));
|
|
714
|
+
/** The member drops of the attempt that produced `lastValidContract`. */
|
|
715
|
+
let lastValidDropped = [];
|
|
709
716
|
for (let attempt = 1; attempt <= MAX_SYNTH_ATTEMPTS; attempt++) {
|
|
710
717
|
const userPrompt = repairNote === undefined
|
|
711
718
|
? baseUserPrompt
|
|
@@ -736,8 +743,14 @@ export async function synthesizeContract(deps, intent, options) {
|
|
|
736
743
|
continue;
|
|
737
744
|
}
|
|
738
745
|
// `buildContract` normalizes every emitted schema (invalid `type`
|
|
739
|
-
// spellings → canonical JSON Schema) before the gate sees it.
|
|
740
|
-
|
|
746
|
+
// spellings → canonical JSON Schema) before the gate sees it. On a
|
|
747
|
+
// repair-in-place it authors only a few members per entry; the agent's
|
|
748
|
+
// DECLARED members (`oneShot` above all) are put back from the draft
|
|
749
|
+
// deterministically, on every spec, and so is any tool the repair
|
|
750
|
+
// dropped. Anything the merged tree cannot carry is named rather than
|
|
751
|
+
// lost (ggui#1421, ggui#1430).
|
|
752
|
+
const restored = restoreDraftDeclaredMembers(options?.draft, buildContract(parsed), underRepair);
|
|
753
|
+
const contract = restored.contract;
|
|
741
754
|
// Defensive gate: re-validate the assembled contract against the
|
|
742
755
|
// canonical schema. Catches LLM outputs that pass the loose tool
|
|
743
756
|
// input_schema but produce structurally invalid contracts. Without
|
|
@@ -797,6 +810,7 @@ export async function synthesizeContract(deps, intent, options) {
|
|
|
797
810
|
// Remember the best VALID candidate so an exhausted budget never
|
|
798
811
|
// returns WORSE than the validity-only gate did (a valid contract).
|
|
799
812
|
lastValidContract = validatedContract;
|
|
813
|
+
lastValidDropped = restored.dropped;
|
|
800
814
|
lastReason = `synthesize-preservation: candidate dropped agent-owned propsSpec seed surface(s) [${dropped.join(', ')}]`;
|
|
801
815
|
lastFindings = allFindings;
|
|
802
816
|
repairNote = buildPreservationRepairNote(validatedContract, dropped);
|
|
@@ -813,6 +827,7 @@ export async function synthesizeContract(deps, intent, options) {
|
|
|
813
827
|
latencyMs: Date.now() - startedAt,
|
|
814
828
|
attempts: attempt,
|
|
815
829
|
findings: allFindings,
|
|
830
|
+
dropped: droppedFor(restored.dropped, options?.draft, validatedContract, underRepair),
|
|
816
831
|
};
|
|
817
832
|
}
|
|
818
833
|
// Budget exhausted. If preservation retries never landed a contract
|
|
@@ -827,8 +842,25 @@ export async function synthesizeContract(deps, intent, options) {
|
|
|
827
842
|
latencyMs: Date.now() - startedAt,
|
|
828
843
|
attempts: MAX_SYNTH_ATTEMPTS,
|
|
829
844
|
findings: lastFindings,
|
|
845
|
+
dropped: lastValidContract !== null ? droppedFor(lastValidDropped, options?.draft, lastValidContract, underRepair) : [],
|
|
830
846
|
};
|
|
831
847
|
}
|
|
848
|
+
/**
|
|
849
|
+
* What a repair could not keep, or put back, against the contract it finally
|
|
850
|
+
* returns: the accepted attempt's member drops and restored tools, minus
|
|
851
|
+
* those on an entry the final contract no longer carries (the entry finding
|
|
852
|
+
* names that loss once), plus one entry finding per draft entry, on every
|
|
853
|
+
* spec, that the final contract no longer carries under its key. Where the
|
|
854
|
+
* overlay itself named an entry it had to un-restore, its finding (which
|
|
855
|
+
* carries the gate's reason) stands for that entry.
|
|
856
|
+
*/
|
|
857
|
+
function droppedFor(overlayFindings, draft, finalContract, underRepair) {
|
|
858
|
+
const overlayEntryDrops = new Map(overlayFindings.filter((f) => f.code === REPAIR_ENTRY_DROPPED).map((f) => [f.path, f]));
|
|
859
|
+
return [
|
|
860
|
+
...overlayFindings.filter((f) => f.code !== REPAIR_ENTRY_DROPPED && isOnSurvivingEntry(f, finalContract)),
|
|
861
|
+
...findDroppedEntries(draft, finalContract, underRepair).map((f) => overlayEntryDrops.get(f.path) ?? f),
|
|
862
|
+
];
|
|
863
|
+
}
|
|
832
864
|
function parseToolInput(raw) {
|
|
833
865
|
if (typeof raw !== 'object' || raw === null)
|
|
834
866
|
return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ggui-ai/negotiator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Contract-synthesis + match-judge engine for ggui's handshake. Synthesizes or repairs a conforming DataContract from an agent's draft, judges blueprint-match candidates for reuse, and validates contract structure + novelty — the primitives composed by decideHandshake in @ggui-ai/mcp-server-handlers. Deployment-agnostic: concrete embedding and vector-store bindings plug in via the storage interfaces from @ggui-ai/mcp-server-core.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"keywords": [
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
}
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@ggui-ai/mcp-server-core": "0.
|
|
51
|
-
"@ggui-ai/protocol": "0.
|
|
50
|
+
"@ggui-ai/mcp-server-core": "0.26.0",
|
|
51
|
+
"@ggui-ai/protocol": "0.26.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@types/node": "^24.0.0",
|
|
@@ -42,8 +42,9 @@ import {
|
|
|
42
42
|
} from '@ggui-ai/protocol';
|
|
43
43
|
import type { LLMCaller } from './llm-caller.js';
|
|
44
44
|
import { synthesizeContract } from './synthesize-contract.js';
|
|
45
|
-
import { normalizeDraft } from './normalize-draft.js';
|
|
45
|
+
import { liftedRequiredNames, normalizeDraft } from './normalize-draft.js';
|
|
46
46
|
import { salvageConformingSubset } from './salvage-draft.js';
|
|
47
|
+
import { REPAIR_ENTRY_RESTORED } from './preserve-declared-members.js';
|
|
47
48
|
|
|
48
49
|
/**
|
|
49
50
|
* How a conforming contract was produced — finer-grained than `origin`,
|
|
@@ -77,7 +78,20 @@ export interface EnsureConformingAccepted {
|
|
|
77
78
|
* warnings on the (valid) draft. On `origin: 'synth'`, the ERROR
|
|
78
79
|
* findings that rejected the agent's draft — so the agent-side model
|
|
79
80
|
* learns what it got wrong, even though we repaired it. On
|
|
80
|
-
* `
|
|
81
|
+
* `llm-repair` they additionally carry one `REPAIR_MEMBER_DROPPED` per
|
|
82
|
+
* declared member the repair could not keep, on any spec, one
|
|
83
|
+
* `REPAIR_ENTRY_DROPPED` per draft entry it no longer carries under its
|
|
84
|
+
* key, and one `REPAIR_ENTRY_RESTORED` (`warn`) per draft tool the repair
|
|
85
|
+
* dropped and the overlay put back (ggui#1421, ggui#1430 — a repair
|
|
86
|
+
* preserves the draft's declarations, `oneShot` above all, and names any
|
|
87
|
+
* it must drop, each at its own path with the gate's reason in the
|
|
88
|
+
* message; a member under repair is named by the gate's own finding at
|
|
89
|
+
* that same path).
|
|
90
|
+
* The error findings are the union of what the gate refused on the
|
|
91
|
+
* raw draft and on its normalized form (the gate stops after the
|
|
92
|
+
* shape phase, so the raw lint alone can hide a semantic finding). On
|
|
93
|
+
* `salvaged-subset` they include one finding per dropped entry, with
|
|
94
|
+
* the gate's own code at the cut path.
|
|
81
95
|
*/
|
|
82
96
|
readonly findings: readonly SuggestionFinding[];
|
|
83
97
|
/** Operator- + LLM-readable explanation. */
|
|
@@ -137,12 +151,25 @@ export async function ensureConformingContract(
|
|
|
137
151
|
};
|
|
138
152
|
}
|
|
139
153
|
|
|
154
|
+
// ggui#1454 — a wrapper-level `propsSpec.required: [...]` is refused by the
|
|
155
|
+
// gate as an unrecognized key and LIFTED by `normalizeDraft` onto the named
|
|
156
|
+
// entries (ggui#1432). The finding stays (the draft used a non-wire shape,
|
|
157
|
+
// and the agent should see that); its message says the key was honoured,
|
|
158
|
+
// so it reads as a repair, not a refusal to fix and re-handshake for.
|
|
159
|
+
const lifted = liftedRequiredNames(args.draft);
|
|
160
|
+
const liftNote =
|
|
161
|
+
lifted.length > 0
|
|
162
|
+
? ` — the wrapper-level \`required\` is not a wire key; it was lifted into ${lifted
|
|
163
|
+
.map((name) => `propsSpec.properties.${name}.required = true`)
|
|
164
|
+
.join(', ')} (repaired, not refused)`
|
|
165
|
+
: '';
|
|
140
166
|
const errorFindings: SuggestionFinding[] = lint.errors.map(
|
|
141
167
|
(e): SuggestionFinding => ({
|
|
142
168
|
code: e.code,
|
|
143
169
|
severity: 'error',
|
|
144
170
|
path: e.path,
|
|
145
|
-
message:
|
|
171
|
+
message:
|
|
172
|
+
e.code === 'CTR_SHAPE_UNRECOGNIZED_KEYS' && e.path === 'propsSpec' ? `${e.message}${liftNote}` : e.message,
|
|
146
173
|
}),
|
|
147
174
|
);
|
|
148
175
|
|
|
@@ -154,6 +181,23 @@ export async function ensureConformingContract(
|
|
|
154
181
|
// LLM call). Semantic deficiencies fall through to the repair loop.
|
|
155
182
|
const normalized = normalizeDraft(args.draft);
|
|
156
183
|
const normLint = lintContract(normalized);
|
|
184
|
+
// The gate stops after the shape phase, so the RAW draft's lint may
|
|
185
|
+
// name only its mechanical errors while the NORMALIZED draft's lint
|
|
186
|
+
// reaches the semantic ones (a dangling `nextStep`). Every path the
|
|
187
|
+
// gate refused on either tree is the agent's to see (ggui#1421): the
|
|
188
|
+
// union, raw first, one finding per (code, path).
|
|
189
|
+
const gateFindings: SuggestionFinding[] = [...errorFindings];
|
|
190
|
+
for (const e of normLint.errors) {
|
|
191
|
+
if (!gateFindings.some((f) => f.code === e.code && f.path === e.path)) {
|
|
192
|
+
gateFindings.push({ code: e.code, severity: 'error', path: e.path, message: e.message });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** `gateFindings` plus `more`, one finding per (code, path) — a drop the gate already named is not named twice. */
|
|
196
|
+
const withGateFindings = (more: readonly SuggestionFinding[]): SuggestionFinding[] => {
|
|
197
|
+
const out = [...gateFindings];
|
|
198
|
+
for (const f of more) if (!out.some((g) => g.code === f.code && g.path === f.path)) out.push(f);
|
|
199
|
+
return out;
|
|
200
|
+
};
|
|
157
201
|
if (normLint.errors.length === 0) {
|
|
158
202
|
return {
|
|
159
203
|
contract: dataContractSchema.parse(normalized),
|
|
@@ -182,12 +226,21 @@ export async function ensureConformingContract(
|
|
|
182
226
|
synth.contract !== null &&
|
|
183
227
|
lintContract(synth.contract).errors.length === 0
|
|
184
228
|
) {
|
|
229
|
+
const droppedPaths = synth.dropped.filter((d) => d.code !== REPAIR_ENTRY_RESTORED).map((d) => d.path);
|
|
230
|
+
const restoredPaths = synth.dropped.filter((d) => d.code === REPAIR_ENTRY_RESTORED).map((d) => d.path);
|
|
185
231
|
return {
|
|
186
232
|
contract: synth.contract,
|
|
187
233
|
origin: 'synth',
|
|
188
234
|
method: 'llm-repair',
|
|
189
|
-
findings:
|
|
190
|
-
reasoning:
|
|
235
|
+
findings: withGateFindings(synth.dropped),
|
|
236
|
+
reasoning:
|
|
237
|
+
`repaired the agent draft to pass validateContract — ${synth.reason}` +
|
|
238
|
+
(droppedPaths.length > 0
|
|
239
|
+
? `; dropped ${droppedPaths.length} declared ${droppedPaths.length === 1 ? 'member/entry' : 'members/entries'} the repair could not keep (${droppedPaths.join(', ')}) — each is a finding`
|
|
240
|
+
: '') +
|
|
241
|
+
(restoredPaths.length > 0
|
|
242
|
+
? `; restored ${restoredPaths.length} declared ${restoredPaths.length === 1 ? 'tool' : 'tools'} the repair dropped (${restoredPaths.join(', ')}) — each is a finding`
|
|
243
|
+
: ''),
|
|
191
244
|
};
|
|
192
245
|
}
|
|
193
246
|
|
|
@@ -203,7 +256,7 @@ export async function ensureConformingContract(
|
|
|
203
256
|
contract: salvaged.contract,
|
|
204
257
|
origin: 'synth',
|
|
205
258
|
method: 'salvaged-subset',
|
|
206
|
-
findings:
|
|
259
|
+
findings: withGateFindings(salvaged.dropped),
|
|
207
260
|
reasoning:
|
|
208
261
|
`could not repair the agent draft within budget (${synth.reason}); ` +
|
|
209
262
|
`proposing the conforming SUBSET of your draft — dropped ${droppedPaths.length} ` +
|
|
@@ -219,7 +272,7 @@ export async function ensureConformingContract(
|
|
|
219
272
|
contract: null,
|
|
220
273
|
origin: 'agent',
|
|
221
274
|
method: 'declined',
|
|
222
|
-
findings:
|
|
275
|
+
findings: gateFindings,
|
|
223
276
|
reasoning:
|
|
224
277
|
`declined: could not repair the agent draft within budget (${synth.reason}) and no entry of it ` +
|
|
225
278
|
`passes the contract gate — nothing to propose. Fix the findings (every one names its path) ` +
|
package/src/index.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
|
-
* -
|
|
12
|
-
*
|
|
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
|
|
@@ -25,9 +28,8 @@
|
|
|
25
28
|
* consumers need. Each additive export carries semver weight.
|
|
26
29
|
*/
|
|
27
30
|
|
|
28
|
-
export {
|
|
29
|
-
export
|
|
30
|
-
export { llmRerankJudge, rerankCandidates } from './llm-rerank.js';
|
|
31
|
+
export type { LLMCaller, LLMCallerConfig, Metered, TokenUsage, ToolSchema } from './llm-caller.js';
|
|
32
|
+
export { llmRerankJudge, rerankCandidates, RERANK_SYSTEM_PROMPT } from './llm-rerank.js';
|
|
31
33
|
export type {
|
|
32
34
|
RerankCandidate,
|
|
33
35
|
RerankDecision,
|
package/src/llm-caller.ts
CHANGED
|
@@ -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
|
|
@@ -48,6 +57,18 @@ export interface ToolSchema {
|
|
|
48
57
|
input_schema: Record<string, unknown>;
|
|
49
58
|
}
|
|
50
59
|
|
|
60
|
+
/** Tokens one provider call consumed, as the provider reported them. */
|
|
61
|
+
export interface TokenUsage {
|
|
62
|
+
readonly input: number;
|
|
63
|
+
readonly output: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A call's result with the usage the provider reported for it; `usage` absent = unmetered. */
|
|
67
|
+
export interface Metered<T> {
|
|
68
|
+
readonly value: T;
|
|
69
|
+
readonly usage?: TokenUsage;
|
|
70
|
+
}
|
|
71
|
+
|
|
51
72
|
/** Chat-model dispatcher consumed by the negotiator's synthesis + judge primitives. */
|
|
52
73
|
export interface LLMCaller {
|
|
53
74
|
/**
|
|
@@ -74,6 +95,19 @@ export interface LLMCaller {
|
|
|
74
95
|
tool: ToolSchema,
|
|
75
96
|
maxTokens?: number,
|
|
76
97
|
): Promise<unknown>;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* {@link callStructured}, with the call's token usage beside the tool
|
|
101
|
+
* input (see the normative semantics above). Omit it when the provider's
|
|
102
|
+
* usage cannot be read; consumers then fall back to `callStructured` and
|
|
103
|
+
* treat the call as unmetered.
|
|
104
|
+
*/
|
|
105
|
+
callStructuredMetered?(
|
|
106
|
+
systemPrompt: string,
|
|
107
|
+
userMessage: string,
|
|
108
|
+
tool: ToolSchema,
|
|
109
|
+
maxTokens?: number,
|
|
110
|
+
): Promise<Metered<unknown>>;
|
|
77
111
|
}
|
|
78
112
|
|
|
79
113
|
/**
|
package/src/llm-rerank.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* realistic workloads observe 30-70%.
|
|
14
14
|
*/
|
|
15
15
|
import { MATCHED_INTENT_MAX_CHARS } from '@ggui-ai/protocol';
|
|
16
|
-
import type { LLMCaller, ToolSchema } from './llm-caller.js';
|
|
16
|
+
import type { LLMCaller, TokenUsage, ToolSchema } from './llm-caller.js';
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* One candidate blueprint for the LLM judge to consider.
|
|
@@ -70,12 +70,14 @@ export interface RerankDecision {
|
|
|
70
70
|
/** Wall-clock latency of the LLM call. */
|
|
71
71
|
readonly latencyMs: number;
|
|
72
72
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
73
|
+
* Tokens the decision cost, as the provider reported them — for the
|
|
74
|
+
* cache-trace sink and cost accounting. ABSENT means unmetered, never
|
|
75
|
+
* zero: the judge called a provider and has no count for the call (a
|
|
76
|
+
* caller without `callStructuredMetered`, a provider that reported no
|
|
77
|
+
* usage, or a call that threw). `{ input: 0, output: 0 }` is a true
|
|
78
|
+
* zero: the decision was made without a provider call (ggui#1418).
|
|
77
79
|
*/
|
|
78
|
-
readonly tokenCost
|
|
80
|
+
readonly tokenCost?: TokenUsage;
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
/** Query the user's request the judge is matching against. */
|
|
@@ -235,7 +237,8 @@ export async function rerankCandidates(
|
|
|
235
237
|
const userMessage = buildUserMessage(query, candidates);
|
|
236
238
|
const candidateIds = new Set(candidates.map((c) => c.id));
|
|
237
239
|
|
|
238
|
-
|
|
240
|
+
const { callStructured, callStructuredMetered } = deps.llm;
|
|
241
|
+
if (typeof callStructuredMetered !== 'function' && typeof callStructured !== 'function') {
|
|
239
242
|
return {
|
|
240
243
|
matchId: null,
|
|
241
244
|
confidence: 0,
|
|
@@ -246,14 +249,18 @@ export async function rerankCandidates(
|
|
|
246
249
|
};
|
|
247
250
|
}
|
|
248
251
|
|
|
252
|
+
// The metered method when the caller has it (its usage is the decision's
|
|
253
|
+
// cost), else the plain one (unmetered: no tokenCost).
|
|
249
254
|
let toolInput: unknown;
|
|
255
|
+
let usage: TokenUsage | undefined;
|
|
250
256
|
try {
|
|
251
|
-
|
|
252
|
-
RERANK_SYSTEM_PROMPT,
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
+
if (typeof callStructuredMetered === 'function') {
|
|
258
|
+
const metered = await callStructuredMetered.call(deps.llm, RERANK_SYSTEM_PROMPT, userMessage, RERANK_TOOL, 512);
|
|
259
|
+
toolInput = metered.value;
|
|
260
|
+
usage = metered.usage;
|
|
261
|
+
} else if (typeof callStructured === 'function') {
|
|
262
|
+
toolInput = await callStructured.call(deps.llm, RERANK_SYSTEM_PROMPT, userMessage, RERANK_TOOL, 512);
|
|
263
|
+
}
|
|
257
264
|
} catch (err) {
|
|
258
265
|
const message = err instanceof Error ? err.message : String(err);
|
|
259
266
|
return {
|
|
@@ -261,7 +268,6 @@ export async function rerankCandidates(
|
|
|
261
268
|
confidence: 0,
|
|
262
269
|
reason: `llm-rerank: callStructured threw — ${message}`,
|
|
263
270
|
latencyMs: Date.now() - startedAt,
|
|
264
|
-
tokenCost: { input: 0, output: 0 },
|
|
265
271
|
};
|
|
266
272
|
}
|
|
267
273
|
|
|
@@ -271,10 +277,7 @@ export async function rerankCandidates(
|
|
|
271
277
|
confidence: parsed.confidence,
|
|
272
278
|
reason: parsed.reason,
|
|
273
279
|
latencyMs: Date.now() - startedAt,
|
|
274
|
-
|
|
275
|
-
// we don't have today. Default to zero; the cost gate is measured
|
|
276
|
-
// out-of-band from billing data during the probe.
|
|
277
|
-
tokenCost: { input: 0, output: 0 },
|
|
280
|
+
...(usage !== undefined ? { tokenCost: usage } : {}),
|
|
278
281
|
};
|
|
279
282
|
}
|
|
280
283
|
|
package/src/normalize-draft.ts
CHANGED
|
@@ -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}
|
|
@@ -23,44 +29,33 @@
|
|
|
23
29
|
* repair loop, where reasoning earns its keep.
|
|
24
30
|
*/
|
|
25
31
|
|
|
32
|
+
import {
|
|
33
|
+
actionEntrySchema,
|
|
34
|
+
agentToolEntrySchema,
|
|
35
|
+
contextEntrySchema,
|
|
36
|
+
propEntrySchema,
|
|
37
|
+
propsSpecSchema,
|
|
38
|
+
streamChannelEntrySchema,
|
|
39
|
+
} from '@ggui-ai/protocol';
|
|
26
40
|
import { normalizeSchema } from './normalize-schema.js';
|
|
27
41
|
|
|
28
|
-
// Allowed-key sets
|
|
29
|
-
// (schemas/data-contract.ts)
|
|
30
|
-
// the
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
'description',
|
|
42
|
-
'schema',
|
|
43
|
-
'default',
|
|
44
|
-
'debounceMs',
|
|
45
|
-
'example',
|
|
46
|
-
]);
|
|
47
|
-
const ACTION_ENTRY_KEYS = new Set([
|
|
48
|
-
'description',
|
|
49
|
-
'label',
|
|
50
|
-
'schema',
|
|
51
|
-
'example',
|
|
52
|
-
'icon',
|
|
53
|
-
'confirm',
|
|
54
|
-
'nextStep',
|
|
55
|
-
]);
|
|
56
|
-
const STREAM_ENTRY_KEYS = new Set(['description', 'schema', 'source']);
|
|
57
|
-
const AGENT_TOOL_KEYS = new Set(['serverInfo', 'toolInfo', 'usage', 'example']);
|
|
42
|
+
// Allowed-key sets are DERIVED from the protocol's `.strict()` spec
|
|
43
|
+
// schemas (schemas/data-contract.ts) — never hand-copied. A hand-copied
|
|
44
|
+
// mirror drifts the day the schema grows: `oneShot` joined
|
|
45
|
+
// `actionEntrySchema` on 2026-09-15 (ggui#1108) and a stale mirror here
|
|
46
|
+
// stripped it from every repaired draft for eleven days (ggui#1421).
|
|
47
|
+
// Stripping anything outside these sets is safe: the strict schema
|
|
48
|
+
// would reject it as CTR_SHAPE_UNRECOGNIZED_KEYS.
|
|
49
|
+
const PROPS_WRAPPER_KEYS: ReadonlySet<string> = new Set(Object.keys(propsSpecSchema.shape));
|
|
50
|
+
const PROP_ENTRY_KEYS: ReadonlySet<string> = new Set(Object.keys(propEntrySchema.shape));
|
|
51
|
+
const CONTEXT_ENTRY_KEYS: ReadonlySet<string> = new Set(Object.keys(contextEntrySchema.shape));
|
|
52
|
+
const ACTION_ENTRY_KEYS: ReadonlySet<string> = new Set(Object.keys(actionEntrySchema.shape));
|
|
53
|
+
const STREAM_ENTRY_KEYS: ReadonlySet<string> = new Set(Object.keys(streamChannelEntrySchema.shape));
|
|
54
|
+
const AGENT_TOOL_KEYS: ReadonlySet<string> = new Set(Object.keys(agentToolEntrySchema.shape));
|
|
58
55
|
/** Inner keys of an {@link AgentToolEntry.toolInfo} (the MCP descriptor). */
|
|
59
|
-
const AGENT_TOOL_INFO_KEYS = new Set(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
'outputSchema',
|
|
63
|
-
]);
|
|
56
|
+
const AGENT_TOOL_INFO_KEYS: ReadonlySet<string> = new Set(
|
|
57
|
+
Object.keys(agentToolEntrySchema.shape.toolInfo.shape),
|
|
58
|
+
);
|
|
64
59
|
|
|
65
60
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
66
61
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -135,20 +130,56 @@ function cleanAgentToolMap(
|
|
|
135
130
|
* (the top-level DataContract schema is `.passthrough()`); only the
|
|
136
131
|
* `.strict()` spec wrappers and entries are cleaned.
|
|
137
132
|
*/
|
|
133
|
+
/**
|
|
134
|
+
* The prop names a wrapper-level `propsSpec.required: [...]` will be
|
|
135
|
+
* lifted onto by {@link normalizeDraft} (ggui#1432): each listed name that
|
|
136
|
+
* has an entry and whose entry declares no `required` of its own, in the
|
|
137
|
+
* wrapper's order and de-duplicated. A ghost name (no entry) and an entry
|
|
138
|
+
* with its own word are skipped, exactly as the lift skips them. Empty when
|
|
139
|
+
* nothing lifts.
|
|
140
|
+
*
|
|
141
|
+
* One source of truth for the lift and for the finding that reports it
|
|
142
|
+
* (ggui#1454): the gate's `CTR_SHAPE_UNRECOGNIZED_KEYS` at `propsSpec` is
|
|
143
|
+
* a repair here, not a refusal, and its message says which entries gained
|
|
144
|
+
* `required: true`.
|
|
145
|
+
*/
|
|
146
|
+
export function liftedRequiredNames(draft: unknown): readonly string[] {
|
|
147
|
+
if (!isRecord(draft) || !isRecord(draft['propsSpec'])) return [];
|
|
148
|
+
const wrapper = draft['propsSpec'];
|
|
149
|
+
if (!Array.isArray(wrapper['required']) || !isRecord(wrapper['properties'])) return [];
|
|
150
|
+
const entries = wrapper['properties'];
|
|
151
|
+
// De-duplicated: a name the wrapper lists twice lifts once and is reported once.
|
|
152
|
+
return [...new Set(wrapper['required'].filter((name): name is string => typeof name === 'string'))].filter((name) => {
|
|
153
|
+
const entry = entries[name];
|
|
154
|
+
return isRecord(entry) && entry['required'] === undefined;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
138
158
|
export function normalizeDraft(draft: unknown): unknown {
|
|
139
159
|
if (!isRecord(draft)) return draft;
|
|
140
160
|
const out: Record<string, unknown> = { ...draft };
|
|
141
161
|
|
|
142
162
|
// propsSpec wrapper: keep {description, properties}; clean each PropEntry.
|
|
163
|
+
// A wrapper-level `required: [...]` is the agent's declaration in JSON
|
|
164
|
+
// Schema's spelling — lift it into the listed entries before the key
|
|
165
|
+
// goes, so the served contract still says which props are required.
|
|
143
166
|
if (isRecord(out['propsSpec'])) {
|
|
167
|
+
const wrapper = out['propsSpec'];
|
|
144
168
|
const ps: Record<string, unknown> = {};
|
|
145
|
-
for (const [key, value] of Object.entries(
|
|
169
|
+
for (const [key, value] of Object.entries(wrapper)) {
|
|
146
170
|
if (PROPS_WRAPPER_KEYS.has(key)) ps[key] = value;
|
|
147
171
|
}
|
|
148
172
|
if (isRecord(ps['properties'])) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
173
|
+
const cleaned = cleanEntryMap(ps['properties'], PROP_ENTRY_KEYS, ['schema']);
|
|
174
|
+
// The same selection `liftedRequiredNames` reports (one source of truth);
|
|
175
|
+
// `cleanEntryMap` keeps an entry's own `required`, so the test is the same on
|
|
176
|
+
// the cleaned map as on the raw one.
|
|
177
|
+
for (const name of liftedRequiredNames(draft)) {
|
|
178
|
+
const entry = cleaned[name];
|
|
179
|
+
if (!isRecord(entry)) continue;
|
|
180
|
+
cleaned[name] = { ...entry, required: true };
|
|
181
|
+
}
|
|
182
|
+
ps['properties'] = cleaned;
|
|
152
183
|
}
|
|
153
184
|
out['propsSpec'] = ps;
|
|
154
185
|
}
|