@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 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. */
@@ -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(() => this.#retire());
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
  }
@@ -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
- /** One Session's declarations and owned Client leases feed its sole tool registry. */
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
  }
@@ -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
- /** One Session's declarations and owned Client leases feed its sole tool registry. */
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 connection = this.#connectionRefs.get(input.connection);
96
- if (!connection)
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 active = this.#calling.getStore();
99
- if (active?.active && active.connection === connection)
100
- throw new AgentError('MAR_AGENT_MCP_CONNECTION_BUSY', 'MCP connection cannot close from its active call.');
101
- await connection.registration.dispose();
102
- await this.hooks.emit('mcp.completed', { operation: 'disconnect', connectionId: connection.ref.id }, effective);
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
- const results = await Promise.allSettled([
12
- Promise.resolve().then(() => response.close()),
13
- Promise.resolve().then(() => iterator?.return?.())
14
- ]);
15
- const failure = results.find((result) => result.status === 'rejected');
16
- if (failure?.status === 'rejected')
17
- throw failure.reason;
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
- return {
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
- if (typeof handle?.cancel === 'function')
50
- await handle.cancel();
51
- }
52
- catch (error) {
53
- failed = true;
54
- firstError = error;
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
- const released = await Promise.allSettled(iterators.map((iterator) => Promise.resolve().then(() => iterator.return?.())));
57
- const failure = released.find((result) => result.status === 'rejected');
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
- return {
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
- /** An instance's managed resources, not an OS sandbox or a process-wide resource owner. */
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
- /** An instance's managed resources, not an OS sandbox or a process-wide resource owner. */
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 acquired = Promise.resolve()
46
- .then(acquire)
47
- .then((release) => {
48
- if (typeof release !== 'function')
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
- let releasing;
53
- const disposalFrame = new AsyncLocalStorage();
54
- const registration = {
55
- ready: acquired.then(() => undefined),
56
- dispose: () => {
57
- if (disposalFrame.getStore())
58
- return Promise.resolve();
59
- releasing ??= acquired.then(async (release) => {
60
- if (typeof release !== 'function')
61
- throw new AgentError('MAR_AGENT_EXTENSION_RESOURCE_INVALID', 'Resource acquisition must return a disposer.');
62
- await disposalFrame.run(true, release);
63
- }, () => undefined);
64
- return releasing;
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
- let count = -1;
130
- while (count !== this.#resources.length) {
131
- count = this.#resources.length;
132
- await Promise.all(this.#resources.map((resource) => resource.ready));
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
- if (!callbacks.getStore())
188
- await running;
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
- failures++;
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
- /** One Session FIFO shared by native writes and the existing parent mailbox. No model loop. */
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
- /** One Session FIFO shared by native writes and the existing parent mailbox. No model loop. */
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 entry = {
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.#records.push(entry);
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.#records
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.#records.filter((entry) => !instanceId || entry.source.ownerInstanceId === instanceId);
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.request.kind === 'submit' &&
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.request.kind === 'submit'));
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
- /** One Session's tool catalog, references, attempt lineage, and invocation frames. */
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
- /** One Session's tool catalog, references, attempt lineage, and invocation frames. */
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.#toolRefs.get(ref)?.mcp !== undefined;
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
- this.#toolRefs.set(ref, binding);
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.#toolRefs.get(call.ref);
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;
@@ -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 definition = copyDefinition(entry.spec);
120
- definition.allowedCallers ??= lower
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;
@@ -57,6 +57,7 @@ export class CompactCoordinator {
57
57
  flight.settled = true;
58
58
  ready();
59
59
  controller.signal.removeEventListener('abort', abort);
60
+ this.#owner.disable();
60
61
  }
61
62
  });
62
63
  // The result remains observable at the next checkpoint, including asynchronous failures.
@@ -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
- /** One real Execution's sources; a retained physical frame is distinct from its active main round. */
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
- /** One real Execution's sources; a retained physical frame is distinct from its active main round. */
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
- frame.active = false;
111
+ finish();
96
112
  });
97
- frame.active = false;
113
+ finish();
98
114
  return result;
99
115
  }
100
116
  catch (error) {
101
- frame.active = false;
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)
@@ -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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/agent",
3
- "version": "0.12.13",
3
+ "version": "0.13.0",
4
4
  "description": "Embeddable MAR coding agent SDK and CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",