@peakflo/agent-harness 0.2.4-rc.1 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capabilities.d.ts +8 -0
- package/dist/capabilities.js +10 -0
- package/dist/contract.d.ts +4 -0
- package/dist/contract.js +4 -0
- package/dist/control-plane.d.ts +8 -1
- package/dist/control-plane.js +26 -0
- package/dist/harness.d.ts +2 -0
- package/dist/harness.js +10 -0
- package/dist/runtime/attachments.d.ts +6 -0
- package/dist/runtime/attachments.js +23 -15
- package/dist/runtime/conversation.d.ts +16 -0
- package/dist/runtime/conversation.js +15 -0
- package/dist/runtime/knowledge-tools.d.ts +44 -0
- package/dist/runtime/knowledge-tools.js +410 -0
- package/dist/runtime/session-runner.d.ts +17 -0
- package/dist/runtime/session-runner.js +69 -9
- package/dist/runtime/space-files.d.ts +82 -0
- package/dist/runtime/space-files.js +422 -0
- package/dist/types.d.ts +73 -0
- package/package.json +1 -1
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.KnowledgeToolHost = exports.KNOWLEDGE_FILE_DOWNLOAD_TOOL = exports.KNOWLEDGE_FILES_LIST_TOOL = exports.KNOWLEDGE_SERVER_NAME = void 0;
|
|
4
|
+
exports.safeDownloadName = safeDownloadName;
|
|
5
|
+
/**
|
|
6
|
+
* KNOWLEDGE TOOLS, SERVED BY THE HARNESS (P9).
|
|
7
|
+
*
|
|
8
|
+
* `knowledge_files_list` and `knowledge_file_download` are MCP tools the agent
|
|
9
|
+
* calls like any other. They are served here, on a loopback socket, rather than
|
|
10
|
+
* by the gateway, because the download has to land in THIS workspace and the
|
|
11
|
+
* gateway has no access to it.
|
|
12
|
+
*
|
|
13
|
+
* model → harness (MCP over loopback, path secret)
|
|
14
|
+
* → control plane (lease-authenticated: who may read what, decided there)
|
|
15
|
+
* → signed URL → harness fetches it into `downloads/<name>`
|
|
16
|
+
*
|
|
17
|
+
* The signed URL goes from the control plane to the harness and stops there.
|
|
18
|
+
* The tool result names the saved path, size and type, and nothing else. The
|
|
19
|
+
* model never holds the URL, the transcript never records it, and the log line
|
|
20
|
+
* carries only ids.
|
|
21
|
+
*
|
|
22
|
+
* THE SERVER IS MINIMAL ON PURPOSE. It implements the three methods an MCP
|
|
23
|
+
* client needs for a tools-only server (`initialize`, `tools/list`,
|
|
24
|
+
* `tools/call`) over the Streamable HTTP transport in its JSON-response form.
|
|
25
|
+
* It never opens a server-sent-events stream (GET is 405), because the tools
|
|
26
|
+
* are synchronous and nothing is pushed to the client.
|
|
27
|
+
*
|
|
28
|
+
* ONE SECRET PER SESSION. Each session's endpoint carries a random path
|
|
29
|
+
* (`/mcp/<token>`). The token is the only thing that maps a request to a
|
|
30
|
+
* session, and the host header must name the loopback address, which blocks a
|
|
31
|
+
* DNS-rebinding page from reaching it. A request outside an active turn is
|
|
32
|
+
* refused, because the lease that authorises it exists only during the turn.
|
|
33
|
+
*/
|
|
34
|
+
const node_crypto_1 = require("node:crypto");
|
|
35
|
+
const promises_1 = require("node:fs/promises");
|
|
36
|
+
const node_http_1 = require("node:http");
|
|
37
|
+
const node_path_1 = require("node:path");
|
|
38
|
+
const control_plane_1 = require("../control-plane");
|
|
39
|
+
const space_files_1 = require("./space-files");
|
|
40
|
+
/** The name the agent sees the server under. */
|
|
41
|
+
exports.KNOWLEDGE_SERVER_NAME = 'pf-knowledge';
|
|
42
|
+
exports.KNOWLEDGE_FILES_LIST_TOOL = 'knowledge_files_list';
|
|
43
|
+
exports.KNOWLEDGE_FILE_DOWNLOAD_TOOL = 'knowledge_file_download';
|
|
44
|
+
const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
45
|
+
const DEFAULT_PROTOCOL_VERSION = '2025-06-18';
|
|
46
|
+
const MAX_BODY_BYTES = 256 * 1024;
|
|
47
|
+
const SERVER_VERSION = '1';
|
|
48
|
+
const TOOLS = [
|
|
49
|
+
{
|
|
50
|
+
name: exports.KNOWLEDGE_FILES_LIST_TOOL,
|
|
51
|
+
description: 'List the knowledge files you may read: id, datastore, name, type, size and status. Metadata only. Use it to find a fileId for knowledge_file_download.',
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: {
|
|
55
|
+
datastoreId: {
|
|
56
|
+
type: 'string',
|
|
57
|
+
description: 'Optional. Only list files of this datastore.'
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
additionalProperties: false
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: exports.KNOWLEDGE_FILE_DOWNLOAD_TOOL,
|
|
65
|
+
description: 'Download one knowledge file into ./downloads (up to 50 MB). Returns the saved path, size and type. Read it with your file tools afterwards.',
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: {
|
|
69
|
+
datastoreId: {
|
|
70
|
+
type: 'string',
|
|
71
|
+
description: 'The datastore id from knowledge_files_list.'
|
|
72
|
+
},
|
|
73
|
+
fileId: {
|
|
74
|
+
type: 'string',
|
|
75
|
+
description: 'The file id from knowledge_files_list.'
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
required: ['datastoreId', 'fileId'],
|
|
79
|
+
additionalProperties: false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
];
|
|
83
|
+
/** A name safe as one path segment, for a download's name. */
|
|
84
|
+
function safeDownloadName(raw) {
|
|
85
|
+
let name = (0, space_files_1.replaceControlCharacters)(String(raw ?? ''))
|
|
86
|
+
.replace(/[\\/:*?"<>|]/g, '_')
|
|
87
|
+
.replace(/\s+/g, ' ')
|
|
88
|
+
.trim()
|
|
89
|
+
.replace(/^\.+/, '')
|
|
90
|
+
.trim();
|
|
91
|
+
if (!name)
|
|
92
|
+
name = 'file';
|
|
93
|
+
if (name.length > 150) {
|
|
94
|
+
const dot = name.lastIndexOf('.');
|
|
95
|
+
const ext = dot > 0 && name.length - dot <= 16 ? name.slice(dot) : '';
|
|
96
|
+
name = name.slice(0, 150 - ext.length) + ext;
|
|
97
|
+
}
|
|
98
|
+
return name;
|
|
99
|
+
}
|
|
100
|
+
/** `name`, or `name (2).ext`, ... for the first slot not already in `downloads/`. */
|
|
101
|
+
async function freeDownloadPath(dir, name) {
|
|
102
|
+
const dot = name.lastIndexOf('.');
|
|
103
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
104
|
+
const ext = dot > 0 ? name.slice(dot) : '';
|
|
105
|
+
for (let index = 1;; index++) {
|
|
106
|
+
const candidate = index === 1 ? name : `${stem} (${index})${ext}`;
|
|
107
|
+
try {
|
|
108
|
+
await (0, promises_1.stat)((0, node_path_1.join)(dir, candidate));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return { name: candidate, path: (0, node_path_1.join)(dir, candidate) };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function formatBytes(bytes) {
|
|
116
|
+
if (bytes < 1024)
|
|
117
|
+
return `${bytes} B`;
|
|
118
|
+
if (bytes < 1024 * 1024)
|
|
119
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
120
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
121
|
+
}
|
|
122
|
+
/** A tool-level failure: a message for the model, never a stack or a URL. */
|
|
123
|
+
class ToolFailure extends Error {
|
|
124
|
+
}
|
|
125
|
+
function plainMessage(error) {
|
|
126
|
+
if (error instanceof ToolFailure)
|
|
127
|
+
return error.message;
|
|
128
|
+
if (error instanceof control_plane_1.ControlPlaneError)
|
|
129
|
+
return error.message;
|
|
130
|
+
return 'The knowledge tool could not complete this request.';
|
|
131
|
+
}
|
|
132
|
+
const ID_PATTERN = /^[0-9a-fA-F-]{36}$/;
|
|
133
|
+
function requiredId(value, field) {
|
|
134
|
+
if (typeof value !== 'string' || !ID_PATTERN.test(value))
|
|
135
|
+
throw new ToolFailure(`${field} must be an id from knowledge_files_list.`);
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
function optionalId(value, field) {
|
|
139
|
+
if (value === undefined || value === null || value === '')
|
|
140
|
+
return undefined;
|
|
141
|
+
return requiredId(value, field);
|
|
142
|
+
}
|
|
143
|
+
/** The request body, or null when it is larger than the limit. */
|
|
144
|
+
function readBody(req, limit) {
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
let size = 0;
|
|
147
|
+
const chunks = [];
|
|
148
|
+
req.on('data', (chunk) => {
|
|
149
|
+
size += chunk.length;
|
|
150
|
+
if (size > limit) {
|
|
151
|
+
resolve(null);
|
|
152
|
+
req.destroy();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
chunks.push(chunk);
|
|
156
|
+
});
|
|
157
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
158
|
+
req.on('error', reject);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
class KnowledgeToolHost {
|
|
162
|
+
options;
|
|
163
|
+
server = null;
|
|
164
|
+
port = 0;
|
|
165
|
+
starting = null;
|
|
166
|
+
tokenBySession = new Map();
|
|
167
|
+
sessionByToken = new Map();
|
|
168
|
+
turns = new Map();
|
|
169
|
+
constructor(options) {
|
|
170
|
+
this.options = options;
|
|
171
|
+
}
|
|
172
|
+
/** The endpoint a session's agent is configured with. Starts the listener on first use. */
|
|
173
|
+
async endpointFor(sessionId) {
|
|
174
|
+
await this.start();
|
|
175
|
+
let token = this.tokenBySession.get(sessionId);
|
|
176
|
+
if (!token) {
|
|
177
|
+
token = (0, node_crypto_1.randomBytes)(24).toString('base64url');
|
|
178
|
+
this.tokenBySession.set(sessionId, token);
|
|
179
|
+
this.sessionByToken.set(token, sessionId);
|
|
180
|
+
}
|
|
181
|
+
return `http://127.0.0.1:${this.port}/mcp/${token}`;
|
|
182
|
+
}
|
|
183
|
+
/** Bind the current turn's lease, or clear it with null when the turn ends. */
|
|
184
|
+
bindTurn(sessionId, turn) {
|
|
185
|
+
if (turn)
|
|
186
|
+
this.turns.set(sessionId, turn);
|
|
187
|
+
else
|
|
188
|
+
this.turns.delete(sessionId);
|
|
189
|
+
}
|
|
190
|
+
async stop() {
|
|
191
|
+
const server = this.server;
|
|
192
|
+
this.server = null;
|
|
193
|
+
this.starting = null;
|
|
194
|
+
this.turns.clear();
|
|
195
|
+
if (server)
|
|
196
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
197
|
+
}
|
|
198
|
+
start() {
|
|
199
|
+
if (this.server)
|
|
200
|
+
return Promise.resolve();
|
|
201
|
+
if (!this.starting) {
|
|
202
|
+
this.starting = new Promise((resolve, reject) => {
|
|
203
|
+
const server = (0, node_http_1.createServer)((req, res) => {
|
|
204
|
+
void this.handle(req, res).catch((error) => {
|
|
205
|
+
this.options.logger.error(`[knowledge] request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
206
|
+
if (!res.headersSent)
|
|
207
|
+
res.statusCode = 500;
|
|
208
|
+
res.end();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
server.once('error', (error) => {
|
|
212
|
+
this.starting = null;
|
|
213
|
+
reject(error);
|
|
214
|
+
});
|
|
215
|
+
// Loopback only. Nothing outside this host can open this socket.
|
|
216
|
+
server.listen(0, '127.0.0.1', () => {
|
|
217
|
+
const address = server.address();
|
|
218
|
+
this.port = typeof address === 'object' && address ? address.port : 0;
|
|
219
|
+
this.server = server;
|
|
220
|
+
// Never keeps the process alive on its own: a one-shot run must still exit.
|
|
221
|
+
server.unref();
|
|
222
|
+
resolve();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return this.starting;
|
|
227
|
+
}
|
|
228
|
+
async handle(req, res) {
|
|
229
|
+
const host = req.headers.host ?? '';
|
|
230
|
+
if (host !== `127.0.0.1:${this.port}` &&
|
|
231
|
+
host !== `localhost:${this.port}`) {
|
|
232
|
+
res.statusCode = 403;
|
|
233
|
+
res.end();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const match = /^\/mcp\/([A-Za-z0-9_-]{20,})$/.exec(req.url ?? '');
|
|
237
|
+
const sessionId = match ? this.sessionByToken.get(match[1]) : undefined;
|
|
238
|
+
if (!sessionId) {
|
|
239
|
+
res.statusCode = 404;
|
|
240
|
+
res.end();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (req.method === 'DELETE') {
|
|
244
|
+
res.statusCode = 200;
|
|
245
|
+
res.end();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
// The server never opens an event stream: a tools-only server has nothing to push.
|
|
249
|
+
if (req.method !== 'POST') {
|
|
250
|
+
res.statusCode = 405;
|
|
251
|
+
res.setHeader('Allow', 'POST, DELETE');
|
|
252
|
+
res.end();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const raw = await readBody(req, MAX_BODY_BYTES);
|
|
256
|
+
if (raw === null) {
|
|
257
|
+
res.statusCode = 413;
|
|
258
|
+
res.end();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
let parsed;
|
|
262
|
+
try {
|
|
263
|
+
parsed = JSON.parse(raw);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return this.send(res, {
|
|
267
|
+
jsonrpc: '2.0',
|
|
268
|
+
id: null,
|
|
269
|
+
error: { code: -32700, message: 'Parse error' }
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
|
273
|
+
const replies = [];
|
|
274
|
+
for (const message of messages) {
|
|
275
|
+
const reply = await this.dispatch(message, sessionId);
|
|
276
|
+
if (reply)
|
|
277
|
+
replies.push(reply);
|
|
278
|
+
}
|
|
279
|
+
if (!replies.length) {
|
|
280
|
+
// Only notifications: the protocol's answer is 202 with no body.
|
|
281
|
+
res.statusCode = 202;
|
|
282
|
+
res.end();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
this.send(res, Array.isArray(parsed) ? replies : replies[0]);
|
|
286
|
+
}
|
|
287
|
+
send(res, body) {
|
|
288
|
+
res.statusCode = 200;
|
|
289
|
+
res.setHeader('Content-Type', 'application/json');
|
|
290
|
+
res.end(JSON.stringify(body));
|
|
291
|
+
}
|
|
292
|
+
async dispatch(message, sessionId) {
|
|
293
|
+
if (!message || typeof message !== 'object' || message.jsonrpc !== '2.0') {
|
|
294
|
+
return {
|
|
295
|
+
jsonrpc: '2.0',
|
|
296
|
+
id: null,
|
|
297
|
+
error: { code: -32600, message: 'Invalid Request' }
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
// No `id` means a notification. Nothing is answered.
|
|
301
|
+
if (message.id === undefined)
|
|
302
|
+
return null;
|
|
303
|
+
const id = message.id;
|
|
304
|
+
const method = typeof message.method === 'string' ? message.method : '';
|
|
305
|
+
switch (method) {
|
|
306
|
+
case 'initialize': {
|
|
307
|
+
const requested = message.params?.protocolVersion;
|
|
308
|
+
const protocolVersion = typeof requested === 'string' &&
|
|
309
|
+
SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
|
|
310
|
+
? requested
|
|
311
|
+
: DEFAULT_PROTOCOL_VERSION;
|
|
312
|
+
return {
|
|
313
|
+
jsonrpc: '2.0',
|
|
314
|
+
id,
|
|
315
|
+
result: {
|
|
316
|
+
protocolVersion,
|
|
317
|
+
capabilities: { tools: {} },
|
|
318
|
+
serverInfo: { name: exports.KNOWLEDGE_SERVER_NAME, version: SERVER_VERSION }
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
case 'ping':
|
|
323
|
+
return { jsonrpc: '2.0', id, result: {} };
|
|
324
|
+
case 'tools/list':
|
|
325
|
+
return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
|
|
326
|
+
case 'tools/call': {
|
|
327
|
+
const params = (message.params ?? {});
|
|
328
|
+
const result = await this.callTool(String(params.name ?? ''), (params.arguments ?? {}), sessionId);
|
|
329
|
+
return { jsonrpc: '2.0', id, result };
|
|
330
|
+
}
|
|
331
|
+
default:
|
|
332
|
+
return {
|
|
333
|
+
jsonrpc: '2.0',
|
|
334
|
+
id,
|
|
335
|
+
error: { code: -32601, message: 'Method not found' }
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async callTool(name, args, sessionId) {
|
|
340
|
+
const turn = this.turns.get(sessionId);
|
|
341
|
+
try {
|
|
342
|
+
if (!turn) {
|
|
343
|
+
throw new ToolFailure('Knowledge files are only available while a turn is running.');
|
|
344
|
+
}
|
|
345
|
+
if (name === exports.KNOWLEDGE_FILES_LIST_TOOL) {
|
|
346
|
+
return {
|
|
347
|
+
content: [{ type: 'text', text: await this.list(turn, args) }]
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
if (name === exports.KNOWLEDGE_FILE_DOWNLOAD_TOOL) {
|
|
351
|
+
return {
|
|
352
|
+
content: [
|
|
353
|
+
{ type: 'text', text: await this.download(turn, args, sessionId) }
|
|
354
|
+
]
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
throw new ToolFailure(`Unknown tool: ${name}`);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
return {
|
|
361
|
+
content: [{ type: 'text', text: plainMessage(error) }],
|
|
362
|
+
isError: true
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
async list(turn, args) {
|
|
367
|
+
const datastoreId = optionalId(args.datastoreId, 'datastoreId');
|
|
368
|
+
const result = await turn.control.list(datastoreId);
|
|
369
|
+
if (!result.files.length)
|
|
370
|
+
return 'No knowledge files are available for this chat.';
|
|
371
|
+
const lines = result.files.map((file) => `${file.fileId} | ${file.datastoreName} | ${file.name} | ${file.mimeType ?? 'unknown type'} | ${file.sizeBytes === null ? 'unknown size' : formatBytes(file.sizeBytes)} | ${file.status}`);
|
|
372
|
+
const header = 'fileId | datastore | name | type | size | status';
|
|
373
|
+
const tail = result.truncated
|
|
374
|
+
? '\n(The list is longer than this; narrow it with datastoreId.)'
|
|
375
|
+
: '';
|
|
376
|
+
return `${header}\n${lines.join('\n')}${tail}`;
|
|
377
|
+
}
|
|
378
|
+
async download(turn, args, sessionId) {
|
|
379
|
+
const datastoreId = requiredId(args.datastoreId, 'datastoreId');
|
|
380
|
+
const fileId = requiredId(args.fileId, 'fileId');
|
|
381
|
+
const signed = await turn.control.download(datastoreId, fileId);
|
|
382
|
+
if (!(Number.isSafeInteger(signed.sizeBytes) && signed.sizeBytes >= 0)) {
|
|
383
|
+
throw new ToolFailure('The file size could not be read.');
|
|
384
|
+
}
|
|
385
|
+
if (signed.sizeBytes > space_files_1.SPACE_FILE_MAX_BYTES) {
|
|
386
|
+
throw new ToolFailure('That file is larger than the 50 MB download limit.');
|
|
387
|
+
}
|
|
388
|
+
const dir = (0, node_path_1.join)(turn.workspaceDir, space_files_1.DOWNLOADS_DIR);
|
|
389
|
+
await (0, promises_1.mkdir)(dir, { recursive: true });
|
|
390
|
+
const { name, path } = await freeDownloadPath(dir, safeDownloadName(signed.name));
|
|
391
|
+
const tmp = (0, node_path_1.join)(dir, `.tmp-${(0, node_crypto_1.randomUUID)()}`);
|
|
392
|
+
try {
|
|
393
|
+
await (0, space_files_1.downloadTo)(signed.url, tmp, {
|
|
394
|
+
maxBytes: signed.sizeBytes,
|
|
395
|
+
timeoutMs: this.options.downloadTimeoutMs ?? space_files_1.SPACE_FILE_DOWNLOAD_TIMEOUT_MS,
|
|
396
|
+
fetchImpl: this.options.fetchImpl ?? fetch
|
|
397
|
+
});
|
|
398
|
+
await (0, promises_1.rename)(tmp, path);
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
await (0, promises_1.rm)(tmp, { force: true });
|
|
402
|
+
// The error is scrubbed of URLs by the caller's plainMessage; the reason is logged without one.
|
|
403
|
+
this.options.logger.warn(`[knowledge] download of ${fileId} failed: ${error instanceof Error ? error.message.replace(/https?:\/\/\S+/gi, '<url>') : 'unknown error'}`);
|
|
404
|
+
throw new ToolFailure('That file could not be downloaded right now. Try again in a moment.');
|
|
405
|
+
}
|
|
406
|
+
this.options.logger.log(`[knowledge] saved ${fileId} to ${space_files_1.DOWNLOADS_DIR}/${name} for session ${sessionId}`);
|
|
407
|
+
return `Saved to ${space_files_1.DOWNLOADS_DIR}/${name} (${formatBytes(signed.sizeBytes)}, ${signed.mimeType ?? 'unknown type'}). Read it with your file tools.`;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
exports.KnowledgeToolHost = KnowledgeToolHost;
|
|
@@ -22,6 +22,7 @@ import type { CodingAgentAdapter } from '../adapters';
|
|
|
22
22
|
import { SessionModelGateway } from '../adapters/model-gateway';
|
|
23
23
|
import { ArtifactInspector, ConversationStore, EventSink, ModelMessageRecord, Notifier, SecretProvider, Telemetry, TranscriptStore, WorkspaceProvider } from '../ports';
|
|
24
24
|
import { LeaseResponse, ModelMessageInput, TranscriptPartInput, TurnMetadata } from '../types';
|
|
25
|
+
import { KnowledgeToolHost, type KnowledgeControl } from './knowledge-tools';
|
|
25
26
|
import { McpAuthProxy } from './mcp-auth-proxy';
|
|
26
27
|
export interface SessionRunnerDeps {
|
|
27
28
|
/** Resolves a backend name from the lease to a working adapter. */
|
|
@@ -102,6 +103,14 @@ export interface SessionRunnerDeps {
|
|
|
102
103
|
* global `fetch` otherwise. Injected so tests need no network.
|
|
103
104
|
*/
|
|
104
105
|
fetchAttachment?: typeof fetch;
|
|
106
|
+
/**
|
|
107
|
+
* The knowledge tools (P9): the process's loopback host, and this lease's
|
|
108
|
+
* control-plane client. Absent on a harness that does not serve them.
|
|
109
|
+
*/
|
|
110
|
+
knowledge?: {
|
|
111
|
+
host: KnowledgeToolHost;
|
|
112
|
+
control: KnowledgeControl;
|
|
113
|
+
};
|
|
105
114
|
}
|
|
106
115
|
export interface RunTurnResult {
|
|
107
116
|
turn: TurnMetadata;
|
|
@@ -499,6 +508,14 @@ export declare class SessionRunner {
|
|
|
499
508
|
* a security position rather than a gap.
|
|
500
509
|
*/
|
|
501
510
|
private setUpSecrets;
|
|
511
|
+
/**
|
|
512
|
+
* Make `space-files/` match this turn's Space files manifest (P9).
|
|
513
|
+
*
|
|
514
|
+
* Runs when the lease carries a manifest, or when this workspace already has a
|
|
515
|
+
* sync record: a chat that moved to a Space with no files must not keep the
|
|
516
|
+
* old Space's copies. Never throws; a failed sync is logged and the turn runs.
|
|
517
|
+
*/
|
|
518
|
+
private syncSpaceFilesForTurn;
|
|
502
519
|
/**
|
|
503
520
|
* Build the backend's session config.
|
|
504
521
|
*
|
|
@@ -32,8 +32,10 @@ const model_gateway_1 = require("../adapters/model-gateway");
|
|
|
32
32
|
const contract_1 = require("../contract");
|
|
33
33
|
const attachments_1 = require("./attachments");
|
|
34
34
|
const conversation_1 = require("./conversation");
|
|
35
|
+
const knowledge_tools_1 = require("./knowledge-tools");
|
|
35
36
|
const permission_mode_1 = require("./permission-mode");
|
|
36
37
|
const projection_1 = require("./projection");
|
|
38
|
+
const space_files_1 = require("./space-files");
|
|
37
39
|
/** How often the runner asks the adapter for new output when nothing pushes. */
|
|
38
40
|
const POLL_INTERVAL_MS = 2_000;
|
|
39
41
|
/**
|
|
@@ -527,6 +529,9 @@ class SessionRunner {
|
|
|
527
529
|
* .droppedAttachments`'s doc comment.
|
|
528
530
|
*/
|
|
529
531
|
const hasDroppedAttachments = (session.input.droppedAttachments?.length ?? 0) > 0;
|
|
532
|
+
if (workspaceDir) {
|
|
533
|
+
await this.syncSpaceFilesForTurn(session, workspaceDir);
|
|
534
|
+
}
|
|
530
535
|
if (workspaceDir && (hasLeasedAttachments || hasDroppedAttachments)) {
|
|
531
536
|
const sync = await (0, attachments_1.syncAttachmentsToWorkspace)(session.input.attachments, workspaceDir, {
|
|
532
537
|
fetchImpl: this.deps.fetchAttachment,
|
|
@@ -619,7 +624,20 @@ class SessionRunner {
|
|
|
619
624
|
adapter = this.deps.getAdapter(session.agent.backend);
|
|
620
625
|
await adapter.initialize();
|
|
621
626
|
secretToken = this.setUpSecrets(session);
|
|
622
|
-
|
|
627
|
+
/*
|
|
628
|
+
* THE KNOWLEDGE TOOLS FOR THIS TURN (P9). Bound to this lease and this
|
|
629
|
+
* workspace, and unbound in `finally`, so a tool call that arrives between
|
|
630
|
+
* turns is refused rather than acting on a lease that has ended.
|
|
631
|
+
*/
|
|
632
|
+
let knowledgeUrl = null;
|
|
633
|
+
if (session.spaceFiles && this.deps.knowledge && workspaceDir) {
|
|
634
|
+
this.deps.knowledge.host.bindTurn(session.sessionId, {
|
|
635
|
+
control: this.deps.knowledge.control,
|
|
636
|
+
workspaceDir
|
|
637
|
+
});
|
|
638
|
+
knowledgeUrl = await this.deps.knowledge.host.endpointFor(session.sessionId);
|
|
639
|
+
}
|
|
640
|
+
const config = this.buildSessionConfig(session, workspaceDir, secretToken, signal, knowledgeUrl);
|
|
623
641
|
/*
|
|
624
642
|
* REMEMBER THE CONVERSATION. This block is the fix for the defect that
|
|
625
643
|
* made this a chat product that could not hold a conversation: the
|
|
@@ -727,7 +745,7 @@ class SessionRunner {
|
|
|
727
745
|
`${memory.backendSessionId} for ${session.sessionId}: ` +
|
|
728
746
|
`${caught instanceof Error ? caught.message : String(caught)}. ` +
|
|
729
747
|
'Falling back to replay.');
|
|
730
|
-
this.deps.conversation?.setCheckpoint(session
|
|
748
|
+
this.deps.conversation?.setCheckpoint((0, conversation_1.conversationKey)(session), null, session.agent.backend, { backendOwnerId: ownBackendInstanceId });
|
|
731
749
|
}
|
|
732
750
|
}
|
|
733
751
|
if (!backendSessionId) {
|
|
@@ -741,7 +759,7 @@ class SessionRunner {
|
|
|
741
759
|
// half-way still leaves a resumable session behind, and the alternative
|
|
742
760
|
// — recording it only on success — loses the context in exactly the case
|
|
743
761
|
// where the user most wants it back.
|
|
744
|
-
this.deps.conversation?.setCheckpoint(session
|
|
762
|
+
this.deps.conversation?.setCheckpoint((0, conversation_1.conversationKey)(session), backendSessionId, session.agent.backend, {
|
|
745
763
|
// WHICH INSTANCE minted it, so only this runner resumes it (#1591).
|
|
746
764
|
backendOwnerId: ownBackendInstanceId,
|
|
747
765
|
// The decision this backend session was CREATED under. The next turn
|
|
@@ -886,6 +904,7 @@ class SessionRunner {
|
|
|
886
904
|
*/
|
|
887
905
|
this.turnContext = null;
|
|
888
906
|
this.closeApproval(session.sessionId);
|
|
907
|
+
this.deps.knowledge?.host.bindTurn(session.sessionId, null);
|
|
889
908
|
this.closeQuestion(session.sessionId);
|
|
890
909
|
this.emittedApprovals.clear();
|
|
891
910
|
/*
|
|
@@ -987,7 +1006,7 @@ class SessionRunner {
|
|
|
987
1006
|
// what lets this runner answer the next turn without a network call, and a
|
|
988
1007
|
// send that happens first would be lost on a crash between the two.
|
|
989
1008
|
try {
|
|
990
|
-
this.deps.conversation?.appendModelMessages(session
|
|
1009
|
+
this.deps.conversation?.appendModelMessages((0, conversation_1.conversationKey)(session), modelMessages);
|
|
991
1010
|
}
|
|
992
1011
|
catch (caught) {
|
|
993
1012
|
// A cache that cannot be written is a slow next turn, not a failed one.
|
|
@@ -1839,8 +1858,8 @@ class SessionRunner {
|
|
|
1839
1858
|
async loadMemory(session, ownBackendInstanceId) {
|
|
1840
1859
|
this.priorMessageCount = 0;
|
|
1841
1860
|
const store = this.deps.conversation;
|
|
1842
|
-
const local = store?.getModelMessages(
|
|
1843
|
-
const localCheckpoint = store?.getCheckpoint(
|
|
1861
|
+
const local = store?.getModelMessages((0, conversation_1.conversationKey)(session)) ?? [];
|
|
1862
|
+
const localCheckpoint = store?.getCheckpoint((0, conversation_1.conversationKey)(session)) ?? null;
|
|
1844
1863
|
/*
|
|
1845
1864
|
* Only meaningful when the checkpoint we are about to RESUME is the local
|
|
1846
1865
|
* one. If the lease hands us a different backend session, the local record
|
|
@@ -1957,7 +1976,7 @@ class SessionRunner {
|
|
|
1957
1976
|
const remote = await this.deps.fetchHistory(session.sessionId);
|
|
1958
1977
|
// Cache it, so a second turn on this runner costs no network call.
|
|
1959
1978
|
try {
|
|
1960
|
-
store?.replaceModelMessages(session
|
|
1979
|
+
store?.replaceModelMessages((0, conversation_1.conversationKey)(session), remote.messages);
|
|
1961
1980
|
}
|
|
1962
1981
|
catch {
|
|
1963
1982
|
/* a cache that will not write is a slow turn, not a failed one */
|
|
@@ -2039,6 +2058,34 @@ class SessionRunner {
|
|
|
2039
2058
|
this.deps.secrets.registerSession(token, session.agent.agentId, []);
|
|
2040
2059
|
return token;
|
|
2041
2060
|
}
|
|
2061
|
+
/**
|
|
2062
|
+
* Make `space-files/` match this turn's Space files manifest (P9).
|
|
2063
|
+
*
|
|
2064
|
+
* Runs when the lease carries a manifest, or when this workspace already has a
|
|
2065
|
+
* sync record: a chat that moved to a Space with no files must not keep the
|
|
2066
|
+
* old Space's copies. Never throws; a failed sync is logged and the turn runs.
|
|
2067
|
+
*/
|
|
2068
|
+
async syncSpaceFilesForTurn(session, workspaceDir) {
|
|
2069
|
+
if (!session.spaceFiles && !(await (0, space_files_1.hasSpaceFilesState)(workspaceDir)))
|
|
2070
|
+
return;
|
|
2071
|
+
try {
|
|
2072
|
+
const sync = await (0, space_files_1.syncSpaceFiles)(session.spaceFiles, workspaceDir, {
|
|
2073
|
+
fetchImpl: this.deps.fetchAttachment,
|
|
2074
|
+
logger: this.logger
|
|
2075
|
+
});
|
|
2076
|
+
this.deps.telemetry.record('provider.turn.space_files', {
|
|
2077
|
+
present: sync.present.length,
|
|
2078
|
+
downloaded: sync.downloaded.length,
|
|
2079
|
+
kept: sync.kept.length,
|
|
2080
|
+
removed: sync.removed.length,
|
|
2081
|
+
failed: sync.failed.length,
|
|
2082
|
+
notDownloaded: sync.notDownloaded.length
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
catch (error) {
|
|
2086
|
+
this.logger.warn(`[SessionRunner] Space files sync failed: ${(error instanceof Error ? error.message : String(error)).replace(/https?:\/\/\S+/gi, '<url>')}`);
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2042
2089
|
/**
|
|
2043
2090
|
* Build the backend's session config.
|
|
2044
2091
|
*
|
|
@@ -2055,7 +2102,7 @@ class SessionRunner {
|
|
|
2055
2102
|
* security perimeter and trusting it to filter its own tools is exactly
|
|
2056
2103
|
* defect G2.
|
|
2057
2104
|
*/
|
|
2058
|
-
buildSessionConfig(session, workspaceDir, secretToken, signal) {
|
|
2105
|
+
buildSessionConfig(session, workspaceDir, secretToken, signal, knowledgeUrl = null) {
|
|
2059
2106
|
const mcpServers = {};
|
|
2060
2107
|
for (const server of session.agent.mcpServers) {
|
|
2061
2108
|
const proxied = this.deps.proxy.registerTarget(server.url, server.name);
|
|
@@ -2073,6 +2120,18 @@ class SessionRunner {
|
|
|
2073
2120
|
this.logger.warn(`[SessionRunner] MCP server "${server.name}" omitted: proxy not running`);
|
|
2074
2121
|
}
|
|
2075
2122
|
}
|
|
2123
|
+
/*
|
|
2124
|
+
* The knowledge server is on loopback with a per-session path secret, so it
|
|
2125
|
+
* is not routed through the GitHub/MCP token proxy: there is no vendor token
|
|
2126
|
+
* on this hop, and the control plane authorises every call by lease.
|
|
2127
|
+
*/
|
|
2128
|
+
if (knowledgeUrl) {
|
|
2129
|
+
mcpServers[knowledge_tools_1.KNOWLEDGE_SERVER_NAME] = {
|
|
2130
|
+
name: knowledge_tools_1.KNOWLEDGE_SERVER_NAME,
|
|
2131
|
+
type: 'http',
|
|
2132
|
+
url: knowledgeUrl
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2076
2135
|
const abort = new AbortController();
|
|
2077
2136
|
signal.addEventListener('abort', () => abort.abort(), { once: true });
|
|
2078
2137
|
const brokerPort = this.deps.secrets.getBrokerPort();
|
|
@@ -2098,7 +2157,8 @@ class SessionRunner {
|
|
|
2098
2157
|
secretShellPath: this.deps.secrets.writeShellWrapper() ?? undefined,
|
|
2099
2158
|
environment: this.deps.workspace.getEnvironment?.(session.sessionId, [
|
|
2100
2159
|
session.agent.model.gatewayUrl ?? '',
|
|
2101
|
-
...session.agent.mcpServers.map((server) => server.url)
|
|
2160
|
+
...session.agent.mcpServers.map((server) => server.url),
|
|
2161
|
+
...(knowledgeUrl ? [knowledgeUrl] : [])
|
|
2102
2162
|
]),
|
|
2103
2163
|
/*
|
|
2104
2164
|
* DEFECT G1, THE OTHER HALF.
|