@ggui-ai/negotiator 0.2.0-alpha.1 → 0.2.0-alpha.4

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 (43) hide show
  1. package/dist/ensure-conforming-contract.d.ts +70 -0
  2. package/dist/ensure-conforming-contract.d.ts.map +1 -0
  3. package/dist/ensure-conforming-contract.js +115 -0
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -0
  7. package/dist/normalize-draft.d.ts +33 -0
  8. package/dist/normalize-draft.d.ts.map +1 -0
  9. package/dist/normalize-draft.js +143 -0
  10. package/dist/preserve-seed-surfaces.d.ts +40 -0
  11. package/dist/preserve-seed-surfaces.d.ts.map +1 -0
  12. package/dist/preserve-seed-surfaces.js +57 -0
  13. package/dist/synth-bench/cli-llm.d.ts +20 -0
  14. package/dist/synth-bench/cli-llm.d.ts.map +1 -0
  15. package/dist/synth-bench/cli-llm.js +97 -0
  16. package/dist/synth-bench/corpus.d.ts +52 -0
  17. package/dist/synth-bench/corpus.d.ts.map +1 -1
  18. package/dist/synth-bench/corpus.js +306 -5
  19. package/dist/synth-bench/round-trip-score.d.ts +87 -0
  20. package/dist/synth-bench/round-trip-score.d.ts.map +1 -0
  21. package/dist/synth-bench/round-trip-score.js +105 -0
  22. package/dist/synth-bench/run-bench-cli.js +6 -82
  23. package/dist/synth-bench/run-repair-bench-cli.d.ts +3 -0
  24. package/dist/synth-bench/run-repair-bench-cli.d.ts.map +1 -0
  25. package/dist/synth-bench/run-repair-bench-cli.js +86 -0
  26. package/dist/synth-bench/run-repair-bench.d.ts +94 -0
  27. package/dist/synth-bench/run-repair-bench.d.ts.map +1 -0
  28. package/dist/synth-bench/run-repair-bench.js +172 -0
  29. package/dist/synthesize-contract.d.ts +38 -6
  30. package/dist/synthesize-contract.d.ts.map +1 -1
  31. package/dist/synthesize-contract.js +246 -32
  32. package/package.json +5 -4
  33. package/src/ensure-conforming-contract.ts +175 -0
  34. package/src/index.ts +2 -0
  35. package/src/normalize-draft.ts +156 -0
  36. package/src/preserve-seed-surfaces.ts +61 -0
  37. package/src/synth-bench/cli-llm.ts +140 -0
  38. package/src/synth-bench/corpus.ts +335 -5
  39. package/src/synth-bench/round-trip-score.ts +169 -0
  40. package/src/synth-bench/run-bench-cli.ts +13 -115
  41. package/src/synth-bench/run-repair-bench-cli.ts +119 -0
  42. package/src/synth-bench/run-repair-bench.ts +266 -0
  43. package/src/synthesize-contract.ts +299 -37
@@ -20,11 +20,33 @@
20
20
  */
21
21
 
22
22
  import type { GadgetDescriptor } from '@ggui-ai/protocol';
23
+ import type { RoundTripExpectation } from './round-trip-score.js';
23
24
 
24
25
  export interface BenchEntry {
25
26
  readonly id: string;
26
27
  readonly intent: string;
27
28
  readonly expected: BenchExpectation;
29
+ /**
30
+ * Repair-path seed. When set, the entry exercises the FORGIVING
31
+ * handshake flow rather than synthesize-from-intent: the runner feeds
32
+ * this (possibly malformed / suboptimal) agent draft to
33
+ * `ensureConformingContract`, which lints it → returns verbatim if
34
+ * clean (origin `agent`) or repairs-in-place if not (origin `synth`).
35
+ * Mirrors what a real agent submits on `ggui_handshake.blueprintDraft`.
36
+ * Typed `unknown` because the draft is untrusted — it may not be a
37
+ * valid `DataContract` (that is the whole point of repairing it).
38
+ *
39
+ * Entries WITHOUT a draft run the classic synthesize-from-intent path.
40
+ */
41
+ readonly draft?: unknown;
42
+ /**
43
+ * Round-trip quality expectation — what the agent intends to do with
44
+ * the AGREED contract next turn (seed props on `ggui_render`, consume
45
+ * gestures). The round-trip scorer asserts the produced contract
46
+ * actually supports it. Only meaningful alongside a `draft` (the
47
+ * repair corpus); leave absent for shape-only entries.
48
+ */
49
+ readonly roundTrip?: RoundTripExpectation;
28
50
  /**
29
51
  * Per-entry registered gadget catalog. When set, the runner
30
52
  * forwards this to `synthesizeContract`'s `appGadgets` option so
@@ -416,13 +438,20 @@ export const BENCH_CORPUS: readonly BenchEntry[] = [
416
438
  intent: 'an agent-backed todo list that persists across sessions',
417
439
  expected: {
418
440
  hasActionSpec: true,
419
- hasContextSpec: true,
441
+ hasContextSpec: false,
420
442
  hasStreamSpec: false,
421
- hasProps: false,
422
- actionNames: ['addTodo', 'deleteTodo', 'add', 'delete', 'create', 'remove'],
423
- contextSlots: ['todos', 'items'],
443
+ hasProps: true,
444
+ actionNames: [
445
+ 'addTodo',
446
+ 'deleteTodo',
447
+ 'toggle',
448
+ 'add',
449
+ 'delete',
450
+ 'create',
451
+ 'remove',
452
+ ],
424
453
  notes:
425
- 'agent-backed = explicit persistence = each add/delete IS a discrete event the agent must witness.',
454
+ 'agent-backed/persisted = the agent OWNS the items → todos seed on propsSpec (refreshed via ggui_update); add/delete/toggle are discrete events on actionSpec. contextSpec has no agent-push channel, so an agent-owned persisted list there cannot round-trip — this is the round-trip-correct shape, aligned with list-message-thread / list-file-browser (both props-bearing agent-supplied collections).',
426
455
  },
427
456
  },
428
457
  {
@@ -1124,3 +1153,304 @@ export const BENCH_CORPUS: readonly BenchEntry[] = [
1124
1153
  appGadgets: [CHART_DESCRIPTOR],
1125
1154
  },
1126
1155
  ];
1156
+
1157
+ /**
1158
+ * Repair-path corpus — the round-trip QUALITY bench.
1159
+ *
1160
+ * Where {@link BENCH_CORPUS} asks "does synth emit the right SHAPE from
1161
+ * an intent?", this corpus asks the contract-quality question: "when an
1162
+ * agent submits a draft to `ggui_handshake`, does the negotiator's
1163
+ * repair produce a contract that is round-trip-USABLE?" Each entry
1164
+ * carries a real-shaped agent `draft` (run through the production
1165
+ * `ensureConformingContract` create-path) plus a `roundTrip`
1166
+ * expectation describing what the agent does next turn (seed props,
1167
+ * consume gestures). The new round-trip scorer mirrors the render +
1168
+ * consume wire gates, so a "valid-but-broken" repair fails here even
1169
+ * though `lintContract` and the shape scorer pass it.
1170
+ *
1171
+ * The four entries map a 2×2 of (repair-fired?) × (round-trip-survives?):
1172
+ * - `repair-todo-seed` — repair FIRES and BREAKS round-trip (RED today;
1173
+ * the contract-quality falsifier — synth reshapes
1174
+ * the seedable `todos` collection propsSpec →
1175
+ * contextSpec, deleting the only agent seed channel).
1176
+ * - `repair-todo-clean` — no repair needed (origin `agent`, fast-path);
1177
+ * round-trip survives. Pins the no-false-positive case.
1178
+ * - `repair-form-nextstep` — repair FIRES (dangling nextStep) and PRESERVES the
1179
+ * action round-trip. A "good repair" of a non-seed error.
1180
+ * - `repair-weather-seed` — repair FIRES (stray wrapper key) and PRESERVES the
1181
+ * props round-trip. A "good repair" that keeps propsSpec.
1182
+ *
1183
+ * Used by run-repair-bench(-cli).ts (live LLM probe) and
1184
+ * round-trip-score.test.ts (deterministic scorer pinning).
1185
+ */
1186
+ export const REPAIR_CORPUS: readonly BenchEntry[] = [
1187
+ {
1188
+ // THE falsifier. The agent reaches for a JSON-Schema reflex — a
1189
+ // collection on propsSpec plus a wrapper-level `required` names-array
1190
+ // (illegal: required-ness is a per-prop boolean; the wrapper only
1191
+ // takes `description` + `properties`). That single
1192
+ // CTR_SHAPE_UNRECOGNIZED_KEYS bounces the draft off the fast path
1193
+ // into repair. The MINIMAL correct repair is "drop the stray
1194
+ // `required` key — `todos` is render-time seed data, it stays on
1195
+ // propsSpec." But synth's "a mutable collection is ALWAYS contextSpec"
1196
+ // heuristic instead reshapes propsSpec → contextSpec, producing a
1197
+ // VALID contract with no propsSpec — which the accept-path then drops
1198
+ // the agent's `props: { todos }` against. RED on BOTH the shape scorer
1199
+ // (has-props-mismatch) and the round-trip scorer (props-no-home) until
1200
+ // the negotiator learns seed data stays on propsSpec.
1201
+ id: 'repair-todo-seed',
1202
+ intent:
1203
+ 'show my todos, let me check off completed ones and add new items',
1204
+ draft: {
1205
+ propsSpec: {
1206
+ description: "The user's todo items",
1207
+ // ⚠️ FATAL: `required` is not a legal key on the propsSpec wrapper.
1208
+ required: ['todos'],
1209
+ properties: {
1210
+ todos: {
1211
+ required: true,
1212
+ schema: {
1213
+ type: 'array',
1214
+ items: {
1215
+ type: 'object',
1216
+ properties: {
1217
+ id: { type: 'string' },
1218
+ text: { type: 'string' },
1219
+ completed: { type: 'boolean' },
1220
+ },
1221
+ required: ['id', 'text', 'completed'],
1222
+ },
1223
+ },
1224
+ },
1225
+ },
1226
+ },
1227
+ actionSpec: {
1228
+ toggleTodo: {
1229
+ label: 'Toggle todo',
1230
+ schema: {
1231
+ type: 'object',
1232
+ properties: { id: { type: 'string' } },
1233
+ required: ['id'],
1234
+ },
1235
+ nextStep: 'todo_toggle',
1236
+ },
1237
+ addTodo: {
1238
+ label: 'Add todo',
1239
+ schema: {
1240
+ type: 'object',
1241
+ properties: { text: { type: 'string' } },
1242
+ required: ['text'],
1243
+ },
1244
+ nextStep: 'todo_add',
1245
+ },
1246
+ },
1247
+ agentCapabilities: {
1248
+ tools: {
1249
+ todo_toggle: {
1250
+ description: 'Flip a todo done/undone',
1251
+ inputSchema: {
1252
+ type: 'object',
1253
+ properties: { id: { type: 'string' } },
1254
+ required: ['id'],
1255
+ },
1256
+ },
1257
+ todo_add: {
1258
+ description: 'Append a new todo',
1259
+ inputSchema: {
1260
+ type: 'object',
1261
+ properties: { text: { type: 'string' } },
1262
+ required: ['text'],
1263
+ },
1264
+ },
1265
+ },
1266
+ },
1267
+ },
1268
+ expected: {
1269
+ // The GOOD outcome: todos stays seedable on propsSpec, toggle/add
1270
+ // stay as actions, NO contextSpec reshape.
1271
+ hasActionSpec: true,
1272
+ hasContextSpec: false,
1273
+ hasStreamSpec: false,
1274
+ hasProps: true,
1275
+ actionNames: ['toggle', 'add'],
1276
+ notes:
1277
+ 'contract-quality falsifier: synth currently reshapes the seedable todos collection propsSpec → contextSpec, breaking the agent seed round-trip. Expected GREEN once the negotiator preserves render-time seed data on propsSpec.',
1278
+ },
1279
+ roundTrip: {
1280
+ renderProps: {
1281
+ todos: [{ id: 't1', text: 'Buy milk', completed: false }],
1282
+ },
1283
+ consumableActions: ['toggleTodo', 'addTodo'],
1284
+ },
1285
+ },
1286
+ {
1287
+ // The fast-path / no-false-positive case. Same intent, but the agent
1288
+ // submitted a CLEAN, round-trip-correct draft (todos on propsSpec, no
1289
+ // stray key, resolvable nextStep cross-refs). ensureConformingContract
1290
+ // returns it VERBATIM (origin `agent`, no LLM call). The round-trip
1291
+ // scorer MUST pass — if it flags this, the scorer is over-eager.
1292
+ id: 'repair-todo-clean',
1293
+ intent:
1294
+ 'show my todos, let me check off completed ones and add new items',
1295
+ draft: {
1296
+ propsSpec: {
1297
+ description: "The user's todo items",
1298
+ properties: {
1299
+ todos: {
1300
+ required: true,
1301
+ schema: {
1302
+ type: 'array',
1303
+ items: {
1304
+ type: 'object',
1305
+ properties: {
1306
+ id: { type: 'string' },
1307
+ text: { type: 'string' },
1308
+ completed: { type: 'boolean' },
1309
+ },
1310
+ required: ['id', 'text', 'completed'],
1311
+ },
1312
+ },
1313
+ },
1314
+ },
1315
+ },
1316
+ actionSpec: {
1317
+ toggleTodo: {
1318
+ label: 'Toggle todo',
1319
+ schema: {
1320
+ type: 'object',
1321
+ properties: { id: { type: 'string' } },
1322
+ required: ['id'],
1323
+ },
1324
+ nextStep: 'todo_toggle',
1325
+ },
1326
+ addTodo: {
1327
+ label: 'Add todo',
1328
+ schema: {
1329
+ type: 'object',
1330
+ properties: { text: { type: 'string' } },
1331
+ required: ['text'],
1332
+ },
1333
+ nextStep: 'todo_add',
1334
+ },
1335
+ },
1336
+ agentCapabilities: {
1337
+ tools: {
1338
+ todo_toggle: {
1339
+ description: 'Flip a todo done/undone',
1340
+ inputSchema: {
1341
+ type: 'object',
1342
+ properties: { id: { type: 'string' } },
1343
+ required: ['id'],
1344
+ },
1345
+ },
1346
+ todo_add: {
1347
+ description: 'Append a new todo',
1348
+ inputSchema: {
1349
+ type: 'object',
1350
+ properties: { text: { type: 'string' } },
1351
+ required: ['text'],
1352
+ },
1353
+ },
1354
+ },
1355
+ },
1356
+ },
1357
+ expected: {
1358
+ hasActionSpec: true,
1359
+ hasContextSpec: false,
1360
+ hasStreamSpec: false,
1361
+ hasProps: true,
1362
+ actionNames: ['toggle', 'add'],
1363
+ notes:
1364
+ 'clean draft — must pass the fast path verbatim (origin agent) and the round-trip scorer. Guards against the scorer false-positiving on a good contract.',
1365
+ },
1366
+ roundTrip: {
1367
+ renderProps: {
1368
+ todos: [{ id: 't1', text: 'Buy milk', completed: false }],
1369
+ },
1370
+ consumableActions: ['toggleTodo', 'addTodo'],
1371
+ },
1372
+ },
1373
+ {
1374
+ // Good repair of a NON-seed error. A contact form: fields are
1375
+ // client-originated draft state (contextSpec), submit is an action.
1376
+ // The draft's submit.nextStep points at a tool that isn't declared
1377
+ // (CTR_REF_NEXT_STEP dangling) → repair fires. The fix (add the tool
1378
+ // or drop the nextStep) has no reason to touch the `submit` action, so
1379
+ // the gesture round-trip survives. No seed props.
1380
+ id: 'repair-form-nextstep',
1381
+ intent: 'a contact form with name, email and message that sends to my backend',
1382
+ draft: {
1383
+ contextSpec: {
1384
+ name: { schema: { type: 'string' }, default: '' },
1385
+ email: { schema: { type: 'string' }, default: '' },
1386
+ message: { schema: { type: 'string' }, default: '' },
1387
+ },
1388
+ actionSpec: {
1389
+ submit: {
1390
+ label: 'Send message',
1391
+ schema: {
1392
+ type: 'object',
1393
+ properties: {
1394
+ name: { type: 'string' },
1395
+ email: { type: 'string' },
1396
+ message: { type: 'string' },
1397
+ },
1398
+ required: ['name', 'email', 'message'],
1399
+ },
1400
+ // ⚠️ dangling: no `send_message` tool declared below.
1401
+ nextStep: 'send_message',
1402
+ },
1403
+ },
1404
+ },
1405
+ expected: {
1406
+ hasActionSpec: true,
1407
+ hasContextSpec: true,
1408
+ hasStreamSpec: false,
1409
+ hasProps: false,
1410
+ actionNames: ['submit', 'send'],
1411
+ contextSlots: ['name', 'email', 'message'],
1412
+ notes:
1413
+ 'good repair of a dangling nextStep — the submit gesture must survive so the form round-trips.',
1414
+ },
1415
+ roundTrip: {
1416
+ consumableActions: ['submit'],
1417
+ },
1418
+ },
1419
+ {
1420
+ // Good repair that must KEEP propsSpec. A weather card is the
1421
+ // canonical props-only display case; the agent seeds city/temp/
1422
+ // conditions. The draft carries a stray `additionalProperties` key on
1423
+ // the propsSpec wrapper (illegal — only description+properties allowed)
1424
+ // → CTR_SHAPE_UNRECOGNIZED_KEYS → repair fires. The fix drops the stray
1425
+ // key; propsSpec (and the seed round-trip) must survive.
1426
+ id: 'repair-weather-seed',
1427
+ intent: 'show a weather card for a city with temperature and conditions',
1428
+ draft: {
1429
+ propsSpec: {
1430
+ description: 'Current weather',
1431
+ // ⚠️ FATAL: not a legal key on the propsSpec wrapper.
1432
+ additionalProperties: false,
1433
+ properties: {
1434
+ city: { schema: { type: 'string' }, required: true },
1435
+ temp: { schema: { type: 'number' }, required: true },
1436
+ conditions: { schema: { type: 'string' }, required: true },
1437
+ },
1438
+ },
1439
+ },
1440
+ expected: {
1441
+ hasActionSpec: false,
1442
+ hasContextSpec: false,
1443
+ hasStreamSpec: false,
1444
+ hasProps: true,
1445
+ notes:
1446
+ 'good repair of a stray wrapper key — propsSpec must survive so the seed round-trips.',
1447
+ },
1448
+ roundTrip: {
1449
+ renderProps: {
1450
+ city: 'San Francisco',
1451
+ temp: 62,
1452
+ conditions: 'Foggy',
1453
+ },
1454
+ },
1455
+ },
1456
+ ];
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Round-trip quality scorer.
3
+ *
4
+ * The shape scorer ({@link scoreSynthesizedContract}) answers "did the
5
+ * negotiator emit the right SPECS?" — a validity floor. This scorer
6
+ * answers the harder question the contract-quality frontier cares
7
+ * about: "is the produced contract actually USABLE end-to-end?" — i.e.
8
+ * after handshake agrees on it, can the agent `ggui_render` its seed
9
+ * data, and `ggui_consume` its gestures, without the wire rejecting or
10
+ * silently dropping anything?
11
+ *
12
+ * Why a structural scorer can't see this: a contract that reshapes a
13
+ * seedable collection from `propsSpec` to `contextSpec` is STRUCTURALLY
14
+ * VALID (passes `lintContract`) and PASSES the shape scorer (it has a
15
+ * contextSpec). Yet it is round-trip-BROKEN: `propsSpec` is the only
16
+ * agent→client seed channel (and the only `ggui_update` target);
17
+ * `contextSpec` is client→agent observed state with NO runtime seed
18
+ * path (`contextSpec.default` is `useState` scaffold, not wire data).
19
+ * So the agent's `ggui_render({props:{todos:[…]}})` hits a contract
20
+ * with no `propsSpec` — the accept-path silently drops the props and
21
+ * the UI renders empty; the override-path hard-throws.
22
+ *
23
+ * This scorer mirrors that exact render-handler gate deterministically
24
+ * (no LLM, no iframe), so the harness catches the "valid-but-broken"
25
+ * class the shape scorer is blind to. The gate it mirrors lives at
26
+ * `mcp-server-handlers/src/renders/render.ts:1218-1269` (props-vs-
27
+ * propsSpec) and the consume action-declaration requirement at
28
+ * `consume.ts` / `submit-action.ts` (every gesture intent MUST be a
29
+ * declared `actionSpec` key).
30
+ */
31
+
32
+ import type { DataContract } from '@ggui-ai/protocol';
33
+ import { validatePropsData } from '@ggui-ai/protocol';
34
+
35
+ /**
36
+ * What the agent intends to do with the agreed contract on the NEXT
37
+ * turn — the round-trip the produced contract must support. Authored
38
+ * per repair-corpus entry from the agent's original draft + intent.
39
+ */
40
+ export interface RoundTripExpectation {
41
+ /**
42
+ * The props the agent intends to pass on `ggui_render` once the
43
+ * contract is agreed — its render-time SEED data. Every key here MUST
44
+ * land in `contract.propsSpec.properties[key]` (the ONLY agent→client
45
+ * seed channel — `contextSpec` has no runtime seed path), and the
46
+ * value MUST satisfy that prop's schema. Mirrors the render handler's
47
+ * props-vs-propsSpec gate. Omit / leave empty for contracts with no
48
+ * agent-seeded data (pure forms, broadcasts, counters).
49
+ */
50
+ readonly renderProps?: Record<string, unknown>;
51
+ /**
52
+ * Gesture intents the agent expects to consume after render (e.g.
53
+ * `toggleTodo`, `submit`). Each MUST be a declared `actionSpec[*]`
54
+ * key, else the gesture is structurally unconsumable — the iframe has
55
+ * no declared intent to dispatch and `ggui_consume` never wakes.
56
+ */
57
+ readonly consumableActions?: readonly string[];
58
+ }
59
+
60
+ export type RoundTripFailureKind =
61
+ /** Repair bailed to an empty `{}` contract — nothing round-trips. */
62
+ | 'contract-empty'
63
+ /**
64
+ * The agent would seed props but the produced contract declares NO
65
+ * `propsSpec` — the accept-path drops them / the override-path throws.
66
+ * The canonical reshape regression (propsSpec → contextSpec).
67
+ */
68
+ | 'props-no-home'
69
+ /** A seed-prop key has no matching `propsSpec.properties` entry. */
70
+ | 'props-key-unhomed'
71
+ /** Seed props fail `validatePropsData` against the propsSpec (the
72
+ * real wire validator — wrong type, missing required, etc.). */
73
+ | 'props-rejected'
74
+ /** A consumable gesture intent is absent from `actionSpec`. */
75
+ | 'action-undeclared';
76
+
77
+ export interface RoundTripFailure {
78
+ readonly kind: RoundTripFailureKind;
79
+ readonly hint: string;
80
+ }
81
+
82
+ export interface RoundTripScore {
83
+ readonly pass: boolean;
84
+ readonly failures: readonly RoundTripFailure[];
85
+ }
86
+
87
+ /**
88
+ * True when a contract declares none of the six spec surfaces — the
89
+ * `EMPTY_CONTRACT` (`{}`) that `ensureConformingContract` returns when a
90
+ * draft is unrepairable. Such a contract is structurally valid but
91
+ * carries no wire at all.
92
+ */
93
+ function isEmptyContract(contract: DataContract): boolean {
94
+ return (
95
+ contract.propsSpec === undefined &&
96
+ contract.actionSpec === undefined &&
97
+ contract.streamSpec === undefined &&
98
+ contract.contextSpec === undefined &&
99
+ contract.agentCapabilities === undefined &&
100
+ contract.clientCapabilities === undefined
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Score whether a produced contract supports the agent's intended
106
+ * round-trip. Pure / deterministic — mirrors the render + consume wire
107
+ * gates so a "valid-but-round-trip-broken" contract fails here even
108
+ * though `lintContract` and {@link scoreSynthesizedContract} pass it.
109
+ */
110
+ export function scoreContractRoundTrip(
111
+ contract: DataContract,
112
+ rt: RoundTripExpectation,
113
+ ): RoundTripScore {
114
+ const failures: RoundTripFailure[] = [];
115
+ const renderProps = rt.renderProps ?? {};
116
+ const seedKeys = Object.keys(renderProps);
117
+ const consumable = rt.consumableActions ?? [];
118
+
119
+ // An empty contract round-trips nothing it was asked to carry.
120
+ if (isEmptyContract(contract) && (seedKeys.length > 0 || consumable.length > 0)) {
121
+ failures.push({
122
+ kind: 'contract-empty',
123
+ hint: `repair produced an empty contract ({}); the agent's seed data {${seedKeys.join(', ')}} and gestures [${consumable.join(', ')}] have no wire — the draft was unrepairable.`,
124
+ });
125
+ }
126
+
127
+ // Props acceptance — mirrors render.ts:1218-1269 (the props-vs-propsSpec gate).
128
+ if (seedKeys.length > 0) {
129
+ const propsSpec = contract.propsSpec;
130
+ if (propsSpec === undefined) {
131
+ failures.push({
132
+ kind: 'props-no-home',
133
+ hint: `agent would seed props {${seedKeys.join(', ')}} on ggui_render, but the produced contract declares NO propsSpec. The accept-path silently drops them (render.ts:1233-1253) and the override-path throws (render.ts:1254-1268); the UI renders empty. A seedable collection was likely reshaped to contextSpec, which has no agent→client seed channel.`,
134
+ });
135
+ } else {
136
+ const properties = propsSpec.properties ?? {};
137
+ const unhomed = seedKeys.filter((k) => properties[k] === undefined);
138
+ if (unhomed.length > 0) {
139
+ failures.push({
140
+ kind: 'props-key-unhomed',
141
+ hint: `seed-prop keys {${unhomed.join(', ')}} have no propsSpec.properties entry — ggui_render would reject them as undeclared props.`,
142
+ });
143
+ }
144
+ // The real wire validator the render handler runs (Branch A).
145
+ const result = validatePropsData(renderProps, propsSpec);
146
+ if (!result.valid) {
147
+ failures.push({
148
+ kind: 'props-rejected',
149
+ hint: `validatePropsData rejected the agent's seed props against the produced propsSpec: ${result.violations
150
+ .map((v) => `${v.field}: ${v.message}`)
151
+ .join('; ')}`,
152
+ });
153
+ }
154
+ }
155
+ }
156
+
157
+ // Consumable gestures — every intent the agent dispatches MUST be a
158
+ // declared actionSpec key, else the gesture is structurally undefined.
159
+ for (const action of consumable) {
160
+ if (contract.actionSpec?.[action] === undefined) {
161
+ failures.push({
162
+ kind: 'action-undeclared',
163
+ hint: `agent expects to consume gesture '${action}', but the produced contract's actionSpec has no '${action}' entry — the iframe has no declared intent to dispatch and ggui_consume never wakes.`,
164
+ });
165
+ }
166
+ }
167
+
168
+ return { pass: failures.length === 0, failures };
169
+ }
@@ -18,116 +18,16 @@
18
18
  *
19
19
  * Bench-only — not exported from the package index.
20
20
  */
21
- import { readFileSync } from 'node:fs';
22
- import { homedir } from 'node:os';
23
- import { resolve as pathResolve } from 'node:path';
24
21
  import { evaluateAgainstCorpus, formatBenchReport } from './run-bench.js';
25
- import type { LLMCaller, ToolSchema } from '../llm-caller.js';
26
22
  import { contractShape } from './corpus.js';
27
-
28
- const ANTHROPIC_API = 'https://api.anthropic.com/v1/messages';
29
- const DEFAULT_MODEL = 'claude-haiku-4-5';
30
-
31
- interface CredsFile {
32
- apps?: { global?: { anthropic?: string } };
33
- }
34
-
35
- function resolveAnthropicKey(): string {
36
- const envKey = process.env['ANTHROPIC_API_KEY'];
37
- if (envKey && envKey.length > 0) return envKey;
38
- const credsPath = pathResolve(homedir(), '.ggui', 'credentials.json');
39
- let parsed: CredsFile;
40
- try {
41
- parsed = JSON.parse(readFileSync(credsPath, 'utf8')) as CredsFile;
42
- } catch (err) {
43
- throw new Error(
44
- `bench-synth: could not read ${credsPath} (${err instanceof Error ? err.message : String(err)}). Set ANTHROPIC_API_KEY env var or run \`ggui auth set anthropic\`.`,
45
- );
46
- }
47
- const key = parsed.apps?.global?.anthropic;
48
- if (typeof key !== 'string' || key.length === 0) {
49
- throw new Error(
50
- `bench-synth: no anthropic key found at apps.global.anthropic in ${credsPath}.`,
51
- );
52
- }
53
- return key;
54
- }
55
-
56
- interface AnthropicContentBlock {
57
- type: string;
58
- name?: string;
59
- input?: unknown;
60
- text?: string;
61
- }
62
-
63
- interface AnthropicResponse {
64
- content?: AnthropicContentBlock[];
65
- usage?: { input_tokens?: number; output_tokens?: number };
66
- stop_reason?: string;
67
- error?: { type?: string; message?: string };
68
- }
69
-
70
- let totalInputTokens = 0;
71
- let totalOutputTokens = 0;
72
-
73
- function buildAnthropicLlmCaller(apiKey: string, model: string): LLMCaller {
74
- return {
75
- async call(): Promise<string> {
76
- throw new Error('bench-synth: text-mode not exercised — synth uses callStructured');
77
- },
78
- async callStructured<T>(
79
- systemPrompt: string,
80
- userMessage: string,
81
- tool: ToolSchema,
82
- maxTokens?: number,
83
- ): Promise<T> {
84
- // `temperature` deprecated on Haiku 4.5+ — Anthropic rejects with
85
- // HTTP 400. `tool_choice: { type: 'tool', name }` below already
86
- // binds output to the input_schema; residual stochasticity stays
87
- // bounded via canonical-key normalization downstream.
88
- const body = {
89
- model,
90
- max_tokens: maxTokens ?? 1024,
91
- system: systemPrompt,
92
- messages: [{ role: 'user', content: userMessage }],
93
- tools: [
94
- {
95
- name: tool.name,
96
- description: tool.description,
97
- input_schema: tool.input_schema,
98
- },
99
- ],
100
- tool_choice: { type: 'tool', name: tool.name },
101
- };
102
- const res = await fetch(ANTHROPIC_API, {
103
- method: 'POST',
104
- headers: {
105
- 'content-type': 'application/json',
106
- 'x-api-key': apiKey,
107
- 'anthropic-version': '2023-06-01',
108
- },
109
- body: JSON.stringify(body),
110
- });
111
- const json = (await res.json()) as AnthropicResponse;
112
- if (!res.ok) {
113
- const errType = json.error?.type ?? 'unknown';
114
- const errMsg = json.error?.message ?? `HTTP ${res.status}`;
115
- throw new Error(`anthropic ${errType}: ${errMsg}`);
116
- }
117
- if (json.usage) {
118
- totalInputTokens += json.usage.input_tokens ?? 0;
119
- totalOutputTokens += json.usage.output_tokens ?? 0;
120
- }
121
- const toolBlock = json.content?.find((b) => b.type === 'tool_use');
122
- if (!toolBlock || toolBlock.input === undefined) {
123
- throw new Error(
124
- `anthropic: no tool_use block in response (stop_reason=${json.stop_reason ?? 'unknown'})`,
125
- );
126
- }
127
- return toolBlock.input as T;
128
- },
129
- };
130
- }
23
+ import {
24
+ DEFAULT_MODEL,
25
+ HAIKU_4_5_PRICE_INPUT_PER_TOKEN,
26
+ HAIKU_4_5_PRICE_OUTPUT_PER_TOKEN,
27
+ buildAnthropicLlmCaller,
28
+ getTokenUsage,
29
+ resolveAnthropicKey,
30
+ } from './cli-llm.js';
131
31
 
132
32
  const SHAPE_FILTERS = [
133
33
  'props-only',
@@ -175,12 +75,9 @@ function parseArgs(argv: readonly string[]): CliArgs {
175
75
  return result;
176
76
  }
177
77
 
178
- const HAIKU_4_5_PRICE_INPUT_PER_TOKEN = 1.0 / 1_000_000;
179
- const HAIKU_4_5_PRICE_OUTPUT_PER_TOKEN = 5.0 / 1_000_000;
180
-
181
78
  async function main(): Promise<void> {
182
79
  const args = parseArgs(process.argv.slice(2));
183
- const apiKey = resolveAnthropicKey();
80
+ const apiKey = resolveAnthropicKey('bench-synth');
184
81
  const llm = buildAnthropicLlmCaller(apiKey, args.model);
185
82
 
186
83
  if (!args.json) {
@@ -216,13 +113,14 @@ async function main(): Promise<void> {
216
113
  process.stdout.write(formatBenchReport(report));
217
114
  process.stdout.write('\n\n');
218
115
 
116
+ const usage = getTokenUsage();
219
117
  const totalCost =
220
- totalInputTokens * HAIKU_4_5_PRICE_INPUT_PER_TOKEN +
221
- totalOutputTokens * HAIKU_4_5_PRICE_OUTPUT_PER_TOKEN;
118
+ usage.input * HAIKU_4_5_PRICE_INPUT_PER_TOKEN +
119
+ usage.output * HAIKU_4_5_PRICE_OUTPUT_PER_TOKEN;
222
120
  const callsMade = report.totals.all - report.totals.synthDeclined;
223
121
  const costPerCall = callsMade === 0 ? 0 : totalCost / callsMade;
224
122
  process.stdout.write(
225
- `Tokens: input=${totalInputTokens} output=${totalOutputTokens}\n`,
123
+ `Tokens: input=${usage.input} output=${usage.output}\n`,
226
124
  );
227
125
  process.stdout.write(
228
126
  `Cost: total=$${totalCost.toFixed(4)} per-call=$${costPerCall.toFixed(4)}\n`,