@data-fair/lib-agents-sim 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +165 -0
- package/bin/init.d.ts +2 -0
- package/bin/init.js +28 -0
- package/bridge/conversation.d.ts +68 -0
- package/bridge/conversation.js +167 -0
- package/bridge/index.d.ts +2 -0
- package/bridge/index.js +18 -0
- package/bridge/openai.d.ts +102 -0
- package/bridge/openai.js +91 -0
- package/bridge/server.d.ts +38 -0
- package/bridge/server.js +196 -0
- package/bridge/sessions.d.ts +46 -0
- package/bridge/sessions.js +109 -0
- package/bridge/tool-server.d.ts +60 -0
- package/bridge/tool-server.js +40 -0
- package/cases.d.ts +10 -0
- package/cases.js +18 -0
- package/chat-driver.d.ts +23 -0
- package/chat-driver.js +41 -0
- package/gateway-capture.d.ts +31 -0
- package/gateway-capture.js +64 -0
- package/index.d.ts +8 -0
- package/index.js +7 -0
- package/isolation.d.ts +16 -0
- package/isolation.js +37 -0
- package/missing-sdk.d.ts +16 -0
- package/missing-sdk.js +27 -0
- package/package.json +44 -0
- package/persona.d.ts +12 -0
- package/persona.js +96 -0
- package/report.d.ts +2 -0
- package/report.js +68 -0
- package/templates/simulate-skill.md +83 -0
- package/templates/simulation-judge.md +65 -0
- package/transcript.d.ts +9 -0
- package/transcript.js +21 -0
- package/types.d.ts +37 -0
- package/types.js +4 -0
package/README.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# @data-fair/lib-agents-sim
|
|
2
|
+
|
|
3
|
+
Primitives for judged browser simulations of an [agents](https://github.com/data-fair/agents)
|
|
4
|
+
chat: drive a real conversation with a simulated user in a real browser, capture
|
|
5
|
+
what happened, and hand the transcript to a judge. It does not own cases, login,
|
|
6
|
+
settings seeding, or the turn loop — those stay in the host repo, which knows
|
|
7
|
+
its own routes, fixtures and account setup. It also ships a small Claude Code
|
|
8
|
+
bridge (a dev tool, not a simulation primitive) that exposes the Claude Agent
|
|
9
|
+
SDK as an OpenAI-compatible provider, so a dev workspace can drive the assistant
|
|
10
|
+
under test on a Claude subscription instead of a metered API key.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm i -D @data-fair/lib-agents-sim
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Which half needs which peer
|
|
19
|
+
|
|
20
|
+
Every peer is **optional**, because the package has two independent halves and
|
|
21
|
+
few consumers want both. Install only what the half you use needs.
|
|
22
|
+
|
|
23
|
+
| You want | Install |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| The **harness** primitives (`createChatDriver`, `captureGateway`, `selectCases`, `reportCases`, `writeEvidence`, …) | `@playwright/test` |
|
|
26
|
+
| The **bridge** (`df-agents-bridge`) | `@anthropic-ai/claude-agent-sdk` and `@modelcontextprotocol/sdk` |
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# harness only
|
|
30
|
+
npm i -D @playwright/test
|
|
31
|
+
# bridge only
|
|
32
|
+
npm i -D @anthropic-ai/claude-agent-sdk @modelcontextprotocol/sdk
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The harness primitives are built against Playwright's `Page` / `FrameLocator`
|
|
36
|
+
types and run inside your own Playwright project, so bring your own version.
|
|
37
|
+
The bridge needs nothing from Playwright, which is why a bridge-only consumer
|
|
38
|
+
is not forced into a browser install.
|
|
39
|
+
|
|
40
|
+
Run `df-agents-bridge` without the two SDKs and it exits with an actionable
|
|
41
|
+
message instead of a raw `ERR_MODULE_NOT_FOUND`, naming the install command
|
|
42
|
+
above.
|
|
43
|
+
|
|
44
|
+
**zod warning.** If your tree also contains the `ai` package, installing the
|
|
45
|
+
Agent SDK may hoist zod 4 and break `ai`'s type inference. Add
|
|
46
|
+
`"overrides": { "@anthropic-ai/claude-agent-sdk": { "zod": "3.25.76" } }`.
|
|
47
|
+
|
|
48
|
+
## `df-agents-sim-init`
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npx df-agents-sim-init [--force]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Copies the `/simulate` skill and the `simulation-judge` sub-agent definition
|
|
55
|
+
into your repo's `.claude/skills/simulate/SKILL.md` and
|
|
56
|
+
`.claude/agents/simulation-judge.md`. These cannot be loaded from
|
|
57
|
+
`node_modules` — Claude Code reads them from the repository — so they are
|
|
58
|
+
copied, not referenced, and **can drift** from the version in this package.
|
|
59
|
+
The command prints the package version it copied from so drift is at least
|
|
60
|
+
detectable; without `--force` it skips a file that already exists rather than
|
|
61
|
+
overwriting local edits.
|
|
62
|
+
|
|
63
|
+
## Minimal runner example
|
|
64
|
+
|
|
65
|
+
A host repo owns the Playwright test that drives one case end to end. For a
|
|
66
|
+
chat embedded in an iframe (the common case for a host application), pass
|
|
67
|
+
`page.frameLocator('iframe')` to `createChatDriver`; for a page where the chat
|
|
68
|
+
*is* the page, pass `page` itself — the driver's selectors are identical
|
|
69
|
+
either way.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { test } from '@playwright/test'
|
|
73
|
+
import {
|
|
74
|
+
createChatDriver, captureGateway, nextUserMessage, isDone,
|
|
75
|
+
writeEvidence, selectCases, type Transcript, type SimulationCase
|
|
76
|
+
} from '@data-fair/lib-agents-sim'
|
|
77
|
+
|
|
78
|
+
const cases: SimulationCase[] = [
|
|
79
|
+
{ name: 'find-a-dataset', route: '/embed/chat', persona: 'A curious analyst.', goal: 'Find last quarter\'s sales dataset.', maxTurns: 6 }
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
for (const simCase of selectCases(cases, [])) {
|
|
83
|
+
test(`simulation: ${simCase.name}`, async ({ page }) => {
|
|
84
|
+
const gateway = captureGateway(page)
|
|
85
|
+
await page.goto(simCase.route)
|
|
86
|
+
|
|
87
|
+
const chat = createChatDriver(page.frameLocator('iframe'))
|
|
88
|
+
const conversation: Array<{ role: string, text: string }> = []
|
|
89
|
+
let error: string | undefined
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
for (let i = 0; i < simCase.maxTurns; i++) {
|
|
93
|
+
const message = await nextUserMessage(simCase, conversation, simCase.maxTurns - i)
|
|
94
|
+
if (isDone(message)) break
|
|
95
|
+
await chat.sendMessage(message)
|
|
96
|
+
await chat.waitForTurn()
|
|
97
|
+
// Read into a local FIRST, then replace: clearing up front means a throw
|
|
98
|
+
// from readConversation leaves the transcript empty, losing every prior
|
|
99
|
+
// turn — and an empty transcript is the one thing a judge cannot judge.
|
|
100
|
+
const read = await chat.readConversation()
|
|
101
|
+
conversation.length = 0
|
|
102
|
+
conversation.push(...read)
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
error = err instanceof Error ? err.message : String(err)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const transcript: Transcript = { case: simCase.name, goal: simCase.goal, persona: simCase.persona, route: simCase.route, conversation, gateway, consoleErrors: [] }
|
|
109
|
+
// `valid` is derived, never hardcoded: the sidecar exists to tell a run that
|
|
110
|
+
// really happened apart from one that fell over, so that `reportCases` says
|
|
111
|
+
// "invalid (…)" instead of re-reporting the previous run's verdict.
|
|
112
|
+
writeEvidence(simCase.name, transcript, {
|
|
113
|
+
case: simCase.name, valid: !error, error,
|
|
114
|
+
assistantModel: 'sonnet', userModel: 'haiku',
|
|
115
|
+
turns: conversation.length / 2, durationMs: 0, finishedAt: new Date().toISOString()
|
|
116
|
+
})
|
|
117
|
+
if (error) throw new Error(`run invalid: ${error}`)
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Then judge each written transcript with the `simulation-judge` sub-agent (via
|
|
123
|
+
the copied `/simulate` skill), and turn the evidence directory into a pass/fail
|
|
124
|
+
summary with `reportCases(cases, evidenceDir)` — the host repo's own report
|
|
125
|
+
script decides where cases live and what to do with the failure count it
|
|
126
|
+
returns.
|
|
127
|
+
|
|
128
|
+
### Where the evidence goes
|
|
129
|
+
|
|
130
|
+
`writeEvidence(name, transcript, sidecar, dir?)` writes `sim-<name>.json` (the
|
|
131
|
+
transcript the judge reads) and `sim-<name>.run.json` (the validity sidecar).
|
|
132
|
+
`dir` defaults to the exported `evidenceDir`, which is
|
|
133
|
+
`path.join(process.cwd(), 'simulations', 'tmp')` — resolved against the host
|
|
134
|
+
repo's working directory, so the default only makes sense if you run your suite
|
|
135
|
+
from the repository root. Pass `dir` explicitly to put evidence anywhere else,
|
|
136
|
+
and hand the same directory to `reportCases(cases, dir)` so the reader and the
|
|
137
|
+
writer agree.
|
|
138
|
+
|
|
139
|
+
## Scripts the copied `/simulate` skill expects
|
|
140
|
+
|
|
141
|
+
`df-agents-sim-init` copies the skill **verbatim**, and the skill refers to npm
|
|
142
|
+
scripts by the names the origin repository uses. It cannot know yours, so define
|
|
143
|
+
these three in your `package.json` (adjust the paths to your layout):
|
|
144
|
+
|
|
145
|
+
```json
|
|
146
|
+
{
|
|
147
|
+
"scripts": {
|
|
148
|
+
"dev-bridge": "df-agents-bridge",
|
|
149
|
+
"simulate": "playwright test -c playwright.sim.config.ts --project=simulate",
|
|
150
|
+
"simulate:report": "node simulations/report.ts"
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
- `dev-bridge` — starts the bridge the simulated user and the assistant both
|
|
156
|
+
talk to. Must be running before `simulate`.
|
|
157
|
+
- `simulate` — runs your Playwright project containing the scenario specs. Keep
|
|
158
|
+
it out of your default `test` script: a bare `npm test` would otherwise spend
|
|
159
|
+
plan quota.
|
|
160
|
+
- `simulate:report` — calls `reportCases(cases, evidenceDir)` and exits non-zero
|
|
161
|
+
on the failure count it returns.
|
|
162
|
+
|
|
163
|
+
If you prefer different names, edit the copied
|
|
164
|
+
`.claude/skills/simulate/SKILL.md` to match — but remember that a later
|
|
165
|
+
`df-agents-sim-init --force` overwrites it.
|
package/bin/init.d.ts
ADDED
package/bin/init.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Copies the judge definition and the /simulate skill into the consuming repo's
|
|
4
|
+
* .claude/ directory. They cannot be loaded from node_modules — Claude Code reads
|
|
5
|
+
* them from the repository — so they are copied and can drift. The version is
|
|
6
|
+
* printed so drift is at least detectable.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const templates = path.join(here, '..', 'templates');
|
|
13
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8'));
|
|
14
|
+
const cwd = process.cwd();
|
|
15
|
+
const targets = [
|
|
16
|
+
{ from: 'simulation-judge.md', to: path.join(cwd, '.claude', 'agents', 'simulation-judge.md') },
|
|
17
|
+
{ from: 'simulate-skill.md', to: path.join(cwd, '.claude', 'skills', 'simulate', 'SKILL.md') }
|
|
18
|
+
];
|
|
19
|
+
for (const { from, to } of targets) {
|
|
20
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
21
|
+
if (fs.existsSync(to) && !process.argv.includes('--force')) {
|
|
22
|
+
console.log(`skipped (exists, use --force): ${path.relative(cwd, to)}`);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
fs.copyFileSync(path.join(templates, from), to);
|
|
26
|
+
console.log(`wrote ${path.relative(cwd, to)}`);
|
|
27
|
+
}
|
|
28
|
+
console.log(`from @data-fair/lib-agents-sim@${pkg.version}`);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type OpenAIToolCall } from './openai.ts';
|
|
2
|
+
export declare const ABORTED_TOOL_RESULT = "ERROR: the conversation was aborted before this tool call could be answered. No result is available.";
|
|
3
|
+
export type TurnOutcome = {
|
|
4
|
+
type: 'tools';
|
|
5
|
+
calls: OpenAIToolCall[];
|
|
6
|
+
} | {
|
|
7
|
+
type: 'done';
|
|
8
|
+
usage?: object;
|
|
9
|
+
} | {
|
|
10
|
+
type: 'error';
|
|
11
|
+
message: string;
|
|
12
|
+
};
|
|
13
|
+
export type SdkMessage = {
|
|
14
|
+
type: string;
|
|
15
|
+
subtype?: string;
|
|
16
|
+
message?: {
|
|
17
|
+
content?: Array<{
|
|
18
|
+
type: string;
|
|
19
|
+
text?: string;
|
|
20
|
+
}>;
|
|
21
|
+
};
|
|
22
|
+
usage?: {
|
|
23
|
+
input_tokens?: number;
|
|
24
|
+
output_tokens?: number;
|
|
25
|
+
};
|
|
26
|
+
result?: unknown;
|
|
27
|
+
};
|
|
28
|
+
export declare class Conversation {
|
|
29
|
+
#private;
|
|
30
|
+
key: string;
|
|
31
|
+
lastSeen: number;
|
|
32
|
+
pending: Map<string, (result: string) => void>;
|
|
33
|
+
/**
|
|
34
|
+
* The tool names the live MCP tool server was built with. A continuation
|
|
35
|
+
* request declaring a different set cannot be served by this query — the model
|
|
36
|
+
* would be offered the stale set — so the server compares before adopting it.
|
|
37
|
+
*/
|
|
38
|
+
toolNames: string[];
|
|
39
|
+
constructor(key: string, toolNames?: string[]);
|
|
40
|
+
/** Handed to the SDK as `options.abortController`, so abort() really cancels the query. */
|
|
41
|
+
get controller(): AbortController;
|
|
42
|
+
get signal(): AbortSignal;
|
|
43
|
+
/**
|
|
44
|
+
* True once the upstream query has ended (done, error or abort). A dead
|
|
45
|
+
* conversation must never be adopted as a continuation: nothing would ever
|
|
46
|
+
* resolve the turn it was handed.
|
|
47
|
+
*/
|
|
48
|
+
get isDead(): boolean;
|
|
49
|
+
abort(): void;
|
|
50
|
+
/**
|
|
51
|
+
* Called by the MCP tool server. Records the call and suspends: the promise is
|
|
52
|
+
* resolved by deliverToolResults when the client's next request arrives.
|
|
53
|
+
*/
|
|
54
|
+
handleToolCall(name: string, args: Record<string, unknown>): Promise<string>;
|
|
55
|
+
deliverToolResults(results: Array<{
|
|
56
|
+
id: string;
|
|
57
|
+
content: string;
|
|
58
|
+
}>): void;
|
|
59
|
+
/** True when every tool call handed back has been answered by this request. */
|
|
60
|
+
awaits(ids: string[]): boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Attach an HTTP response as the sink and wait for this turn to end.
|
|
63
|
+
* Start the consumer loop on first use.
|
|
64
|
+
*/
|
|
65
|
+
beginTurn(sink: (text: string) => void, iterator?: AsyncIterable<SdkMessage>): Promise<TurnOutcome>;
|
|
66
|
+
/** Mark the calls just handed back, so the next turn only reports new ones. */
|
|
67
|
+
handedBack(count: number): void;
|
|
68
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One live SDK query, spanning many HTTP requests.
|
|
3
|
+
*
|
|
4
|
+
* The tricky part, and why this is not driven straight off the iterator: when the
|
|
5
|
+
* model calls a tool, the SDK yields the assistant message and THEN invokes the
|
|
6
|
+
* MCP handler. A consumer that checked for tool calls inside its `for await` body
|
|
7
|
+
* would look before the handler had run, loop, and block forever on an iterator
|
|
8
|
+
* the SDK is no longer feeding — the chat would hang on its first tool call.
|
|
9
|
+
*
|
|
10
|
+
* So the consumer loop runs for the whole conversation, writing into whichever
|
|
11
|
+
* HTTP response is currently attached, and a turn ends on whichever comes first:
|
|
12
|
+
* the handlers suspending (tool calls to hand back) or the query finishing.
|
|
13
|
+
*/
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import { mcpNameToTool, mapUsage } from "./openai.js";
|
|
16
|
+
// Parallel tool calls in one assistant message arrive as separate handler
|
|
17
|
+
// invocations a tick apart. Wait this long after the first before closing the
|
|
18
|
+
// turn, so they are handed back together as OpenAI expects.
|
|
19
|
+
const SETTLE_MS = 50;
|
|
20
|
+
// What a suspended tool handler returns when the conversation is aborted. It
|
|
21
|
+
// MUST read as a failure: the model receives it as the tool's result, and a
|
|
22
|
+
// bare word like "aborted" would be taken for a successful answer.
|
|
23
|
+
export const ABORTED_TOOL_RESULT = 'ERROR: the conversation was aborted before this tool call could be answered. No result is available.';
|
|
24
|
+
export class Conversation {
|
|
25
|
+
key;
|
|
26
|
+
lastSeen = Date.now();
|
|
27
|
+
pending = new Map();
|
|
28
|
+
/**
|
|
29
|
+
* The tool names the live MCP tool server was built with. A continuation
|
|
30
|
+
* request declaring a different set cannot be served by this query — the model
|
|
31
|
+
* would be offered the stale set — so the server compares before adopting it.
|
|
32
|
+
*/
|
|
33
|
+
toolNames;
|
|
34
|
+
#collected = [];
|
|
35
|
+
#handedBack = 0;
|
|
36
|
+
#sink = null;
|
|
37
|
+
#resolveTurn = null;
|
|
38
|
+
#settle = null;
|
|
39
|
+
#controller = new AbortController();
|
|
40
|
+
#consuming = false;
|
|
41
|
+
#terminal = null;
|
|
42
|
+
constructor(key, toolNames = []) {
|
|
43
|
+
this.key = key;
|
|
44
|
+
this.toolNames = toolNames;
|
|
45
|
+
}
|
|
46
|
+
/** Handed to the SDK as `options.abortController`, so abort() really cancels the query. */
|
|
47
|
+
get controller() { return this.#controller; }
|
|
48
|
+
get signal() { return this.#controller.signal; }
|
|
49
|
+
/**
|
|
50
|
+
* True once the upstream query has ended (done, error or abort). A dead
|
|
51
|
+
* conversation must never be adopted as a continuation: nothing would ever
|
|
52
|
+
* resolve the turn it was handed.
|
|
53
|
+
*/
|
|
54
|
+
get isDead() { return this.#terminal !== null; }
|
|
55
|
+
abort() {
|
|
56
|
+
this.#terminal ??= { type: 'error', message: 'conversation aborted' };
|
|
57
|
+
this.#controller.abort();
|
|
58
|
+
// Unblock anything still waiting on a tool result.
|
|
59
|
+
for (const resolve of this.pending.values())
|
|
60
|
+
resolve(ABORTED_TOOL_RESULT);
|
|
61
|
+
this.pending.clear();
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Called by the MCP tool server. Records the call and suspends: the promise is
|
|
65
|
+
* resolved by deliverToolResults when the client's next request arrives.
|
|
66
|
+
*/
|
|
67
|
+
handleToolCall(name, args) {
|
|
68
|
+
const id = `call_${crypto.randomUUID()}`;
|
|
69
|
+
this.#collected.push({
|
|
70
|
+
id,
|
|
71
|
+
type: 'function',
|
|
72
|
+
function: { name: mcpNameToTool(name), arguments: JSON.stringify(args) }
|
|
73
|
+
});
|
|
74
|
+
if (this.#settle)
|
|
75
|
+
clearTimeout(this.#settle);
|
|
76
|
+
this.#settle = setTimeout(() => { this.#endTurn({ type: 'tools', calls: this.#collected.slice(this.#handedBack) }); }, SETTLE_MS);
|
|
77
|
+
if (this.#settle.unref)
|
|
78
|
+
this.#settle.unref();
|
|
79
|
+
return new Promise(resolve => { this.pending.set(id, resolve); });
|
|
80
|
+
}
|
|
81
|
+
deliverToolResults(results) {
|
|
82
|
+
for (const r of results) {
|
|
83
|
+
const resolve = this.pending.get(r.id);
|
|
84
|
+
if (!resolve)
|
|
85
|
+
continue;
|
|
86
|
+
this.pending.delete(r.id);
|
|
87
|
+
resolve(r.content);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** True when every tool call handed back has been answered by this request. */
|
|
91
|
+
awaits(ids) {
|
|
92
|
+
if (this.#terminal)
|
|
93
|
+
return false;
|
|
94
|
+
return ids.length > 0 && ids.every(id => this.pending.has(id));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Attach an HTTP response as the sink and wait for this turn to end.
|
|
98
|
+
* Start the consumer loop on first use.
|
|
99
|
+
*/
|
|
100
|
+
beginTurn(sink, iterator) {
|
|
101
|
+
if (this.#terminal) {
|
|
102
|
+
// The query ended between turns (rate limit, crash) with no response
|
|
103
|
+
// attached, so its outcome was recorded rather than dropped. Replay it:
|
|
104
|
+
// without this the request would await a promise nothing can settle.
|
|
105
|
+
return Promise.resolve(this.#terminal);
|
|
106
|
+
}
|
|
107
|
+
this.#sink = sink;
|
|
108
|
+
const turn = new Promise(resolve => { this.#resolveTurn = resolve; });
|
|
109
|
+
if (iterator && !this.#consuming) {
|
|
110
|
+
this.#consuming = true;
|
|
111
|
+
// #consume reports every failure through #endTurn, so nothing escapes here.
|
|
112
|
+
this.#consume(iterator).catch(() => { });
|
|
113
|
+
}
|
|
114
|
+
// A tool call can land after the previous turn's settle window closed: #endTurn
|
|
115
|
+
// was then a no-op and no timer stays armed, so the call would sit here forever
|
|
116
|
+
// while the SDK waits for a tool_result. Hand any such stragglers back now.
|
|
117
|
+
// A still-armed timer is left alone: it is about to close the turn properly,
|
|
118
|
+
// with the parallel siblings it is waiting for.
|
|
119
|
+
if (!this.#settle && this.#collected.length > this.#handedBack) {
|
|
120
|
+
this.#endTurn({ type: 'tools', calls: this.#collected.slice(this.#handedBack) });
|
|
121
|
+
}
|
|
122
|
+
return turn;
|
|
123
|
+
}
|
|
124
|
+
/** Mark the calls just handed back, so the next turn only reports new ones. */
|
|
125
|
+
handedBack(count) {
|
|
126
|
+
this.#handedBack += count;
|
|
127
|
+
}
|
|
128
|
+
async #consume(iterator) {
|
|
129
|
+
let usage;
|
|
130
|
+
try {
|
|
131
|
+
for await (const msg of iterator) {
|
|
132
|
+
if (msg.type === 'assistant') {
|
|
133
|
+
for (const block of msg.message?.content ?? []) {
|
|
134
|
+
if (block.type === 'text' && block.text)
|
|
135
|
+
this.#sink?.(block.text);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (msg.type === 'result') {
|
|
139
|
+
usage = mapUsage(msg.usage);
|
|
140
|
+
if (msg.subtype !== 'success') {
|
|
141
|
+
this.#endTurn({ type: 'error', message: String(msg.result ?? msg.subtype) });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
this.#endTurn({ type: 'done', usage });
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
this.#endTurn({ type: 'error', message: err instanceof Error ? err.message : String(err) });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
#endTurn(outcome) {
|
|
153
|
+
if (this.#settle) {
|
|
154
|
+
clearTimeout(this.#settle);
|
|
155
|
+
this.#settle = null;
|
|
156
|
+
}
|
|
157
|
+
const resolve = this.#resolveTurn;
|
|
158
|
+
this.#resolveTurn = null;
|
|
159
|
+
this.#sink = null;
|
|
160
|
+
// Anything but a hand-back means the query is over. Remember it: between two
|
|
161
|
+
// HTTP requests there is no resolver, and dropping the outcome here is what
|
|
162
|
+
// left the next request awaiting a promise that could never settle.
|
|
163
|
+
if (outcome.type !== 'tools')
|
|
164
|
+
this.#terminal ??= outcome;
|
|
165
|
+
resolve?.(outcome);
|
|
166
|
+
}
|
|
167
|
+
}
|
package/bridge/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { MISSING_SDK_MESSAGE, isMissingSdkError } from "../missing-sdk.js";
|
|
3
|
+
const port = Number(process.env.BRIDGE_PORT ?? 3194);
|
|
4
|
+
try {
|
|
5
|
+
const { createServer } = await import("./server.js");
|
|
6
|
+
createServer({ port });
|
|
7
|
+
console.log(`claude-bridge listening on http://localhost:${port}`);
|
|
8
|
+
console.log('configure an "OpenAI Compatible" provider with this base URL + /v1 and Compatibility Mode = compatible');
|
|
9
|
+
}
|
|
10
|
+
catch (err) {
|
|
11
|
+
if (isMissingSdkError(err)) {
|
|
12
|
+
console.error(MISSING_SDK_MESSAGE);
|
|
13
|
+
process.exitCode = 1;
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
throw err;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure translation between the OpenAI chat-completions wire format the agents
|
|
3
|
+
* gateway speaks and what the Claude Agent SDK accepts. No I/O, no SDK import —
|
|
4
|
+
* everything here is unit-tested.
|
|
5
|
+
*/
|
|
6
|
+
export type OpenAIToolCall = {
|
|
7
|
+
id: string;
|
|
8
|
+
type: 'function';
|
|
9
|
+
function: {
|
|
10
|
+
name: string;
|
|
11
|
+
arguments: string;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
export type OpenAIMessage = {
|
|
15
|
+
role: 'system' | 'user' | 'assistant' | 'tool';
|
|
16
|
+
content?: string | null;
|
|
17
|
+
tool_calls?: OpenAIToolCall[];
|
|
18
|
+
tool_call_id?: string;
|
|
19
|
+
};
|
|
20
|
+
export type OpenAIToolDef = {
|
|
21
|
+
type: 'function';
|
|
22
|
+
function: {
|
|
23
|
+
name: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
parameters?: Record<string, unknown>;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
export declare const MCP_SERVER_NAME = "bridge";
|
|
29
|
+
export declare function toolNameToMcp(name: string): string;
|
|
30
|
+
export declare function mcpNameToTool(name: string): string;
|
|
31
|
+
export declare function extractSystemPrompt(messages: OpenAIMessage[]): string;
|
|
32
|
+
/**
|
|
33
|
+
* Render the conversation as a single prompt, for the replay path (a fresh
|
|
34
|
+
* session). The continuation path never comes through here — it delivers tool
|
|
35
|
+
* results into a query that is already holding the history.
|
|
36
|
+
*/
|
|
37
|
+
export declare function renderTranscript(messages: OpenAIMessage[]): string;
|
|
38
|
+
export declare function textChunk(id: string, model: string, text: string): {
|
|
39
|
+
choices: {
|
|
40
|
+
index: number;
|
|
41
|
+
delta: {
|
|
42
|
+
content: string;
|
|
43
|
+
};
|
|
44
|
+
finish_reason: null;
|
|
45
|
+
}[];
|
|
46
|
+
id: string;
|
|
47
|
+
object: string;
|
|
48
|
+
created: number;
|
|
49
|
+
model: string;
|
|
50
|
+
};
|
|
51
|
+
export declare function toolCallsChunk(id: string, model: string, calls: OpenAIToolCall[]): {
|
|
52
|
+
choices: {
|
|
53
|
+
index: number;
|
|
54
|
+
delta: {
|
|
55
|
+
tool_calls: {
|
|
56
|
+
index: number;
|
|
57
|
+
id: string;
|
|
58
|
+
type: string;
|
|
59
|
+
function: {
|
|
60
|
+
name: string;
|
|
61
|
+
arguments: string;
|
|
62
|
+
};
|
|
63
|
+
}[];
|
|
64
|
+
};
|
|
65
|
+
finish_reason: null;
|
|
66
|
+
}[];
|
|
67
|
+
id: string;
|
|
68
|
+
object: string;
|
|
69
|
+
created: number;
|
|
70
|
+
model: string;
|
|
71
|
+
};
|
|
72
|
+
export declare function finalChunk(id: string, model: string, finishReason: 'stop' | 'tool_calls', usage?: object): {
|
|
73
|
+
usage?: object | undefined;
|
|
74
|
+
choices: {
|
|
75
|
+
index: number;
|
|
76
|
+
delta: {};
|
|
77
|
+
finish_reason: "stop" | "tool_calls";
|
|
78
|
+
}[];
|
|
79
|
+
id: string;
|
|
80
|
+
object: string;
|
|
81
|
+
created: number;
|
|
82
|
+
model: string;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* The SDK also reports total_cost_usd, deliberately not forwarded: it is list-price
|
|
86
|
+
* bookkeeping, while consumption is actually against the plan's rate limits. Showing
|
|
87
|
+
* it in the usage UI would invite reading it as real spend.
|
|
88
|
+
*/
|
|
89
|
+
export declare function mapUsage(usage: {
|
|
90
|
+
input_tokens?: number;
|
|
91
|
+
output_tokens?: number;
|
|
92
|
+
} | undefined): {
|
|
93
|
+
prompt_tokens: number;
|
|
94
|
+
completion_tokens: number;
|
|
95
|
+
total_tokens: number;
|
|
96
|
+
} | undefined;
|
|
97
|
+
export declare function errorBody(message: string, type?: string): {
|
|
98
|
+
error: {
|
|
99
|
+
message: string;
|
|
100
|
+
type: string;
|
|
101
|
+
};
|
|
102
|
+
};
|
package/bridge/openai.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure translation between the OpenAI chat-completions wire format the agents
|
|
3
|
+
* gateway speaks and what the Claude Agent SDK accepts. No I/O, no SDK import —
|
|
4
|
+
* everything here is unit-tested.
|
|
5
|
+
*/
|
|
6
|
+
// Tools reach the model namespaced by their MCP server, so every name makes a
|
|
7
|
+
// round trip through this prefix and must be mapped back before it is emitted.
|
|
8
|
+
export const MCP_SERVER_NAME = 'bridge';
|
|
9
|
+
const PREFIX = `mcp__${MCP_SERVER_NAME}__`;
|
|
10
|
+
export function toolNameToMcp(name) {
|
|
11
|
+
return PREFIX + name;
|
|
12
|
+
}
|
|
13
|
+
export function mcpNameToTool(name) {
|
|
14
|
+
return name.startsWith(PREFIX) ? name.slice(PREFIX.length) : name;
|
|
15
|
+
}
|
|
16
|
+
export function extractSystemPrompt(messages) {
|
|
17
|
+
return messages
|
|
18
|
+
.filter(m => m.role === 'system')
|
|
19
|
+
.map(m => m.content ?? '')
|
|
20
|
+
.join('\n\n');
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Render the conversation as a single prompt, for the replay path (a fresh
|
|
24
|
+
* session). The continuation path never comes through here — it delivers tool
|
|
25
|
+
* results into a query that is already holding the history.
|
|
26
|
+
*/
|
|
27
|
+
export function renderTranscript(messages) {
|
|
28
|
+
const lines = ['<conversation_history>'];
|
|
29
|
+
for (const m of messages) {
|
|
30
|
+
if (m.role === 'system')
|
|
31
|
+
continue;
|
|
32
|
+
if (m.role === 'tool') {
|
|
33
|
+
lines.push(`tool result (id=${m.tool_call_id}): ${m.content ?? ''}`);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (m.role === 'assistant' && m.tool_calls?.length) {
|
|
37
|
+
for (const c of m.tool_calls) {
|
|
38
|
+
lines.push(`assistant called tool (id=${c.id}) ${c.function.name} with ${c.function.arguments}`);
|
|
39
|
+
}
|
|
40
|
+
if (m.content)
|
|
41
|
+
lines.push(`assistant: ${m.content}`);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
lines.push(`${m.role}: ${m.content ?? ''}`);
|
|
45
|
+
}
|
|
46
|
+
lines.push('</conversation_history>');
|
|
47
|
+
lines.push('');
|
|
48
|
+
lines.push('Continue this conversation: produce the next assistant turn.');
|
|
49
|
+
return lines.join('\n');
|
|
50
|
+
}
|
|
51
|
+
const base = (id, model) => ({
|
|
52
|
+
id,
|
|
53
|
+
object: 'chat.completion.chunk',
|
|
54
|
+
created: Math.floor(Date.now() / 1000),
|
|
55
|
+
model
|
|
56
|
+
});
|
|
57
|
+
export function textChunk(id, model, text) {
|
|
58
|
+
return { ...base(id, model), choices: [{ index: 0, delta: { content: text }, finish_reason: null }] };
|
|
59
|
+
}
|
|
60
|
+
export function toolCallsChunk(id, model, calls) {
|
|
61
|
+
return {
|
|
62
|
+
...base(id, model),
|
|
63
|
+
choices: [{
|
|
64
|
+
index: 0,
|
|
65
|
+
delta: { tool_calls: calls.map((c, index) => ({ index, id: c.id, type: 'function', function: c.function })) },
|
|
66
|
+
finish_reason: null
|
|
67
|
+
}]
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function finalChunk(id, model, finishReason, usage) {
|
|
71
|
+
return {
|
|
72
|
+
...base(id, model),
|
|
73
|
+
choices: [{ index: 0, delta: {}, finish_reason: finishReason }],
|
|
74
|
+
...(usage ? { usage } : {})
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The SDK also reports total_cost_usd, deliberately not forwarded: it is list-price
|
|
79
|
+
* bookkeeping, while consumption is actually against the plan's rate limits. Showing
|
|
80
|
+
* it in the usage UI would invite reading it as real spend.
|
|
81
|
+
*/
|
|
82
|
+
export function mapUsage(usage) {
|
|
83
|
+
if (!usage)
|
|
84
|
+
return undefined;
|
|
85
|
+
const prompt = usage.input_tokens ?? 0;
|
|
86
|
+
const completion = usage.output_tokens ?? 0;
|
|
87
|
+
return { prompt_tokens: prompt, completion_tokens: completion, total_tokens: prompt + completion };
|
|
88
|
+
}
|
|
89
|
+
export function errorBody(message, type = 'api_error') {
|
|
90
|
+
return { error: { message, type } };
|
|
91
|
+
}
|