@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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP layer for the Claude Code bridge. Dev-only: presents the Claude Agent SDK
|
|
3
|
+
* as an OpenAI-compatible provider so the dev workspace can run on subscription
|
|
4
|
+
* models. Never imported by api/, ui/ or the published libs.
|
|
5
|
+
*/
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import { type Options } from '@anthropic-ai/claude-agent-sdk';
|
|
8
|
+
import { type SdkMessage } from './conversation.ts';
|
|
9
|
+
export declare const MODELS: {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
}[];
|
|
13
|
+
/**
|
|
14
|
+
* The SDK's own `Options`, with every key the bridge depends on made REQUIRED.
|
|
15
|
+
*
|
|
16
|
+
* Every one of these is optional upstream, and the call site builds them by
|
|
17
|
+
* spreading `isolationOptions(...)` — so dropping the spread, or deleting the
|
|
18
|
+
* `abortController` line, used to leave tsc, eslint and the whole suite green
|
|
19
|
+
* while silently handing the model the repository's own settings, memory and
|
|
20
|
+
* 27 built-in tools. Naming the keys through `Pick` also makes an upstream
|
|
21
|
+
* RENAME fail closed: `Pick<Options, 'settingSources'>` stops compiling if the
|
|
22
|
+
* key goes away, where a spread would just have dropped it.
|
|
23
|
+
*/
|
|
24
|
+
type BridgeQueryOptions = Options & Required<Pick<Options, 'cwd' | 'env' | 'settingSources' | 'tools' | 'strictMcpConfig' | 'model' | 'systemPrompt' | 'mcpServers' | 'allowedTools' | 'abortController'>>;
|
|
25
|
+
/**
|
|
26
|
+
* The SDK's `query`, narrowed to what the bridge uses. Injectable so a test can
|
|
27
|
+
* observe the options actually handed over without a network or a model — the
|
|
28
|
+
* isolation guarantee is a property of this call site, not only of the factory.
|
|
29
|
+
*/
|
|
30
|
+
export type BridgeQuery = (args: {
|
|
31
|
+
prompt: string;
|
|
32
|
+
options: BridgeQueryOptions;
|
|
33
|
+
}) => AsyncIterable<SdkMessage>;
|
|
34
|
+
export declare function createServer(opts: {
|
|
35
|
+
port: number;
|
|
36
|
+
query?: BridgeQuery;
|
|
37
|
+
}): http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
|
|
38
|
+
export {};
|
package/bridge/server.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP layer for the Claude Code bridge. Dev-only: presents the Claude Agent SDK
|
|
3
|
+
* as an OpenAI-compatible provider so the dev workspace can run on subscription
|
|
4
|
+
* models. Never imported by api/, ui/ or the published libs.
|
|
5
|
+
*/
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
9
|
+
import { createNeutralCwd, isolationOptions } from "../isolation.js";
|
|
10
|
+
import { createToolServer, TOOL_TIMEOUT_MS } from "./tool-server.js";
|
|
11
|
+
import { SessionStore, continuationOf, hashMessages, sameToolSet } from "./sessions.js";
|
|
12
|
+
import { Conversation } from "./conversation.js";
|
|
13
|
+
import { extractSystemPrompt, renderTranscript, toolNameToMcp, textChunk, toolCallsChunk, finalChunk, errorBody, MCP_SERVER_NAME } from "./openai.js";
|
|
14
|
+
// The settings UI populates its dropdown from GET {baseURL}/models
|
|
15
|
+
// (api/src/models/router.ts fetchOpenAICompatibleModels). A fixed list avoids a
|
|
16
|
+
// network round trip; ids are what get passed back as the `model` field.
|
|
17
|
+
export const MODELS = [
|
|
18
|
+
{ id: 'opus', name: 'Claude Opus (Claude Code default alias)' },
|
|
19
|
+
{ id: 'sonnet', name: 'Claude Sonnet (Claude Code default alias)' },
|
|
20
|
+
{ id: 'haiku', name: 'Claude Haiku (Claude Code default alias)' },
|
|
21
|
+
{ id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' }
|
|
22
|
+
];
|
|
23
|
+
// Hard ceiling on one HTTP request, so no upstream failure can hang the client
|
|
24
|
+
// forever. Equal to TOOL_TIMEOUT_MS in tool-server.ts: a suspended tool handler
|
|
25
|
+
// may legitimately wait that long (a tool wired to a human action button), so a
|
|
26
|
+
// shorter ceiling would cut off a legitimate turn, and a longer one would leave
|
|
27
|
+
// the client waiting past the point the SDK itself has given up.
|
|
28
|
+
const TURN_CEILING_MS = TOOL_TIMEOUT_MS;
|
|
29
|
+
// Dev-only and unauthenticated: it spends the developer's subscription. Binding
|
|
30
|
+
// 0.0.0.0 would offer that to anyone on the network.
|
|
31
|
+
const HOST = '127.0.0.1';
|
|
32
|
+
// One neutral cwd for the process: the isolation guarantee only needs it to be
|
|
33
|
+
// outside any project, and re-creating it per request would litter /tmp.
|
|
34
|
+
// Created lazily, on first use, not at module load: importing this module
|
|
35
|
+
// (e.g. from a test) must not have the side effect of creating a temp dir.
|
|
36
|
+
let neutralCwd;
|
|
37
|
+
function getNeutralCwd() {
|
|
38
|
+
neutralCwd ??= createNeutralCwd();
|
|
39
|
+
return neutralCwd;
|
|
40
|
+
}
|
|
41
|
+
function readBody(req) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
let raw = '';
|
|
44
|
+
req.on('data', c => { raw += c; });
|
|
45
|
+
req.on('end', () => { resolve(raw); });
|
|
46
|
+
req.on('error', reject);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export function createServer(opts) {
|
|
50
|
+
const runQuery = opts.query ?? query;
|
|
51
|
+
const store = new SessionStore();
|
|
52
|
+
const sweeper = setInterval(() => { store.sweep(); }, 60000);
|
|
53
|
+
sweeper.unref();
|
|
54
|
+
const server = http.createServer((req, res) => {
|
|
55
|
+
if (req.method === 'GET' && req.url === '/v1/models') {
|
|
56
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
57
|
+
res.end(JSON.stringify({ object: 'list', data: MODELS }));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (req.method === 'GET' && req.url === '/_bridge/status') {
|
|
61
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
62
|
+
res.end(JSON.stringify({ liveSessions: store.size, cwd: getNeutralCwd() }));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (req.method === 'POST' && req.url === '/v1/chat/completions') {
|
|
66
|
+
handleCompletion(req, res, store, runQuery).catch((err) => {
|
|
67
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
68
|
+
console.error('claude-bridge request failed:', message);
|
|
69
|
+
if (res.writableEnded)
|
|
70
|
+
return;
|
|
71
|
+
if (!res.headersSent) {
|
|
72
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
73
|
+
res.end(JSON.stringify(errorBody(message)));
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
res.write(`data: ${JSON.stringify(errorBody(message))}\n\n`);
|
|
77
|
+
res.end();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
83
|
+
res.end(JSON.stringify(errorBody('not found', 'invalid_request_error')));
|
|
84
|
+
});
|
|
85
|
+
server.listen(opts.port, HOST);
|
|
86
|
+
return server;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Race a turn against the ceiling. Every await of a turn goes through here:
|
|
90
|
+
* a promise that never settles used to leave the SSE response open forever.
|
|
91
|
+
*/
|
|
92
|
+
function withCeiling(turn) {
|
|
93
|
+
let timer;
|
|
94
|
+
const ceiling = new Promise(resolve => {
|
|
95
|
+
timer = setTimeout(() => { resolve({ type: 'error', message: `the upstream query produced nothing for ${TURN_CEILING_MS}ms` }); }, TURN_CEILING_MS);
|
|
96
|
+
if (timer.unref)
|
|
97
|
+
timer.unref();
|
|
98
|
+
});
|
|
99
|
+
return Promise.race([turn, ceiling]).finally(() => { if (timer)
|
|
100
|
+
clearTimeout(timer); });
|
|
101
|
+
}
|
|
102
|
+
async function handleCompletion(req, res, store, runQuery) {
|
|
103
|
+
const body = JSON.parse(await readBody(req));
|
|
104
|
+
const messages = body.messages ?? [];
|
|
105
|
+
const tools = body.tools ?? [];
|
|
106
|
+
const toolNames = tools.map(t => t.function.name);
|
|
107
|
+
const model = body.model ?? 'sonnet';
|
|
108
|
+
const id = `chatcmpl-${crypto.randomUUID()}`;
|
|
109
|
+
res.writeHead(200, {
|
|
110
|
+
'content-type': 'text/event-stream',
|
|
111
|
+
'cache-control': 'no-cache',
|
|
112
|
+
connection: 'keep-alive'
|
|
113
|
+
});
|
|
114
|
+
const send = (payload) => {
|
|
115
|
+
if (res.writableEnded || res.destroyed)
|
|
116
|
+
return;
|
|
117
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
118
|
+
};
|
|
119
|
+
const sink = (text) => { send(textChunk(id, model, text)); };
|
|
120
|
+
// The client can vanish mid-turn: the tab closes, or the UI's idle watchdog
|
|
121
|
+
// aborts the fetch. Without this the conversation would stay in the store with
|
|
122
|
+
// a claude subprocess alive, streaming into a dead socket.
|
|
123
|
+
let active = null;
|
|
124
|
+
res.on('close', () => {
|
|
125
|
+
if (res.writableEnded)
|
|
126
|
+
return;
|
|
127
|
+
if (active)
|
|
128
|
+
store.delete(active.key);
|
|
129
|
+
});
|
|
130
|
+
// --- fast path: hand the tool results to the query that is waiting for them ---
|
|
131
|
+
const continuation = continuationOf(messages);
|
|
132
|
+
if (continuation) {
|
|
133
|
+
const live = store.get(continuation.key);
|
|
134
|
+
if (live && !live.isDead && live.awaits(continuation.toolResults.map(r => r.id)) && sameToolSet(live.toolNames, toolNames)) {
|
|
135
|
+
active = live;
|
|
136
|
+
live.lastSeen = Date.now();
|
|
137
|
+
// The history has grown by this turn, so the NEXT continuation hashes
|
|
138
|
+
// against it. rekey, never delete+set: delete aborts the query.
|
|
139
|
+
store.rekey(continuation.key, hashMessages(messages));
|
|
140
|
+
const turn = live.beginTurn(sink);
|
|
141
|
+
live.deliverToolResults(continuation.toolResults);
|
|
142
|
+
finish(await withCeiling(turn), live, store, res, send, id, model);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// Diverged, expired, dead, answering calls this session never made, or
|
|
146
|
+
// declaring a tool set the live query was not built with.
|
|
147
|
+
if (live)
|
|
148
|
+
store.delete(continuation.key);
|
|
149
|
+
}
|
|
150
|
+
// --- replay path: a fresh session carrying the whole history ---
|
|
151
|
+
const key = hashMessages(messages);
|
|
152
|
+
store.delete(key);
|
|
153
|
+
const conv = new Conversation(key, toolNames);
|
|
154
|
+
active = conv;
|
|
155
|
+
const toolServer = createToolServer(tools, (name, args) => conv.handleToolCall(name, args));
|
|
156
|
+
const iterator = runQuery({
|
|
157
|
+
prompt: renderTranscript(messages),
|
|
158
|
+
options: {
|
|
159
|
+
...isolationOptions(getNeutralCwd()),
|
|
160
|
+
model,
|
|
161
|
+
systemPrompt: extractSystemPrompt(messages),
|
|
162
|
+
// The tool server deliberately holds the LOW-LEVEL MCP `Server` (see
|
|
163
|
+
// tool-server.ts: the high-level `McpServer` helper rejects raw JSON
|
|
164
|
+
// Schema, which is exactly what an OpenAI request hands us). The SDK's
|
|
165
|
+
// config type names the helper; the two are wire-compatible, so the cast
|
|
166
|
+
// keeps the deliberate choice while letting the file type-check.
|
|
167
|
+
mcpServers: { [MCP_SERVER_NAME]: toolServer },
|
|
168
|
+
allowedTools: tools.map(t => toolNameToMcp(t.function.name)),
|
|
169
|
+
// Without this the SDK query is unabortable and every eviction — TTL, LRU,
|
|
170
|
+
// divergence, client disconnect — would drop the map entry while leaving a
|
|
171
|
+
// claude subprocess running.
|
|
172
|
+
abortController: conv.controller
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
store.set(conv);
|
|
176
|
+
finish(await withCeiling(conv.beginTurn(sink, iterator)), conv, store, res, send, id, model);
|
|
177
|
+
}
|
|
178
|
+
function finish(outcome, conv, store, res, send, id, model) {
|
|
179
|
+
if (outcome.type === 'tools') {
|
|
180
|
+
send(toolCallsChunk(id, model, outcome.calls));
|
|
181
|
+
conv.handedBack(outcome.calls.length);
|
|
182
|
+
send(finalChunk(id, model, 'tool_calls'));
|
|
183
|
+
// The session stays alive: its handlers are suspended, waiting for the
|
|
184
|
+
// results the client is about to compute.
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
if (outcome.type === 'error')
|
|
188
|
+
send(errorBody(outcome.message));
|
|
189
|
+
send(finalChunk(id, model, 'stop', outcome.type === 'done' ? outcome.usage : undefined));
|
|
190
|
+
store.delete(conv.key);
|
|
191
|
+
}
|
|
192
|
+
if (res.writableEnded || res.destroyed)
|
|
193
|
+
return;
|
|
194
|
+
res.write('data: [DONE]\n\n');
|
|
195
|
+
res.end();
|
|
196
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { OpenAIMessage } from './openai.ts';
|
|
2
|
+
export type LiveSession = {
|
|
3
|
+
key: string;
|
|
4
|
+
pending: Map<string, (result: string) => void>;
|
|
5
|
+
abort: () => void;
|
|
6
|
+
lastSeen: number;
|
|
7
|
+
};
|
|
8
|
+
export declare function hashMessages(messages: OpenAIMessage[]): string;
|
|
9
|
+
/**
|
|
10
|
+
* A continuation request has exactly one shape: an assistant message carrying
|
|
11
|
+
* tool_calls, followed by the tool messages answering every one of them. Strip
|
|
12
|
+
* that suffix and you have the array the bridge saw when it suspended.
|
|
13
|
+
* Anything else is not a continuation.
|
|
14
|
+
*/
|
|
15
|
+
export declare function continuationOf(messages: OpenAIMessage[]): {
|
|
16
|
+
key: string;
|
|
17
|
+
toolResults: {
|
|
18
|
+
id: string;
|
|
19
|
+
content: string;
|
|
20
|
+
}[];
|
|
21
|
+
} | null;
|
|
22
|
+
/**
|
|
23
|
+
* The live query keeps the MCP tool server it was built with on the FIRST request
|
|
24
|
+
* of the turn, but the product registers tools mid-turn (use-agent-chat.ts
|
|
25
|
+
* reconciles the set as panels open). Adopting a live session whose tool set no
|
|
26
|
+
* longer matches would offer the model a stale set and make a freshly registered
|
|
27
|
+
* tool uncallable — so a mismatch forfeits the cache and replays instead.
|
|
28
|
+
*/
|
|
29
|
+
export declare function sameToolSet(a: string[], b: string[]): boolean;
|
|
30
|
+
export declare class SessionStore {
|
|
31
|
+
#private;
|
|
32
|
+
constructor(opts?: {
|
|
33
|
+
ttlMs?: number;
|
|
34
|
+
max?: number;
|
|
35
|
+
});
|
|
36
|
+
get size(): number;
|
|
37
|
+
get(key: string, now?: number): LiveSession | undefined;
|
|
38
|
+
set(session: LiveSession): void;
|
|
39
|
+
delete(key: string): void;
|
|
40
|
+
/**
|
|
41
|
+
* Move a still-running session to the key its grown history now hashes to.
|
|
42
|
+
* Deliberately not `delete` + `set`: delete aborts the query.
|
|
43
|
+
*/
|
|
44
|
+
rekey(oldKey: string, newKey: string): void;
|
|
45
|
+
sweep(now?: number): void;
|
|
46
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live session continuity (spec §2.4).
|
|
3
|
+
*
|
|
4
|
+
* The bridge keeps one SDK query alive per conversation so the prompt cache
|
|
5
|
+
* carries across turns. Correctness rests on one property: the key is a content
|
|
6
|
+
* hash of the exact history prefix, so ANY change upstream — compaction replacing
|
|
7
|
+
* turns with a summary, media tool results being redacted, a user retrying —
|
|
8
|
+
* produces a different key and therefore a MISS, which degrades to a full replay.
|
|
9
|
+
* There is no path by which a superseded history is silently answered.
|
|
10
|
+
*/
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
export function hashMessages(messages) {
|
|
13
|
+
return crypto.createHash('sha256').update(JSON.stringify(messages)).digest('hex');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A continuation request has exactly one shape: an assistant message carrying
|
|
17
|
+
* tool_calls, followed by the tool messages answering every one of them. Strip
|
|
18
|
+
* that suffix and you have the array the bridge saw when it suspended.
|
|
19
|
+
* Anything else is not a continuation.
|
|
20
|
+
*/
|
|
21
|
+
export function continuationOf(messages) {
|
|
22
|
+
let i = messages.length;
|
|
23
|
+
const results = [];
|
|
24
|
+
while (i > 0 && messages[i - 1].role === 'tool') {
|
|
25
|
+
i--;
|
|
26
|
+
results.unshift({ id: messages[i].tool_call_id ?? '', content: messages[i].content ?? '' });
|
|
27
|
+
}
|
|
28
|
+
if (results.length === 0)
|
|
29
|
+
return null;
|
|
30
|
+
const assistant = messages[i - 1];
|
|
31
|
+
if (!assistant || assistant.role !== 'assistant' || !assistant.tool_calls?.length)
|
|
32
|
+
return null;
|
|
33
|
+
// Every call must be answered; a partial answer is not a continuation.
|
|
34
|
+
const answered = new Set(results.map(r => r.id));
|
|
35
|
+
if (assistant.tool_calls.some(c => !answered.has(c.id)))
|
|
36
|
+
return null;
|
|
37
|
+
return { key: hashMessages(messages.slice(0, i - 1)), toolResults: results };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The live query keeps the MCP tool server it was built with on the FIRST request
|
|
41
|
+
* of the turn, but the product registers tools mid-turn (use-agent-chat.ts
|
|
42
|
+
* reconciles the set as panels open). Adopting a live session whose tool set no
|
|
43
|
+
* longer matches would offer the model a stale set and make a freshly registered
|
|
44
|
+
* tool uncallable — so a mismatch forfeits the cache and replays instead.
|
|
45
|
+
*/
|
|
46
|
+
export function sameToolSet(a, b) {
|
|
47
|
+
if (a.length !== b.length)
|
|
48
|
+
return false;
|
|
49
|
+
const set = new Set(a);
|
|
50
|
+
return b.every(name => set.has(name));
|
|
51
|
+
}
|
|
52
|
+
export class SessionStore {
|
|
53
|
+
#sessions = new Map();
|
|
54
|
+
#ttlMs;
|
|
55
|
+
#max;
|
|
56
|
+
constructor(opts = {}) {
|
|
57
|
+
this.#ttlMs = opts.ttlMs ?? 15 * 60 * 1000;
|
|
58
|
+
this.#max = opts.max ?? 20;
|
|
59
|
+
}
|
|
60
|
+
get size() { return this.#sessions.size; }
|
|
61
|
+
get(key, now = Date.now()) {
|
|
62
|
+
const s = this.#sessions.get(key);
|
|
63
|
+
if (!s)
|
|
64
|
+
return undefined;
|
|
65
|
+
if (now - s.lastSeen > this.#ttlMs) {
|
|
66
|
+
this.delete(key);
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
return s;
|
|
70
|
+
}
|
|
71
|
+
set(session) {
|
|
72
|
+
this.#sessions.set(session.key, session);
|
|
73
|
+
while (this.#sessions.size > this.#max) {
|
|
74
|
+
let oldest;
|
|
75
|
+
for (const s of this.#sessions.values()) {
|
|
76
|
+
if (!oldest || s.lastSeen < oldest.lastSeen)
|
|
77
|
+
oldest = s;
|
|
78
|
+
}
|
|
79
|
+
if (!oldest)
|
|
80
|
+
break;
|
|
81
|
+
this.delete(oldest.key);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
delete(key) {
|
|
85
|
+
const s = this.#sessions.get(key);
|
|
86
|
+
if (!s)
|
|
87
|
+
return;
|
|
88
|
+
this.#sessions.delete(key);
|
|
89
|
+
s.abort();
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Move a still-running session to the key its grown history now hashes to.
|
|
93
|
+
* Deliberately not `delete` + `set`: delete aborts the query.
|
|
94
|
+
*/
|
|
95
|
+
rekey(oldKey, newKey) {
|
|
96
|
+
const s = this.#sessions.get(oldKey);
|
|
97
|
+
if (!s)
|
|
98
|
+
return;
|
|
99
|
+
this.#sessions.delete(oldKey);
|
|
100
|
+
s.key = newKey;
|
|
101
|
+
this.#sessions.set(newKey, s);
|
|
102
|
+
}
|
|
103
|
+
sweep(now = Date.now()) {
|
|
104
|
+
for (const [key, s] of [...this.#sessions]) {
|
|
105
|
+
if (now - s.lastSeen > this.#ttlMs)
|
|
106
|
+
this.delete(key);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Republishes the tool definitions from an OpenAI request as an in-process MCP
|
|
3
|
+
* server the SDK can offer to the model.
|
|
4
|
+
*
|
|
5
|
+
* Two non-obvious choices:
|
|
6
|
+
* - the LOW-LEVEL `Server` is used, not the `McpServer` helper: the helper rejects
|
|
7
|
+
* raw JSON Schema and demands Zod, while a request hands us JSON Schema already.
|
|
8
|
+
* - handlers SUSPEND. `onCall` returns a promise the HTTP layer resolves when the
|
|
9
|
+
* client's next request delivers the tool result, so the same query — and its
|
|
10
|
+
* prompt cache — carries the whole conversation, and the model's tool_use is
|
|
11
|
+
* answered by a real tool_result rather than by user text.
|
|
12
|
+
*/
|
|
13
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
14
|
+
import { type OpenAIToolDef } from './openai.ts';
|
|
15
|
+
export declare const TOOL_TIMEOUT_MS = 600000;
|
|
16
|
+
export declare function listToolsFor(tools: OpenAIToolDef[]): {
|
|
17
|
+
name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
inputSchema: Record<string, unknown>;
|
|
20
|
+
}[];
|
|
21
|
+
export declare function createToolServer(tools: OpenAIToolDef[], onCall: (name: string, args: Record<string, unknown>) => Promise<string>): {
|
|
22
|
+
type: "sdk";
|
|
23
|
+
name: string;
|
|
24
|
+
instance: Server<{
|
|
25
|
+
method: string;
|
|
26
|
+
params?: {
|
|
27
|
+
[x: string]: unknown;
|
|
28
|
+
_meta?: {
|
|
29
|
+
[x: string]: unknown;
|
|
30
|
+
progressToken?: string | number | undefined;
|
|
31
|
+
"io.modelcontextprotocol/related-task"?: {
|
|
32
|
+
taskId: string;
|
|
33
|
+
} | undefined;
|
|
34
|
+
} | undefined;
|
|
35
|
+
} | undefined;
|
|
36
|
+
}, {
|
|
37
|
+
method: string;
|
|
38
|
+
params?: {
|
|
39
|
+
[x: string]: unknown;
|
|
40
|
+
_meta?: {
|
|
41
|
+
[x: string]: unknown;
|
|
42
|
+
progressToken?: string | number | undefined;
|
|
43
|
+
"io.modelcontextprotocol/related-task"?: {
|
|
44
|
+
taskId: string;
|
|
45
|
+
} | undefined;
|
|
46
|
+
} | undefined;
|
|
47
|
+
} | undefined;
|
|
48
|
+
}, {
|
|
49
|
+
[x: string]: unknown;
|
|
50
|
+
_meta?: {
|
|
51
|
+
[x: string]: unknown;
|
|
52
|
+
progressToken?: string | number | undefined;
|
|
53
|
+
"io.modelcontextprotocol/related-task"?: {
|
|
54
|
+
taskId: string;
|
|
55
|
+
} | undefined;
|
|
56
|
+
} | undefined;
|
|
57
|
+
}>;
|
|
58
|
+
alwaysLoad: true;
|
|
59
|
+
timeout: number;
|
|
60
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Republishes the tool definitions from an OpenAI request as an in-process MCP
|
|
3
|
+
* server the SDK can offer to the model.
|
|
4
|
+
*
|
|
5
|
+
* Two non-obvious choices:
|
|
6
|
+
* - the LOW-LEVEL `Server` is used, not the `McpServer` helper: the helper rejects
|
|
7
|
+
* raw JSON Schema and demands Zod, while a request hands us JSON Schema already.
|
|
8
|
+
* - handlers SUSPEND. `onCall` returns a promise the HTTP layer resolves when the
|
|
9
|
+
* client's next request delivers the tool result, so the same query — and its
|
|
10
|
+
* prompt cache — carries the whole conversation, and the model's tool_use is
|
|
11
|
+
* answered by a real tool_result rather than by user text.
|
|
12
|
+
*/
|
|
13
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
14
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
15
|
+
import { MCP_SERVER_NAME } from "./openai.js";
|
|
16
|
+
// A suspended handler must outlive a slow client turn — a tool wired to a human
|
|
17
|
+
// action button can wait minutes. The default MCP timeout would abort it.
|
|
18
|
+
export const TOOL_TIMEOUT_MS = 600000;
|
|
19
|
+
export function listToolsFor(tools) {
|
|
20
|
+
return tools.map(t => ({
|
|
21
|
+
name: t.function.name,
|
|
22
|
+
description: t.function.description ?? '',
|
|
23
|
+
inputSchema: t.function.parameters ?? { type: 'object', properties: {} }
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
export function createToolServer(tools, onCall) {
|
|
27
|
+
const instance = new Server({ name: MCP_SERVER_NAME, version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
28
|
+
instance.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: listToolsFor(tools) }));
|
|
29
|
+
instance.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
30
|
+
const text = await onCall(req.params.name, (req.params.arguments ?? {}));
|
|
31
|
+
return { content: [{ type: 'text', text }] };
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
type: 'sdk',
|
|
35
|
+
name: MCP_SERVER_NAME,
|
|
36
|
+
instance,
|
|
37
|
+
alwaysLoad: true,
|
|
38
|
+
timeout: TOOL_TIMEOUT_MS
|
|
39
|
+
};
|
|
40
|
+
}
|
package/cases.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SimulationCase } from './types.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Select cases by name, or all of them when no name is given. Throws on an
|
|
4
|
+
* unknown name rather than silently running a subset — a typo that quietly
|
|
5
|
+
* runs nothing is worse than an error.
|
|
6
|
+
*
|
|
7
|
+
* Generic in the case type so a host repo's own case fields (added on top of
|
|
8
|
+
* SimulationCase) survive the round trip through this function.
|
|
9
|
+
*/
|
|
10
|
+
export declare function selectCases<T extends SimulationCase>(all: T[], names: string[]): T[];
|
package/cases.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Select cases by name, or all of them when no name is given. Throws on an
|
|
3
|
+
* unknown name rather than silently running a subset — a typo that quietly
|
|
4
|
+
* runs nothing is worse than an error.
|
|
5
|
+
*
|
|
6
|
+
* Generic in the case type so a host repo's own case fields (added on top of
|
|
7
|
+
* SimulationCase) survive the round trip through this function.
|
|
8
|
+
*/
|
|
9
|
+
export function selectCases(all, names) {
|
|
10
|
+
if (names.length === 0)
|
|
11
|
+
return all;
|
|
12
|
+
return names.map(name => {
|
|
13
|
+
const found = all.find(c => c.name === name);
|
|
14
|
+
if (!found)
|
|
15
|
+
throw new Error(`unknown simulation case: ${name} (have: ${all.map(c => c.name).join(', ')})`);
|
|
16
|
+
return found;
|
|
17
|
+
});
|
|
18
|
+
}
|
package/chat-driver.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side interaction with the agents chat.
|
|
3
|
+
*
|
|
4
|
+
* The root is a Page when the chat IS the page (this repo's _dev pages) and a
|
|
5
|
+
* FrameLocator when it is embedded (data-fair, portals — lib-vuetify renders
|
|
6
|
+
* agents' own UI in an iframe, so the selectors are identical either way).
|
|
7
|
+
*
|
|
8
|
+
* Turn completion is detected from the composer button, not from message text:
|
|
9
|
+
* AgentChatInput renders a Stop button while streaming and a Send button
|
|
10
|
+
* otherwise. Waiting for text would end the turn at the first token of a
|
|
11
|
+
* multi-step tool conversation.
|
|
12
|
+
*/
|
|
13
|
+
import { type Page, type FrameLocator } from '@playwright/test';
|
|
14
|
+
export type ChatRoot = Page | FrameLocator;
|
|
15
|
+
export declare const TURN_TIMEOUT_MS: number;
|
|
16
|
+
export declare function createChatDriver(root: ChatRoot): {
|
|
17
|
+
sendMessage(text: string): Promise<void>;
|
|
18
|
+
waitForTurn(timeoutMs?: number): Promise<void>;
|
|
19
|
+
readConversation(): Promise<{
|
|
20
|
+
role: "user" | "assistant";
|
|
21
|
+
text: string;
|
|
22
|
+
}[]>;
|
|
23
|
+
};
|
package/chat-driver.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side interaction with the agents chat.
|
|
3
|
+
*
|
|
4
|
+
* The root is a Page when the chat IS the page (this repo's _dev pages) and a
|
|
5
|
+
* FrameLocator when it is embedded (data-fair, portals — lib-vuetify renders
|
|
6
|
+
* agents' own UI in an iframe, so the selectors are identical either way).
|
|
7
|
+
*
|
|
8
|
+
* Turn completion is detected from the composer button, not from message text:
|
|
9
|
+
* AgentChatInput renders a Stop button while streaming and a Send button
|
|
10
|
+
* otherwise. Waiting for text would end the turn at the first token of a
|
|
11
|
+
* multi-step tool conversation.
|
|
12
|
+
*/
|
|
13
|
+
import { expect } from '@playwright/test';
|
|
14
|
+
const INPUT = 'Type your message...';
|
|
15
|
+
// The app's own watchdog is a 90s IDLE timer that re-arms per stream part, so a
|
|
16
|
+
// legitimate multi-step turn has no fixed ceiling on total time. This bounds the
|
|
17
|
+
// harness generously rather than recording a slow-but-working turn as a failure.
|
|
18
|
+
export const TURN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
19
|
+
export function createChatDriver(root) {
|
|
20
|
+
return {
|
|
21
|
+
async sendMessage(text) {
|
|
22
|
+
await root.getByPlaceholder(INPUT).fill(text);
|
|
23
|
+
await root.getByRole('button', { name: 'Send' }).click();
|
|
24
|
+
},
|
|
25
|
+
async waitForTurn(timeoutMs = TURN_TIMEOUT_MS) {
|
|
26
|
+
const stop = root.getByRole('button', { name: 'Stop' });
|
|
27
|
+
// The turn may already be finished by the time we look, so a missing Stop
|
|
28
|
+
// button is not an error — only one that never goes away is.
|
|
29
|
+
await stop.waitFor({ state: 'visible', timeout: 15000 }).catch(() => { });
|
|
30
|
+
await expect(stop).toHaveCount(0, { timeout: timeoutMs });
|
|
31
|
+
},
|
|
32
|
+
async readConversation() {
|
|
33
|
+
// evaluateAll, not page.evaluate: FrameLocator has no evaluate, and this
|
|
34
|
+
// runs in the right frame's context either way while preserving document order.
|
|
35
|
+
return await root.locator('.agent-chat__user-bubble, .assistant-content').evaluateAll(els => els.map(el => ({
|
|
36
|
+
role: el.classList.contains('agent-chat__user-bubble') ? 'user' : 'assistant',
|
|
37
|
+
text: (el.textContent ?? '').trim()
|
|
38
|
+
})));
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The evidence a run is judged on.
|
|
3
|
+
*
|
|
4
|
+
* Requests to the gateway carry the full message array AND the tool definitions
|
|
5
|
+
* the page registered, so they show what the assistant was actually offered and
|
|
6
|
+
* what it did with it — including sub-agent turns and compaction. The rendered
|
|
7
|
+
* DOM only shows what survived to the screen.
|
|
8
|
+
*/
|
|
9
|
+
import type { Page } from '@playwright/test';
|
|
10
|
+
export type GatewayExchange = {
|
|
11
|
+
at: number;
|
|
12
|
+
model: string;
|
|
13
|
+
toolNames: string[];
|
|
14
|
+
messageCount: number;
|
|
15
|
+
lastUserMessage: string;
|
|
16
|
+
/** Tool calls are cumulative over the conversation so far, because the chat-completions
|
|
17
|
+
* protocol resends the entire message history on every request. Exchange N contains
|
|
18
|
+
* every tool call from turns 1..N, not just that turn's calls. This ensures the evidence
|
|
19
|
+
* shows the complete instruction context the assistant saw.
|
|
20
|
+
*/
|
|
21
|
+
toolCalls: Array<{
|
|
22
|
+
name: string;
|
|
23
|
+
arguments: string;
|
|
24
|
+
}>;
|
|
25
|
+
/** When true, this exchange was received but postData could not be parsed. Evidence of
|
|
26
|
+
* a failed capture rather than an absent request.
|
|
27
|
+
*/
|
|
28
|
+
unparsed?: true;
|
|
29
|
+
};
|
|
30
|
+
export declare function summariseRequest(body: unknown): GatewayExchange | null;
|
|
31
|
+
export declare function captureGateway(page: Page): GatewayExchange[];
|