@ggui-ai/negotiator 0.1.0-rc.1
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/LICENSE +201 -0
- package/README.md +49 -0
- package/dist/contract-hash.d.ts +54 -0
- package/dist/contract-hash.d.ts.map +1 -0
- package/dist/contract-hash.js +96 -0
- package/dist/contract-validators.d.ts +171 -0
- package/dist/contract-validators.d.ts.map +1 -0
- package/dist/contract-validators.js +478 -0
- package/dist/decision-input.d.ts +48 -0
- package/dist/decision-input.d.ts.map +1 -0
- package/dist/decision-input.js +14 -0
- package/dist/decision.d.ts +54 -0
- package/dist/decision.d.ts.map +1 -0
- package/dist/decision.js +500 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/intent.d.ts +22 -0
- package/dist/intent.d.ts.map +1 -0
- package/dist/intent.js +28 -0
- package/dist/llm-caller.d.ts +70 -0
- package/dist/llm-caller.d.ts.map +1 -0
- package/dist/llm-caller.js +38 -0
- package/dist/llm-rerank.d.ts +101 -0
- package/dist/llm-rerank.d.ts.map +1 -0
- package/dist/llm-rerank.js +178 -0
- package/dist/negotiate.d.ts +141 -0
- package/dist/negotiate.d.ts.map +1 -0
- package/dist/negotiate.js +161 -0
- package/dist/normalize-schema.d.ts +22 -0
- package/dist/normalize-schema.d.ts.map +1 -0
- package/dist/normalize-schema.js +191 -0
- package/dist/pure.d.ts +30 -0
- package/dist/pure.d.ts.map +1 -0
- package/dist/pure.js +43 -0
- package/dist/rag-search.d.ts +73 -0
- package/dist/rag-search.d.ts.map +1 -0
- package/dist/rag-search.js +192 -0
- package/dist/rerank-eval/pairs.d.ts +28 -0
- package/dist/rerank-eval/pairs.d.ts.map +1 -0
- package/dist/rerank-eval/pairs.js +531 -0
- package/dist/rerank-eval/run-probe-cli.d.ts +3 -0
- package/dist/rerank-eval/run-probe-cli.d.ts.map +1 -0
- package/dist/rerank-eval/run-probe-cli.js +146 -0
- package/dist/rerank-eval/run-probe.d.ts +68 -0
- package/dist/rerank-eval/run-probe.d.ts.map +1 -0
- package/dist/rerank-eval/run-probe.js +113 -0
- package/dist/session.d.ts +42 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +21 -0
- package/dist/suggestion.d.ts +38 -0
- package/dist/suggestion.d.ts.map +1 -0
- package/dist/suggestion.js +47 -0
- package/dist/synth-bench/corpus.d.ts +106 -0
- package/dist/synth-bench/corpus.d.ts.map +1 -0
- package/dist/synth-bench/corpus.js +994 -0
- package/dist/synth-bench/run-bench-cli.d.ts +3 -0
- package/dist/synth-bench/run-bench-cli.d.ts.map +1 -0
- package/dist/synth-bench/run-bench-cli.js +181 -0
- package/dist/synth-bench/run-bench.d.ts +101 -0
- package/dist/synth-bench/run-bench.d.ts.map +1 -0
- package/dist/synth-bench/run-bench.js +374 -0
- package/dist/synthesize-contract.d.ts +131 -0
- package/dist/synthesize-contract.d.ts.map +1 -0
- package/dist/synthesize-contract.js +948 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/package.json +74 -0
- package/src/contract-hash.ts +102 -0
- package/src/contract-validators.ts +604 -0
- package/src/decision-input.ts +49 -0
- package/src/decision.ts +581 -0
- package/src/index.ts +63 -0
- package/src/intent.ts +37 -0
- package/src/llm-caller.ts +82 -0
- package/src/llm-rerank.ts +280 -0
- package/src/negotiate.ts +312 -0
- package/src/normalize-schema.ts +193 -0
- package/src/pure.ts +46 -0
- package/src/rag-search.ts +274 -0
- package/src/rerank-eval/pairs.ts +624 -0
- package/src/rerank-eval/run-probe-cli.ts +197 -0
- package/src/rerank-eval/run-probe.ts +198 -0
- package/src/session.ts +41 -0
- package/src/suggestion.ts +73 -0
- package/src/synth-bench/corpus.ts +1126 -0
- package/src/synth-bench/run-bench-cli.ts +237 -0
- package/src/synth-bench/run-bench.ts +525 -0
- package/src/synthesize-contract.ts +1161 -0
- package/src/types.ts +31 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic safety validators for `DataContract` shapes.
|
|
3
|
+
*
|
|
4
|
+
* Two detectors live here:
|
|
5
|
+
*
|
|
6
|
+
* - {@link validateContractStructure} — pure structural heuristics that
|
|
7
|
+
* flag over-specified contracts without any runtime dependency. The
|
|
8
|
+
* load-bearing finding is `redundant-action`: an empty-payload
|
|
9
|
+
* `actionSpec` entry whose name parses as a mutator of an existing
|
|
10
|
+
* `contextSpec` slot. Real example that motivated this module: a
|
|
11
|
+
* synthesizer emitted both `actionSpec.increment` (empty payload)
|
|
12
|
+
* and `contextSpec.count` for a counter widget. The generator wired
|
|
13
|
+
* the increment button to `useAction("increment")`, dispatching to
|
|
14
|
+
* the agent instead of locally bumping the count slot — a button
|
|
15
|
+
* that looks right but does nothing. The structural fix is to
|
|
16
|
+
* declare `actionSpec[X]` IFF X is a discrete event the agent must
|
|
17
|
+
* witness; mutators of context slots use the slot setter.
|
|
18
|
+
*
|
|
19
|
+
* - {@link validateContractNovelty} — embeds the contract via the
|
|
20
|
+
* shared `summarizeContract` helper and computes cosine distance to
|
|
21
|
+
* the nearest registered blueprint. Distance above the threshold
|
|
22
|
+
* yields a `novel-shape` finding so operators can review before the
|
|
23
|
+
* contract pollutes the registry's neighborhood.
|
|
24
|
+
*
|
|
25
|
+
* Both validators return a `ContractValidationResult` carrying readonly
|
|
26
|
+
* findings — callers (synthesizer, registerBlueprint) decide how to act
|
|
27
|
+
* on warnings vs errors. Findings are deterministic; no LLM judgment.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { DataContract, JsonSchema } from '@ggui-ai/protocol';
|
|
31
|
+
import { summarizeContract } from '@ggui-ai/protocol';
|
|
32
|
+
import type {
|
|
33
|
+
EmbeddingProvider,
|
|
34
|
+
VectorStore,
|
|
35
|
+
} from '@ggui-ai/mcp-server-core';
|
|
36
|
+
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// Public types
|
|
39
|
+
// =============================================================================
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Discriminated finding kinds. Each kind documents a distinct
|
|
43
|
+
* structural signal — consumers MAY render them differently
|
|
44
|
+
* (`redundant-action` is a code smell on the synthesizer side;
|
|
45
|
+
* `novel-shape` is an operator-review prompt).
|
|
46
|
+
*
|
|
47
|
+
* Open-ended for forward compat: future detectors (e.g.,
|
|
48
|
+
* `unreachable-stream-channel`, `payload-collision`) add to the union
|
|
49
|
+
* without breaking consumers that switch with a default arm.
|
|
50
|
+
*/
|
|
51
|
+
export type ContractValidationFindingKind =
|
|
52
|
+
| 'redundant-action'
|
|
53
|
+
| 'novel-shape'
|
|
54
|
+
| 'actions-vs-context-name-collision'
|
|
55
|
+
| 'action-name-looks-state-y'
|
|
56
|
+
| 'context-name-looks-action-y'
|
|
57
|
+
| 'incoherent-no-data-surface'
|
|
58
|
+
| 'context-props-name-collision'
|
|
59
|
+
| (string & {});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One observation about a contract. `severity` is `'warn'` for
|
|
63
|
+
* heuristics that may produce false positives — current default for
|
|
64
|
+
* the structural detector. `'error'` is reserved for unambiguous
|
|
65
|
+
* contract bugs once we have evidence the heuristic doesn't false-
|
|
66
|
+
* positive at production volume.
|
|
67
|
+
*/
|
|
68
|
+
export interface ContractValidationFinding {
|
|
69
|
+
readonly kind: ContractValidationFindingKind;
|
|
70
|
+
readonly severity: 'warn' | 'error';
|
|
71
|
+
readonly actionName?: string;
|
|
72
|
+
readonly slotName?: string;
|
|
73
|
+
readonly cosine?: number;
|
|
74
|
+
readonly hint: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ContractValidationResult {
|
|
78
|
+
readonly findings: readonly ContractValidationFinding[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Dependencies the novelty detector needs. Embedding provider + vector
|
|
83
|
+
* store are the same seams the negotiator's RAG path uses, so the
|
|
84
|
+
* novelty check operates over the production index without any extra
|
|
85
|
+
* infrastructure.
|
|
86
|
+
*/
|
|
87
|
+
export interface ContractValidationNoveltyDeps {
|
|
88
|
+
readonly embedding: EmbeddingProvider;
|
|
89
|
+
readonly vectorStore: VectorStore;
|
|
90
|
+
/** Tenant / partition the nearest-neighbor query runs against. Same
|
|
91
|
+
* semantics as `RagSearchInput.scope`. */
|
|
92
|
+
readonly scope: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ContractValidationNoveltyOptions {
|
|
96
|
+
/**
|
|
97
|
+
* Cosine distance threshold above which the contract is flagged as
|
|
98
|
+
* novel. Distance is `1 - cosine_similarity`. Default `0.8` —
|
|
99
|
+
* matches the negotiator's `RETRIEVAL_MIN_SCORE = 0.15` (=cosine
|
|
100
|
+
* similarity 0.15, distance 0.85) one-tail boundary, with a small
|
|
101
|
+
* buffer so contracts that hover near retrieval but slightly above
|
|
102
|
+
* still flag for review.
|
|
103
|
+
*/
|
|
104
|
+
readonly thresholdCosine?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// =============================================================================
|
|
108
|
+
// Mutator verb dictionary
|
|
109
|
+
// =============================================================================
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Verbs whose presence at the START of an action name signals "this
|
|
113
|
+
* action mutates state the agent observes" — i.e., a candidate slot
|
|
114
|
+
* setter masquerading as an action.
|
|
115
|
+
*
|
|
116
|
+
* Scope decisions (in vs out):
|
|
117
|
+
*
|
|
118
|
+
* IN: verbs that almost always mutate observable state in
|
|
119
|
+
* counter/list/form/UI patterns we've seen in benchmark traces.
|
|
120
|
+
*
|
|
121
|
+
* OUT: verbs that often LOOK like mutators but legitimately want
|
|
122
|
+
* the agent in the loop:
|
|
123
|
+
* - `submit` — submit IS the discrete event; the agent must
|
|
124
|
+
* witness "user pressed submit" beyond the form's
|
|
125
|
+
* draft slot. Form pattern is `actionSpec.submit` +
|
|
126
|
+
* `contextSpec.formData` (legitimate pairing).
|
|
127
|
+
* - `save` — save typically triggers a wired tool (write to
|
|
128
|
+
* storage). Same pattern as submit.
|
|
129
|
+
* - `cancel` — discrete user gesture, not a slot mutation.
|
|
130
|
+
* - `confirm` — discrete user gesture.
|
|
131
|
+
* - `select` — selection often IS the slot value (e.g., select
|
|
132
|
+
* reduces to setting `selectedId`), but it's also
|
|
133
|
+
* used as a discrete event ("user picked X, fetch
|
|
134
|
+
* detail"). Out by default; false-negatives here
|
|
135
|
+
* are cheaper than false-positives.
|
|
136
|
+
* - `open`/`close` — often dialog gestures, not slot mutations.
|
|
137
|
+
*
|
|
138
|
+
* Authors who want a tighter or looser policy override the list at
|
|
139
|
+
* call site (option for future expansion — not exposed yet to keep
|
|
140
|
+
* the API minimal).
|
|
141
|
+
*/
|
|
142
|
+
const MUTATOR_VERBS: readonly string[] = [
|
|
143
|
+
'increment',
|
|
144
|
+
'decrement',
|
|
145
|
+
'reset',
|
|
146
|
+
'set',
|
|
147
|
+
'add',
|
|
148
|
+
'remove',
|
|
149
|
+
'delete',
|
|
150
|
+
'update',
|
|
151
|
+
'change',
|
|
152
|
+
'toggle',
|
|
153
|
+
'flip',
|
|
154
|
+
'clear',
|
|
155
|
+
'append',
|
|
156
|
+
'prepend',
|
|
157
|
+
'insert',
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Returns the mutator verb that prefixes `actionName`, or `null` when
|
|
162
|
+
* none does. Matches case-insensitively and only at the START — `set`
|
|
163
|
+
* matches `setCount` but not `unsetCount`; `reset` is its own verb (not
|
|
164
|
+
* `set` + `Count` with a leading `re`).
|
|
165
|
+
*
|
|
166
|
+
* Strict longest-match: when multiple verbs are valid prefixes (e.g.
|
|
167
|
+
* `reset` is also matched by `set`-with-`re`-prefix only via different
|
|
168
|
+
* boundary, but we don't allow that), we walk verbs longest-first so
|
|
169
|
+
* `reset*` is parsed as `reset|*` not `re|set*`.
|
|
170
|
+
*/
|
|
171
|
+
function stripMutatorVerb(actionName: string): {
|
|
172
|
+
verb: string;
|
|
173
|
+
remainder: string;
|
|
174
|
+
} | null {
|
|
175
|
+
if (actionName.length === 0) return null;
|
|
176
|
+
const lower = actionName.toLowerCase();
|
|
177
|
+
// Longest-prefix-first to disambiguate `reset` vs (hypothetical) `re`.
|
|
178
|
+
const sorted = [...MUTATOR_VERBS].sort((a, b) => b.length - a.length);
|
|
179
|
+
for (const verb of sorted) {
|
|
180
|
+
if (!lower.startsWith(verb)) continue;
|
|
181
|
+
const tail = actionName.slice(verb.length);
|
|
182
|
+
// Boundary: either the verb consumed the whole name, or the
|
|
183
|
+
// character after the verb is a word boundary (uppercase letter,
|
|
184
|
+
// digit, or underscore — typical camelCase / snake_case break).
|
|
185
|
+
if (tail.length === 0) {
|
|
186
|
+
return { verb, remainder: '' };
|
|
187
|
+
}
|
|
188
|
+
const next = tail.charAt(0);
|
|
189
|
+
const isBoundary =
|
|
190
|
+
(next >= 'A' && next <= 'Z') ||
|
|
191
|
+
(next >= '0' && next <= '9') ||
|
|
192
|
+
next === '_';
|
|
193
|
+
if (!isBoundary) continue;
|
|
194
|
+
return { verb, remainder: tail };
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Decide whether an action name with a mutator-verb prefix targets the
|
|
201
|
+
* given context slot.
|
|
202
|
+
*
|
|
203
|
+
* Algorithm:
|
|
204
|
+
*
|
|
205
|
+
* 1. Strip the verb prefix → remainder.
|
|
206
|
+
* 2. Empty remainder (action is just the verb, e.g., "increment")
|
|
207
|
+
* AND the contract has exactly one context slot ⇒ flag.
|
|
208
|
+
* 3. Non-empty remainder ⇒ flag iff remainder contains slotName OR
|
|
209
|
+
* slotName contains remainder, case-insensitively. This catches
|
|
210
|
+
* `incrementCount` ↔ `count`, `setUserName` ↔ `userName`,
|
|
211
|
+
* `addItem` ↔ `items`, etc., without false-positiving on
|
|
212
|
+
* `setTheme` ↔ `count`.
|
|
213
|
+
*
|
|
214
|
+
* The single-slot case is the load-bearing one for the counter bug:
|
|
215
|
+
* `increment` (no remainder) + the only slot being `count` is the
|
|
216
|
+
* exact pattern.
|
|
217
|
+
*/
|
|
218
|
+
function nameImpliesMutation(args: {
|
|
219
|
+
actionName: string;
|
|
220
|
+
slotName: string;
|
|
221
|
+
totalSlots: number;
|
|
222
|
+
}): boolean {
|
|
223
|
+
const { actionName, slotName, totalSlots } = args;
|
|
224
|
+
const stripped = stripMutatorVerb(actionName);
|
|
225
|
+
if (!stripped) return false;
|
|
226
|
+
if (stripped.remainder.length === 0) {
|
|
227
|
+
return totalSlots === 1;
|
|
228
|
+
}
|
|
229
|
+
const a = stripped.remainder.toLowerCase();
|
|
230
|
+
const b = slotName.toLowerCase();
|
|
231
|
+
return a.includes(b) || b.includes(a);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// =============================================================================
|
|
235
|
+
// Empty-payload detection
|
|
236
|
+
// =============================================================================
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* True when an action's schema is "empty payload" — either omitted
|
|
240
|
+
* entirely or the canonical `{type:'object', properties:{},
|
|
241
|
+
* additionalProperties:false}` shape the synthesizer emits when the LLM
|
|
242
|
+
* declines to declare fields. Actions with declared payload fields
|
|
243
|
+
* (e.g., `{chipText: string}`) are NOT empty — those legitimately
|
|
244
|
+
* carry data the agent needs.
|
|
245
|
+
*/
|
|
246
|
+
function isEmptyPayloadSchema(schema: JsonSchema | undefined): boolean {
|
|
247
|
+
if (schema === undefined) return true;
|
|
248
|
+
if (schema.type !== 'object') return false;
|
|
249
|
+
if (schema.properties === undefined) return true;
|
|
250
|
+
return Object.keys(schema.properties).length === 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// =============================================================================
|
|
254
|
+
// Structural validator (synchronous, dependency-free)
|
|
255
|
+
// =============================================================================
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Run the synchronous structural detectors against `contract`.
|
|
259
|
+
*
|
|
260
|
+
* Currently:
|
|
261
|
+
* - `redundant-action`: empty-payload action whose name parses as a
|
|
262
|
+
* mutator of an existing context slot.
|
|
263
|
+
*
|
|
264
|
+
* Returns an empty findings array for contracts that don't trip any
|
|
265
|
+
* heuristic.
|
|
266
|
+
*/
|
|
267
|
+
export function validateContractStructure(
|
|
268
|
+
contract: DataContract,
|
|
269
|
+
): ContractValidationResult {
|
|
270
|
+
const findings: ContractValidationFinding[] = [];
|
|
271
|
+
const actionSpec = contract.actionSpec;
|
|
272
|
+
const contextSpec = contract.contextSpec;
|
|
273
|
+
if (!actionSpec || !contextSpec) {
|
|
274
|
+
return { findings };
|
|
275
|
+
}
|
|
276
|
+
const slotNames = Object.keys(contextSpec);
|
|
277
|
+
if (slotNames.length === 0) {
|
|
278
|
+
return { findings };
|
|
279
|
+
}
|
|
280
|
+
for (const [actionName, entry] of Object.entries(actionSpec)) {
|
|
281
|
+
if (!isEmptyPayloadSchema(entry.schema)) continue;
|
|
282
|
+
for (const slotName of slotNames) {
|
|
283
|
+
if (
|
|
284
|
+
!nameImpliesMutation({
|
|
285
|
+
actionName,
|
|
286
|
+
slotName,
|
|
287
|
+
totalSlots: slotNames.length,
|
|
288
|
+
})
|
|
289
|
+
) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
findings.push({
|
|
293
|
+
kind: 'redundant-action',
|
|
294
|
+
severity: 'warn',
|
|
295
|
+
actionName,
|
|
296
|
+
slotName,
|
|
297
|
+
hint: `Action "${actionName}" has empty payload and looks like a mutator of context slot "${slotName}". Prefer setting the slot directly from the component instead of declaring an action — the agent observes slot changes without a discrete event.`,
|
|
298
|
+
});
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return { findings };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// =============================================================================
|
|
306
|
+
// Actions-vs-context placement validator (synchronous, loose / advisory)
|
|
307
|
+
// =============================================================================
|
|
308
|
+
//
|
|
309
|
+
// Loose self-check: every finding is `severity: 'warn'`. Synth's
|
|
310
|
+
// pipeline can log warnings to telemetry without blocking output. The
|
|
311
|
+
// rule being checked: actions drive agent turns, context observes
|
|
312
|
+
// state, and there is no third category.
|
|
313
|
+
//
|
|
314
|
+
// Three checks, each scoped narrowly to avoid false positives:
|
|
315
|
+
//
|
|
316
|
+
// 1. Name collision across specs — same key in both actionSpec and
|
|
317
|
+
// contextSpec. Structurally a category error: synth couldn't
|
|
318
|
+
// decide which bucket. High-confidence rule.
|
|
319
|
+
//
|
|
320
|
+
// 2. State-y name in actionSpec — names matching common state-mirror
|
|
321
|
+
// patterns (autosave, draft, typing, change, focus, blur, …).
|
|
322
|
+
// These usually represent continuous state, not turn-driving
|
|
323
|
+
// events. Warning, not error — false positives possible (a real
|
|
324
|
+
// "submitDraft" action is legitimate).
|
|
325
|
+
//
|
|
326
|
+
// 3. Action-y name in contextSpec — names matching common terminal
|
|
327
|
+
// verbs (submit, send, confirm, cancel, next, done, apply, …).
|
|
328
|
+
// These usually represent discrete events, not state. Warning,
|
|
329
|
+
// not error — false positives possible (a "submitTime" timestamp
|
|
330
|
+
// slot is legitimate state).
|
|
331
|
+
//
|
|
332
|
+
// All three checks are intentionally LOOSE. They surface candidates;
|
|
333
|
+
// consumers (synth, operator dashboards) decide to act on them.
|
|
334
|
+
|
|
335
|
+
const STATE_LIKE_NAME_PATTERN =
|
|
336
|
+
/^(auto|on)?(save|draft|change|update|sync|saved|typing|scroll|hover|focus|blur|input)/i;
|
|
337
|
+
|
|
338
|
+
const ACTION_LIKE_NAME_PATTERN =
|
|
339
|
+
/^(submit|send|confirm|cancel|next|back|done|apply|delete|create|approve|reject)$/i;
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Validate the actions-vs-context placement rule: actions drive agent
|
|
343
|
+
* turns, context observes state. Findings 1-3 are advisory `warn`;
|
|
344
|
+
* finding 4 (`context-props-name-collision`) is `error` — it is a
|
|
345
|
+
* SPEC §2.10 MUST. Synth pipelines use it as a self-check over their
|
|
346
|
+
* own output.
|
|
347
|
+
*
|
|
348
|
+
* Four findings:
|
|
349
|
+
*
|
|
350
|
+
* - `actions-vs-context-name-collision` — same key appears in both
|
|
351
|
+
* `actionSpec` AND `contextSpec`. Synth couldn't decide which
|
|
352
|
+
* bucket. Pick one.
|
|
353
|
+
*
|
|
354
|
+
* - `action-name-looks-state-y` — `actionSpec` entry name matches a
|
|
355
|
+
* state-mirror pattern (autosave, draft, typing, change, …). The
|
|
356
|
+
* thing might be observable state (continuous) rather than a
|
|
357
|
+
* turn-driving event. Consider `contextSpec`.
|
|
358
|
+
*
|
|
359
|
+
* - `context-name-looks-action-y` — `contextSpec` entry name matches
|
|
360
|
+
* a discrete-event pattern (submit, send, confirm, …). The thing
|
|
361
|
+
* might be a one-shot event the agent reacts to. Consider
|
|
362
|
+
* `actionSpec`.
|
|
363
|
+
*
|
|
364
|
+
* - `context-props-name-collision` (ERROR) — a `contextSpec` slot key
|
|
365
|
+
* equals a `propsSpec` property name. SPEC §2.10 MUST: the
|
|
366
|
+
* generated boilerplate would shadow the prop binding.
|
|
367
|
+
*
|
|
368
|
+
* Pure / synchronous / no dependencies. Safe to call on every synth
|
|
369
|
+
* output without measurable cost.
|
|
370
|
+
*/
|
|
371
|
+
export function validateActionsVsContext(
|
|
372
|
+
contract: DataContract,
|
|
373
|
+
): ContractValidationResult {
|
|
374
|
+
const findings: ContractValidationFinding[] = [];
|
|
375
|
+
const actionKeys = Object.keys(contract.actionSpec ?? {});
|
|
376
|
+
const contextKeys = Object.keys(contract.contextSpec ?? {});
|
|
377
|
+
|
|
378
|
+
// 1. Same key in both specs — category collision.
|
|
379
|
+
for (const k of actionKeys) {
|
|
380
|
+
if (contextKeys.includes(k)) {
|
|
381
|
+
findings.push({
|
|
382
|
+
kind: 'actions-vs-context-name-collision',
|
|
383
|
+
severity: 'warn',
|
|
384
|
+
actionName: k,
|
|
385
|
+
slotName: k,
|
|
386
|
+
hint: `"${k}" appears in both actionSpec and contextSpec. Pick one — actions drive agent turns; contextSpec is observed state.`,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// 2. State-y name in actionSpec.
|
|
392
|
+
for (const k of actionKeys) {
|
|
393
|
+
if (STATE_LIKE_NAME_PATTERN.test(k)) {
|
|
394
|
+
findings.push({
|
|
395
|
+
kind: 'action-name-looks-state-y',
|
|
396
|
+
severity: 'warn',
|
|
397
|
+
actionName: k,
|
|
398
|
+
hint: `actionSpec entry "${k}" sounds like state (autosave/draft/change/etc.). If it doesn't drive an agent turn, consider contextSpec instead.`,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// 3. Action-y name in contextSpec.
|
|
404
|
+
for (const k of contextKeys) {
|
|
405
|
+
if (ACTION_LIKE_NAME_PATTERN.test(k)) {
|
|
406
|
+
findings.push({
|
|
407
|
+
kind: 'context-name-looks-action-y',
|
|
408
|
+
severity: 'warn',
|
|
409
|
+
slotName: k,
|
|
410
|
+
hint: `contextSpec entry "${k}" sounds like a discrete event (submit/send/confirm/etc.). If it drives an agent turn, consider actionSpec instead.`,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// 4. contextSpec slot key collides with a propsSpec property name.
|
|
416
|
+
// SPEC §2.10 MUST — the generated boilerplate binds both into the
|
|
417
|
+
// same scope, so a collision would shadow the prop. The protocol's
|
|
418
|
+
// `CTR_DUP_NAME` only covers action/stream/context collisions, NOT
|
|
419
|
+
// props↔context — so this is the one cross-spec collision the synth
|
|
420
|
+
// gate would otherwise miss. `error` severity → the synth's repair
|
|
421
|
+
// loop fixes it.
|
|
422
|
+
const propKeys = Object.keys(contract.propsSpec?.properties ?? {});
|
|
423
|
+
for (const k of contextKeys) {
|
|
424
|
+
if (propKeys.includes(k)) {
|
|
425
|
+
findings.push({
|
|
426
|
+
kind: 'context-props-name-collision',
|
|
427
|
+
severity: 'error',
|
|
428
|
+
slotName: k,
|
|
429
|
+
hint: `contextSpec slot "${k}" collides with propsSpec.properties.${k} — the generated boilerplate would shadow the prop binding. Rename one of them.`,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return { findings };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// =============================================================================
|
|
438
|
+
// Coherence validator (synchronous, intent-aware — the one validator
|
|
439
|
+
// that reads the intent, not just the contract)
|
|
440
|
+
// =============================================================================
|
|
441
|
+
//
|
|
442
|
+
// Catches the "degenerate contract" flake: the synthesizer occasionally
|
|
443
|
+
// emits a contract that is JUST an actionSpec with no data surface at
|
|
444
|
+
// all — no contextSpec, no propsSpec, no streamSpec, no
|
|
445
|
+
// clientCapabilities. The agent then receives a contentless event it
|
|
446
|
+
// cannot act on (a `finish` for a checkout it has no data for; a
|
|
447
|
+
// `share` for an article it was never given).
|
|
448
|
+
//
|
|
449
|
+
// `{actionSpec: {confirm, cancel}}` with no data surface is the ONE
|
|
450
|
+
// legitimate no-surface shape (a pure-decision modal) — so the rule is
|
|
451
|
+
// gated on the intent: it fires only when the intent describes a UI
|
|
452
|
+
// that demonstrably displays or collects data (a flow / form / article
|
|
453
|
+
// / profile / …). A pure-decision modal intent matches none of those,
|
|
454
|
+
// so it is never flagged.
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Intent signal for "this UI displays or collects data, so the
|
|
458
|
+
* contract MUST declare a data surface." Deliberately narrow — every
|
|
459
|
+
* keyword is an intent that genuinely cannot work as actionSpec-only.
|
|
460
|
+
*/
|
|
461
|
+
// `card` and `flow` are deliberately EXCLUDED — too common in English
|
|
462
|
+
// ("a card to confirm this flow" is a legit pure-decision modal). The
|
|
463
|
+
// flow-* corpus intents still match via `wizard` / `checkout` /
|
|
464
|
+
// `onboard` / `multi-step`, so coverage is unchanged.
|
|
465
|
+
const DATA_SURFACE_INTENT_PATTERN =
|
|
466
|
+
/\bwizard\b|multi-?step|\d+-step|\bcheckout\b|\bonboard|\barticle\b|\bdocument\b|\bprofile\b|\bdashboard\b|\bform\b|\breport\b|\beditor\b/i;
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Validate that a contract is coherent with its intent. The only
|
|
470
|
+
* intent-aware validator: it reads the natural-language intent
|
|
471
|
+
* alongside the contract.
|
|
472
|
+
*
|
|
473
|
+
* One rule — `incoherent-no-data-surface`: the intent describes a
|
|
474
|
+
* data-bearing UI but the contract declares only EMPTY-payload
|
|
475
|
+
* actions, with no contextSpec / propsSpec / streamSpec /
|
|
476
|
+
* clientCapabilities. That contract is structurally degenerate — the
|
|
477
|
+
* agent gets a contentless event with nothing behind it. Emitted at
|
|
478
|
+
* `severity: 'error'` so the synth's repair loop retries (the
|
|
479
|
+
* contract is protocol-valid, so nothing else flags it).
|
|
480
|
+
*
|
|
481
|
+
* The empty-payload condition matters: an action that DOES carry a
|
|
482
|
+
* payload (e.g. an `autosave` action whose schema includes the draft
|
|
483
|
+
* text) gives the agent data through the payload — that is not
|
|
484
|
+
* degenerate and is NOT flagged.
|
|
485
|
+
*
|
|
486
|
+
* Pure / synchronous. The rule fires ONLY on the degenerate output —
|
|
487
|
+
* a correct contract for any data-bearing intent always has a
|
|
488
|
+
* surface, so it never false-positives on good output.
|
|
489
|
+
*/
|
|
490
|
+
export function validateContractCoherence(
|
|
491
|
+
contract: DataContract,
|
|
492
|
+
intent: string,
|
|
493
|
+
): ContractValidationResult {
|
|
494
|
+
const findings: ContractValidationFinding[] = [];
|
|
495
|
+
const actions = contract.actionSpec ?? {};
|
|
496
|
+
const hasAction = Object.keys(actions).length > 0;
|
|
497
|
+
const allActionsEmptyPayload = Object.values(actions).every((a) =>
|
|
498
|
+
isEmptyPayloadSchema(a.schema),
|
|
499
|
+
);
|
|
500
|
+
const hasContext =
|
|
501
|
+
contract.contextSpec !== undefined &&
|
|
502
|
+
Object.keys(contract.contextSpec).length > 0;
|
|
503
|
+
const hasProps =
|
|
504
|
+
contract.propsSpec?.properties !== undefined &&
|
|
505
|
+
Object.keys(contract.propsSpec.properties).length > 0;
|
|
506
|
+
const hasStream =
|
|
507
|
+
contract.streamSpec !== undefined &&
|
|
508
|
+
Object.keys(contract.streamSpec).length > 0;
|
|
509
|
+
const hasGadgets =
|
|
510
|
+
contract.clientCapabilities?.gadgets !== undefined &&
|
|
511
|
+
Object.keys(contract.clientCapabilities.gadgets).length > 0;
|
|
512
|
+
|
|
513
|
+
if (
|
|
514
|
+
hasAction &&
|
|
515
|
+
allActionsEmptyPayload &&
|
|
516
|
+
!hasContext &&
|
|
517
|
+
!hasProps &&
|
|
518
|
+
!hasStream &&
|
|
519
|
+
!hasGadgets &&
|
|
520
|
+
DATA_SURFACE_INTENT_PATTERN.test(intent)
|
|
521
|
+
) {
|
|
522
|
+
findings.push({
|
|
523
|
+
kind: 'incoherent-no-data-surface',
|
|
524
|
+
severity: 'error',
|
|
525
|
+
hint: 'The intent describes a UI that displays or collects data, but the contract declares only an actionSpec — no contextSpec, propsSpec, or streamSpec. The agent would receive an event with nothing behind it. Declare the data surface: contextSpec for state the user enters / the UI tracks (a wizard\'s step + form fields), or propsSpec for content the agent supplies at render (an article, a profile).',
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
return { findings };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// =============================================================================
|
|
532
|
+
// Novelty validator (async, depends on embedding + vector store)
|
|
533
|
+
// =============================================================================
|
|
534
|
+
|
|
535
|
+
const DEFAULT_NOVELTY_THRESHOLD_COSINE = 0.8;
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Run the cosine-distance novelty detector. Embeds the contract via
|
|
539
|
+
* `summarizeContract` and queries the vector store for the nearest
|
|
540
|
+
* neighbor in `scope`. Distance above `thresholdCosine` yields a
|
|
541
|
+
* `novel-shape` finding so operators see "this contract is far from
|
|
542
|
+
* anything we've registered — review encouraged" before it ships.
|
|
543
|
+
*
|
|
544
|
+
* Defaults to `severity: 'warn'`. Distance is computed as
|
|
545
|
+
* `1 - cosineSimilarity`; `VectorStore.query` returns
|
|
546
|
+
* cosine-similarity scores in `[0, 1]` per the seam contract.
|
|
547
|
+
*
|
|
548
|
+
* Empty index (no nearest neighbor in scope) ⇒ flag with
|
|
549
|
+
* `cosine: undefined` because a fresh registry will register
|
|
550
|
+
* everything as novel; operators learn that the registry is empty.
|
|
551
|
+
*/
|
|
552
|
+
export async function validateContractNovelty(
|
|
553
|
+
contract: DataContract,
|
|
554
|
+
deps: ContractValidationNoveltyDeps,
|
|
555
|
+
options: ContractValidationNoveltyOptions = {},
|
|
556
|
+
): Promise<ContractValidationResult> {
|
|
557
|
+
const threshold = options.thresholdCosine ?? DEFAULT_NOVELTY_THRESHOLD_COSINE;
|
|
558
|
+
const summary = summarizeContract(contract);
|
|
559
|
+
const queryEmbedding = await deps.embedding.embed(summary);
|
|
560
|
+
const results = await deps.vectorStore.query(deps.scope, queryEmbedding, 1);
|
|
561
|
+
if (results.length === 0) {
|
|
562
|
+
return {
|
|
563
|
+
findings: [
|
|
564
|
+
{
|
|
565
|
+
kind: 'novel-shape',
|
|
566
|
+
severity: 'warn',
|
|
567
|
+
hint: `No registered blueprints in scope "${deps.scope}" — this contract has no neighbors. Review encouraged before it becomes the seed for the registry.`,
|
|
568
|
+
},
|
|
569
|
+
],
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
const nearest = results[0];
|
|
573
|
+
if (!nearest) {
|
|
574
|
+
return { findings: [] };
|
|
575
|
+
}
|
|
576
|
+
const distance = 1 - nearest.score;
|
|
577
|
+
if (distance < threshold) {
|
|
578
|
+
return { findings: [] };
|
|
579
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
findings: [
|
|
582
|
+
{
|
|
583
|
+
kind: 'novel-shape',
|
|
584
|
+
severity: 'warn',
|
|
585
|
+
cosine: nearest.score,
|
|
586
|
+
hint: `Cosine distance ${distance.toFixed(3)} from nearest registered blueprint exceeds threshold ${threshold.toFixed(3)} — review encouraged before the contract pollutes the registry's neighborhood.`,
|
|
587
|
+
},
|
|
588
|
+
],
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Render a findings array as a single human-readable line for use in
|
|
594
|
+
* synthesizer `reason` strings, cache trace events, and operator logs.
|
|
595
|
+
* Empty findings → empty string so callers can append unconditionally.
|
|
596
|
+
*/
|
|
597
|
+
export function formatValidationFindings(
|
|
598
|
+
result: ContractValidationResult,
|
|
599
|
+
): string {
|
|
600
|
+
if (result.findings.length === 0) return '';
|
|
601
|
+
return result.findings
|
|
602
|
+
.map((f) => `[${f.severity}:${f.kind}] ${f.hint}`)
|
|
603
|
+
.join(' | ');
|
|
604
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `NegotiatorDecisionInput` — the full input to `makeDecision`.
|
|
3
|
+
*
|
|
4
|
+
* Kept in its own file so a typed re-export shim in
|
|
5
|
+
* `core/negotiation/src/types.ts` can keep the legacy import path
|
|
6
|
+
* alive without pulling the (bigger, commit-5) decision runtime
|
|
7
|
+
* into a types-only import.
|
|
8
|
+
*
|
|
9
|
+
* The `blueprintCandidates` entry shape is inlined intentionally —
|
|
10
|
+
* those five fields are the only projection `makeDecision` reads
|
|
11
|
+
* from a `NegotiatorOption`; extracting a named type here would
|
|
12
|
+
* grow public surface for no consumer.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { GadgetDescriptor, DataContract } from '@ggui-ai/protocol';
|
|
16
|
+
import type { SessionState } from './session.js';
|
|
17
|
+
|
|
18
|
+
/** Input to the decision engine. */
|
|
19
|
+
export interface NegotiatorDecisionInput {
|
|
20
|
+
agentData?: Record<string, unknown>;
|
|
21
|
+
agentPrompt?: string;
|
|
22
|
+
agentContext?: string | Record<string, unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* MCP tools the AGENT invokes (catalog seed). The decision engine
|
|
25
|
+
* merges these into the resulting contract's
|
|
26
|
+
* `agentCapabilities.tools` catalog. Cross-references are authored
|
|
27
|
+
* by the LLM: the catalog is referenced from
|
|
28
|
+
* `actionSpec[*].nextStep` (post-action hint for the agent's next
|
|
29
|
+
* turn) and `streamSpec[*].source.tool` (channel data source). The
|
|
30
|
+
* component never calls these.
|
|
31
|
+
*/
|
|
32
|
+
agentTools?: string[];
|
|
33
|
+
/**
|
|
34
|
+
* Browser-capability gadget catalog declared for the app. The
|
|
35
|
+
* handshake handler reads this from `app.gadgets` and
|
|
36
|
+
* threads it here so the decision LLM knows which gadget bindings
|
|
37
|
+
* the produced UI may reference (and so the merge step can enrich
|
|
38
|
+
* partial LLM output with canonical entries from the catalog).
|
|
39
|
+
*/
|
|
40
|
+
gadgets?: readonly GadgetDescriptor[];
|
|
41
|
+
sessionState: SessionState;
|
|
42
|
+
blueprintCandidates: Array<{
|
|
43
|
+
blueprintId: string;
|
|
44
|
+
description: string;
|
|
45
|
+
contract?: DataContract;
|
|
46
|
+
similarity: number;
|
|
47
|
+
verdict: 'exact' | 'partial';
|
|
48
|
+
}>;
|
|
49
|
+
}
|