@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 +6 -1
- package/dist/ensure-conforming-contract.d.ts +12 -1
- package/dist/ensure-conforming-contract.d.ts.map +1 -1
- package/dist/ensure-conforming-contract.js +40 -6
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- 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 +26 -25
- package/dist/llm-rerank.d.ts.map +1 -1
- package/dist/llm-rerank.js +42 -9
- 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-action-members.d.ts +79 -0
- package/dist/preserve-action-members.d.ts.map +1 -0
- package/dist/preserve-action-members.js +199 -0
- package/dist/rerank-eval/run-probe-cli.js +4 -1
- 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 +30 -2
- package/package.json +3 -3
- package/src/ensure-conforming-contract.ts +53 -7
- package/src/index.ts +3 -2
- package/src/llm-caller.ts +34 -0
- package/src/llm-rerank.ts +52 -21
- package/src/normalize-draft.ts +70 -39
- package/src/preserve-action-members.ts +224 -0
- package/src/rerank-eval/run-probe-cli.ts +3 -1
- package/src/rerank-eval/run-probe.ts +1 -1
- package/src/synthesize-contract.ts +47 -3
package/src/llm-rerank.ts
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* judge restores precision. Combined break-even hit rate is ~10%;
|
|
13
13
|
* realistic workloads observe 30-70%.
|
|
14
14
|
*/
|
|
15
|
-
import
|
|
15
|
+
import { MATCHED_INTENT_MAX_CHARS } from '@ggui-ai/protocol';
|
|
16
|
+
import type { LLMCaller, TokenUsage, ToolSchema } from './llm-caller.js';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* One candidate blueprint for the LLM judge to consider.
|
|
@@ -60,20 +61,23 @@ export interface RerankDecision {
|
|
|
60
61
|
*/
|
|
61
62
|
readonly confidence: number;
|
|
62
63
|
/**
|
|
63
|
-
* Free-text reason from the judge
|
|
64
|
+
* Free-text reason from the judge, when it gives one — a judge that
|
|
65
|
+
* decides without prose sends none (ggui#1235). Surface in trace logs so
|
|
64
66
|
* operators can debug "why didn't this hit." Truncate at the
|
|
65
67
|
* persistence boundary if cardinality is a concern.
|
|
66
68
|
*/
|
|
67
|
-
readonly reason
|
|
69
|
+
readonly reason?: string;
|
|
68
70
|
/** Wall-clock latency of the LLM call. */
|
|
69
71
|
readonly latencyMs: number;
|
|
70
72
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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).
|
|
75
79
|
*/
|
|
76
|
-
readonly tokenCost
|
|
80
|
+
readonly tokenCost?: TokenUsage;
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
/** Query the user's request the judge is matching against. */
|
|
@@ -135,7 +139,11 @@ function buildUserMessage(
|
|
|
135
139
|
for (const c of candidates) {
|
|
136
140
|
lines.push('---');
|
|
137
141
|
lines.push(` id: ${c.id}`);
|
|
138
|
-
|
|
142
|
+
// The cap is the protocol's `MATCHED_INTENT_MAX_CHARS`: the same
|
|
143
|
+
// string the judge reads here is what a judged hit hands back to the
|
|
144
|
+
// agent as `blueprintMeta.matchedIntent` (ggui#1336), so the two cuts
|
|
145
|
+
// are one constant, not two literals that happen to agree.
|
|
146
|
+
lines.push(` intent: ${truncate(c.cachedIntent, MATCHED_INTENT_MAX_CHARS)}`);
|
|
139
147
|
lines.push(` contract: ${c.cachedContractSummary}`);
|
|
140
148
|
if (typeof c.cosine === 'number') {
|
|
141
149
|
lines.push(` cosine: ${c.cosine.toFixed(3)}`);
|
|
@@ -229,7 +237,8 @@ export async function rerankCandidates(
|
|
|
229
237
|
const userMessage = buildUserMessage(query, candidates);
|
|
230
238
|
const candidateIds = new Set(candidates.map((c) => c.id));
|
|
231
239
|
|
|
232
|
-
|
|
240
|
+
const { callStructured, callStructuredMetered } = deps.llm;
|
|
241
|
+
if (typeof callStructuredMetered !== 'function' && typeof callStructured !== 'function') {
|
|
233
242
|
return {
|
|
234
243
|
matchId: null,
|
|
235
244
|
confidence: 0,
|
|
@@ -240,14 +249,18 @@ export async function rerankCandidates(
|
|
|
240
249
|
};
|
|
241
250
|
}
|
|
242
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).
|
|
243
254
|
let toolInput: unknown;
|
|
255
|
+
let usage: TokenUsage | undefined;
|
|
244
256
|
try {
|
|
245
|
-
|
|
246
|
-
RERANK_SYSTEM_PROMPT,
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
+
}
|
|
251
264
|
} catch (err) {
|
|
252
265
|
const message = err instanceof Error ? err.message : String(err);
|
|
253
266
|
return {
|
|
@@ -255,7 +268,6 @@ export async function rerankCandidates(
|
|
|
255
268
|
confidence: 0,
|
|
256
269
|
reason: `llm-rerank: callStructured threw — ${message}`,
|
|
257
270
|
latencyMs: Date.now() - startedAt,
|
|
258
|
-
tokenCost: { input: 0, output: 0 },
|
|
259
271
|
};
|
|
260
272
|
}
|
|
261
273
|
|
|
@@ -265,12 +277,31 @@ export async function rerankCandidates(
|
|
|
265
277
|
confidence: parsed.confidence,
|
|
266
278
|
reason: parsed.reason,
|
|
267
279
|
latencyMs: Date.now() - startedAt,
|
|
268
|
-
|
|
269
|
-
// we don't have today. Default to zero; the cost gate is measured
|
|
270
|
-
// out-of-band from billing data during the probe.
|
|
271
|
-
tokenCost: { input: 0, output: 0 },
|
|
280
|
+
...(usage !== undefined ? { tokenCost: usage } : {}),
|
|
272
281
|
};
|
|
273
282
|
}
|
|
274
283
|
|
|
275
284
|
// Re-exports for the eval harness — keep public surface explicit.
|
|
285
|
+
/**
|
|
286
|
+
* The judge seam (ggui#1235): one function from a query and its candidates
|
|
287
|
+
* to a {@link RerankDecision}. The matcher takes a judge together with the
|
|
288
|
+
* confidence threshold it was measured on (the pair), so a judge on
|
|
289
|
+
* another scale never meets a cut calibrated for a different one.
|
|
290
|
+
* `matchId: null` is a judge's only decline; `confidence` is the judge's
|
|
291
|
+
* own, compared by the caller against the pair's threshold.
|
|
292
|
+
*/
|
|
293
|
+
export type RerankJudge = (
|
|
294
|
+
query: RerankQuery,
|
|
295
|
+
candidates: readonly RerankCandidate[],
|
|
296
|
+
) => Promise<RerankDecision>;
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Today's judge as a {@link RerankJudge}: {@link rerankCandidates} bound to
|
|
300
|
+
* an {@link LLMCaller} — the same prompt, the same tool, the same decision,
|
|
301
|
+
* so a caller that injects nothing else behaves exactly as before.
|
|
302
|
+
*/
|
|
303
|
+
export function llmRerankJudge(llm: LLMCaller): RerankJudge {
|
|
304
|
+
return (query, candidates) => rerankCandidates({ llm }, query, candidates);
|
|
305
|
+
}
|
|
306
|
+
|
|
276
307
|
export { RERANK_SYSTEM_PROMPT, RERANK_TOOL };
|
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
|
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action-entry member preservation — the deterministic backstop that
|
|
3
|
+
* keeps a repair FAITHFUL to what the agent DECLARED on its actions
|
|
4
|
+
* (ggui#1421).
|
|
5
|
+
*
|
|
6
|
+
* The repair tool authors exactly two members per action, `label` and
|
|
7
|
+
* `schema` (`SYNTHESIZE_TOOL`); every other member of an
|
|
8
|
+
* {@link ActionEntry} — `description`, `example`, `icon`, `confirm`,
|
|
9
|
+
* `oneShot`, `nextStep` — is the agent's declaration, and the whole
|
|
10
|
+
* one-shot line (ggui#1108 → ggui#1223: the runtime's second-dispatch
|
|
11
|
+
* guard, the server's spend record, ui-gen's `useActionSpent` binding)
|
|
12
|
+
* keys on `oneShot === true` being ON THE SERVED CONTRACT. A repair that
|
|
13
|
+
* re-emits an entry from the tool's answer alone serves a card with no
|
|
14
|
+
* guard, silently. So the overlay below puts the draft's declarations
|
|
15
|
+
* back on the repaired candidate, deterministically, and NAMES whatever
|
|
16
|
+
* it cannot keep:
|
|
17
|
+
*
|
|
18
|
+
* - `REPAIR_MEMBER_DROPPED` at `actionSpec.<name>.<member>` — a
|
|
19
|
+
* declared member the merged contract cannot carry (the gate refuses
|
|
20
|
+
* it there, e.g. a `nextStep` whose tool the repair did not
|
|
21
|
+
* re-declare, or a member that fails its own schema);
|
|
22
|
+
* - `REPAIR_ENTRY_DROPPED` at `actionSpec.<name>` — a draft action the
|
|
23
|
+
* candidate no longer carries under that name (renamed or removed),
|
|
24
|
+
* which takes every member with it.
|
|
25
|
+
*
|
|
26
|
+
* A member whose path a DRAFT finding already names is under repair: it
|
|
27
|
+
* is not restored, and its absence is named by that finding, at the same
|
|
28
|
+
* path. Every other loss is named HERE, at the member's or the entry's
|
|
29
|
+
* own path, with the gate's reason in the message — so the invariant an
|
|
30
|
+
* agent can rely on is one sentence: a declared member or action missing
|
|
31
|
+
* from the served proposal always has a finding at its own path. The
|
|
32
|
+
* `CTR_*` codes say what the gate refused; the `REPAIR_*` codes say what
|
|
33
|
+
* the served proposal does not carry of the declaration, and why. The
|
|
34
|
+
* codes are handshake-decision codes like `COVERAGE_GAP` — declared where
|
|
35
|
+
* they are emitted, read by the agent as strings in `validationFindings`.
|
|
36
|
+
* Model-independent: the overlay works whatever the repair LLM returns.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
actionEntrySchema,
|
|
41
|
+
CTR_SCHEMA_INCOMPAT,
|
|
42
|
+
lintContract,
|
|
43
|
+
type DataContract,
|
|
44
|
+
type SuggestionFinding,
|
|
45
|
+
} from '@ggui-ai/protocol';
|
|
46
|
+
|
|
47
|
+
/** A declared action-entry member the repair could not keep; path `actionSpec.<name>.<member>`. */
|
|
48
|
+
export const REPAIR_MEMBER_DROPPED = 'REPAIR_MEMBER_DROPPED' as const;
|
|
49
|
+
/** A draft action entry the repair no longer carries under its name; path `actionSpec.<name>`. */
|
|
50
|
+
export const REPAIR_ENTRY_DROPPED = 'REPAIR_ENTRY_DROPPED' as const;
|
|
51
|
+
|
|
52
|
+
type ActionEntry = NonNullable<DataContract['actionSpec']>[string];
|
|
53
|
+
type ActionMember = Exclude<keyof typeof actionEntrySchema.shape, 'label' | 'schema'>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The members the agent declares — the protocol's action-entry schema
|
|
57
|
+
* minus the pair the repair tool authors. Derived, never listed, so a
|
|
58
|
+
* new schema member is preserved the day it lands.
|
|
59
|
+
*/
|
|
60
|
+
const DECLARED_MEMBERS: readonly ActionMember[] = (
|
|
61
|
+
Object.keys(actionEntrySchema.shape) as (keyof typeof actionEntrySchema.shape)[]
|
|
62
|
+
).filter((k): k is ActionMember => k !== 'label' && k !== 'schema');
|
|
63
|
+
|
|
64
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
65
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The draft's `actionSpec` map when it is one; `undefined` otherwise (the draft is untrusted). */
|
|
69
|
+
function draftActionSpec(draft: unknown): Record<string, unknown> | undefined {
|
|
70
|
+
if (!isRecord(draft)) return undefined;
|
|
71
|
+
const actionSpec = draft['actionSpec'];
|
|
72
|
+
return isRecord(actionSpec) ? actionSpec : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `entry` without `member`. Removing an optional member from a parsed
|
|
77
|
+
* entry leaves a parsed entry, so the strict re-parse re-derives the
|
|
78
|
+
* typed shape without a cast.
|
|
79
|
+
*/
|
|
80
|
+
function withoutMember(entry: ActionEntry, member: ActionMember): ActionEntry {
|
|
81
|
+
return actionEntrySchema.parse(
|
|
82
|
+
Object.fromEntries(Object.entries(entry).filter(([key]) => key !== member)),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function memberDropped(name: string, member: ActionMember, reason: string): SuggestionFinding {
|
|
87
|
+
return {
|
|
88
|
+
code: REPAIR_MEMBER_DROPPED,
|
|
89
|
+
severity: 'error',
|
|
90
|
+
path: `actionSpec.${name}.${member}`,
|
|
91
|
+
message: `the repair could not keep the declared \`${member}\` on action '${name}': ${reason}`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether `finding` is a member drop on one of `entryNames` — matched by
|
|
97
|
+
* the exact `actionSpec.<name>.<member>` path per declared member, never
|
|
98
|
+
* by a dotted prefix (an action named `a` is not a prefix of `a.b`).
|
|
99
|
+
*/
|
|
100
|
+
export function isMemberDropOf(finding: SuggestionFinding, entryNames: readonly string[]): boolean {
|
|
101
|
+
return entryNames.some((name) => DECLARED_MEMBERS.some((member) => finding.path === `actionSpec.${name}.${member}`));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Overlay the draft's declared members onto the candidate's action
|
|
106
|
+
* entries (same name), keeping the candidate's `label` and `schema`. A
|
|
107
|
+
* member whose exact path a draft finding names is under repair and
|
|
108
|
+
* stays out. Then lint the merged contract and un-restore, until the
|
|
109
|
+
* tree is stable, every restored member the gate refuses — at its own
|
|
110
|
+
* path, or (for `nextStep`) as `CTR_SCHEMA_INCOMPAT` at the sibling
|
|
111
|
+
* `.schema`, the path the compatibility check reports on — naming each
|
|
112
|
+
* one, so the returned contract never fails the gate BECAUSE of the
|
|
113
|
+
* overlay. A malformed candidate entry (no `label`) is left un-overlaid
|
|
114
|
+
* for the loop's own gate to retry; nothing here throws on the model's
|
|
115
|
+
* answer. Entries the candidate does not carry get nothing back here —
|
|
116
|
+
* see {@link findDroppedActionEntries}.
|
|
117
|
+
*/
|
|
118
|
+
export function restoreDraftActionMembers(
|
|
119
|
+
draft: unknown,
|
|
120
|
+
candidate: DataContract,
|
|
121
|
+
underRepair: ReadonlySet<string>,
|
|
122
|
+
): { readonly contract: DataContract; readonly dropped: readonly SuggestionFinding[] } {
|
|
123
|
+
const draftActions = draftActionSpec(draft);
|
|
124
|
+
if (draftActions === undefined || candidate.actionSpec === undefined) {
|
|
125
|
+
return { contract: candidate, dropped: [] };
|
|
126
|
+
}
|
|
127
|
+
const dropped: SuggestionFinding[] = [];
|
|
128
|
+
/** Per entry, the members the overlay put back. */
|
|
129
|
+
const restored = new Map<string, Set<ActionMember>>();
|
|
130
|
+
const merged: Record<string, ActionEntry> = {};
|
|
131
|
+
for (const [name, entry] of Object.entries(candidate.actionSpec)) {
|
|
132
|
+
const draftEntry = Object.hasOwn(draftActions, name) ? draftActions[name] : undefined;
|
|
133
|
+
if (!isRecord(draftEntry)) {
|
|
134
|
+
merged[name] = entry;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const next: Record<string, unknown> = { ...entry };
|
|
138
|
+
const put = new Set<ActionMember>();
|
|
139
|
+
for (const member of DECLARED_MEMBERS) {
|
|
140
|
+
const value = draftEntry[member];
|
|
141
|
+
if (value === undefined || entry[member] !== undefined || underRepair.has(`actionSpec.${name}.${member}`)) continue;
|
|
142
|
+
const parsed = actionEntrySchema.shape[member].safeParse(value);
|
|
143
|
+
if (!parsed.success) {
|
|
144
|
+
dropped.push(memberDropped(name, member, parsed.error.issues[0]?.message ?? 'invalid'));
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
next[member] = parsed.data;
|
|
148
|
+
put.add(member);
|
|
149
|
+
}
|
|
150
|
+
const reparsed = actionEntrySchema.safeParse(next);
|
|
151
|
+
if (put.size === 0 || !reparsed.success) {
|
|
152
|
+
// Nothing to put back, or the CANDIDATE's own label/schema are not
|
|
153
|
+
// an entry yet — the loop's gate retries that; not this overlay's.
|
|
154
|
+
merged[name] = entry;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
merged[name] = reparsed.data;
|
|
158
|
+
restored.set(name, put);
|
|
159
|
+
}
|
|
160
|
+
let actionSpec: Record<string, ActionEntry> = merged;
|
|
161
|
+
if (restored.size === 0) return { contract: { ...candidate, actionSpec }, dropped };
|
|
162
|
+
|
|
163
|
+
// Cross-reference and compatibility errors only exist on the MERGED
|
|
164
|
+
// tree. Un-restore exactly the members the gate names — at their own
|
|
165
|
+
// path, or a `nextStep` as `CTR_SCHEMA_INCOMPAT` at the sibling
|
|
166
|
+
// `.schema`, the path the compatibility check reports on — naming each
|
|
167
|
+
// at ITS OWN path with the gate's reason; re-lint, and stop when a pass
|
|
168
|
+
// changes nothing. Every pass removes at least one member, so the loop
|
|
169
|
+
// is bounded by what was restored.
|
|
170
|
+
for (let pass = 0; pass < DECLARED_MEMBERS.length * restored.size + 1; pass += 1) {
|
|
171
|
+
const errors = lintContract({ ...candidate, actionSpec }).errors;
|
|
172
|
+
let changed = false;
|
|
173
|
+
for (const [name, members] of restored) {
|
|
174
|
+
for (const member of [...members]) {
|
|
175
|
+
const refusal =
|
|
176
|
+
errors.find((issue) => issue.path === `actionSpec.${name}.${member}`) ??
|
|
177
|
+
(member === 'nextStep'
|
|
178
|
+
? errors.find((issue) => issue.code === CTR_SCHEMA_INCOMPAT && issue.path === `actionSpec.${name}.schema`)
|
|
179
|
+
: undefined);
|
|
180
|
+
if (refusal === undefined) continue;
|
|
181
|
+
const entry = actionSpec[name];
|
|
182
|
+
if (entry === undefined) continue;
|
|
183
|
+
actionSpec = { ...actionSpec, [name]: withoutMember(entry, member) };
|
|
184
|
+
members.delete(member);
|
|
185
|
+
dropped.push(memberDropped(name, member, `refused by the gate as ${refusal.code} at ${refusal.path}: ${refusal.message}`));
|
|
186
|
+
changed = true;
|
|
187
|
+
}
|
|
188
|
+
if (members.size === 0) restored.delete(name);
|
|
189
|
+
}
|
|
190
|
+
if (!changed || restored.size === 0) break;
|
|
191
|
+
}
|
|
192
|
+
return { contract: { ...candidate, actionSpec }, dropped };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* One `REPAIR_ENTRY_DROPPED` per draft action entry the candidate no
|
|
197
|
+
* longer carries under that name — renamed or removed by the repair (or
|
|
198
|
+
* pruned by the loop's own placement pass) — always, because the members
|
|
199
|
+
* it carried (`oneShot` above all) went with it and nothing else names
|
|
200
|
+
* that. When the gate refused the NAME itself (`CTR_DUP_NAME` /
|
|
201
|
+
* `CTR_RESERVED_NAME` at `actionSpec.<name>`, so the path is under
|
|
202
|
+
* repair) the message says so and does not send the agent back to a
|
|
203
|
+
* name the gate will refuse again. Empty when every draft entry survived,
|
|
204
|
+
* and for drafts that declare no actions.
|
|
205
|
+
*/
|
|
206
|
+
export function findDroppedActionEntries(
|
|
207
|
+
draft: unknown,
|
|
208
|
+
candidate: DataContract,
|
|
209
|
+
underRepair: ReadonlySet<string>,
|
|
210
|
+
): readonly SuggestionFinding[] {
|
|
211
|
+
const draftActions = draftActionSpec(draft);
|
|
212
|
+
if (draftActions === undefined) return [];
|
|
213
|
+
const kept = candidate.actionSpec ?? {};
|
|
214
|
+
return Object.keys(draftActions)
|
|
215
|
+
.filter((name) => !Object.hasOwn(kept, name))
|
|
216
|
+
.map((name) => ({
|
|
217
|
+
code: REPAIR_ENTRY_DROPPED,
|
|
218
|
+
severity: 'error',
|
|
219
|
+
path: `actionSpec.${name}`,
|
|
220
|
+
message: underRepair.has(`actionSpec.${name}`)
|
|
221
|
+
? `the gate refused the action name '${name}' (see the finding at this path) and the repair removed or renamed it; every member it carried, oneShot included, went with it — declare those members again under a name the gate accepts, via ggui_render override or a corrected re-handshake`
|
|
222
|
+
: `the repair no longer carries the action '${name}' you declared (renamed or removed); every member it carried, oneShot included, went with it — re-declare it via ggui_render override or a corrected re-handshake`,
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
@@ -79,7 +79,9 @@ async function main(): Promise<void> {
|
|
|
79
79
|
usage.input * HAIKU_4_5_PRICE_INPUT_PER_TOKEN +
|
|
80
80
|
usage.output * HAIKU_4_5_PRICE_OUTPUT_PER_TOKEN;
|
|
81
81
|
const callsMade = report.outcomes.filter(
|
|
82
|
-
|
|
82
|
+
// ggui#1235 — `reason` is optional on the seam; a judge with no prose
|
|
83
|
+
// still made a call.
|
|
84
|
+
(o) => !/short-circuited/.test(o.decision.reason ?? ''),
|
|
83
85
|
).length;
|
|
84
86
|
const costPerCall = callsMade === 0 ? 0 : totalCost / callsMade;
|
|
85
87
|
process.stdout.write(
|
|
@@ -190,7 +190,7 @@ export function formatReport(report: ProbeReport): string {
|
|
|
190
190
|
lines.push(
|
|
191
191
|
` [${o.pair.kind}] ${o.pair.id}: predicted=${o.predictedMatchId ?? 'null'}, gold=${o.pair.goldMatchId ?? 'null'}, conf=${o.decision.confidence.toFixed(2)}`,
|
|
192
192
|
);
|
|
193
|
-
lines.push(` reason: ${o.decision.reason}`);
|
|
193
|
+
lines.push(` reason: ${o.decision.reason ?? '(none)'}`);
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
196
|
|
|
@@ -44,9 +44,10 @@ import {
|
|
|
44
44
|
type GadgetDescriptor,
|
|
45
45
|
type JsonSchema,
|
|
46
46
|
} from '@ggui-ai/protocol';
|
|
47
|
-
import { lintContract, type ContractIssue } from '@ggui-ai/protocol';
|
|
47
|
+
import { lintContract, type ContractIssue, type SuggestionFinding } from '@ggui-ai/protocol';
|
|
48
48
|
import type { LLMCaller, ToolSchema } from './llm-caller.js';
|
|
49
49
|
import { normalizeSchema } from './normalize-schema.js';
|
|
50
|
+
import { findDroppedActionEntries, isMemberDropOf, restoreDraftActionMembers } from './preserve-action-members.js';
|
|
50
51
|
import {
|
|
51
52
|
draftSeedPropKeys,
|
|
52
53
|
findDroppedSeedSurfaces,
|
|
@@ -102,6 +103,16 @@ export interface SynthesizeContractResult {
|
|
|
102
103
|
* findings without re-running the detector.
|
|
103
104
|
*/
|
|
104
105
|
readonly findings: readonly ContractValidationFinding[];
|
|
106
|
+
/**
|
|
107
|
+
* What a repair-in-place could NOT keep of the agent's draft
|
|
108
|
+
* (ggui#1421): one `REPAIR_MEMBER_DROPPED` per declared action-entry
|
|
109
|
+
* member the merged contract cannot carry, one `REPAIR_ENTRY_DROPPED`
|
|
110
|
+
* per draft action the contract no longer carries under its name.
|
|
111
|
+
* Empty on the cold path (no draft) and whenever every declaration
|
|
112
|
+
* survived. The caller surfaces these to the agent beside the gate's
|
|
113
|
+
* own findings, so a drop is never silent.
|
|
114
|
+
*/
|
|
115
|
+
readonly dropped: readonly SuggestionFinding[];
|
|
105
116
|
}
|
|
106
117
|
|
|
107
118
|
/**
|
|
@@ -837,6 +848,7 @@ export async function synthesizeContract(
|
|
|
837
848
|
latencyMs: Date.now() - startedAt,
|
|
838
849
|
attempts: 0,
|
|
839
850
|
findings: [],
|
|
851
|
+
dropped: [],
|
|
840
852
|
};
|
|
841
853
|
}
|
|
842
854
|
|
|
@@ -878,6 +890,10 @@ export async function synthesizeContract(
|
|
|
878
890
|
const draftSeedKeys =
|
|
879
891
|
options?.draft !== undefined ? draftSeedPropKeys(options.draft) : [];
|
|
880
892
|
let lastValidContract: DataContract | null = null;
|
|
893
|
+
/** The paths the draft's own findings name — members under repair are not restored. */
|
|
894
|
+
const underRepair: ReadonlySet<string> = new Set((options?.draftFindings ?? []).map((f) => f.path));
|
|
895
|
+
/** The member drops of the attempt that produced `lastValidContract`. */
|
|
896
|
+
let lastValidDropped: readonly SuggestionFinding[] = [];
|
|
881
897
|
|
|
882
898
|
for (let attempt = 1; attempt <= MAX_SYNTH_ATTEMPTS; attempt++) {
|
|
883
899
|
const userPrompt =
|
|
@@ -916,8 +932,13 @@ export async function synthesizeContract(
|
|
|
916
932
|
}
|
|
917
933
|
|
|
918
934
|
// `buildContract` normalizes every emitted schema (invalid `type`
|
|
919
|
-
// spellings → canonical JSON Schema) before the gate sees it.
|
|
920
|
-
|
|
935
|
+
// spellings → canonical JSON Schema) before the gate sees it. On a
|
|
936
|
+
// repair-in-place it authors only `{label, schema}` per action; the
|
|
937
|
+
// agent's DECLARED members (`oneShot` above all) are put back from
|
|
938
|
+
// the draft deterministically, and anything the merged tree cannot
|
|
939
|
+
// carry is named rather than lost (ggui#1421).
|
|
940
|
+
const restored = restoreDraftActionMembers(options?.draft, buildContract(parsed), underRepair);
|
|
941
|
+
const contract = restored.contract;
|
|
921
942
|
|
|
922
943
|
// Defensive gate: re-validate the assembled contract against the
|
|
923
944
|
// canonical schema. Catches LLM outputs that pass the loose tool
|
|
@@ -991,6 +1012,7 @@ export async function synthesizeContract(
|
|
|
991
1012
|
// Remember the best VALID candidate so an exhausted budget never
|
|
992
1013
|
// returns WORSE than the validity-only gate did (a valid contract).
|
|
993
1014
|
lastValidContract = validatedContract;
|
|
1015
|
+
lastValidDropped = restored.dropped;
|
|
994
1016
|
lastReason = `synthesize-preservation: candidate dropped agent-owned propsSpec seed surface(s) [${dropped.join(', ')}]`;
|
|
995
1017
|
lastFindings = allFindings;
|
|
996
1018
|
repairNote = buildPreservationRepairNote(validatedContract, dropped);
|
|
@@ -1011,6 +1033,7 @@ export async function synthesizeContract(
|
|
|
1011
1033
|
latencyMs: Date.now() - startedAt,
|
|
1012
1034
|
attempts: attempt,
|
|
1013
1035
|
findings: allFindings,
|
|
1036
|
+
dropped: droppedFor(restored.dropped, options?.draft, validatedContract, underRepair),
|
|
1014
1037
|
};
|
|
1015
1038
|
}
|
|
1016
1039
|
|
|
@@ -1027,9 +1050,30 @@ export async function synthesizeContract(
|
|
|
1027
1050
|
latencyMs: Date.now() - startedAt,
|
|
1028
1051
|
attempts: MAX_SYNTH_ATTEMPTS,
|
|
1029
1052
|
findings: lastFindings,
|
|
1053
|
+
dropped: lastValidContract !== null ? droppedFor(lastValidDropped, options?.draft, lastValidContract, underRepair) : [],
|
|
1030
1054
|
};
|
|
1031
1055
|
}
|
|
1032
1056
|
|
|
1057
|
+
/**
|
|
1058
|
+
* What a repair could not keep, against the contract it finally
|
|
1059
|
+
* returns: the member drops of the accepted attempt, minus those on an
|
|
1060
|
+
* entry the loop's own placement pass pruned afterwards (the entry
|
|
1061
|
+
* finding names that loss once, with every member), plus one entry
|
|
1062
|
+
* finding per draft action the final contract no longer carries.
|
|
1063
|
+
*/
|
|
1064
|
+
function droppedFor(
|
|
1065
|
+
memberDrops: readonly SuggestionFinding[],
|
|
1066
|
+
draft: unknown,
|
|
1067
|
+
finalContract: DataContract,
|
|
1068
|
+
underRepair: ReadonlySet<string>,
|
|
1069
|
+
): readonly SuggestionFinding[] {
|
|
1070
|
+
const survivingEntries = Object.keys(finalContract.actionSpec ?? {});
|
|
1071
|
+
return [
|
|
1072
|
+
...memberDrops.filter((f) => isMemberDropOf(f, survivingEntries)),
|
|
1073
|
+
...findDroppedActionEntries(draft, finalContract, underRepair),
|
|
1074
|
+
];
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1033
1077
|
function parseToolInput(raw: unknown): SynthesizeToolInput | null {
|
|
1034
1078
|
if (typeof raw !== 'object' || raw === null) return null;
|
|
1035
1079
|
const obj = raw as Record<string, unknown>;
|