@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.
Files changed (91) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +49 -0
  3. package/dist/contract-hash.d.ts +54 -0
  4. package/dist/contract-hash.d.ts.map +1 -0
  5. package/dist/contract-hash.js +96 -0
  6. package/dist/contract-validators.d.ts +171 -0
  7. package/dist/contract-validators.d.ts.map +1 -0
  8. package/dist/contract-validators.js +478 -0
  9. package/dist/decision-input.d.ts +48 -0
  10. package/dist/decision-input.d.ts.map +1 -0
  11. package/dist/decision-input.js +14 -0
  12. package/dist/decision.d.ts +54 -0
  13. package/dist/decision.d.ts.map +1 -0
  14. package/dist/decision.js +500 -0
  15. package/dist/index.d.ts +36 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +25 -0
  18. package/dist/intent.d.ts +22 -0
  19. package/dist/intent.d.ts.map +1 -0
  20. package/dist/intent.js +28 -0
  21. package/dist/llm-caller.d.ts +70 -0
  22. package/dist/llm-caller.d.ts.map +1 -0
  23. package/dist/llm-caller.js +38 -0
  24. package/dist/llm-rerank.d.ts +101 -0
  25. package/dist/llm-rerank.d.ts.map +1 -0
  26. package/dist/llm-rerank.js +178 -0
  27. package/dist/negotiate.d.ts +141 -0
  28. package/dist/negotiate.d.ts.map +1 -0
  29. package/dist/negotiate.js +161 -0
  30. package/dist/normalize-schema.d.ts +22 -0
  31. package/dist/normalize-schema.d.ts.map +1 -0
  32. package/dist/normalize-schema.js +191 -0
  33. package/dist/pure.d.ts +30 -0
  34. package/dist/pure.d.ts.map +1 -0
  35. package/dist/pure.js +43 -0
  36. package/dist/rag-search.d.ts +73 -0
  37. package/dist/rag-search.d.ts.map +1 -0
  38. package/dist/rag-search.js +192 -0
  39. package/dist/rerank-eval/pairs.d.ts +28 -0
  40. package/dist/rerank-eval/pairs.d.ts.map +1 -0
  41. package/dist/rerank-eval/pairs.js +531 -0
  42. package/dist/rerank-eval/run-probe-cli.d.ts +3 -0
  43. package/dist/rerank-eval/run-probe-cli.d.ts.map +1 -0
  44. package/dist/rerank-eval/run-probe-cli.js +146 -0
  45. package/dist/rerank-eval/run-probe.d.ts +68 -0
  46. package/dist/rerank-eval/run-probe.d.ts.map +1 -0
  47. package/dist/rerank-eval/run-probe.js +113 -0
  48. package/dist/session.d.ts +42 -0
  49. package/dist/session.d.ts.map +1 -0
  50. package/dist/session.js +21 -0
  51. package/dist/suggestion.d.ts +38 -0
  52. package/dist/suggestion.d.ts.map +1 -0
  53. package/dist/suggestion.js +47 -0
  54. package/dist/synth-bench/corpus.d.ts +106 -0
  55. package/dist/synth-bench/corpus.d.ts.map +1 -0
  56. package/dist/synth-bench/corpus.js +994 -0
  57. package/dist/synth-bench/run-bench-cli.d.ts +3 -0
  58. package/dist/synth-bench/run-bench-cli.d.ts.map +1 -0
  59. package/dist/synth-bench/run-bench-cli.js +181 -0
  60. package/dist/synth-bench/run-bench.d.ts +101 -0
  61. package/dist/synth-bench/run-bench.d.ts.map +1 -0
  62. package/dist/synth-bench/run-bench.js +374 -0
  63. package/dist/synthesize-contract.d.ts +131 -0
  64. package/dist/synthesize-contract.d.ts.map +1 -0
  65. package/dist/synthesize-contract.js +948 -0
  66. package/dist/types.d.ts +30 -0
  67. package/dist/types.d.ts.map +1 -0
  68. package/dist/types.js +13 -0
  69. package/package.json +74 -0
  70. package/src/contract-hash.ts +102 -0
  71. package/src/contract-validators.ts +604 -0
  72. package/src/decision-input.ts +49 -0
  73. package/src/decision.ts +581 -0
  74. package/src/index.ts +63 -0
  75. package/src/intent.ts +37 -0
  76. package/src/llm-caller.ts +82 -0
  77. package/src/llm-rerank.ts +280 -0
  78. package/src/negotiate.ts +312 -0
  79. package/src/normalize-schema.ts +193 -0
  80. package/src/pure.ts +46 -0
  81. package/src/rag-search.ts +274 -0
  82. package/src/rerank-eval/pairs.ts +624 -0
  83. package/src/rerank-eval/run-probe-cli.ts +197 -0
  84. package/src/rerank-eval/run-probe.ts +198 -0
  85. package/src/session.ts +41 -0
  86. package/src/suggestion.ts +73 -0
  87. package/src/synth-bench/corpus.ts +1126 -0
  88. package/src/synth-bench/run-bench-cli.ts +237 -0
  89. package/src/synth-bench/run-bench.ts +525 -0
  90. package/src/synthesize-contract.ts +1161 -0
  91. 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
+ }