@namzu/sdk 48.0.0 → 48.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/CHANGELOG.md +34 -0
- package/dist/connector/mcp/adapter.d.ts.map +1 -1
- package/dist/connector/mcp/adapter.js +11 -0
- package/dist/connector/mcp/adapter.js.map +1 -1
- package/dist/connector/mcp/client.d.ts +19 -0
- package/dist/connector/mcp/client.d.ts.map +1 -1
- package/dist/connector/mcp/client.js +363 -2
- package/dist/connector/mcp/client.js.map +1 -1
- package/dist/connector/mcp/era.d.ts.map +1 -1
- package/dist/connector/mcp/era.js +7 -0
- package/dist/connector/mcp/era.js.map +1 -1
- package/dist/connector/mcp/streamable-http.d.ts +15 -0
- package/dist/connector/mcp/streamable-http.d.ts.map +1 -1
- package/dist/connector/mcp/streamable-http.js +281 -6
- package/dist/connector/mcp/streamable-http.js.map +1 -1
- package/dist/manager/agent/lifecycle.d.ts +1 -0
- package/dist/manager/agent/lifecycle.d.ts.map +1 -1
- package/dist/manager/agent/lifecycle.js +137 -18
- package/dist/manager/agent/lifecycle.js.map +1 -1
- package/dist/manager/resident/initiative.d.ts +6 -6
- package/dist/manager/resident/outbox.d.ts +4 -4
- package/dist/scheduler/local.d.ts.map +1 -1
- package/dist/scheduler/local.js +2 -0
- package/dist/scheduler/local.js.map +1 -1
- package/dist/session/retention/archive.js +1 -1
- package/dist/session/retention/archive.js.map +1 -1
- package/dist/store/session/disk.d.ts.map +1 -1
- package/dist/store/session/disk.js +2 -0
- package/dist/store/session/disk.js.map +1 -1
- package/dist/tools/builtins/browser.js +2 -2
- package/dist/tools/builtins/browser.js.map +1 -1
- package/dist/tools/schedules/schedule-tool.d.ts.map +1 -1
- package/dist/tools/schedules/schedule-tool.js +12 -5
- package/dist/tools/schedules/schedule-tool.js.map +1 -1
- package/dist/tools/schedules/types.d.ts +6 -0
- package/dist/tools/schedules/types.d.ts.map +1 -1
- package/dist/types/agent/scheduler.d.ts +6 -1
- package/dist/types/agent/scheduler.d.ts.map +1 -1
- package/dist/types/agent/task.d.ts +19 -1
- package/dist/types/agent/task.d.ts.map +1 -1
- package/dist/types/agent/task.js.map +1 -1
- package/dist/types/connector/mcp.d.ts +13 -0
- package/dist/types/connector/mcp.d.ts.map +1 -1
- package/dist/types/session/sub-session.d.ts +2 -0
- package/dist/types/session/sub-session.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/connector/mcp/adapter.ts +15 -0
- package/src/connector/mcp/client.ts +405 -2
- package/src/connector/mcp/era.ts +7 -0
- package/src/connector/mcp/streamable-http.ts +302 -5
- package/src/manager/agent/lifecycle.ts +158 -19
- package/src/scheduler/local.ts +2 -0
- package/src/session/retention/archive.ts +1 -1
- package/src/store/session/disk.ts +3 -0
- package/src/tools/builtins/browser.ts +2 -2
- package/src/tools/schedules/schedule-tool.ts +16 -5
- package/src/tools/schedules/types.ts +6 -0
- package/src/types/agent/scheduler.ts +6 -1
- package/src/types/agent/task.ts +21 -1
- package/src/types/connector/mcp.ts +14 -0
- package/src/types/session/sub-session.ts +2 -0
|
@@ -7,7 +7,11 @@ import type {
|
|
|
7
7
|
} from '../../types/connector/index.js'
|
|
8
8
|
import { SCOPE_ATTRIBUTE } from '../../utils/log/types.js'
|
|
9
9
|
import { type Logger, resolveLogger } from '../../utils/logger.js'
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
ConnectorHttpOperation,
|
|
12
|
+
readConnectorResponseBody,
|
|
13
|
+
validateConnectorTimeoutMs,
|
|
14
|
+
} from '../http-operation.js'
|
|
11
15
|
import { MCPHttpStatusError } from './errors.js'
|
|
12
16
|
import { refuseMcpHttpRedirect } from './http-redirect.js'
|
|
13
17
|
|
|
@@ -21,6 +25,23 @@ const DEFAULT_TIMEOUT_MS = 30_000
|
|
|
21
25
|
* an unresponsive peer does not accumulate as a dangling request forever.
|
|
22
26
|
*/
|
|
23
27
|
const SESSION_DELETE_TIMEOUT_MS = 5_000
|
|
28
|
+
/** SSE streams may live for hours; bound each event and read rather than their lifetime. */
|
|
29
|
+
const MAX_SSE_EVENT_CHARS = 1_048_576
|
|
30
|
+
const MAX_SSE_CHUNK_BYTES = 8_388_608
|
|
31
|
+
/** A single event may contain a JSON-RPC batch; bound its dispatch work too. */
|
|
32
|
+
const MAX_RESPONSE_SSE_MESSAGES_PER_EVENT = 256
|
|
33
|
+
const MAX_SUBSCRIPTION_ERROR_BODY_BYTES = 65_536
|
|
34
|
+
|
|
35
|
+
/** A successful HTTP status can still carry a JSON-RPC listen refusal. */
|
|
36
|
+
export class MCPSubscriptionResponseError extends Error {
|
|
37
|
+
readonly bodyText: string
|
|
38
|
+
|
|
39
|
+
constructor(bodyText: string) {
|
|
40
|
+
super('MCP subscriptions/listen returned JSON instead of an SSE response stream')
|
|
41
|
+
this.name = 'MCPSubscriptionResponseError'
|
|
42
|
+
this.bodyText = bodyText
|
|
43
|
+
}
|
|
44
|
+
}
|
|
24
45
|
|
|
25
46
|
export class StreamableHttpTransport implements MCPTransport {
|
|
26
47
|
private messageHandlers: Array<(message: MCPJsonRpcMessage) => void> = []
|
|
@@ -202,7 +223,7 @@ export class StreamableHttpTransport implements MCPTransport {
|
|
|
202
223
|
// POST capable of poisoning every later request on this connection.
|
|
203
224
|
if (message.method === 'initialize') this.captureSessionId(response)
|
|
204
225
|
|
|
205
|
-
await this.dispatchResponseMessages(response, operation, owned.generation)
|
|
226
|
+
await this.dispatchResponseMessages(response, operation, owned.generation, message.id)
|
|
206
227
|
} finally {
|
|
207
228
|
// A streamable HTTP send owns one POST; its rejection is delivered to
|
|
208
229
|
// that caller. It is not evidence that the logical MCP session died.
|
|
@@ -213,6 +234,124 @@ export class StreamableHttpTransport implements MCPTransport {
|
|
|
213
234
|
}
|
|
214
235
|
}
|
|
215
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Modern MCP subscriptions are long-lived POST response streams. The ordinary
|
|
239
|
+
* `send` has a request deadline and stops at its terminal reply; a
|
|
240
|
+
* subscription may remain open indefinitely. The client owns the initial
|
|
241
|
+
* acknowledgment deadline and this stream's abort signal.
|
|
242
|
+
*/
|
|
243
|
+
async sendSubscription(
|
|
244
|
+
message: MCPJsonRpcMessage,
|
|
245
|
+
options: MCPTransportSendOptions,
|
|
246
|
+
onMessage?: (message: MCPJsonRpcMessage) => void,
|
|
247
|
+
): Promise<void> {
|
|
248
|
+
if (message.method !== 'subscriptions/listen' || message.id === undefined) {
|
|
249
|
+
throw new Error('sendSubscription requires a subscriptions/listen request')
|
|
250
|
+
}
|
|
251
|
+
if (!this.connected) throw new Error('StreamableHttpTransport: not connected')
|
|
252
|
+
const owned = this.beginSend(options.signal)
|
|
253
|
+
try {
|
|
254
|
+
const response = await this.fetchImpl(this.config.url, {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
headers: this.buildHeaders(options.headers),
|
|
257
|
+
body: JSON.stringify(message),
|
|
258
|
+
redirect: 'manual',
|
|
259
|
+
signal: owned.controller.signal,
|
|
260
|
+
})
|
|
261
|
+
refuseMcpHttpRedirect(response, message.method)
|
|
262
|
+
if (!response.ok) {
|
|
263
|
+
throw new MCPHttpStatusError(
|
|
264
|
+
'StreamableHttpTransport',
|
|
265
|
+
response.status,
|
|
266
|
+
response.statusText,
|
|
267
|
+
await readSubscriptionErrorBody(response, owned.controller.signal, this.timeoutMs),
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
const mediaType = (response.headers.get('content-type') ?? '')
|
|
271
|
+
.split(';', 1)[0]
|
|
272
|
+
?.trim()
|
|
273
|
+
.toLowerCase()
|
|
274
|
+
if (mediaType !== 'text/event-stream' || !response.body) {
|
|
275
|
+
// A 200 JSON response can be a valid in-band JSON-RPC refusal.
|
|
276
|
+
// Read it under the same byte and time bounds as a failed HTTP
|
|
277
|
+
// response, then release its body before retry classification.
|
|
278
|
+
if (mediaType === 'application/json' && response.body) {
|
|
279
|
+
throw new MCPSubscriptionResponseError(
|
|
280
|
+
await readSubscriptionErrorBody(response, owned.controller.signal, this.timeoutMs),
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
// Unknown response media types still own a body. Never leave a
|
|
284
|
+
// held POST open while the client schedules its next attempt.
|
|
285
|
+
void response.body?.cancel().catch(() => undefined)
|
|
286
|
+
throw new Error('MCP subscriptions/listen did not return an SSE response stream')
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const reader = response.body.getReader()
|
|
290
|
+
const decoder = new TextDecoder()
|
|
291
|
+
let buffer = ''
|
|
292
|
+
let previousChunkEndedWithCr = false
|
|
293
|
+
const cancelReader = (): void => {
|
|
294
|
+
void reader.cancel().catch(() => undefined)
|
|
295
|
+
}
|
|
296
|
+
owned.controller.signal.addEventListener('abort', cancelReader, { once: true })
|
|
297
|
+
if (owned.controller.signal.aborted) cancelReader()
|
|
298
|
+
try {
|
|
299
|
+
while (this.connected && owned.generation === this.generation) {
|
|
300
|
+
const { done, value } = await reader.read()
|
|
301
|
+
if (done) break
|
|
302
|
+
if (
|
|
303
|
+
!this.connected ||
|
|
304
|
+
owned.generation !== this.generation ||
|
|
305
|
+
owned.controller.signal.aborted
|
|
306
|
+
)
|
|
307
|
+
break
|
|
308
|
+
if (value.byteLength > MAX_SSE_CHUNK_BYTES) {
|
|
309
|
+
throw new Error('MCP subscription SSE chunk exceeds its size limit')
|
|
310
|
+
}
|
|
311
|
+
const oldLength = buffer.length
|
|
312
|
+
let chunk = decoder.decode(value, { stream: true })
|
|
313
|
+
// SSE permits LF, CRLF, and bare CR. A CRLF split across chunks
|
|
314
|
+
// still counts as one line break, not an empty line.
|
|
315
|
+
if (previousChunkEndedWithCr && chunk.startsWith('\n')) chunk = chunk.slice(1)
|
|
316
|
+
previousChunkEndedWithCr = chunk.endsWith('\r')
|
|
317
|
+
buffer += chunk.replace(/\r\n|\r/g, '\n')
|
|
318
|
+
let searchFrom = Math.max(0, oldLength - 1)
|
|
319
|
+
while (true) {
|
|
320
|
+
const boundary = findSseEventBoundary(buffer, searchFrom)
|
|
321
|
+
if (!boundary) break
|
|
322
|
+
if (boundary.start > MAX_SSE_EVENT_CHARS) {
|
|
323
|
+
throw new Error('MCP subscription SSE event exceeds its size limit')
|
|
324
|
+
}
|
|
325
|
+
const event = buffer.slice(0, boundary.start)
|
|
326
|
+
buffer = buffer.slice(boundary.end)
|
|
327
|
+
searchFrom = 0
|
|
328
|
+
for (const frame of parseSseMessages(event).messages) {
|
|
329
|
+
if (
|
|
330
|
+
!this.connected ||
|
|
331
|
+
owned.generation !== this.generation ||
|
|
332
|
+
owned.controller.signal.aborted
|
|
333
|
+
)
|
|
334
|
+
return
|
|
335
|
+
if (onMessage) onMessage(frame)
|
|
336
|
+
else for (const handler of [...this.messageHandlers]) handler(frame)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (buffer.length > MAX_SSE_EVENT_CHARS) {
|
|
340
|
+
throw new Error('MCP subscription SSE event exceeds its size limit')
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// A dangling partial event is not a notification. It cannot be
|
|
344
|
+
// forwarded as a complete JSON-RPC frame when the stream closes.
|
|
345
|
+
} finally {
|
|
346
|
+
owned.controller.signal.removeEventListener('abort', cancelReader)
|
|
347
|
+
cancelReader()
|
|
348
|
+
reader.releaseLock()
|
|
349
|
+
}
|
|
350
|
+
} finally {
|
|
351
|
+
owned.dispose()
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
216
355
|
onMessage(handler: (message: MCPJsonRpcMessage) => void): void {
|
|
217
356
|
this.messageHandlers.push(handler)
|
|
218
357
|
}
|
|
@@ -300,22 +439,119 @@ export class StreamableHttpTransport implements MCPTransport {
|
|
|
300
439
|
response: Response,
|
|
301
440
|
operation: ConnectorHttpOperation,
|
|
302
441
|
generation: number,
|
|
442
|
+
requestId: string | number | undefined,
|
|
303
443
|
): Promise<void> {
|
|
444
|
+
const contentType = response.headers.get('content-type') ?? ''
|
|
445
|
+
if (contentType.includes('text/event-stream') && response.body) {
|
|
446
|
+
await this.dispatchSseResponseMessages(response.body, operation, generation, requestId)
|
|
447
|
+
return
|
|
448
|
+
}
|
|
304
449
|
const text = await operation.run(() => response.text())
|
|
305
450
|
this.assertCurrent(generation, operation)
|
|
306
451
|
if (text.trim().length === 0) return
|
|
307
452
|
|
|
308
|
-
const contentType = response.headers.get('content-type') ?? ''
|
|
309
453
|
const messages = contentType.includes('text/event-stream')
|
|
310
454
|
? this.parseSseAndCaptureEventId(text)
|
|
311
455
|
: parseJsonMessages(text)
|
|
312
456
|
|
|
313
457
|
for (const message of messages) {
|
|
458
|
+
this.dispatchMessage(message, operation, generation)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private dispatchMessage(
|
|
463
|
+
message: MCPJsonRpcMessage,
|
|
464
|
+
operation: ConnectorHttpOperation,
|
|
465
|
+
generation: number,
|
|
466
|
+
): void {
|
|
467
|
+
this.assertCurrent(generation, operation)
|
|
468
|
+
for (const handler of [...this.messageHandlers]) {
|
|
314
469
|
this.assertCurrent(generation, operation)
|
|
315
|
-
|
|
470
|
+
handler(message)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Dispatch a complete SSE event as it arrives, without waiting for EOF. */
|
|
475
|
+
private async dispatchSseResponseMessages(
|
|
476
|
+
body: ReadableStream<Uint8Array>,
|
|
477
|
+
operation: ConnectorHttpOperation,
|
|
478
|
+
generation: number,
|
|
479
|
+
requestId: string | number | undefined,
|
|
480
|
+
): Promise<void> {
|
|
481
|
+
const reader = body.getReader()
|
|
482
|
+
const decoder = new TextDecoder()
|
|
483
|
+
let buffer = ''
|
|
484
|
+
let previousChunkEndedWithCr = false
|
|
485
|
+
const dispatchEvent = (event: string): boolean => {
|
|
486
|
+
if (event.length > MAX_SSE_EVENT_CHARS) {
|
|
487
|
+
throw new Error('MCP response SSE event exceeds its size limit')
|
|
488
|
+
}
|
|
489
|
+
if (sseBatchExceedsMessageLimit(event, MAX_RESPONSE_SSE_MESSAGES_PER_EVENT)) {
|
|
490
|
+
throw new Error('MCP response SSE event exceeds its message limit')
|
|
491
|
+
}
|
|
492
|
+
const parsed = parseSseMessages(event)
|
|
493
|
+
if (parsed.lastEventId !== undefined) this.lastEventId = parsed.lastEventId
|
|
494
|
+
if (parsed.messages.length > MAX_RESPONSE_SSE_MESSAGES_PER_EVENT) {
|
|
495
|
+
throw new Error('MCP response SSE event exceeds its message limit')
|
|
496
|
+
}
|
|
497
|
+
for (const message of parsed.messages) {
|
|
498
|
+
this.dispatchMessage(message, operation, generation)
|
|
499
|
+
if (
|
|
500
|
+
requestId !== undefined &&
|
|
501
|
+
message.id === requestId &&
|
|
502
|
+
message.method === undefined &&
|
|
503
|
+
(Object.hasOwn(message, 'result') || Object.hasOwn(message, 'error'))
|
|
504
|
+
) {
|
|
505
|
+
// The request is complete even if the peer keeps its SSE body open.
|
|
506
|
+
return true
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return false
|
|
510
|
+
}
|
|
511
|
+
const appendChunk = (decoded: string): boolean => {
|
|
512
|
+
if (!decoded) return false
|
|
513
|
+
let chunk = decoded
|
|
514
|
+
// A CRLF split across reads is one line break; LF, CRLF and bare
|
|
515
|
+
// CR all delimit SSE lines.
|
|
516
|
+
if (previousChunkEndedWithCr && chunk.startsWith('\n')) chunk = chunk.slice(1)
|
|
517
|
+
previousChunkEndedWithCr = chunk.endsWith('\r')
|
|
518
|
+
buffer += chunk.replace(/\r\n|\r/g, '\n')
|
|
519
|
+
while (true) {
|
|
520
|
+
const boundary = findSseEventBoundary(buffer, 0)
|
|
521
|
+
if (!boundary) break
|
|
522
|
+
if (boundary.start > MAX_SSE_EVENT_CHARS) {
|
|
523
|
+
throw new Error('MCP response SSE event exceeds its size limit')
|
|
524
|
+
}
|
|
525
|
+
const event = buffer.slice(0, boundary.start)
|
|
526
|
+
buffer = buffer.slice(boundary.end)
|
|
527
|
+
if (dispatchEvent(event)) return true
|
|
528
|
+
}
|
|
529
|
+
if (buffer.length > MAX_SSE_EVENT_CHARS) {
|
|
530
|
+
throw new Error('MCP response SSE event exceeds its size limit')
|
|
531
|
+
}
|
|
532
|
+
return false
|
|
533
|
+
}
|
|
534
|
+
try {
|
|
535
|
+
while (true) {
|
|
536
|
+
const { done, value } = await operation.run(() => reader.read())
|
|
316
537
|
this.assertCurrent(generation, operation)
|
|
317
|
-
|
|
538
|
+
if (done) {
|
|
539
|
+
if (appendChunk(decoder.decode())) return
|
|
540
|
+
// Retain the old whole-body reader's acceptance of a final event
|
|
541
|
+
// without a trailing blank line when the peer actually closes.
|
|
542
|
+
if (buffer.trim()) dispatchEvent(buffer)
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
if (value.byteLength > MAX_SSE_CHUNK_BYTES) {
|
|
546
|
+
throw new Error('MCP response SSE chunk exceeds its size limit')
|
|
547
|
+
}
|
|
548
|
+
if (appendChunk(decoder.decode(value, { stream: true }))) return
|
|
318
549
|
}
|
|
550
|
+
} finally {
|
|
551
|
+
// Cancelling is best effort: a hostile or custom body must not hold
|
|
552
|
+
// send() open after a terminal reply, timeout, or caller abort.
|
|
553
|
+
void reader.cancel().catch(() => undefined)
|
|
554
|
+
reader.releaseLock()
|
|
319
555
|
}
|
|
320
556
|
}
|
|
321
557
|
|
|
@@ -334,6 +570,67 @@ export class StreamableHttpTransport implements MCPTransport {
|
|
|
334
570
|
}
|
|
335
571
|
}
|
|
336
572
|
|
|
573
|
+
/** Count only top-level JSON-array separators, skipping nested values and strings. */
|
|
574
|
+
function sseBatchExceedsMessageLimit(event: string, limit: number): boolean {
|
|
575
|
+
const data = event
|
|
576
|
+
.split('\n')
|
|
577
|
+
.filter((line) => line.startsWith('data:'))
|
|
578
|
+
.map((line) => line.slice('data:'.length).trimStart())
|
|
579
|
+
.join('\n')
|
|
580
|
+
.trimStart()
|
|
581
|
+
if (!data.startsWith('[')) return false
|
|
582
|
+
let depth = 0
|
|
583
|
+
let inString = false
|
|
584
|
+
let escaped = false
|
|
585
|
+
let separators = 0
|
|
586
|
+
for (const character of data) {
|
|
587
|
+
if (inString) {
|
|
588
|
+
if (escaped) escaped = false
|
|
589
|
+
else if (character === '\\') escaped = true
|
|
590
|
+
else if (character === '"') inString = false
|
|
591
|
+
continue
|
|
592
|
+
}
|
|
593
|
+
if (character === '"') inString = true
|
|
594
|
+
else if (character === '[' || character === '{') depth++
|
|
595
|
+
else if (character === ']' || character === '}') depth--
|
|
596
|
+
else if (character === ',' && depth === 1 && ++separators >= limit) return true
|
|
597
|
+
}
|
|
598
|
+
return false
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Find an SSE blank line after line endings have been normalized to LF. */
|
|
602
|
+
function findSseEventBoundary(
|
|
603
|
+
value: string,
|
|
604
|
+
from: number,
|
|
605
|
+
): { start: number; end: number } | undefined {
|
|
606
|
+
const start = value.indexOf('\n\n', from)
|
|
607
|
+
return start < 0 ? undefined : { start, end: start + 2 }
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Keep a rejected listen response useful for diagnosis without buffering an unbounded body. */
|
|
611
|
+
async function readSubscriptionErrorBody(
|
|
612
|
+
response: Response,
|
|
613
|
+
signal: AbortSignal,
|
|
614
|
+
timeoutMs: number,
|
|
615
|
+
): Promise<string> {
|
|
616
|
+
const operation = new ConnectorHttpOperation(signal, timeoutMs, 'MCP subscription error body')
|
|
617
|
+
try {
|
|
618
|
+
const body = await readConnectorResponseBody(
|
|
619
|
+
response,
|
|
620
|
+
operation,
|
|
621
|
+
MAX_SUBSCRIPTION_ERROR_BODY_BYTES,
|
|
622
|
+
)
|
|
623
|
+
return typeof body === 'string' ? body : (JSON.stringify(body) ?? '')
|
|
624
|
+
} catch {
|
|
625
|
+
// The status is still the answer when a peer sends an unreadable,
|
|
626
|
+
// oversized, or stalled error body.
|
|
627
|
+
return ''
|
|
628
|
+
} finally {
|
|
629
|
+
operation.close()
|
|
630
|
+
void response.body?.cancel().catch(() => undefined)
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
337
634
|
/**
|
|
338
635
|
* The body of a failed response, or an empty string.
|
|
339
636
|
*
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { rm } from 'node:fs/promises'
|
|
1
|
+
import { realpath, rm, stat } from 'node:fs/promises'
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
|
2
3
|
import { AGENT_MANAGER_DEFAULTS } from '../../constants/agent/index.js'
|
|
3
4
|
import { EMPTY_TOKEN_USAGE } from '../../constants/limits.js'
|
|
4
5
|
import { GENAI } from '../../constants/telemetry/index.js'
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
} from '../../session/handoff/capacity.js'
|
|
10
11
|
import type { SessionLocator, SessionPaths } from '../../session/paths.js'
|
|
11
12
|
import type { SessionSummaryMaterializer } from '../../session/summary/materialize.js'
|
|
13
|
+
import type { WorkspaceBackendDriver } from '../../session/workspace/driver.js'
|
|
12
14
|
import type { WorkspaceBackendRegistry } from '../../session/workspace/registry.js'
|
|
13
15
|
import {
|
|
14
16
|
DiskSessionLog,
|
|
@@ -151,6 +153,7 @@ interface ChildSpawnRecord {
|
|
|
151
153
|
/** Removes the child's log from the process-local lookup. */
|
|
152
154
|
releaseLog?: () => void
|
|
153
155
|
workspaceRef?: WorkspaceRef
|
|
156
|
+
workspaceRetention?: 'retain'
|
|
154
157
|
/**
|
|
155
158
|
* What this child was actually granted, after the ancestor union.
|
|
156
159
|
*
|
|
@@ -180,6 +183,81 @@ function mergeEnv(
|
|
|
180
183
|
return { ...base, ...override }
|
|
181
184
|
}
|
|
182
185
|
|
|
186
|
+
/** A requested isolated child must never resolve to its caller's directory. */
|
|
187
|
+
async function requireIsolatedWorktreePath(
|
|
188
|
+
ref: WorkspaceRef | undefined,
|
|
189
|
+
callerCwd?: string,
|
|
190
|
+
subdirectory?: string,
|
|
191
|
+
): Promise<string> {
|
|
192
|
+
const meta = ref?.meta
|
|
193
|
+
if (
|
|
194
|
+
typeof ref?.id !== 'string' ||
|
|
195
|
+
ref.id.length === 0 ||
|
|
196
|
+
meta?.backend !== 'git-worktree' ||
|
|
197
|
+
typeof meta.repoRoot !== 'string' ||
|
|
198
|
+
!isAbsolute(meta.repoRoot) ||
|
|
199
|
+
typeof meta.branch !== 'string' ||
|
|
200
|
+
meta.branch.length === 0 ||
|
|
201
|
+
typeof meta.worktreePath !== 'string' ||
|
|
202
|
+
!isAbsolute(meta.worktreePath) ||
|
|
203
|
+
resolve(meta.worktreePath) === resolve(callerCwd ?? process.cwd())
|
|
204
|
+
) {
|
|
205
|
+
throw new Error('Isolated git-worktree driver returned an invalid workspace ref or path')
|
|
206
|
+
}
|
|
207
|
+
// A custom driver can return a different absolute spelling of the same
|
|
208
|
+
// directory (for example, a symlink to the caller). Both paths must exist
|
|
209
|
+
// before the child is admitted, and their physical roots must differ.
|
|
210
|
+
let physicalPaths: [string, string]
|
|
211
|
+
try {
|
|
212
|
+
physicalPaths = await Promise.all([
|
|
213
|
+
realpath(meta.worktreePath),
|
|
214
|
+
realpath(resolve(callerCwd ?? process.cwd())),
|
|
215
|
+
])
|
|
216
|
+
if (!(await stat(physicalPaths[0])).isDirectory()) {
|
|
217
|
+
throw new Error('The worktree path is not a directory')
|
|
218
|
+
}
|
|
219
|
+
} catch (cause) {
|
|
220
|
+
throw new Error('Isolated git-worktree driver returned an invalid workspace ref or path', {
|
|
221
|
+
cause,
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
if (physicalPaths[0] === physicalPaths[1]) {
|
|
225
|
+
throw new Error('Isolated git-worktree driver returned an invalid workspace ref or path')
|
|
226
|
+
}
|
|
227
|
+
if (subdirectory === undefined) return meta.worktreePath
|
|
228
|
+
if (typeof subdirectory !== 'string' || subdirectory.length === 0 || isAbsolute(subdirectory)) {
|
|
229
|
+
throw new Error('Isolated workspace subdirectory must be a nonempty relative path')
|
|
230
|
+
}
|
|
231
|
+
const lexicalChild = resolve(meta.worktreePath, subdirectory)
|
|
232
|
+
const lexicalRelative = relative(resolve(meta.worktreePath), lexicalChild)
|
|
233
|
+
if (
|
|
234
|
+
isAbsolute(lexicalRelative) ||
|
|
235
|
+
lexicalRelative === '..' ||
|
|
236
|
+
lexicalRelative.startsWith(`..${sep}`)
|
|
237
|
+
) {
|
|
238
|
+
throw new Error('Isolated workspace subdirectory escapes the worktree')
|
|
239
|
+
}
|
|
240
|
+
let physicalChild: string
|
|
241
|
+
try {
|
|
242
|
+
physicalChild = await realpath(lexicalChild)
|
|
243
|
+
if (!(await stat(physicalChild)).isDirectory()) {
|
|
244
|
+
throw new Error('The selected worktree path is not a directory')
|
|
245
|
+
}
|
|
246
|
+
} catch (cause) {
|
|
247
|
+
throw new Error('Isolated workspace subdirectory is missing or not a directory', { cause })
|
|
248
|
+
}
|
|
249
|
+
const physicalRelative = relative(physicalPaths[0], physicalChild)
|
|
250
|
+
if (
|
|
251
|
+
physicalRelative === '' ||
|
|
252
|
+
isAbsolute(physicalRelative) ||
|
|
253
|
+
physicalRelative === '..' ||
|
|
254
|
+
physicalRelative.startsWith(`..${sep}`)
|
|
255
|
+
) {
|
|
256
|
+
throw new Error('Isolated workspace subdirectory resolves outside its worktree')
|
|
257
|
+
}
|
|
258
|
+
return physicalChild
|
|
259
|
+
}
|
|
260
|
+
|
|
183
261
|
/**
|
|
184
262
|
* One `reviewAllowedCalls` that answers `true` when any of `sources` does.
|
|
185
263
|
*
|
|
@@ -283,7 +361,9 @@ export class AgentManager {
|
|
|
283
361
|
if (task.context === context || context.parentAbortController.signal.aborted)
|
|
284
362
|
this.cancel(task.taskId, 'parent')
|
|
285
363
|
}
|
|
286
|
-
task.childAbortController.signal.addEventListener('abort', onAbort, {
|
|
364
|
+
task.childAbortController.signal.addEventListener('abort', onAbort, {
|
|
365
|
+
once: true,
|
|
366
|
+
})
|
|
287
367
|
const entry: PendingSpawn = {
|
|
288
368
|
ready: false,
|
|
289
369
|
task,
|
|
@@ -522,9 +602,25 @@ export class AgentManager {
|
|
|
522
602
|
await childBudget.flush()
|
|
523
603
|
throw error
|
|
524
604
|
}
|
|
605
|
+
let childOptions: SendMessageOptions = options
|
|
525
606
|
let childAbortController: AbortController | undefined
|
|
526
607
|
let agentTask: AgentTask | undefined
|
|
527
608
|
try {
|
|
609
|
+
// Explicit isolation is the child's execution root, not a side
|
|
610
|
+
// resource. Keep validation in this rollback envelope too.
|
|
611
|
+
if (options.workspace?.mode === 'isolated') {
|
|
612
|
+
childOptions = {
|
|
613
|
+
...options,
|
|
614
|
+
input: {
|
|
615
|
+
...options.input,
|
|
616
|
+
workingDirectory: await requireIsolatedWorktreePath(
|
|
617
|
+
spawnRecord.workspaceRef,
|
|
618
|
+
options.input.workingDirectory,
|
|
619
|
+
options.workspace.subdirectory,
|
|
620
|
+
),
|
|
621
|
+
},
|
|
622
|
+
}
|
|
623
|
+
}
|
|
528
624
|
childAbortController =
|
|
529
625
|
queuedTask?.childAbortController ??
|
|
530
626
|
createChildAbortController(context.parentAbortController)
|
|
@@ -599,6 +695,7 @@ export class AgentManager {
|
|
|
599
695
|
state: 'pending',
|
|
600
696
|
pendingMessages: queuedTask?.pendingMessages ?? [],
|
|
601
697
|
createdAt: queuedTask?.createdAt ?? Date.now(),
|
|
698
|
+
workspace: options.workspace?.mode === 'isolated' ? spawnRecord.workspaceRef : undefined,
|
|
602
699
|
sessionEventListener: listener,
|
|
603
700
|
} satisfies AgentTask)
|
|
604
701
|
|
|
@@ -676,6 +773,7 @@ export class AgentManager {
|
|
|
676
773
|
...(context.factoryOptions ?? {}),
|
|
677
774
|
tokenBudget: allocatedTokens,
|
|
678
775
|
timeoutMs: options.budgetAllocation?.timeoutMs ?? this.config.childTimeoutMs,
|
|
776
|
+
workingDirectory: childOptions.input.workingDirectory,
|
|
679
777
|
sessionId: spawnRecord.childSessionId,
|
|
680
778
|
parentSessionId: spawnRecord.parentSessionId,
|
|
681
779
|
parentTurnId: spawnRecord.parentTurnId,
|
|
@@ -872,11 +970,11 @@ export class AgentManager {
|
|
|
872
970
|
spawnRecord,
|
|
873
971
|
childConfig,
|
|
874
972
|
context,
|
|
875
|
-
|
|
973
|
+
childOptions,
|
|
876
974
|
taskId,
|
|
877
975
|
)
|
|
878
976
|
if (childLog) {
|
|
879
|
-
await this.startChildSessionLog(childLog, spawnRecord, context,
|
|
977
|
+
await this.startChildSessionLog(childLog, spawnRecord, context, childOptions, taskId, {
|
|
880
978
|
id: agent.metadata.id,
|
|
881
979
|
name: agent.metadata.name,
|
|
882
980
|
})
|
|
@@ -903,7 +1001,7 @@ export class AgentManager {
|
|
|
903
1001
|
childAbortController.signal.throwIfAborted()
|
|
904
1002
|
this.executingTasks.add(taskId)
|
|
905
1003
|
const runningTask = agentTask
|
|
906
|
-
this.runChild(runningTask,
|
|
1004
|
+
this.runChild(runningTask, childOptions, childConfig, listener)
|
|
907
1005
|
.catch(async (err) => {
|
|
908
1006
|
// A thrown invocation supplied no final usage receipt. Keep its
|
|
909
1007
|
// reservation, including when its task handle was canceled or evicted.
|
|
@@ -999,6 +1097,9 @@ export class AgentManager {
|
|
|
999
1097
|
this.scheduleEviction(agentTask.taskId)
|
|
1000
1098
|
} else this.instances.delete(agentTask.taskId)
|
|
1001
1099
|
this.dropSpawnRecord(agentTask.taskId)
|
|
1100
|
+
// Admission never completed. The checkout is rollback-owned, not a
|
|
1101
|
+
// retained result that a terminal handle may advertise.
|
|
1102
|
+
agentTask.workspace = undefined
|
|
1002
1103
|
this.resolveCompletionCallbacks(agentTask.taskId)
|
|
1003
1104
|
await this.rollbackSpawnResources(spawnRecord)
|
|
1004
1105
|
}
|
|
@@ -1015,7 +1116,7 @@ export class AgentManager {
|
|
|
1015
1116
|
}),
|
|
1016
1117
|
)
|
|
1017
1118
|
}
|
|
1018
|
-
await this.disposeChildWorkspace(spawnRecord)
|
|
1119
|
+
await this.disposeChildWorkspace({ ...spawnRecord, workspaceRetention: undefined })
|
|
1019
1120
|
try {
|
|
1020
1121
|
// The edge must be removed before its child: stores reject deletion
|
|
1021
1122
|
// of a session that still has a subsession reference.
|
|
@@ -1135,7 +1236,10 @@ export class AgentManager {
|
|
|
1135
1236
|
taskId: TaskId,
|
|
1136
1237
|
agent: { readonly id: string; readonly name: string },
|
|
1137
1238
|
): Promise<void> {
|
|
1138
|
-
const lease = await log.claim({
|
|
1239
|
+
const lease = await log.claim({
|
|
1240
|
+
holder: `namzu:agent-manager:${taskId}`,
|
|
1241
|
+
ttlMs: 30_000,
|
|
1242
|
+
})
|
|
1139
1243
|
if (lease === null) {
|
|
1140
1244
|
throw new Error(`Child session ${spawnRecord.childSessionId} is already leased`)
|
|
1141
1245
|
}
|
|
@@ -1459,6 +1563,7 @@ export class AgentManager {
|
|
|
1459
1563
|
// parent but counted against `maxDelegationWidth`.
|
|
1460
1564
|
let subSession: Awaited<ReturnType<typeof store.createSubSession>> | undefined
|
|
1461
1565
|
let workspaceRef: WorkspaceRef | undefined
|
|
1566
|
+
let workspaceDriver: WorkspaceBackendDriver | undefined
|
|
1462
1567
|
try {
|
|
1463
1568
|
// Flip to 'active' so the materializer's atomic write + status flip
|
|
1464
1569
|
// lands on terminal — §5.3: pending→active→idle.
|
|
@@ -1483,10 +1588,30 @@ export class AgentManager {
|
|
|
1483
1588
|
// provisioning: an unregistered backend leaves `workspaceRef:
|
|
1484
1589
|
// undefined` on the spawn record, not a hard error — the registry
|
|
1485
1590
|
// is the capability surface.
|
|
1486
|
-
const
|
|
1487
|
-
|
|
1591
|
+
const request = options.workspace
|
|
1592
|
+
const backend =
|
|
1593
|
+
request?.mode === 'shared'
|
|
1594
|
+
? undefined
|
|
1595
|
+
: request?.mode === 'isolated'
|
|
1596
|
+
? request.backend
|
|
1597
|
+
: (options.workspaceBackend ??
|
|
1598
|
+
(this.config.workspaceDefault === 'shared' ? undefined : 'git-worktree'))
|
|
1599
|
+
if (request?.mode === 'isolated' && !this.deps.workspaceRegistry.has(request.backend)) {
|
|
1600
|
+
throw new Error(`Isolated workspace backend ${request.backend} is unavailable`)
|
|
1601
|
+
}
|
|
1602
|
+
if (backend && this.deps.workspaceRegistry.has(backend)) {
|
|
1488
1603
|
const driver = this.deps.workspaceRegistry.get(backend)
|
|
1489
|
-
|
|
1604
|
+
workspaceDriver = driver
|
|
1605
|
+
workspaceRef = await driver.create({
|
|
1606
|
+
label: subSession.id,
|
|
1607
|
+
...(request?.mode === 'isolated' && request.baseRef ? { baseRef: request.baseRef } : {}),
|
|
1608
|
+
})
|
|
1609
|
+
if (request?.mode === 'isolated')
|
|
1610
|
+
await requireIsolatedWorktreePath(
|
|
1611
|
+
workspaceRef,
|
|
1612
|
+
options.input.workingDirectory,
|
|
1613
|
+
request.subdirectory,
|
|
1614
|
+
)
|
|
1490
1615
|
|
|
1491
1616
|
// Write the workspace onto the record that outlives this process.
|
|
1492
1617
|
//
|
|
@@ -1503,16 +1628,23 @@ export class AgentManager {
|
|
|
1503
1628
|
// exist before the record it is named after. Inside the try, so
|
|
1504
1629
|
// the compensating rollback below covers it like every other
|
|
1505
1630
|
// mutation here.
|
|
1506
|
-
subSession = {
|
|
1631
|
+
subSession = {
|
|
1632
|
+
...subSession,
|
|
1633
|
+
workspaceId: workspaceRef.id,
|
|
1634
|
+
...(request?.mode === 'isolated' && request.retention === 'retain'
|
|
1635
|
+
? { workspaceRetention: 'retain' as const }
|
|
1636
|
+
: {}),
|
|
1637
|
+
}
|
|
1507
1638
|
await store.updateSubSession(subSession, context.tenantId)
|
|
1508
1639
|
}
|
|
1509
1640
|
} catch (err) {
|
|
1510
|
-
if (workspaceRef &&
|
|
1511
|
-
await
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1641
|
+
if (workspaceRef && workspaceDriver)
|
|
1642
|
+
await workspaceDriver.dispose(workspaceRef).catch((disposeErr) =>
|
|
1643
|
+
this.log.warn('Unstarted workspace dispose failed', {
|
|
1644
|
+
'namzu.sub_session.id': subSession?.id,
|
|
1645
|
+
'exception.message': toErrorMessage(disposeErr),
|
|
1646
|
+
}),
|
|
1647
|
+
)
|
|
1516
1648
|
// Compensating rollback order is mandated by the store's
|
|
1517
1649
|
// deny-by-default cascade policy (Convention #5): `deleteSession`
|
|
1518
1650
|
// throws when any subsession still references it, so the subsession
|
|
@@ -1540,6 +1672,9 @@ export class AgentManager {
|
|
|
1540
1672
|
ancestry: parentAncestry,
|
|
1541
1673
|
childDepth,
|
|
1542
1674
|
workspaceRef,
|
|
1675
|
+
...(options.workspace?.mode === 'isolated' && options.workspace.retention === 'retain'
|
|
1676
|
+
? { workspaceRetention: 'retain' as const }
|
|
1677
|
+
: {}),
|
|
1543
1678
|
}
|
|
1544
1679
|
}
|
|
1545
1680
|
|
|
@@ -1769,7 +1904,10 @@ export class AgentManager {
|
|
|
1769
1904
|
status: 'failed',
|
|
1770
1905
|
usage: agentTask.context.budget.ownUsage,
|
|
1771
1906
|
budget: agentTask.context.budget.summary(),
|
|
1772
|
-
cost: {
|
|
1907
|
+
cost: {
|
|
1908
|
+
...ZERO_COST,
|
|
1909
|
+
unpricedTokens: agentTask.context.budget.ownTokens,
|
|
1910
|
+
},
|
|
1773
1911
|
iterations: 0,
|
|
1774
1912
|
durationMs: Date.now() - agentTask.createdAt,
|
|
1775
1913
|
messages: [],
|
|
@@ -1835,8 +1973,9 @@ export class AgentManager {
|
|
|
1835
1973
|
* removed is an operator's problem and silence is how it stays one.
|
|
1836
1974
|
*/
|
|
1837
1975
|
private async disposeChildWorkspace(
|
|
1838
|
-
spawnRecord: Pick<ChildSpawnRecord, 'workspaceRef' | 'subSessionId'>,
|
|
1976
|
+
spawnRecord: Pick<ChildSpawnRecord, 'workspaceRef' | 'workspaceRetention' | 'subSessionId'>,
|
|
1839
1977
|
): Promise<void> {
|
|
1978
|
+
if (spawnRecord.workspaceRetention === 'retain') return
|
|
1840
1979
|
if (!spawnRecord.workspaceRef) return
|
|
1841
1980
|
const backend = spawnRecord.workspaceRef.meta.backend
|
|
1842
1981
|
if (!this.deps.workspaceRegistry.has(backend)) return
|
package/src/scheduler/local.ts
CHANGED
|
@@ -113,6 +113,7 @@ export class LocalTaskScheduler implements TaskScheduler {
|
|
|
113
113
|
{
|
|
114
114
|
agentId: options.agentId,
|
|
115
115
|
beforeStart: options.beforeStart,
|
|
116
|
+
...(options.workspace ? { workspace: options.workspace } : {}),
|
|
116
117
|
...(options.planId ? { planId: options.planId } : {}),
|
|
117
118
|
...(options.planStepId ? { planStepId: options.planStepId } : {}),
|
|
118
119
|
// Display grouping travels with the spawn so the manager can put it
|
|
@@ -468,5 +469,6 @@ function toHandle(task: import('../types/agent/task.js').AgentTask): TaskHandle
|
|
|
468
469
|
result: task.result,
|
|
469
470
|
createdAt: task.createdAt,
|
|
470
471
|
completedAt: task.completedAt,
|
|
472
|
+
...(task.workspace ? { workspace: task.workspace } : {}),
|
|
471
473
|
}
|
|
472
474
|
}
|