@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,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=run-bench-cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run-bench-cli.d.ts","sourceRoot":"","sources":["../../src/synth-bench/run-bench-cli.ts"],"names":[],"mappings":""}
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Synthesizer bench CLI — live LLM probe.
4
+ *
5
+ * Reads ~/.ggui/credentials.json for the Anthropic API key, runs
6
+ * synthesizeContract over BENCH_CORPUS, prints per-shape precision
7
+ * + the redundant-action firing count + p50/p95 latency.
8
+ *
9
+ * Costs ~$0.001 per entry on Haiku 4.5 → roughly $0.05 for the full
10
+ * 50+ corpus run. NOT run in CI; opt-in only.
11
+ *
12
+ * Usage:
13
+ * pnpm -F @ggui-ai/negotiator bench-synth
14
+ * pnpm -F @ggui-ai/negotiator bench-synth -- --limit 10
15
+ * pnpm -F @ggui-ai/negotiator bench-synth -- --shape context+action
16
+ * ANTHROPIC_API_KEY=sk-... pnpm -F @ggui-ai/negotiator bench-synth
17
+ * pnpm -F @ggui-ai/negotiator bench-synth -- --json > report.json
18
+ *
19
+ * Bench-only — not exported from the package index.
20
+ */
21
+ import { readFileSync } from 'node:fs';
22
+ import { homedir } from 'node:os';
23
+ import { resolve as pathResolve } from 'node:path';
24
+ import { evaluateAgainstCorpus, formatBenchReport } from './run-bench.js';
25
+ import { contractShape } from './corpus.js';
26
+ const ANTHROPIC_API = 'https://api.anthropic.com/v1/messages';
27
+ const DEFAULT_MODEL = 'claude-haiku-4-5';
28
+ function resolveAnthropicKey() {
29
+ const envKey = process.env['ANTHROPIC_API_KEY'];
30
+ if (envKey && envKey.length > 0)
31
+ return envKey;
32
+ const credsPath = pathResolve(homedir(), '.ggui', 'credentials.json');
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(readFileSync(credsPath, 'utf8'));
36
+ }
37
+ catch (err) {
38
+ throw new Error(`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\`.`);
39
+ }
40
+ const key = parsed.apps?.global?.anthropic;
41
+ if (typeof key !== 'string' || key.length === 0) {
42
+ throw new Error(`bench-synth: no anthropic key found at apps.global.anthropic in ${credsPath}.`);
43
+ }
44
+ return key;
45
+ }
46
+ let totalInputTokens = 0;
47
+ let totalOutputTokens = 0;
48
+ function buildAnthropicLlmCaller(apiKey, model) {
49
+ return {
50
+ async call() {
51
+ throw new Error('bench-synth: text-mode not exercised — synth uses callStructured');
52
+ },
53
+ async callStructured(systemPrompt, userMessage, tool, maxTokens) {
54
+ // `temperature` deprecated on Haiku 4.5+ — Anthropic rejects with
55
+ // HTTP 400. `tool_choice: { type: 'tool', name }` below already
56
+ // binds output to the input_schema; residual stochasticity stays
57
+ // bounded via canonical-key normalization downstream.
58
+ const body = {
59
+ model,
60
+ max_tokens: maxTokens ?? 1024,
61
+ system: systemPrompt,
62
+ messages: [{ role: 'user', content: userMessage }],
63
+ tools: [
64
+ {
65
+ name: tool.name,
66
+ description: tool.description,
67
+ input_schema: tool.input_schema,
68
+ },
69
+ ],
70
+ tool_choice: { type: 'tool', name: tool.name },
71
+ };
72
+ const res = await fetch(ANTHROPIC_API, {
73
+ method: 'POST',
74
+ headers: {
75
+ 'content-type': 'application/json',
76
+ 'x-api-key': apiKey,
77
+ 'anthropic-version': '2023-06-01',
78
+ },
79
+ body: JSON.stringify(body),
80
+ });
81
+ const json = (await res.json());
82
+ if (!res.ok) {
83
+ const errType = json.error?.type ?? 'unknown';
84
+ const errMsg = json.error?.message ?? `HTTP ${res.status}`;
85
+ throw new Error(`anthropic ${errType}: ${errMsg}`);
86
+ }
87
+ if (json.usage) {
88
+ totalInputTokens += json.usage.input_tokens ?? 0;
89
+ totalOutputTokens += json.usage.output_tokens ?? 0;
90
+ }
91
+ const toolBlock = json.content?.find((b) => b.type === 'tool_use');
92
+ if (!toolBlock || toolBlock.input === undefined) {
93
+ throw new Error(`anthropic: no tool_use block in response (stop_reason=${json.stop_reason ?? 'unknown'})`);
94
+ }
95
+ return toolBlock.input;
96
+ },
97
+ };
98
+ }
99
+ const SHAPE_FILTERS = [
100
+ 'props-only',
101
+ 'context-only',
102
+ 'context+action',
103
+ 'stream',
104
+ 'with-gadgets',
105
+ 'empty',
106
+ ];
107
+ function parseArgs(argv) {
108
+ let limit;
109
+ let shapeFilter;
110
+ let model = DEFAULT_MODEL;
111
+ let json = false;
112
+ for (let i = 0; i < argv.length; i++) {
113
+ const a = argv[i];
114
+ if (a === '--limit' && argv[i + 1]) {
115
+ limit = Number(argv[++i]);
116
+ }
117
+ else if (a === '--shape' && argv[i + 1]) {
118
+ const v = argv[++i];
119
+ if (SHAPE_FILTERS.includes(v)) {
120
+ shapeFilter = v;
121
+ }
122
+ else {
123
+ throw new Error(`unknown shape: ${v} (expected one of ${SHAPE_FILTERS.join(', ')})`);
124
+ }
125
+ }
126
+ else if (a === '--model' && argv[i + 1]) {
127
+ model = argv[++i];
128
+ }
129
+ else if (a === '--json') {
130
+ json = true;
131
+ }
132
+ }
133
+ const result = { model, json };
134
+ if (limit !== undefined)
135
+ result.limit = limit;
136
+ if (shapeFilter !== undefined)
137
+ result.shapeFilter = shapeFilter;
138
+ return result;
139
+ }
140
+ const HAIKU_4_5_PRICE_INPUT_PER_TOKEN = 1.0 / 1_000_000;
141
+ const HAIKU_4_5_PRICE_OUTPUT_PER_TOKEN = 5.0 / 1_000_000;
142
+ async function main() {
143
+ const args = parseArgs(process.argv.slice(2));
144
+ const apiKey = resolveAnthropicKey();
145
+ const llm = buildAnthropicLlmCaller(apiKey, args.model);
146
+ if (!args.json) {
147
+ process.stdout.write(`bench-synth: model=${args.model}\n\n`);
148
+ }
149
+ const report = await evaluateAgainstCorpus({ llm }, {
150
+ ...(args.limit !== undefined ? { limit: args.limit } : {}),
151
+ ...(args.shapeFilter !== undefined
152
+ ? { shapeFilter: args.shapeFilter }
153
+ : {}),
154
+ onProgress: args.json
155
+ ? undefined
156
+ : (outcome, idx, total) => {
157
+ const status = outcome.score.pass ? 'OK ' : 'NO ';
158
+ const id = outcome.entry.id.padEnd(28);
159
+ const shape = contractShape(outcome.entry.expected).padEnd(16);
160
+ process.stdout.write(`[${status}] ${(idx + 1).toString().padStart(2)}/${total} ${shape} ${id} ${outcome.latencyMs}ms\n`);
161
+ },
162
+ });
163
+ if (args.json) {
164
+ process.stdout.write(JSON.stringify(report, null, 2));
165
+ process.stdout.write('\n');
166
+ return;
167
+ }
168
+ process.stdout.write('\n');
169
+ process.stdout.write(formatBenchReport(report));
170
+ process.stdout.write('\n\n');
171
+ const totalCost = totalInputTokens * HAIKU_4_5_PRICE_INPUT_PER_TOKEN +
172
+ totalOutputTokens * HAIKU_4_5_PRICE_OUTPUT_PER_TOKEN;
173
+ const callsMade = report.totals.all - report.totals.synthDeclined;
174
+ const costPerCall = callsMade === 0 ? 0 : totalCost / callsMade;
175
+ process.stdout.write(`Tokens: input=${totalInputTokens} output=${totalOutputTokens}\n`);
176
+ process.stdout.write(`Cost: total=$${totalCost.toFixed(4)} per-call=$${costPerCall.toFixed(4)}\n`);
177
+ }
178
+ main().catch((err) => {
179
+ process.stderr.write(`bench-synth failed: ${err instanceof Error ? err.message : String(err)}\n`);
180
+ process.exit(1);
181
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Synthesizer bench runner.
3
+ *
4
+ * Two evaluators:
5
+ *
6
+ * - {@link evaluateAgainstCorpus} — given an `LLMCaller`, runs
7
+ * `synthesizeContract` over the corpus and compares each output to
8
+ * the expected structural shape. Used by {@link runSynthBench} for
9
+ * a real LLM probe (opt-in CLI, costs ~$0.001 per entry on Haiku).
10
+ *
11
+ * - {@link scoreSynthesizedContract} — pure structural compare. Used
12
+ * by the deterministic structure-bench test against fixtures and
13
+ * by the LLM bench to grade live outputs.
14
+ *
15
+ * Pass criteria:
16
+ * - `hasActionSpec` / `hasContextSpec` / `hasStreamSpec` / `hasProps`
17
+ * match, OR the entry sets `tolerateEitherShape: true`.
18
+ * - Gadget / agent-tool *identity* (`capabilityHooks`,
19
+ * `forbiddenCapabilityHooks`) — registry-bounded names, so a
20
+ * mismatch is a real retrieval/discrimination bug and DOES fail.
21
+ *
22
+ * Advisory (reported, NOT pass-gating) — see {@link ADVISORY_FAILURE_KINDS}:
23
+ * - `actionNames` / `contextSlots` / `agentToolNames` are
24
+ * free-vocabulary: the synth invents the action/slot/tool name and
25
+ * a checkout-completion action (`finish` / `complete` /
26
+ * `completePurchase` / `placeOrder` / …) has an unbounded valid
27
+ * name space. An exact-name allow-list only generates false
28
+ * negatives, so a name mismatch is surfaced but does not fail the
29
+ * bench — spec-presence + placement already verify the shape.
30
+ *
31
+ * Validator findings ride along on the report so an operator inspecting
32
+ * a regression can see WHY a synthesized contract was flagged
33
+ * (redundant-action vs novel-shape vs nothing).
34
+ */
35
+ import type { DataContract } from '@ggui-ai/protocol';
36
+ import { type ContractValidationFinding } from '../contract-validators.js';
37
+ import type { LLMCaller } from '../llm-caller.js';
38
+ import { type BenchEntry, type BenchExpectation } from './corpus.js';
39
+ export interface ScoreFailure {
40
+ readonly kind: 'synth-declined' | 'has-action-mismatch' | 'has-context-mismatch' | 'has-stream-mismatch' | 'has-props-mismatch' | 'has-client-capabilities-mismatch' | 'has-agent-tools-mismatch' | 'action-names-disjoint' | 'context-slots-disjoint' | 'capability-hooks-disjoint' | 'forbidden-capability-hooks-present' | 'agent-tool-names-disjoint';
41
+ readonly hint: string;
42
+ }
43
+ export interface ScoreResult {
44
+ readonly pass: boolean;
45
+ /** True when expectation tolerated either shape and structural match
46
+ * was loose. Used to distinguish strict pass from tolerated pass in
47
+ * reporting. */
48
+ readonly tolerated: boolean;
49
+ readonly failures: readonly ScoreFailure[];
50
+ }
51
+ /**
52
+ * Compare a synthesized contract against the expected shape. Pure /
53
+ * deterministic — used both by the deterministic structure-bench and
54
+ * by the live-LLM probe.
55
+ */
56
+ export declare function scoreSynthesizedContract(contract: DataContract, expected: BenchExpectation): ScoreResult;
57
+ export interface BenchOutcome {
58
+ readonly entry: BenchEntry;
59
+ readonly contract: DataContract | null;
60
+ readonly score: ScoreResult;
61
+ readonly findings: readonly ContractValidationFinding[];
62
+ readonly latencyMs: number;
63
+ /** LLM attempts the synthesizer made (1 = no repair retry needed). */
64
+ readonly attempts: number;
65
+ readonly synthReason: string;
66
+ }
67
+ export interface BenchReport {
68
+ readonly outcomes: readonly BenchOutcome[];
69
+ readonly totals: {
70
+ readonly all: number;
71
+ readonly pass: number;
72
+ readonly fail: number;
73
+ readonly synthDeclined: number;
74
+ readonly precision: number;
75
+ };
76
+ readonly byShape: Readonly<Record<string, {
77
+ all: number;
78
+ pass: number;
79
+ precision: number;
80
+ }>>;
81
+ readonly redundantActionFindings: number;
82
+ readonly latency: {
83
+ readonly p50Ms: number;
84
+ readonly p95Ms: number;
85
+ };
86
+ }
87
+ export interface RunSynthBenchOptions {
88
+ readonly limit?: number;
89
+ /** Filter the corpus to entries of one `contractShape` bucket. */
90
+ readonly shapeFilter?: string;
91
+ readonly onProgress?: (outcome: BenchOutcome, index: number, total: number) => void;
92
+ }
93
+ export declare function evaluateAgainstCorpus(deps: {
94
+ readonly llm: LLMCaller;
95
+ }, options?: RunSynthBenchOptions, corpus?: readonly BenchEntry[]): Promise<BenchReport>;
96
+ export declare function summarize(outcomes: readonly BenchOutcome[]): BenchReport;
97
+ export declare function formatBenchReport(report: BenchReport): string;
98
+ export declare function runSynthBench(deps: {
99
+ readonly llm: LLMCaller;
100
+ }, options?: RunSynthBenchOptions): Promise<BenchReport>;
101
+ //# sourceMappingURL=run-bench.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run-bench.d.ts","sourceRoot":"","sources":["../../src/synth-bench/run-bench.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Synthesizer bench runner.
3
+ *
4
+ * Two evaluators:
5
+ *
6
+ * - {@link evaluateAgainstCorpus} — given an `LLMCaller`, runs
7
+ * `synthesizeContract` over the corpus and compares each output to
8
+ * the expected structural shape. Used by {@link runSynthBench} for
9
+ * a real LLM probe (opt-in CLI, costs ~$0.001 per entry on Haiku).
10
+ *
11
+ * - {@link scoreSynthesizedContract} — pure structural compare. Used
12
+ * by the deterministic structure-bench test against fixtures and
13
+ * by the LLM bench to grade live outputs.
14
+ *
15
+ * Pass criteria:
16
+ * - `hasActionSpec` / `hasContextSpec` / `hasStreamSpec` / `hasProps`
17
+ * match, OR the entry sets `tolerateEitherShape: true`.
18
+ * - Gadget / agent-tool *identity* (`capabilityHooks`,
19
+ * `forbiddenCapabilityHooks`) — registry-bounded names, so a
20
+ * mismatch is a real retrieval/discrimination bug and DOES fail.
21
+ *
22
+ * Advisory (reported, NOT pass-gating) — see {@link ADVISORY_FAILURE_KINDS}:
23
+ * - `actionNames` / `contextSlots` / `agentToolNames` are
24
+ * free-vocabulary: the synth invents the action/slot/tool name and
25
+ * a checkout-completion action (`finish` / `complete` /
26
+ * `completePurchase` / `placeOrder` / …) has an unbounded valid
27
+ * name space. An exact-name allow-list only generates false
28
+ * negatives, so a name mismatch is surfaced but does not fail the
29
+ * bench — spec-presence + placement already verify the shape.
30
+ *
31
+ * Validator findings ride along on the report so an operator inspecting
32
+ * a regression can see WHY a synthesized contract was flagged
33
+ * (redundant-action vs novel-shape vs nothing).
34
+ */
35
+ import { listContractGadgets } from '@ggui-ai/protocol';
36
+ import { synthesizeContract } from '../synthesize-contract.js';
37
+ import { validateContractStructure, } from '../contract-validators.js';
38
+ import { BENCH_CORPUS, contractShape, } from './corpus.js';
39
+ /**
40
+ * Failure kinds that are ADVISORY — surfaced in the report but do NOT
41
+ * gate pass/fail. These check free-vocabulary names (action / slot /
42
+ * agent-tool keys the synth invents); an exact-name allow-list cannot
43
+ * enumerate the unbounded valid space, so a mismatch is a bench
44
+ * false-negative, not a synth bug. Spec-presence + placement carry the
45
+ * real verdict. Gadget-identity kinds are deliberately NOT here —
46
+ * those names are registry-bounded.
47
+ */
48
+ const ADVISORY_FAILURE_KINDS = new Set([
49
+ 'action-names-disjoint',
50
+ 'context-slots-disjoint',
51
+ 'agent-tool-names-disjoint',
52
+ ]);
53
+ /**
54
+ * Compare a synthesized contract against the expected shape. Pure /
55
+ * deterministic — used both by the deterministic structure-bench and
56
+ * by the live-LLM probe.
57
+ */
58
+ export function scoreSynthesizedContract(contract, expected) {
59
+ const failures = [];
60
+ const tolerate = expected.tolerateEitherShape === true;
61
+ const hasActionSpec = contract.actionSpec !== undefined &&
62
+ Object.keys(contract.actionSpec).length > 0;
63
+ const hasContextSpec = contract.contextSpec !== undefined &&
64
+ Object.keys(contract.contextSpec).length > 0;
65
+ const hasStreamSpec = contract.streamSpec !== undefined &&
66
+ Object.keys(contract.streamSpec).length > 0;
67
+ const hasProps = contract.propsSpec !== undefined &&
68
+ contract.propsSpec.properties !== undefined &&
69
+ Object.keys(contract.propsSpec.properties).length > 0;
70
+ // The wire `clientCapabilities.gadgets` map is package-keyed two-level —
71
+ // `listContractGadgets` flattens it to `(package, name)` use
72
+ // records. The export NAME is the discriminating identifier (a
73
+ // `use`-prefixed hook or a PascalCase component). Default empty when
74
+ // the catalog is absent.
75
+ const gadgetUses = listContractGadgets(contract);
76
+ const hasClientCapabilities = gadgetUses.length > 0;
77
+ const agentToolMap = contract.agentCapabilities?.tools ?? {};
78
+ const hasAgentTools = Object.keys(agentToolMap).length > 0;
79
+ if (!tolerate) {
80
+ if (hasActionSpec !== expected.hasActionSpec) {
81
+ failures.push({
82
+ kind: 'has-action-mismatch',
83
+ hint: `expected hasActionSpec=${expected.hasActionSpec}, got ${hasActionSpec}${hasActionSpec ? ` (actions: ${Object.keys(contract.actionSpec ?? {}).join(', ')})` : ''}`,
84
+ });
85
+ }
86
+ if (hasContextSpec !== expected.hasContextSpec) {
87
+ failures.push({
88
+ kind: 'has-context-mismatch',
89
+ hint: `expected hasContextSpec=${expected.hasContextSpec}, got ${hasContextSpec}${hasContextSpec ? ` (slots: ${Object.keys(contract.contextSpec ?? {}).join(', ')})` : ''}`,
90
+ });
91
+ }
92
+ if (hasStreamSpec !== expected.hasStreamSpec) {
93
+ failures.push({
94
+ kind: 'has-stream-mismatch',
95
+ hint: `expected hasStreamSpec=${expected.hasStreamSpec}, got ${hasStreamSpec}`,
96
+ });
97
+ }
98
+ if (hasProps !== expected.hasProps) {
99
+ failures.push({
100
+ kind: 'has-props-mismatch',
101
+ hint: `expected hasProps=${expected.hasProps}, got ${hasProps}`,
102
+ });
103
+ }
104
+ // EE+ surfaces — only enforced when the corpus entry opts in
105
+ // (`hasClientCapabilities`/`hasAgentTools` field present). Legacy
106
+ // entries leave them undefined and skip the check.
107
+ if (expected.hasClientCapabilities !== undefined) {
108
+ if (hasClientCapabilities !== expected.hasClientCapabilities) {
109
+ failures.push({
110
+ kind: 'has-client-capabilities-mismatch',
111
+ hint: `expected hasClientCapabilities=${expected.hasClientCapabilities}, got ${hasClientCapabilities}${hasClientCapabilities ? ` (exports: ${gadgetUses.map((u) => u.name).join(', ')})` : ''}`,
112
+ });
113
+ }
114
+ }
115
+ if (expected.hasAgentTools !== undefined) {
116
+ if (hasAgentTools !== expected.hasAgentTools) {
117
+ failures.push({
118
+ kind: 'has-agent-tools-mismatch',
119
+ hint: `expected hasAgentTools=${expected.hasAgentTools}, got ${hasAgentTools}${hasAgentTools ? ` (tools: ${Object.keys(agentToolMap).join(', ')})` : ''}`,
120
+ });
121
+ }
122
+ }
123
+ }
124
+ if (expected.actionNames !== undefined &&
125
+ expected.actionNames.length > 0 &&
126
+ hasActionSpec) {
127
+ const allowed = expected.actionNames.map(toFold);
128
+ const got = Object.keys(contract.actionSpec ?? {});
129
+ const intersects = got.some((name) => allowed.some((a) => containsEither(toFold(name), a)));
130
+ if (!intersects) {
131
+ failures.push({
132
+ kind: 'action-names-disjoint',
133
+ hint: `synthesized actions [${got.join(', ')}] disjoint from allowed [${expected.actionNames.join(', ')}]`,
134
+ });
135
+ }
136
+ }
137
+ if (expected.contextSlots !== undefined &&
138
+ expected.contextSlots.length > 0 &&
139
+ hasContextSpec) {
140
+ const allowed = expected.contextSlots.map(toFold);
141
+ const got = Object.keys(contract.contextSpec ?? {});
142
+ const intersects = got.some((slot) => allowed.some((a) => containsEither(toFold(slot), a)));
143
+ if (!intersects) {
144
+ failures.push({
145
+ kind: 'context-slots-disjoint',
146
+ hint: `synthesized slots [${got.join(', ')}] disjoint from allowed [${expected.contextSlots.join(', ')}]`,
147
+ });
148
+ }
149
+ }
150
+ if (expected.capabilityHooks !== undefined &&
151
+ expected.capabilityHooks.length > 0 &&
152
+ hasClientCapabilities) {
153
+ const allowed = new Set(expected.capabilityHooks.map(toFold));
154
+ const got = gadgetUses.map((u) => u.name);
155
+ const intersects = got.some((h) => allowed.has(toFold(h)));
156
+ if (!intersects) {
157
+ failures.push({
158
+ kind: 'capability-hooks-disjoint',
159
+ hint: `synthesized hooks [${got.join(', ')}] disjoint from allowed [${expected.capabilityHooks.join(', ')}]`,
160
+ });
161
+ }
162
+ }
163
+ // Forbidden hooks check. Fires even when
164
+ // `hasClientCapabilities=false` is the headline expectation, because
165
+ // the LLM might violate by attaching the wrapper anyway. The check
166
+ // is silent when the contract has no clientCapabilities (nothing to
167
+ // forbid).
168
+ if (expected.forbiddenCapabilityHooks !== undefined &&
169
+ expected.forbiddenCapabilityHooks.length > 0) {
170
+ const forbidden = new Set(expected.forbiddenCapabilityHooks.map(toFold));
171
+ const got = gadgetUses.map((u) => u.name);
172
+ const violating = got.filter((h) => forbidden.has(toFold(h)));
173
+ if (violating.length > 0) {
174
+ failures.push({
175
+ kind: 'forbidden-capability-hooks-present',
176
+ hint: `synthesized hooks [${violating.join(', ')}] present in forbidden set [${expected.forbiddenCapabilityHooks.join(', ')}] — registered wrapper attached without intent justification`,
177
+ });
178
+ }
179
+ }
180
+ if (expected.agentToolNames !== undefined &&
181
+ expected.agentToolNames.length > 0 &&
182
+ hasAgentTools) {
183
+ const allowed = expected.agentToolNames.map(toFold);
184
+ const got = Object.keys(agentToolMap);
185
+ const intersects = got.some((name) => allowed.some((a) => containsEither(toFold(name), a)));
186
+ if (!intersects) {
187
+ failures.push({
188
+ kind: 'agent-tool-names-disjoint',
189
+ hint: `synthesized agentTools [${got.join(', ')}] disjoint from allowed [${expected.agentToolNames.join(', ')}]`,
190
+ });
191
+ }
192
+ }
193
+ // Pass when there are no NON-advisory failures. Advisory findings
194
+ // (free-vocabulary name mismatches) ride along on `failures` for
195
+ // report visibility but never gate the verdict.
196
+ const pass = failures.every((f) => ADVISORY_FAILURE_KINDS.has(f.kind));
197
+ return {
198
+ pass,
199
+ tolerated: tolerate && pass,
200
+ failures,
201
+ };
202
+ }
203
+ function toFold(s) {
204
+ return s.toLowerCase();
205
+ }
206
+ function containsEither(a, b) {
207
+ return a.includes(b) || b.includes(a);
208
+ }
209
+ export async function evaluateAgainstCorpus(deps, options = {}, corpus = BENCH_CORPUS) {
210
+ let subset = corpus;
211
+ if (options.shapeFilter !== undefined) {
212
+ const shapeFilter = options.shapeFilter;
213
+ subset = subset.filter((e) => contractShape(e.expected) === shapeFilter);
214
+ }
215
+ if (options.limit !== undefined) {
216
+ subset = subset.slice(0, options.limit);
217
+ }
218
+ const outcomes = [];
219
+ for (let i = 0; i < subset.length; i++) {
220
+ const entry = subset[i];
221
+ // Thread the entry's per-app registered catalog through to
222
+ // `synthesizeContract`. When absent, synth falls through to the
223
+ // static stdlib hint baked into the system prompt.
224
+ const synth = await synthesizeContract({ llm: deps.llm }, entry.intent, entry.appGadgets !== undefined
225
+ ? { appGadgets: entry.appGadgets }
226
+ : undefined);
227
+ let outcome;
228
+ if (synth.contract === null) {
229
+ outcome = {
230
+ entry,
231
+ contract: null,
232
+ score: {
233
+ pass: false,
234
+ tolerated: false,
235
+ failures: [
236
+ {
237
+ kind: 'synth-declined',
238
+ hint: `synth declined: ${synth.reason}`,
239
+ },
240
+ ],
241
+ },
242
+ findings: [],
243
+ latencyMs: synth.latencyMs,
244
+ attempts: synth.attempts,
245
+ synthReason: synth.reason,
246
+ };
247
+ }
248
+ else {
249
+ const findings = validateContractStructure(synth.contract).findings;
250
+ const score = scoreSynthesizedContract(synth.contract, entry.expected);
251
+ outcome = {
252
+ entry,
253
+ contract: synth.contract,
254
+ score,
255
+ findings,
256
+ latencyMs: synth.latencyMs,
257
+ attempts: synth.attempts,
258
+ synthReason: synth.reason,
259
+ };
260
+ }
261
+ outcomes.push(outcome);
262
+ options.onProgress?.(outcome, i, subset.length);
263
+ }
264
+ return summarize(outcomes);
265
+ }
266
+ export function summarize(outcomes) {
267
+ const all = outcomes.length;
268
+ const pass = outcomes.filter((o) => o.score.pass).length;
269
+ const synthDeclined = outcomes.filter((o) => o.contract === null).length;
270
+ const fail = all - pass;
271
+ const precision = all === 0 ? 0 : pass / all;
272
+ // Roll precision up by contract SHAPE (the retired archetype
273
+ // categories are gone). Canonical order keeps the report stable;
274
+ // any shape the corpus doesn't exercise simply drops out.
275
+ const shapeOrder = [
276
+ 'props-only',
277
+ 'context-only',
278
+ 'context+action',
279
+ 'stream',
280
+ 'with-gadgets',
281
+ 'empty',
282
+ ];
283
+ const presentShapes = new Set(outcomes.map((o) => contractShape(o.entry.expected)));
284
+ const byShape = Object.fromEntries(shapeOrder
285
+ .filter((shape) => presentShapes.has(shape))
286
+ .map((shape) => {
287
+ const subset = outcomes.filter((o) => contractShape(o.entry.expected) === shape);
288
+ const subsetAll = subset.length;
289
+ const subsetPass = subset.filter((o) => o.score.pass).length;
290
+ return [
291
+ shape,
292
+ {
293
+ all: subsetAll,
294
+ pass: subsetPass,
295
+ precision: subsetAll === 0 ? 0 : subsetPass / subsetAll,
296
+ },
297
+ ];
298
+ }));
299
+ const redundantActionFindings = outcomes
300
+ .flatMap((o) => o.findings)
301
+ .filter((f) => f.kind === 'redundant-action').length;
302
+ const latencies = outcomes.map((o) => o.latencyMs).sort((a, b) => a - b);
303
+ const p50 = percentile(latencies, 0.5);
304
+ const p95 = percentile(latencies, 0.95);
305
+ return {
306
+ outcomes,
307
+ totals: { all, pass, fail, synthDeclined, precision },
308
+ byShape,
309
+ redundantActionFindings,
310
+ latency: { p50Ms: p50, p95Ms: p95 },
311
+ };
312
+ }
313
+ function percentile(sorted, p) {
314
+ if (sorted.length === 0)
315
+ return 0;
316
+ const idx = Math.min(sorted.length - 1, Math.floor(p * sorted.length));
317
+ return sorted[idx] ?? 0;
318
+ }
319
+ export function formatBenchReport(report) {
320
+ const lines = [];
321
+ lines.push('=== synth bench report ===');
322
+ lines.push('');
323
+ lines.push('Precision by contract shape:');
324
+ for (const [shape, stats] of Object.entries(report.byShape)) {
325
+ if (stats.all === 0)
326
+ continue;
327
+ const pct = (stats.precision * 100).toFixed(1);
328
+ lines.push(` ${shape.padEnd(16)} ${stats.pass}/${stats.all} (${pct}%)`);
329
+ }
330
+ lines.push('');
331
+ lines.push(`Overall: ${report.totals.pass}/${report.totals.all} (${(report.totals.precision * 100).toFixed(1)}%)`);
332
+ lines.push(`Synth declined: ${report.totals.synthDeclined}`);
333
+ lines.push(`Redundant-action firings: ${report.redundantActionFindings}`);
334
+ // Free-vocabulary name mismatches — surfaced, non-gating.
335
+ const advisoryCount = report.outcomes.reduce((n, o) => n +
336
+ o.score.failures.filter((f) => ADVISORY_FAILURE_KINDS.has(f.kind))
337
+ .length, 0);
338
+ lines.push(`Name advisories (non-gating): ${advisoryCount}`);
339
+ lines.push(`Latency: p50=${report.latency.p50Ms}ms p95=${report.latency.p95Ms}ms`);
340
+ // Repair-loop turn distribution — `1t×N` means N entries synthesized
341
+ // on the first attempt (no repair retry). Early-skip outcomes
342
+ // (attempts=0) are excluded.
343
+ const turnDist = new Map();
344
+ for (const o of report.outcomes) {
345
+ if (o.attempts > 0) {
346
+ turnDist.set(o.attempts, (turnDist.get(o.attempts) ?? 0) + 1);
347
+ }
348
+ }
349
+ const turnStr = [...turnDist.keys()]
350
+ .sort((a, b) => a - b)
351
+ .map((t) => `${t}t×${turnDist.get(t)}`)
352
+ .join(' ');
353
+ lines.push(`Attempts: ${turnStr || '(none)'}`);
354
+ const failed = report.outcomes.filter((o) => !o.score.pass);
355
+ if (failed.length > 0) {
356
+ lines.push('');
357
+ lines.push('Failures:');
358
+ for (const o of failed) {
359
+ lines.push(` [${contractShape(o.entry.expected)}] ${o.entry.id}: ${o.entry.intent.slice(0, 60)}`);
360
+ for (const f of o.score.failures) {
361
+ lines.push(` ${f.kind}: ${f.hint}`);
362
+ }
363
+ if (o.findings.length > 0) {
364
+ for (const f of o.findings) {
365
+ lines.push(` [${f.severity}:${f.kind}] ${f.hint.slice(0, 120)}`);
366
+ }
367
+ }
368
+ }
369
+ }
370
+ return lines.join('\n');
371
+ }
372
+ export function runSynthBench(deps, options = {}) {
373
+ return evaluateAgainstCorpus(deps, options);
374
+ }