@myagentroam/agent 0.12.13 → 0.13.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/dist/error.d.ts +2 -0
- package/dist/error.js +10 -0
- package/dist/extensions/hooks.d.ts +2 -0
- package/dist/extensions/hooks.js +4 -0
- package/dist/extensions/manager.js +8 -1
- package/dist/extensions/mcp.d.ts +6 -1
- package/dist/extensions/mcp.js +38 -11
- package/dist/extensions/network.js +50 -10
- package/dist/extensions/operation.d.ts +2 -0
- package/dist/extensions/operation.js +4 -0
- package/dist/extensions/processes.js +109 -27
- package/dist/extensions/resources.d.ts +4 -1
- package/dist/extensions/resources.js +88 -37
- package/dist/extensions/session-input.d.ts +4 -1
- package/dist/extensions/session-input.js +30 -10
- package/dist/extensions/session-tools.d.ts +6 -1
- package/dist/extensions/session-tools.js +31 -4
- package/dist/extensions/session.js +3 -0
- package/dist/extensions/tools.d.ts +5 -0
- package/dist/extensions/tools.js +88 -81
- package/dist/runtime/compaction/coordinator.js +1 -0
- package/dist/sdk/extension-tools.d.ts +4 -1
- package/dist/sdk/extension-tools.js +25 -4
- package/dist/tools/scheduler.js +4 -0
- package/package.json +1 -1
package/dist/error.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export declare class AgentError extends Error {
|
|
|
9
9
|
cause?: unknown;
|
|
10
10
|
});
|
|
11
11
|
}
|
|
12
|
+
/** Cache stack text before retaining a failure, rather than its lazy callback/receiver frames. */
|
|
13
|
+
export declare function materializeErrorStack(error: unknown): void;
|
|
12
14
|
export declare function describeAgentError(error: unknown, fallbackCode: string, fallbackMessage?: string, includeCauses?: boolean): {
|
|
13
15
|
code: string;
|
|
14
16
|
message: string;
|
package/dist/error.js
CHANGED
|
@@ -12,6 +12,16 @@ export class AgentError extends Error {
|
|
|
12
12
|
this.retryable = options.retryable ?? false;
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
+
/** Cache stack text before retaining a failure, rather than its lazy callback/receiver frames. */
|
|
16
|
+
export function materializeErrorStack(error) {
|
|
17
|
+
try {
|
|
18
|
+
if (error instanceof Error)
|
|
19
|
+
void error.stack;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// A caller's custom stack formatter must not replace the original failure.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
15
25
|
const MAX_ERROR_MESSAGE_CHARACTERS = 1_000;
|
|
16
26
|
export function describeAgentError(error, fallbackCode, fallbackMessage = 'MAR Agent operation failed.', includeCauses = true) {
|
|
17
27
|
const code = diagnosticCode(error) ?? fallbackCode;
|
|
@@ -20,6 +20,8 @@ export declare class ExtensionHooks<Operations extends {
|
|
|
20
20
|
readonly diagnose: (diagnostic: ExtensionHookDiagnostic) => void;
|
|
21
21
|
readonly callback: ExtensionCallbackRunner;
|
|
22
22
|
constructor(diagnose?: (diagnostic: ExtensionHookDiagnostic) => void, callback?: ExtensionCallbackRunner);
|
|
23
|
+
/** The Session owner calls this after all registered scopes have drained. */
|
|
24
|
+
dispose(): void;
|
|
23
25
|
/** Internal physical callback ingress; unrelated dispatch must not inherit reentry suppression. */
|
|
24
26
|
independent<Value>(run: () => Value): Value;
|
|
25
27
|
/** Avoid materializing public views when no active interceptor can consume them. */
|
package/dist/extensions/hooks.js
CHANGED
|
@@ -10,6 +10,10 @@ export class ExtensionHooks {
|
|
|
10
10
|
this.diagnose = diagnose;
|
|
11
11
|
this.callback = callback;
|
|
12
12
|
}
|
|
13
|
+
/** The Session owner calls this after all registered scopes have drained. */
|
|
14
|
+
dispose() {
|
|
15
|
+
this.#active.disable();
|
|
16
|
+
}
|
|
13
17
|
/** Internal physical callback ingress; unrelated dispatch must not inherit reentry suppression. */
|
|
14
18
|
independent(run) {
|
|
15
19
|
return this.#active.run([], run);
|
|
@@ -188,7 +188,14 @@ export class ExtensionManager {
|
|
|
188
188
|
await Promise.all(endings);
|
|
189
189
|
for (const id of this.#sessions.keys())
|
|
190
190
|
this.retireSession(id);
|
|
191
|
-
await this.#serial(() =>
|
|
191
|
+
await this.#serial(async () => {
|
|
192
|
+
try {
|
|
193
|
+
await this.#retire();
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
this.operations.dispose();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
192
199
|
})();
|
|
193
200
|
return this.#disposal;
|
|
194
201
|
}
|
package/dist/extensions/mcp.d.ts
CHANGED
|
@@ -79,7 +79,10 @@ export interface ExtensionMcpCompletion {
|
|
|
79
79
|
readonly toolCount?: number;
|
|
80
80
|
}
|
|
81
81
|
type Run = <Value>(operation: (call: ExtensionOperationContext) => Promise<Value>, signal?: AbortSignal) => Promise<Value>;
|
|
82
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* One Session's declarations and owned Client leases feed its sole tool registry.
|
|
84
|
+
* Closed connection refs replay cleanup without owning the released Client.
|
|
85
|
+
*/
|
|
83
86
|
export declare class ExtensionMcp {
|
|
84
87
|
#private;
|
|
85
88
|
readonly tools: ExtensionTools;
|
|
@@ -91,6 +94,8 @@ export declare class ExtensionMcp {
|
|
|
91
94
|
readonly diagnose: () => void;
|
|
92
95
|
readonly validateCatalog: (catalog: McpToolCatalog) => void;
|
|
93
96
|
constructor(tools: ExtensionTools, hooks: Pick<ExtensionHooks<DomainMcpOperations, DomainMcpFacts>, 'invoke' | 'emit'>, getTools: (call: ExtensionOperationContext) => ReadonlyMap<string, ExtensionToolBinding>, toolRef: (binding: ExtensionToolBinding) => ExtensionToolRef, isMcpRef: (ref: ExtensionToolRef) => boolean, invoke: (input: ExtensionToolInvocation, call: ExtensionOperationContext) => Promise<ToolExecutionResult>, diagnose: () => void, validateCatalog: (catalog: McpToolCatalog) => void);
|
|
97
|
+
/** Connection work and leases are joined by the Session's resource scopes first. */
|
|
98
|
+
dispose(): void;
|
|
94
99
|
bind(owner: ExtensionResourceScope, extensionId: string, backend: ExtensionMcpBackend, run: Run): ExtensionMcpApi;
|
|
95
100
|
servers(): McpToolCatalog['servers'];
|
|
96
101
|
}
|
package/dist/extensions/mcp.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { isDeepStrictEqual } from 'node:util';
|
|
4
|
-
import { AgentError } from '../error.js';
|
|
4
|
+
import { AgentError, materializeErrorStack } from '../error.js';
|
|
5
5
|
import { cloneCoreJson } from './data.js';
|
|
6
6
|
import { ExtensionResourceScope } from './resources.js';
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* One Session's declarations and owned Client leases feed its sole tool registry.
|
|
9
|
+
* Closed connection refs replay cleanup without owning the released Client.
|
|
10
|
+
*/
|
|
8
11
|
export class ExtensionMcp {
|
|
9
12
|
tools;
|
|
10
13
|
hooks;
|
|
@@ -29,6 +32,10 @@ export class ExtensionMcp {
|
|
|
29
32
|
this.diagnose = diagnose;
|
|
30
33
|
this.validateCatalog = validateCatalog;
|
|
31
34
|
}
|
|
35
|
+
/** Connection work and leases are joined by the Session's resource scopes first. */
|
|
36
|
+
dispose() {
|
|
37
|
+
this.#calling.disable();
|
|
38
|
+
}
|
|
32
39
|
bind(owner, extensionId, backend, run) {
|
|
33
40
|
return {
|
|
34
41
|
register: (definition) => this.#register(owner, extensionId, definition),
|
|
@@ -92,14 +99,19 @@ export class ExtensionMcp {
|
|
|
92
99
|
disconnect: async (input) => {
|
|
93
100
|
const request = captureInput(input, ['connection', 'signal']);
|
|
94
101
|
return run((call) => this.hooks.invoke('mcp.disconnect', request, call, async (input, effective) => {
|
|
95
|
-
const
|
|
96
|
-
if (!
|
|
102
|
+
const reference = this.#connectionRefs.get(input.connection);
|
|
103
|
+
if (!reference)
|
|
97
104
|
throw new AgentError('MAR_AGENT_MCP_REFERENCE_INVALID', 'MCP connection reference is invalid.');
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
105
|
+
const connection = reference.connection;
|
|
106
|
+
if (connection) {
|
|
107
|
+
const active = this.#calling.getStore();
|
|
108
|
+
if (active?.active && active.connection === connection)
|
|
109
|
+
throw new AgentError('MAR_AGENT_MCP_CONNECTION_BUSY', 'MCP connection cannot close from its active call.');
|
|
110
|
+
await connection.registration.dispose();
|
|
111
|
+
}
|
|
112
|
+
else
|
|
113
|
+
await reference.closing;
|
|
114
|
+
await this.hooks.emit('mcp.completed', { operation: 'disconnect', connectionId: input.connection.id }, effective);
|
|
103
115
|
}), request.signal);
|
|
104
116
|
}
|
|
105
117
|
};
|
|
@@ -156,7 +168,7 @@ export class ExtensionMcp {
|
|
|
156
168
|
call.signal.throwIfAborted();
|
|
157
169
|
await this.#refresh(connection, call);
|
|
158
170
|
call.signal.throwIfAborted();
|
|
159
|
-
this.#connectionRefs.set(ref, connection);
|
|
171
|
+
this.#connectionRefs.set(ref, { connection });
|
|
160
172
|
this.#connections.add(connection);
|
|
161
173
|
await this.hooks.emit('mcp.completed', { operation: 'connect', connectionId: ref.id, toolCount: connection.catalog.tools.length }, call);
|
|
162
174
|
return ref;
|
|
@@ -312,8 +324,14 @@ export class ExtensionMcp {
|
|
|
312
324
|
connection.scope.dispose(),
|
|
313
325
|
...connection.sources.map((source) => source.registration.dispose())
|
|
314
326
|
]);
|
|
327
|
+
// Captured tool refs may outlive this leaf; no closed ref needs its lease or sibling sources.
|
|
328
|
+
connection.backend = undefined;
|
|
329
|
+
connection.sources = [];
|
|
330
|
+
connection.catalog = { revision: '', servers: [], tools: [] };
|
|
331
|
+
delete connection.tail;
|
|
315
332
|
const failure = results.find((result) => result.status === 'rejected');
|
|
316
333
|
if (failure?.status === 'rejected') {
|
|
334
|
+
materializeErrorStack(failure.reason);
|
|
317
335
|
if (failure.reason instanceof AgentError)
|
|
318
336
|
throw failure.reason;
|
|
319
337
|
throw new AgentError('MAR_AGENT_MCP_CLEANUP_FAILED', 'MCP Client lease did not close.', {
|
|
@@ -321,6 +339,14 @@ export class ExtensionMcp {
|
|
|
321
339
|
});
|
|
322
340
|
}
|
|
323
341
|
})();
|
|
342
|
+
const reference = this.#connectionRefs.get(connection.ref);
|
|
343
|
+
if (reference) {
|
|
344
|
+
reference.closing = connection.closing;
|
|
345
|
+
const release = () => {
|
|
346
|
+
delete reference.connection;
|
|
347
|
+
};
|
|
348
|
+
void connection.closing.then(release, release);
|
|
349
|
+
}
|
|
324
350
|
return connection.closing;
|
|
325
351
|
}
|
|
326
352
|
#register(owner, extensionId, input) {
|
|
@@ -350,6 +376,7 @@ export class ExtensionMcp {
|
|
|
350
376
|
const index = this.#declarations.indexOf(entry);
|
|
351
377
|
if (index !== -1)
|
|
352
378
|
this.#declarations.splice(index, 1);
|
|
379
|
+
this.#serverRefs.delete(ref);
|
|
353
380
|
});
|
|
354
381
|
this.#declarations.push(entry);
|
|
355
382
|
this.#serverRefs.set(ref, entry);
|
|
@@ -369,7 +396,7 @@ export class ExtensionMcp {
|
|
|
369
396
|
return entry;
|
|
370
397
|
}
|
|
371
398
|
#connection(ref) {
|
|
372
|
-
const connection = this.#connectionRefs.get(ref);
|
|
399
|
+
const connection = this.#connectionRefs.get(ref)?.connection;
|
|
373
400
|
if (!connection || connection.closed)
|
|
374
401
|
throw new AgentError('MAR_AGENT_MCP_REFERENCE_INVALID', 'MCP connection is unavailable.');
|
|
375
402
|
return connection;
|
|
@@ -1,4 +1,33 @@
|
|
|
1
1
|
import { AgentError } from '../error.js';
|
|
2
|
+
/** Closed response facades keep metadata and cleanup outcome, not the transport's callback graph. */
|
|
3
|
+
function responseFacade(lease, resource) {
|
|
4
|
+
let current = lease;
|
|
5
|
+
let closed = false;
|
|
6
|
+
let closing;
|
|
7
|
+
const close = () => {
|
|
8
|
+
closed = true;
|
|
9
|
+
return (closing ??= current?.close() ?? resource.dispose());
|
|
10
|
+
};
|
|
11
|
+
const body = {
|
|
12
|
+
[Symbol.asyncIterator]() {
|
|
13
|
+
return this;
|
|
14
|
+
},
|
|
15
|
+
next: () => closed || !current
|
|
16
|
+
? Promise.resolve({ done: true, value: undefined })
|
|
17
|
+
: current.body[Symbol.asyncIterator]().next(),
|
|
18
|
+
async return() {
|
|
19
|
+
await close();
|
|
20
|
+
return { done: true, value: undefined };
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
response: { ...lease, body, close },
|
|
25
|
+
release() {
|
|
26
|
+
closed = true;
|
|
27
|
+
current = undefined;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
2
31
|
export function bindCoreNetwork(backend, run, scope, hooks, diagnose) {
|
|
3
32
|
return {
|
|
4
33
|
fetch: (input) => run((call) => hooks.invoke('network.fetch', input, call, async (input, effective) => {
|
|
@@ -6,15 +35,23 @@ export function bindCoreNetwork(backend, run, scope, hooks, diagnose) {
|
|
|
6
35
|
throw new AgentError('MAR_AGENT_HOST_PORT_UNAVAILABLE', 'Native HTTP port is unavailable.');
|
|
7
36
|
const response = await backend.fetch(input, effective);
|
|
8
37
|
let iterator;
|
|
38
|
+
let done = false;
|
|
39
|
+
let release = () => { };
|
|
9
40
|
const close = async () => {
|
|
41
|
+
done = true;
|
|
10
42
|
// Close the transport first so an in-flight pull is released before iterator cleanup.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
43
|
+
try {
|
|
44
|
+
const results = await Promise.allSettled([
|
|
45
|
+
Promise.resolve().then(() => response.close()),
|
|
46
|
+
Promise.resolve().then(() => iterator?.return?.())
|
|
47
|
+
]);
|
|
48
|
+
const failure = results.find((result) => result.status === 'rejected');
|
|
49
|
+
if (failure?.status === 'rejected')
|
|
50
|
+
throw failure.reason;
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
release();
|
|
54
|
+
}
|
|
18
55
|
};
|
|
19
56
|
let registration;
|
|
20
57
|
try {
|
|
@@ -33,7 +70,6 @@ export function bindCoreNetwork(backend, run, scope, hooks, diagnose) {
|
|
|
33
70
|
throw error;
|
|
34
71
|
}
|
|
35
72
|
const resource = registration;
|
|
36
|
-
let done = false;
|
|
37
73
|
let nextTail = Promise.resolve();
|
|
38
74
|
const body = {
|
|
39
75
|
[Symbol.asyncIterator]() {
|
|
@@ -80,7 +116,7 @@ export function bindCoreNetwork(backend, run, scope, hooks, diagnose) {
|
|
|
80
116
|
throw error;
|
|
81
117
|
}
|
|
82
118
|
}
|
|
83
|
-
|
|
119
|
+
const facade = responseFacade({
|
|
84
120
|
url: response.url,
|
|
85
121
|
status: response.status,
|
|
86
122
|
statusText: response.statusText,
|
|
@@ -91,7 +127,11 @@ export function bindCoreNetwork(backend, run, scope, hooks, diagnose) {
|
|
|
91
127
|
done = true;
|
|
92
128
|
await resource.dispose();
|
|
93
129
|
}
|
|
94
|
-
};
|
|
130
|
+
}, resource);
|
|
131
|
+
release = facade.release;
|
|
132
|
+
if (done)
|
|
133
|
+
release();
|
|
134
|
+
return facade.response;
|
|
95
135
|
}), input.signal)
|
|
96
136
|
};
|
|
97
137
|
}
|
|
@@ -32,6 +32,8 @@ export type ExtensionOperationRunner = <Value>(operation: (call: ExtensionOperat
|
|
|
32
32
|
export declare class ExtensionOperations {
|
|
33
33
|
#private;
|
|
34
34
|
current(): ExtensionOperationContext | undefined;
|
|
35
|
+
/** The Agent owner must join Session and agent-scope work before releasing identity context. */
|
|
36
|
+
dispose(): void;
|
|
35
37
|
/** Bind the effective owner signal before author code can call another facade. */
|
|
36
38
|
callback<Value>(call: ExtensionOperationContext, callback: (call: ExtensionOperationContext) => Value | Promise<Value>, settled?: boolean): Promise<Value>;
|
|
37
39
|
/** Physical transport ingress starts a new ancestry; native plugin calls cannot choose it. */
|
|
@@ -7,6 +7,10 @@ export class ExtensionOperations {
|
|
|
7
7
|
const frame = this.#current.getStore();
|
|
8
8
|
return frame?.active ? frame.context : undefined;
|
|
9
9
|
}
|
|
10
|
+
/** The Agent owner must join Session and agent-scope work before releasing identity context. */
|
|
11
|
+
dispose() {
|
|
12
|
+
this.#current.disable();
|
|
13
|
+
}
|
|
10
14
|
/** Bind the effective owner signal before author code can call another facade. */
|
|
11
15
|
callback(call, callback, settled = false) {
|
|
12
16
|
const controller = new AbortController();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AgentError } from '../error.js';
|
|
1
|
+
import { AgentError, materializeErrorStack } from '../error.js';
|
|
2
2
|
export function validateCoreProcessInput(input) {
|
|
3
3
|
if (!input ||
|
|
4
4
|
typeof input.executable !== 'string' ||
|
|
@@ -31,6 +31,76 @@ function copyProcessInput(input) {
|
|
|
31
31
|
...(input.input instanceof Uint8Array ? { input: new Uint8Array(input.input) } : {})
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
|
+
/** Retain unread pipes while live; after cleanup retain only wait/cancel outcomes. */
|
|
35
|
+
function processFacade(lease, resource) {
|
|
36
|
+
let current = lease;
|
|
37
|
+
let completion;
|
|
38
|
+
let closed = false;
|
|
39
|
+
let waited = false;
|
|
40
|
+
let ended = 0;
|
|
41
|
+
const cancel = () => {
|
|
42
|
+
closed = true;
|
|
43
|
+
return resource.dispose();
|
|
44
|
+
};
|
|
45
|
+
const finish = () => {
|
|
46
|
+
if (waited && ended === 2 && !closed)
|
|
47
|
+
void cancel().catch(() => undefined);
|
|
48
|
+
};
|
|
49
|
+
const wait = () => {
|
|
50
|
+
if (!completion) {
|
|
51
|
+
completion = current.wait();
|
|
52
|
+
const settled = () => {
|
|
53
|
+
waited = true;
|
|
54
|
+
finish();
|
|
55
|
+
};
|
|
56
|
+
void completion.then(settled, (error) => {
|
|
57
|
+
materializeErrorStack(error);
|
|
58
|
+
settled();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return completion;
|
|
62
|
+
};
|
|
63
|
+
const stream = (kind) => {
|
|
64
|
+
let done = false;
|
|
65
|
+
return {
|
|
66
|
+
[Symbol.asyncIterator]() {
|
|
67
|
+
return this;
|
|
68
|
+
},
|
|
69
|
+
async next() {
|
|
70
|
+
if (closed || done || !current)
|
|
71
|
+
return { done: true, value: undefined };
|
|
72
|
+
const next = await current[kind][Symbol.asyncIterator]().next();
|
|
73
|
+
if (next.done && !done) {
|
|
74
|
+
done = true;
|
|
75
|
+
ended++;
|
|
76
|
+
finish();
|
|
77
|
+
}
|
|
78
|
+
return next;
|
|
79
|
+
},
|
|
80
|
+
async return() {
|
|
81
|
+
await cancel();
|
|
82
|
+
return { done: true, value: undefined };
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
handle: {
|
|
88
|
+
processId: lease.processId,
|
|
89
|
+
stdout: stream('stdout'),
|
|
90
|
+
stderr: stream('stderr'),
|
|
91
|
+
wait,
|
|
92
|
+
cancel
|
|
93
|
+
},
|
|
94
|
+
release() {
|
|
95
|
+
closed = true;
|
|
96
|
+
// Join/replay the physical exit even if the first wait comes after cancellation.
|
|
97
|
+
const detach = () => {
|
|
98
|
+
current = undefined;
|
|
99
|
+
};
|
|
100
|
+
void wait().then(detach, detach);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
34
104
|
export function bindCoreProcesses(backend, run, scope, hooks, diagnose) {
|
|
35
105
|
const acquire = async (input, call, emitExit = true) => {
|
|
36
106
|
input = copyProcessInput(input);
|
|
@@ -39,33 +109,41 @@ export function bindCoreProcesses(backend, run, scope, hooks, diagnose) {
|
|
|
39
109
|
let closed = false;
|
|
40
110
|
const iterators = [];
|
|
41
111
|
let pending;
|
|
112
|
+
let release = () => { };
|
|
42
113
|
const resource = scope.effect(() => async () => {
|
|
43
114
|
closed = true;
|
|
44
|
-
call.signal.removeEventListener('abort', abort);
|
|
45
|
-
const handle = await pending.catch(() => undefined);
|
|
46
|
-
let firstError;
|
|
47
|
-
let failed = false;
|
|
48
115
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
116
|
+
call.signal.removeEventListener('abort', abort);
|
|
117
|
+
const handle = await pending.catch(() => undefined);
|
|
118
|
+
let firstError;
|
|
119
|
+
let failed = false;
|
|
120
|
+
try {
|
|
121
|
+
if (typeof handle?.cancel === 'function')
|
|
122
|
+
await handle.cancel();
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
failed = true;
|
|
126
|
+
firstError = error;
|
|
127
|
+
}
|
|
128
|
+
const released = await Promise.allSettled(iterators.map((iterator) => Promise.resolve().then(() => iterator.return?.())));
|
|
129
|
+
const failure = released.find((result) => result.status === 'rejected');
|
|
130
|
+
if (failed || failure?.status === 'rejected') {
|
|
131
|
+
const error = failed
|
|
132
|
+
? firstError
|
|
133
|
+
: failure?.status === 'rejected'
|
|
134
|
+
? failure.reason
|
|
135
|
+
: undefined;
|
|
136
|
+
const cleanupError = new AgentError('MAR_AGENT_PROCESS_CLEANUP_FAILED', 'Native process cleanup failed.', {
|
|
137
|
+
details: {
|
|
138
|
+
reasonCode: error instanceof AgentError ? error.code : 'BACKEND_CLEANUP_FAILED'
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
materializeErrorStack(cleanupError);
|
|
142
|
+
throw cleanupError;
|
|
143
|
+
}
|
|
55
144
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (failed || failure?.status === 'rejected') {
|
|
59
|
-
const error = failed
|
|
60
|
-
? firstError
|
|
61
|
-
: failure?.status === 'rejected'
|
|
62
|
-
? failure.reason
|
|
63
|
-
: undefined;
|
|
64
|
-
throw new AgentError('MAR_AGENT_PROCESS_CLEANUP_FAILED', 'Native process cleanup failed.', {
|
|
65
|
-
details: {
|
|
66
|
-
reasonCode: error instanceof AgentError ? error.code : 'BACKEND_CLEANUP_FAILED'
|
|
67
|
-
}
|
|
68
|
-
});
|
|
145
|
+
finally {
|
|
146
|
+
release();
|
|
69
147
|
}
|
|
70
148
|
});
|
|
71
149
|
const cancel = () => resource.dispose();
|
|
@@ -148,7 +226,7 @@ export function bindCoreProcesses(backend, run, scope, hooks, diagnose) {
|
|
|
148
226
|
};
|
|
149
227
|
};
|
|
150
228
|
let completion;
|
|
151
|
-
|
|
229
|
+
const facade = processFacade({
|
|
152
230
|
processId: handle.processId,
|
|
153
231
|
stdout: stream(handle.stdout),
|
|
154
232
|
stderr: stream(handle.stderr),
|
|
@@ -164,7 +242,7 @@ export function bindCoreProcesses(backend, run, scope, hooks, diagnose) {
|
|
|
164
242
|
throw new AgentError('MAR_AGENT_PROCESS_OUTPUT_INVALID', 'Native process exit result is invalid.');
|
|
165
243
|
if (emitExit)
|
|
166
244
|
await hooks.emit('process.completed', { processId: handle.processId, ...exit }, call);
|
|
167
|
-
return exit;
|
|
245
|
+
return { code: exit.code, signal: exit.signal };
|
|
168
246
|
}, call.signal)
|
|
169
247
|
.catch(async (error) => {
|
|
170
248
|
try {
|
|
@@ -175,7 +253,11 @@ export function bindCoreProcesses(backend, run, scope, hooks, diagnose) {
|
|
|
175
253
|
}
|
|
176
254
|
throw error;
|
|
177
255
|
}))
|
|
178
|
-
};
|
|
256
|
+
}, resource);
|
|
257
|
+
release = facade.release;
|
|
258
|
+
if (closed)
|
|
259
|
+
release();
|
|
260
|
+
return facade.handle;
|
|
179
261
|
};
|
|
180
262
|
return {
|
|
181
263
|
spawn: async (input) => {
|
|
@@ -3,7 +3,10 @@ export interface ExtensionResourceRegistration {
|
|
|
3
3
|
readonly ready: Promise<void>;
|
|
4
4
|
dispose(): Promise<void>;
|
|
5
5
|
}
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Own one instance's live resources and pending acquisitions, not an OS sandbox or global owner.
|
|
8
|
+
* Settled handles detach; readiness and cleanup failures remain observable without retaining payloads.
|
|
9
|
+
*/
|
|
7
10
|
export declare class ExtensionResourceScope {
|
|
8
11
|
#private;
|
|
9
12
|
readonly instanceId: `${string}-${string}-${string}-${string}-${string}`;
|
|
@@ -1,7 +1,49 @@
|
|
|
1
|
-
import { AgentError } from '../error.js';
|
|
1
|
+
import { AgentError, materializeErrorStack } from '../error.js';
|
|
2
2
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
-
/**
|
|
4
|
+
/** Own one acquisition and release without retaining its payload or former owner after disposal. */
|
|
5
|
+
function createResourceRegistration(acquire, settled) {
|
|
6
|
+
let release;
|
|
7
|
+
const ready = Promise.resolve()
|
|
8
|
+
.then(acquire)
|
|
9
|
+
.then((disposer) => {
|
|
10
|
+
if (typeof disposer !== 'function')
|
|
11
|
+
throw new AgentError('MAR_AGENT_EXTENSION_RESOURCE_INVALID', 'Resource acquisition must return a disposer.');
|
|
12
|
+
release = disposer;
|
|
13
|
+
});
|
|
14
|
+
let releasing;
|
|
15
|
+
const disposalFrame = new AsyncLocalStorage();
|
|
16
|
+
const finish = (failed) => {
|
|
17
|
+
disposalFrame.disable();
|
|
18
|
+
const notify = settled;
|
|
19
|
+
settled = undefined;
|
|
20
|
+
notify?.(failed);
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
ready,
|
|
24
|
+
dispose: () => {
|
|
25
|
+
if (disposalFrame.getStore())
|
|
26
|
+
return Promise.resolve();
|
|
27
|
+
releasing ??= ready
|
|
28
|
+
.then(async () => {
|
|
29
|
+
const dispose = release;
|
|
30
|
+
release = undefined;
|
|
31
|
+
if (dispose)
|
|
32
|
+
await disposalFrame.run(true, dispose);
|
|
33
|
+
}, () => undefined)
|
|
34
|
+
.then(() => finish(false), (error) => {
|
|
35
|
+
materializeErrorStack(error);
|
|
36
|
+
finish(true);
|
|
37
|
+
throw error;
|
|
38
|
+
});
|
|
39
|
+
return releasing;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Own one instance's live resources and pending acquisitions, not an OS sandbox or global owner.
|
|
45
|
+
* Settled handles detach; readiness and cleanup failures remain observable without retaining payloads.
|
|
46
|
+
*/
|
|
5
47
|
export class ExtensionResourceScope {
|
|
6
48
|
instanceId = randomUUID();
|
|
7
49
|
static #initialization = new AsyncLocalStorage();
|
|
@@ -21,7 +63,10 @@ export class ExtensionResourceScope {
|
|
|
21
63
|
}
|
|
22
64
|
#state = 'STAGED';
|
|
23
65
|
#controller = new AbortController();
|
|
24
|
-
#resources =
|
|
66
|
+
#resources = new Set();
|
|
67
|
+
#acquisitions = new Set();
|
|
68
|
+
#acquisitionFailure;
|
|
69
|
+
#cleanupFailures = 0;
|
|
25
70
|
#operations = new Set();
|
|
26
71
|
#activators = new Set();
|
|
27
72
|
#disposal;
|
|
@@ -42,31 +87,24 @@ export class ExtensionResourceScope {
|
|
|
42
87
|
}
|
|
43
88
|
effect(acquire) {
|
|
44
89
|
this.#assertOpen();
|
|
45
|
-
const
|
|
46
|
-
.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
throw new AgentError('MAR_AGENT_EXTENSION_RESOURCE_INVALID', 'Resource acquisition must return a disposer.');
|
|
50
|
-
return release;
|
|
90
|
+
const registration = createResourceRegistration(acquire, (failed) => {
|
|
91
|
+
this.#resources.delete(registration);
|
|
92
|
+
if (failed)
|
|
93
|
+
this.#cleanupFailures++;
|
|
51
94
|
});
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
};
|
|
67
|
-
// Acquisition errors remain observable through ready, without unhandled rejection noise.
|
|
68
|
-
void registration.ready.catch(() => undefined);
|
|
69
|
-
this.#resources.push(registration);
|
|
95
|
+
const ready = registration.ready;
|
|
96
|
+
this.#acquisitions.add(ready);
|
|
97
|
+
// Readiness keeps the first acquisition failure, not a history of completed registrations.
|
|
98
|
+
void ready.then(() => {
|
|
99
|
+
this.#acquisitions.delete(ready);
|
|
100
|
+
}, (error) => {
|
|
101
|
+
this.#acquisitions.delete(ready);
|
|
102
|
+
materializeErrorStack(error);
|
|
103
|
+
this.#acquisitionFailure ??= { error };
|
|
104
|
+
// A failed acquisition owns no resource, even when its caller never explicitly disposes it.
|
|
105
|
+
void registration.dispose();
|
|
106
|
+
});
|
|
107
|
+
this.#resources.add(registration);
|
|
70
108
|
return registration;
|
|
71
109
|
}
|
|
72
110
|
async run(operation, parent) {
|
|
@@ -126,11 +164,10 @@ export class ExtensionResourceScope {
|
|
|
126
164
|
}
|
|
127
165
|
/** Wait for acquisitions added by setup before publishing any callbacks. */
|
|
128
166
|
async ready() {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
167
|
+
while (this.#acquisitions.size)
|
|
168
|
+
await Promise.all([...this.#acquisitions]);
|
|
169
|
+
if (this.#acquisitionFailure)
|
|
170
|
+
throw this.#acquisitionFailure.error;
|
|
134
171
|
}
|
|
135
172
|
retire() {
|
|
136
173
|
if (this.#state === 'DISPOSED')
|
|
@@ -169,8 +206,15 @@ export class ExtensionResourceScope {
|
|
|
169
206
|
onError(error);
|
|
170
207
|
})
|
|
171
208
|
.finally(() => {
|
|
172
|
-
if (repeat)
|
|
209
|
+
if (stopped || !repeat || this.#state !== 'ACTIVE')
|
|
210
|
+
callbacks.disable();
|
|
211
|
+
else
|
|
173
212
|
schedule();
|
|
213
|
+
if (!repeat)
|
|
214
|
+
// Join running before releasing the one-shot registration; never await ourselves.
|
|
215
|
+
void running
|
|
216
|
+
.then(() => registration.dispose(), () => registration.dispose())
|
|
217
|
+
.catch(() => undefined);
|
|
174
218
|
});
|
|
175
219
|
void running.catch(() => undefined);
|
|
176
220
|
}, Math.min(Math.max(0, due - performance.now()), 2_147_483_647));
|
|
@@ -184,8 +228,15 @@ export class ExtensionResourceScope {
|
|
|
184
228
|
if (timer !== undefined)
|
|
185
229
|
clearTimeout(timer);
|
|
186
230
|
timerController.abort();
|
|
187
|
-
|
|
188
|
-
|
|
231
|
+
// A callback releasing itself must retain its context until running settles.
|
|
232
|
+
if (!callbacks.getStore()) {
|
|
233
|
+
try {
|
|
234
|
+
await running;
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
callbacks.disable();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
189
240
|
});
|
|
190
241
|
if (this.#state === 'ACTIVE')
|
|
191
242
|
schedule();
|
|
@@ -199,15 +250,15 @@ export class ExtensionResourceScope {
|
|
|
199
250
|
this.retire();
|
|
200
251
|
this.#disposal = Promise.resolve().then(async () => {
|
|
201
252
|
await Promise.allSettled([...this.#operations]);
|
|
202
|
-
let failures = 0;
|
|
203
253
|
for (const resource of [...this.#resources].reverse()) {
|
|
204
254
|
try {
|
|
205
255
|
await resource.dispose();
|
|
206
256
|
}
|
|
207
257
|
catch {
|
|
208
|
-
|
|
258
|
+
// Each registration records its failure once, including earlier manual disposal.
|
|
209
259
|
}
|
|
210
260
|
}
|
|
261
|
+
const failures = this.#cleanupFailures;
|
|
211
262
|
if (failures)
|
|
212
263
|
throw new AgentError('MAR_AGENT_EXTENSION_CLEANUP_FAILED', 'Extension resource cleanup failed.', { details: { failures } });
|
|
213
264
|
this.#state = 'DISPOSED';
|
|
@@ -129,7 +129,10 @@ export interface SessionInputEntry {
|
|
|
129
129
|
}
|
|
130
130
|
/** Internal writer evidence survives errors after commit, without settling the operation early. */
|
|
131
131
|
export declare function recordSessionInputCommit(entry: SessionInputEntry, outcome: ExtensionSessionActionResult): void;
|
|
132
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* One Session FIFO shared by native writes and the parent mailbox. No model loop.
|
|
134
|
+
* Active entries own requests and callbacks; settled history owns only public receipts.
|
|
135
|
+
*/
|
|
133
136
|
export declare class SessionInputCoordinator {
|
|
134
137
|
#private;
|
|
135
138
|
readonly diagnose: (receipt: ExtensionSessionReceipt) => void;
|
|
@@ -20,11 +20,15 @@ export function recordSessionInputCommit(entry, outcome) {
|
|
|
20
20
|
...(committed.executionId === undefined ? {} : { executionId: committed.executionId })
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* One Session FIFO shared by native writes and the parent mailbox. No model loop.
|
|
25
|
+
* Active entries own requests and callbacks; settled history owns only public receipts.
|
|
26
|
+
*/
|
|
24
27
|
export class SessionInputCoordinator {
|
|
25
28
|
diagnose;
|
|
26
29
|
#pending = [];
|
|
27
30
|
#records = [];
|
|
31
|
+
#active = new Set();
|
|
28
32
|
#current = new AsyncLocalStorage();
|
|
29
33
|
#sequence = 0;
|
|
30
34
|
#closed = false;
|
|
@@ -155,10 +159,7 @@ export class SessionInputCoordinator {
|
|
|
155
159
|
validateCoreSessionRequest(request);
|
|
156
160
|
let complete;
|
|
157
161
|
let started;
|
|
158
|
-
const
|
|
159
|
-
request: structuredClone(request),
|
|
160
|
-
source,
|
|
161
|
-
action,
|
|
162
|
+
const record = {
|
|
162
163
|
receipt: {
|
|
163
164
|
operationId: source.operationId || randomUUID(),
|
|
164
165
|
sequence: ++this.#sequence,
|
|
@@ -167,6 +168,17 @@ export class SessionInputCoordinator {
|
|
|
167
168
|
...(source.ownerInstanceId ? { ownerInstanceId: source.ownerInstanceId } : {}),
|
|
168
169
|
...(source.extensionId ? { extensionId: source.extensionId } : {}),
|
|
169
170
|
...(source.parentOperationId ? { parentOperationId: source.parentOperationId } : {})
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
const entry = {
|
|
174
|
+
request: structuredClone(request),
|
|
175
|
+
source,
|
|
176
|
+
action,
|
|
177
|
+
get receipt() {
|
|
178
|
+
return record.receipt;
|
|
179
|
+
},
|
|
180
|
+
set receipt(value) {
|
|
181
|
+
record.receipt = value;
|
|
170
182
|
},
|
|
171
183
|
started: new Promise((resolve) => {
|
|
172
184
|
started = resolve;
|
|
@@ -186,7 +198,8 @@ export class SessionInputCoordinator {
|
|
|
186
198
|
this.#finish(entry, 'cancelled');
|
|
187
199
|
};
|
|
188
200
|
source.signal.addEventListener('abort', abort, { once: true });
|
|
189
|
-
this.#
|
|
201
|
+
this.#active.add(entry);
|
|
202
|
+
this.#records.push(record);
|
|
190
203
|
this.#trim();
|
|
191
204
|
return entry;
|
|
192
205
|
}
|
|
@@ -314,18 +327,21 @@ export class SessionInputCoordinator {
|
|
|
314
327
|
}
|
|
315
328
|
async idle() {
|
|
316
329
|
await this.#draining;
|
|
317
|
-
await Promise.all(this.#
|
|
330
|
+
await Promise.all([...this.#active]
|
|
318
331
|
.filter((entry) => entry.receipt.status === 'in-progress')
|
|
319
332
|
.map((entry) => entry.terminal));
|
|
320
333
|
}
|
|
321
334
|
async retire(instanceId) {
|
|
322
335
|
if (!instanceId)
|
|
323
336
|
this.#closed = true;
|
|
324
|
-
const owned = this.#
|
|
337
|
+
const owned = [...this.#active].filter((entry) => !instanceId || entry.receipt.ownerInstanceId === instanceId);
|
|
325
338
|
for (const entry of owned)
|
|
326
339
|
if (entry.receipt.status === 'queued' || entry.receipt.status === 'accepted')
|
|
327
340
|
this.#finish(entry, 'cancelled');
|
|
328
341
|
await Promise.all(owned.filter((entry) => entry.receipt.status === 'in-progress').map((entry) => entry.terminal));
|
|
342
|
+
// Retiring one extension must not clear another extension's in-flight input context.
|
|
343
|
+
if (!instanceId)
|
|
344
|
+
this.#current.disable();
|
|
329
345
|
}
|
|
330
346
|
startExecution(entry, executionId) {
|
|
331
347
|
entry.receipt = { ...entry.receipt, executionId };
|
|
@@ -336,7 +352,7 @@ export class SessionInputCoordinator {
|
|
|
336
352
|
if (this.#settled.size > 200)
|
|
337
353
|
this.#settled.delete(this.#settled.keys().next().value);
|
|
338
354
|
for (const entry of this.#records)
|
|
339
|
-
if (entry.
|
|
355
|
+
if (entry.receipt.kind === 'submit' &&
|
|
340
356
|
entry.receipt.executionId === executionId &&
|
|
341
357
|
entry.receipt.status === 'committed')
|
|
342
358
|
entry.receipt = { ...entry.receipt, status };
|
|
@@ -361,6 +377,10 @@ export class SessionInputCoordinator {
|
|
|
361
377
|
entry.receipt = { ...entry.receipt, status: settled };
|
|
362
378
|
}
|
|
363
379
|
entry.complete();
|
|
380
|
+
this.#active.delete(entry);
|
|
381
|
+
const pending = this.#pending.indexOf(entry);
|
|
382
|
+
if (pending !== -1)
|
|
383
|
+
this.#pending.splice(pending, 1);
|
|
364
384
|
if (status === 'failed')
|
|
365
385
|
this.diagnose(Object.freeze({ ...entry.receipt }));
|
|
366
386
|
this.#trim();
|
|
@@ -368,7 +388,7 @@ export class SessionInputCoordinator {
|
|
|
368
388
|
#trim() {
|
|
369
389
|
while (this.#records.length > 200) {
|
|
370
390
|
const index = this.#records.findIndex((entry) => !['queued', 'accepted', 'in-progress'].includes(entry.receipt.status) &&
|
|
371
|
-
!(entry.receipt.status === 'committed' && entry.
|
|
391
|
+
!(entry.receipt.status === 'committed' && entry.receipt.kind === 'submit'));
|
|
372
392
|
if (index === -1)
|
|
373
393
|
break;
|
|
374
394
|
this.#records.splice(index, 1);
|
|
@@ -4,7 +4,10 @@ import type { ExtensionOperationContext, ExtensionOperationRunner, ExtensionOper
|
|
|
4
4
|
import { ExtensionResourceScope } from './resources.js';
|
|
5
5
|
import type { ExtensionToolRuntime } from './tool-runtime.js';
|
|
6
6
|
import { ExtensionTools, type ExtensionToolsApi, type ExtensionToolBinding, type ExtensionToolRef, type ExtensionToolInvocation, type ExtensionToolCheck, type DomainToolOperations, type DomainToolFacts } from './tools.js';
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Own one Session's tool catalog, attempt lineage, and invocation frames.
|
|
9
|
+
* References retain captured bindings only until their source owner has drained its scoped work.
|
|
10
|
+
*/
|
|
8
11
|
export declare class ExtensionSessionTools {
|
|
9
12
|
#private;
|
|
10
13
|
private readonly toolRuntime;
|
|
@@ -18,6 +21,8 @@ export declare class ExtensionSessionTools {
|
|
|
18
21
|
constructor(toolRuntime: ExtensionToolRuntime, operations: Pick<ExtensionOperations, 'callback' | 'continue'>, hooks: Pick<ExtensionHooks<DomainToolOperations, DomainToolFacts>, 'invoke' | 'emit'>, baseTools: (context?: ExtensionOperationContext) => readonly ExtensionToolBinding[], allowTool: (name: string, definition?: ExtensionToolBinding['definition']) => boolean, scheduleTool: <T>(definition: ExtensionToolBinding['definition'], run: () => Promise<T>) => Promise<T>, assertLive: () => void, isRetired: () => boolean);
|
|
19
22
|
readonly tools: ExtensionTools;
|
|
20
23
|
private get toolFrame();
|
|
24
|
+
/** Release propagation only after the Session owner has joined all scoped tool work. */
|
|
25
|
+
dispose(): void;
|
|
21
26
|
withTools<T>(bindings: ReadonlyMap<string, ExtensionToolBinding>, schedule: <Value>(definition: ExtensionToolBinding['definition'], run: () => Promise<Value>) => Promise<Value>, run: () => T): T;
|
|
22
27
|
independent<T>(run: () => T): T;
|
|
23
28
|
isMcp(ref: ExtensionToolRef): boolean;
|
|
@@ -4,7 +4,10 @@ import { isDeepStrictEqual } from 'node:util';
|
|
|
4
4
|
import { AgentError } from '../error.js';
|
|
5
5
|
import { ExtensionResourceScope } from './resources.js';
|
|
6
6
|
import { ExtensionTools } from './tools.js';
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Own one Session's tool catalog, attempt lineage, and invocation frames.
|
|
9
|
+
* References retain captured bindings only until their source owner has drained its scoped work.
|
|
10
|
+
*/
|
|
8
11
|
export class ExtensionSessionTools {
|
|
9
12
|
toolRuntime;
|
|
10
13
|
operations;
|
|
@@ -25,6 +28,8 @@ export class ExtensionSessionTools {
|
|
|
25
28
|
this.isRetired = isRetired;
|
|
26
29
|
}
|
|
27
30
|
#toolRefs = new WeakMap();
|
|
31
|
+
#ownerToolRefs = new Map();
|
|
32
|
+
#sessionToolRefs = { bindings: new WeakMap() };
|
|
28
33
|
tools = new ExtensionTools((binding, stage) => Object.freeze({
|
|
29
34
|
...binding,
|
|
30
35
|
invoke: (input, call) => this.toolAttempt(input, call, stage, async () => {
|
|
@@ -47,6 +52,15 @@ export class ExtensionSessionTools {
|
|
|
47
52
|
const frame = this.#toolFrame.getStore();
|
|
48
53
|
return frame?.active ? frame : undefined;
|
|
49
54
|
}
|
|
55
|
+
/** Release propagation only after the Session owner has joined all scoped tool work. */
|
|
56
|
+
dispose() {
|
|
57
|
+
this.#attempt.disable();
|
|
58
|
+
this.#toolFrame.disable();
|
|
59
|
+
// Host/Session refs outlive individual extensions, but never the Session owner.
|
|
60
|
+
this.#toolRefs = new WeakMap();
|
|
61
|
+
this.#ownerToolRefs.clear();
|
|
62
|
+
this.#sessionToolRefs.bindings = new WeakMap();
|
|
63
|
+
}
|
|
50
64
|
withTools(bindings, schedule, run) {
|
|
51
65
|
this.assertLive();
|
|
52
66
|
const frame = { bindings, schedule, active: true };
|
|
@@ -70,9 +84,15 @@ export class ExtensionSessionTools {
|
|
|
70
84
|
return this.#toolFrame.run(undefined, () => this.#attempt.run(undefined, run));
|
|
71
85
|
}
|
|
72
86
|
isMcp(ref) {
|
|
73
|
-
return this.#
|
|
87
|
+
return this.#referenceBinding(ref)?.mcp !== undefined;
|
|
74
88
|
}
|
|
75
89
|
bind(scope, extensionId, run, current) {
|
|
90
|
+
const refs = { bindings: new WeakMap() };
|
|
91
|
+
this.#ownerToolRefs.set(scope.instanceId, refs);
|
|
92
|
+
scope.effect(() => () => {
|
|
93
|
+
// Keep an empty index for this owner so late snapshots cannot acquire Session lifetime.
|
|
94
|
+
refs.bindings = undefined;
|
|
95
|
+
});
|
|
76
96
|
return {
|
|
77
97
|
register: (spec) => this.tools.register(scope, extensionId, spec),
|
|
78
98
|
catalog: () => Object.freeze([...this.capture(current()).values()].map((binding) => this.toolRef(binding))),
|
|
@@ -86,9 +106,16 @@ export class ExtensionSessionTools {
|
|
|
86
106
|
source: binding.source,
|
|
87
107
|
...(binding.mcp === undefined ? {} : { mcp: binding.mcp })
|
|
88
108
|
});
|
|
89
|
-
|
|
109
|
+
const refs = binding.referenceIndex ??
|
|
110
|
+
this.#ownerToolRefs.get(binding.source.instanceId ?? '') ??
|
|
111
|
+
this.#sessionToolRefs;
|
|
112
|
+
refs.bindings?.set(ref, binding);
|
|
113
|
+
this.#toolRefs.set(ref, refs);
|
|
90
114
|
return ref;
|
|
91
115
|
}
|
|
116
|
+
#referenceBinding(ref) {
|
|
117
|
+
return this.#toolRefs.get(ref)?.bindings?.get(ref);
|
|
118
|
+
}
|
|
92
119
|
capture(context, includeStaged = false, base) {
|
|
93
120
|
this.assertLive();
|
|
94
121
|
if (!base && !includeStaged && this.toolFrame)
|
|
@@ -217,7 +244,7 @@ export class ExtensionSessionTools {
|
|
|
217
244
|
const snapshot = this.capture(context);
|
|
218
245
|
if (!call.ref)
|
|
219
246
|
return snapshot;
|
|
220
|
-
const binding = this.#
|
|
247
|
+
const binding = this.#referenceBinding(call.ref);
|
|
221
248
|
if (!binding || binding.definition.name !== call.name)
|
|
222
249
|
throw new AgentError('MAR_AGENT_TOOL_NOT_AVAILABLE', 'Tool reference is unavailable.');
|
|
223
250
|
return new Map([...snapshot, [call.name, binding]]);
|
|
@@ -245,6 +245,9 @@ export class ExtensionSession {
|
|
|
245
245
|
failures++;
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
|
+
this.mcp.dispose();
|
|
249
|
+
this.#toolDomain.dispose();
|
|
250
|
+
this.hooks.dispose();
|
|
248
251
|
if (failures)
|
|
249
252
|
throw new AgentError('MAR_AGENT_EXTENSION_CLEANUP_FAILED', 'Session extension cleanup failed.', { details: { failures } });
|
|
250
253
|
}
|
|
@@ -41,7 +41,12 @@ export interface ExtensionToolFailure extends ExtensionToolAttempt {
|
|
|
41
41
|
readonly status: 'failed' | 'cancelled';
|
|
42
42
|
readonly errorCode: string;
|
|
43
43
|
}
|
|
44
|
+
/** Internal reference storage, released only by the source owner after its work has drained. */
|
|
45
|
+
export interface ExtensionToolReferenceIndex {
|
|
46
|
+
bindings: WeakMap<ExtensionToolRef, ExtensionToolBinding> | undefined;
|
|
47
|
+
}
|
|
44
48
|
export interface ExtensionToolBinding extends ExtensionToolRef {
|
|
49
|
+
readonly referenceIndex?: ExtensionToolReferenceIndex;
|
|
45
50
|
readonly mcp?: McpCatalogTool;
|
|
46
51
|
registered?(): boolean;
|
|
47
52
|
available?(context: ExtensionOperationContext): boolean;
|
package/dist/extensions/tools.js
CHANGED
|
@@ -116,87 +116,8 @@ export class ExtensionTools {
|
|
|
116
116
|
continue;
|
|
117
117
|
throw new AgentError('MAR_AGENT_EXTENSION_DEPENDENCY_MISSING', 'Tool wrapper has no lower implementation.');
|
|
118
118
|
}
|
|
119
|
-
const
|
|
120
|
-
definition.
|
|
121
|
-
? (lower.definition.allowedCallers ??
|
|
122
|
-
(lower.source.kind === 'mcp' ? ['direct', 'programmatic'] : ['direct']))
|
|
123
|
-
: ['direct', 'programmatic'];
|
|
124
|
-
if (entry.spec.override === 'wrap' && lower?.definition.agentScope !== undefined)
|
|
125
|
-
definition.agentScope ??= lower.definition.agentScope;
|
|
126
|
-
if (entry.spec.override === 'wrap' && lower?.definition.codeModeExposure !== undefined)
|
|
127
|
-
definition.codeModeExposure ??= lower.definition.codeModeExposure;
|
|
128
|
-
const binding = Object.freeze({
|
|
129
|
-
definition: deepFreeze(definition),
|
|
130
|
-
...(entry.spec.override === 'wrap' && lower?.mcp ? { mcp: lower.mcp } : {}),
|
|
131
|
-
source: Object.freeze({
|
|
132
|
-
kind: 'extension',
|
|
133
|
-
id: entry.id,
|
|
134
|
-
revision: entry.revision,
|
|
135
|
-
instanceId: entry.owner.instanceId
|
|
136
|
-
}),
|
|
137
|
-
available: () => entry.owner.state === 'ACTIVE' ||
|
|
138
|
-
(entry.owner.state === 'STAGED' && ExtensionResourceScope.initializing),
|
|
139
|
-
invoke: async (call, context) => entry.owner.run((signal) => this.callback(Object.freeze({
|
|
140
|
-
...context,
|
|
141
|
-
signal,
|
|
142
|
-
extensionId: entry.id,
|
|
143
|
-
ownerInstanceId: entry.owner.instanceId
|
|
144
|
-
}), async (context) => {
|
|
145
|
-
const signal = context.signal;
|
|
146
|
-
const approvedArguments = context.permission === undefined ? undefined : structuredClone(call.arguments);
|
|
147
|
-
const input = await entry.parse(call.arguments);
|
|
148
|
-
const { permission: _permission, ...unapproved } = context;
|
|
149
|
-
const parsedContext = context.permission !== undefined && !isDeepStrictEqual(input, approvedArguments)
|
|
150
|
-
? unapproved
|
|
151
|
-
: context;
|
|
152
|
-
signal.throwIfAborted();
|
|
153
|
-
const controller = new AbortController();
|
|
154
|
-
const effective = AbortSignal.any([signal, controller.signal]);
|
|
155
|
-
const pending = new Set();
|
|
156
|
-
let open = true;
|
|
157
|
-
let output;
|
|
158
|
-
try {
|
|
159
|
-
output = await entry.spec.execute(input, Object.freeze({
|
|
160
|
-
...parsedContext,
|
|
161
|
-
signal: effective,
|
|
162
|
-
ownerInstanceId: entry.owner.instanceId
|
|
163
|
-
}), (input) => {
|
|
164
|
-
const result = Promise.resolve().then(() => {
|
|
165
|
-
effective.throwIfAborted();
|
|
166
|
-
if (!open)
|
|
167
|
-
throw new AgentError('MAR_AGENT_TOOL_NOT_AVAILABLE', 'Tool continuation has finished.');
|
|
168
|
-
if (!lower || entry.spec.override !== 'wrap')
|
|
169
|
-
throw new AgentError('MAR_AGENT_EXTENSION_DEPENDENCY_MISSING', 'Tool has no wrapper continuation.');
|
|
170
|
-
return lower.invoke({ ...call, arguments: input }, Object.freeze({
|
|
171
|
-
...(parsedContext.permission !== undefined &&
|
|
172
|
-
isDeepStrictEqual(input, approvedArguments)
|
|
173
|
-
? parsedContext
|
|
174
|
-
: unapproved),
|
|
175
|
-
signal: effective
|
|
176
|
-
}));
|
|
177
|
-
});
|
|
178
|
-
const settled = result.then(() => undefined, () => undefined);
|
|
179
|
-
pending.add(settled);
|
|
180
|
-
void settled.then(() => pending.delete(settled));
|
|
181
|
-
return result;
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
catch (error) {
|
|
185
|
-
controller.abort();
|
|
186
|
-
throw error;
|
|
187
|
-
}
|
|
188
|
-
finally {
|
|
189
|
-
while (pending.size)
|
|
190
|
-
await Promise.all([...pending]);
|
|
191
|
-
open = false;
|
|
192
|
-
}
|
|
193
|
-
if (!output || typeof output.content !== 'string')
|
|
194
|
-
throw new AgentError('MAR_AGENT_TOOL_OUTPUT_INVALID', 'Tool result must contain string content.');
|
|
195
|
-
await entry.validateOutput?.(output.data);
|
|
196
|
-
return output;
|
|
197
|
-
}), context.signal)
|
|
198
|
-
});
|
|
199
|
-
bindings.set(definition.name, this.trace(binding, entry.spec.override === 'wrap' ? 'wrapper' : 'leaf'));
|
|
119
|
+
const binding = this.#bindContribution(entry, lower);
|
|
120
|
+
bindings.set(binding.definition.name, this.trace(binding, entry.spec.override === 'wrap' ? 'wrapper' : 'leaf'));
|
|
200
121
|
}
|
|
201
122
|
const view = Object.freeze({
|
|
202
123
|
get size() {
|
|
@@ -215,6 +136,92 @@ export class ExtensionTools {
|
|
|
215
136
|
});
|
|
216
137
|
return view;
|
|
217
138
|
}
|
|
139
|
+
/** Do not let a retained replacement close over the capture's unrelated source map. */
|
|
140
|
+
#bindContribution(entry, lower) {
|
|
141
|
+
const definition = copyDefinition(entry.spec);
|
|
142
|
+
const continuation = entry.spec.override === 'wrap' ? lower : undefined;
|
|
143
|
+
definition.allowedCallers ??= lower
|
|
144
|
+
? (lower.definition.allowedCallers ??
|
|
145
|
+
(lower.source.kind === 'mcp' ? ['direct', 'programmatic'] : ['direct']))
|
|
146
|
+
: ['direct', 'programmatic'];
|
|
147
|
+
if (entry.spec.override === 'wrap' && lower?.definition.agentScope !== undefined)
|
|
148
|
+
definition.agentScope ??= lower.definition.agentScope;
|
|
149
|
+
if (entry.spec.override === 'wrap' && lower?.definition.codeModeExposure !== undefined)
|
|
150
|
+
definition.codeModeExposure ??= lower.definition.codeModeExposure;
|
|
151
|
+
const binding = Object.freeze({
|
|
152
|
+
definition: deepFreeze(definition),
|
|
153
|
+
...(continuation?.referenceIndex ? { referenceIndex: continuation.referenceIndex } : {}),
|
|
154
|
+
...(entry.spec.override === 'wrap' && lower?.mcp ? { mcp: lower.mcp } : {}),
|
|
155
|
+
source: Object.freeze({
|
|
156
|
+
kind: 'extension',
|
|
157
|
+
id: entry.id,
|
|
158
|
+
revision: entry.revision,
|
|
159
|
+
instanceId: entry.owner.instanceId
|
|
160
|
+
}),
|
|
161
|
+
available: () => entry.owner.state === 'ACTIVE' ||
|
|
162
|
+
(entry.owner.state === 'STAGED' && ExtensionResourceScope.initializing),
|
|
163
|
+
invoke: async (call, context) => entry.owner.run((signal) => this.callback(Object.freeze({
|
|
164
|
+
...context,
|
|
165
|
+
signal,
|
|
166
|
+
extensionId: entry.id,
|
|
167
|
+
ownerInstanceId: entry.owner.instanceId
|
|
168
|
+
}), async (context) => {
|
|
169
|
+
const signal = context.signal;
|
|
170
|
+
const approvedArguments = context.permission === undefined ? undefined : structuredClone(call.arguments);
|
|
171
|
+
const input = await entry.parse(call.arguments);
|
|
172
|
+
const { permission: _permission, ...unapproved } = context;
|
|
173
|
+
const parsedContext = context.permission !== undefined && !isDeepStrictEqual(input, approvedArguments)
|
|
174
|
+
? unapproved
|
|
175
|
+
: context;
|
|
176
|
+
signal.throwIfAborted();
|
|
177
|
+
const controller = new AbortController();
|
|
178
|
+
const effective = AbortSignal.any([signal, controller.signal]);
|
|
179
|
+
const pending = new Set();
|
|
180
|
+
let open = true;
|
|
181
|
+
let output;
|
|
182
|
+
try {
|
|
183
|
+
output = await entry.spec.execute(input, Object.freeze({
|
|
184
|
+
...parsedContext,
|
|
185
|
+
signal: effective,
|
|
186
|
+
ownerInstanceId: entry.owner.instanceId
|
|
187
|
+
}), (input) => {
|
|
188
|
+
const result = Promise.resolve().then(() => {
|
|
189
|
+
effective.throwIfAborted();
|
|
190
|
+
if (!open)
|
|
191
|
+
throw new AgentError('MAR_AGENT_TOOL_NOT_AVAILABLE', 'Tool continuation has finished.');
|
|
192
|
+
if (!continuation)
|
|
193
|
+
throw new AgentError('MAR_AGENT_EXTENSION_DEPENDENCY_MISSING', 'Tool has no wrapper continuation.');
|
|
194
|
+
return continuation.invoke({ ...call, arguments: input }, Object.freeze({
|
|
195
|
+
...(parsedContext.permission !== undefined &&
|
|
196
|
+
isDeepStrictEqual(input, approvedArguments)
|
|
197
|
+
? parsedContext
|
|
198
|
+
: unapproved),
|
|
199
|
+
signal: effective
|
|
200
|
+
}));
|
|
201
|
+
});
|
|
202
|
+
const settled = result.then(() => undefined, () => undefined);
|
|
203
|
+
pending.add(settled);
|
|
204
|
+
void settled.then(() => pending.delete(settled));
|
|
205
|
+
return result;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
controller.abort();
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
while (pending.size)
|
|
214
|
+
await Promise.all([...pending]);
|
|
215
|
+
open = false;
|
|
216
|
+
}
|
|
217
|
+
if (!output || typeof output.content !== 'string')
|
|
218
|
+
throw new AgentError('MAR_AGENT_TOOL_OUTPUT_INVALID', 'Tool result must contain string content.');
|
|
219
|
+
await entry.validateOutput?.(output.data);
|
|
220
|
+
return output;
|
|
221
|
+
}), context.signal)
|
|
222
|
+
});
|
|
223
|
+
return binding;
|
|
224
|
+
}
|
|
218
225
|
}
|
|
219
226
|
function schemaValidator(schema, code) {
|
|
220
227
|
let validator;
|
|
@@ -2,7 +2,10 @@ import type { ExtensionToolBinding } from '../extensions/tools.js';
|
|
|
2
2
|
import type { ClientToolDefinition, McpToolCatalog } from '../tools/contracts.js';
|
|
3
3
|
import { type RoundToolScope } from '../tools/execution.js';
|
|
4
4
|
import type { ToolRuntimeContext } from '../tools/runtime-context.js';
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Own one real Execution's sources and native-reference index.
|
|
7
|
+
* Retained physical frames outlive the main round; release references only after the last one drains.
|
|
8
|
+
*/
|
|
6
9
|
export declare class ExecutionToolSources {
|
|
7
10
|
#private;
|
|
8
11
|
readonly executionId: string;
|
|
@@ -4,14 +4,19 @@ import { AgentError } from '../error.js';
|
|
|
4
4
|
import { toolCallers } from '../tools/callers.js';
|
|
5
5
|
import { createRoundToolRuntimeContext } from '../tools/execution.js';
|
|
6
6
|
import { executeCapturedTool } from '../tools/runtime.js';
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Own one real Execution's sources and native-reference index.
|
|
9
|
+
* Retained physical frames outlive the main round; release references only after the last one drains.
|
|
10
|
+
*/
|
|
8
11
|
export class ExecutionToolSources {
|
|
9
12
|
executionId;
|
|
10
13
|
sessionId;
|
|
11
14
|
bindings;
|
|
15
|
+
#referenceIndex = { bindings: new WeakMap() };
|
|
12
16
|
#frame = new AsyncLocalStorage();
|
|
13
17
|
#scope;
|
|
14
18
|
#closed = false;
|
|
19
|
+
#frames = 0;
|
|
15
20
|
constructor(executionId, sessionId, definitions, builtins, catalog, supplemental = []) {
|
|
16
21
|
this.executionId = executionId;
|
|
17
22
|
this.sessionId = sessionId;
|
|
@@ -26,6 +31,7 @@ export class ExecutionToolSources {
|
|
|
26
31
|
});
|
|
27
32
|
return Object.freeze({
|
|
28
33
|
definition,
|
|
34
|
+
referenceIndex: this.#referenceIndex,
|
|
29
35
|
source: Object.freeze({
|
|
30
36
|
kind,
|
|
31
37
|
id: mcp?.sourceIdentity ?? `${kind}:${executionId}:${definition.name}`,
|
|
@@ -86,19 +92,29 @@ export class ExecutionToolSources {
|
|
|
86
92
|
withFrame(context, run) {
|
|
87
93
|
if (context.executionId !== this.executionId || context.sessionId !== this.sessionId)
|
|
88
94
|
throw unavailable();
|
|
95
|
+
// A later retained call gets a fresh index, never the refs released by an earlier frame.
|
|
96
|
+
this.#referenceIndex.bindings ??= new WeakMap();
|
|
89
97
|
const frame = { context, active: true };
|
|
98
|
+
this.#frames++;
|
|
99
|
+
const finish = () => {
|
|
100
|
+
frame.active = false;
|
|
101
|
+
if (--this.#frames === 0 && this.#closed) {
|
|
102
|
+
this.#referenceIndex.bindings = undefined;
|
|
103
|
+
this.#frame.disable();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
90
106
|
return this.#frame.run(frame, () => {
|
|
91
107
|
try {
|
|
92
108
|
const result = run();
|
|
93
109
|
if (result instanceof Promise)
|
|
94
110
|
return result.finally(() => {
|
|
95
|
-
|
|
111
|
+
finish();
|
|
96
112
|
});
|
|
97
|
-
|
|
113
|
+
finish();
|
|
98
114
|
return result;
|
|
99
115
|
}
|
|
100
116
|
catch (error) {
|
|
101
|
-
|
|
117
|
+
finish();
|
|
102
118
|
throw error;
|
|
103
119
|
}
|
|
104
120
|
});
|
|
@@ -106,6 +122,11 @@ export class ExecutionToolSources {
|
|
|
106
122
|
close() {
|
|
107
123
|
this.#closed = true;
|
|
108
124
|
this.#scope = undefined;
|
|
125
|
+
// Retained physical callbacks may outlive the main round and must keep their own frame.
|
|
126
|
+
if (this.#frames === 0) {
|
|
127
|
+
this.#referenceIndex.bindings = undefined;
|
|
128
|
+
this.#frame.disable();
|
|
129
|
+
}
|
|
109
130
|
}
|
|
110
131
|
#context(call) {
|
|
111
132
|
if (call.executionId !== this.executionId || call.sessionId !== this.sessionId)
|
package/dist/tools/scheduler.js
CHANGED
|
@@ -7,11 +7,13 @@ export function withIndependentSessionTools(run) {
|
|
|
7
7
|
/** One barrier domain for foreground tools and all retained cells in a Session. */
|
|
8
8
|
export function createSessionToolScheduler() {
|
|
9
9
|
const leases = new AsyncLocalStorage();
|
|
10
|
+
let activeJobs = 0;
|
|
10
11
|
const definitions = [
|
|
11
12
|
{ name: 'safe', parallelSafety: 'safe' },
|
|
12
13
|
{ name: 'serial', parallelSafety: 'serial' }
|
|
13
14
|
];
|
|
14
15
|
const execute = async (job) => {
|
|
16
|
+
activeJobs++;
|
|
15
17
|
const lease = {
|
|
16
18
|
active: true,
|
|
17
19
|
ingress: sessionToolIngress.getStore(),
|
|
@@ -27,6 +29,8 @@ export function createSessionToolScheduler() {
|
|
|
27
29
|
while (lease.children.size)
|
|
28
30
|
await Promise.all([...lease.children]);
|
|
29
31
|
lease.active = false;
|
|
32
|
+
if (--activeJobs === 0)
|
|
33
|
+
leases.disable();
|
|
30
34
|
}
|
|
31
35
|
});
|
|
32
36
|
};
|