@animalabs/agent-framework 0.7.4 → 0.8.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/src/code-execution/index.d.ts +1 -1
- package/dist/src/code-execution/index.d.ts.map +1 -1
- package/dist/src/code-execution/index.js.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +23 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +40 -4
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +15 -2
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +156 -7
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +27 -2
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/framework.d.ts +73 -0
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +818 -23
- package/dist/src/framework.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +28 -2
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +53 -7
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/transport.d.ts +1 -1
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +25 -7
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +10 -0
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/module-registry.d.ts +12 -0
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +26 -0
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +20 -0
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +48 -15
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/sync.d.ts +4 -0
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +1 -1
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/types/framework.d.ts +10 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +22 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/test/busy-agent-scheduler.test.js +1 -0
- package/dist/test/busy-agent-scheduler.test.js.map +1 -1
- package/dist/test/code-execution.test.js +327 -1
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/deferred-wake-integrity.test.d.ts +29 -0
- package/dist/test/deferred-wake-integrity.test.d.ts.map +1 -0
- package/dist/test/deferred-wake-integrity.test.js +320 -0
- package/dist/test/deferred-wake-integrity.test.js.map +1 -0
- package/dist/test/machine-close-provenance.test.d.ts +2 -0
- package/dist/test/machine-close-provenance.test.d.ts.map +1 -0
- package/dist/test/machine-close-provenance.test.js +110 -0
- package/dist/test/machine-close-provenance.test.js.map +1 -0
- package/dist/test/mcpl-access-provider.test.d.ts +2 -0
- package/dist/test/mcpl-access-provider.test.d.ts.map +1 -0
- package/dist/test/mcpl-access-provider.test.js +20 -0
- package/dist/test/mcpl-access-provider.test.js.map +1 -0
- package/dist/test/module-context-integration.test.d.ts +18 -0
- package/dist/test/module-context-integration.test.d.ts.map +1 -0
- package/dist/test/module-context-integration.test.js +181 -0
- package/dist/test/module-context-integration.test.js.map +1 -0
- package/dist/test/present-while-acting.test.js +72 -0
- package/dist/test/present-while-acting.test.js.map +1 -1
- package/dist/test/utils-surface.test.d.ts +2 -0
- package/dist/test/utils-surface.test.d.ts.map +1 -0
- package/dist/test/utils-surface.test.js +145 -0
- package/dist/test/utils-surface.test.js.map +1 -0
- package/dist/test/workspace-read-image.test.js +71 -0
- package/dist/test/workspace-read-image.test.js.map +1 -1
- package/dist/test/workspace-restore-order.test.d.ts +16 -0
- package/dist/test/workspace-restore-order.test.d.ts.map +1 -0
- package/dist/test/workspace-restore-order.test.js +140 -0
- package/dist/test/workspace-restore-order.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/code-execution/index.ts +1 -0
- package/src/code-execution/py-runner.ts +56 -4
- package/src/code-execution/runtime-py.ts +156 -7
- package/src/code-execution/tool-definition.ts +27 -2
- package/src/framework.ts +931 -25
- package/src/mcpl/channel-registry.ts +98 -4
- package/src/mcpl/transport.ts +26 -7
- package/src/mcpl/types.ts +11 -0
- package/src/module-registry.ts +34 -0
- package/src/modules/workspace/index.ts +49 -15
- package/src/modules/workspace/sync.ts +1 -1
- package/src/types/framework.ts +10 -0
- package/src/types/module.ts +24 -0
|
@@ -337,6 +337,21 @@ interface ChannelRegistryOptions {
|
|
|
337
337
|
// ChannelRegistry
|
|
338
338
|
// ============================================================================
|
|
339
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Who is actually making a channel tool call, as established by the
|
|
342
|
+
* framework's own dispatch — model-origin calls come through
|
|
343
|
+
* dispatchToolCall (which knows the agent), module calls come through
|
|
344
|
+
* ModuleContext.callTool. Tool INPUT can claim anything; this cannot.
|
|
345
|
+
*/
|
|
346
|
+
export type ChannelToolOrigin =
|
|
347
|
+
| { kind: 'module'; moduleName?: string }
|
|
348
|
+
| { kind: 'agent'; agentName: string };
|
|
349
|
+
|
|
350
|
+
/** The machine decision sources a module-origin channel_close may record.
|
|
351
|
+
* Closed on purpose: honest provenance means a small, auditable vocabulary,
|
|
352
|
+
* not arbitrary self-description. */
|
|
353
|
+
export const MACHINE_CLOSE_SOURCES = new Set(['subscription-gc', 'housekeeping']);
|
|
354
|
+
|
|
340
355
|
export class ChannelRegistry {
|
|
341
356
|
private serverRegistry: McplServerRegistry;
|
|
342
357
|
private featureSetManager: FeatureSetManager;
|
|
@@ -855,8 +870,21 @@ export class ChannelRegistry {
|
|
|
855
870
|
|
|
856
871
|
/**
|
|
857
872
|
* Handle a call to one of the synthesized channel tools.
|
|
873
|
+
*
|
|
874
|
+
* `origin` is TRUSTED DISPATCH CONTEXT, supplied by the framework's own
|
|
875
|
+
* routing (dispatchChannelToolCall and the public executeToolCall for
|
|
876
|
+
* model-origin calls, the framework's private ModuleRegistry closure for
|
|
877
|
+
* module ctx.callTool) — never derived from tool input. Machine
|
|
878
|
+
* provenance on channel_close is honored only for module origin; a
|
|
879
|
+
* model-origin call carrying the same fields is recorded as the agent
|
|
880
|
+
* decision it actually is. Absent origin is treated as agent-origin (the
|
|
881
|
+
* untrusted-safe default).
|
|
858
882
|
*/
|
|
859
|
-
async handleChannelToolCall(
|
|
883
|
+
async handleChannelToolCall(
|
|
884
|
+
toolName: string,
|
|
885
|
+
input: unknown,
|
|
886
|
+
origin?: ChannelToolOrigin,
|
|
887
|
+
): Promise<ToolResult> {
|
|
860
888
|
switch (toolName) {
|
|
861
889
|
case 'channel_list':
|
|
862
890
|
return this.handleToolList();
|
|
@@ -870,7 +898,15 @@ export class ChannelRegistry {
|
|
|
870
898
|
});
|
|
871
899
|
|
|
872
900
|
case 'channel_close':
|
|
873
|
-
return this.handleToolClose(
|
|
901
|
+
return this.handleToolClose(
|
|
902
|
+
input as {
|
|
903
|
+
channelId: string;
|
|
904
|
+
serverId?: string;
|
|
905
|
+
source?: string;
|
|
906
|
+
overrideExplicitOpen?: boolean;
|
|
907
|
+
},
|
|
908
|
+
origin,
|
|
909
|
+
);
|
|
874
910
|
|
|
875
911
|
case 'channel_decline':
|
|
876
912
|
return this.handleToolDecline(input as {
|
|
@@ -1588,7 +1624,25 @@ export class ChannelRegistry {
|
|
|
1588
1624
|
}
|
|
1589
1625
|
}
|
|
1590
1626
|
|
|
1591
|
-
private async handleToolClose(
|
|
1627
|
+
private async handleToolClose(
|
|
1628
|
+
input: {
|
|
1629
|
+
channelId: string;
|
|
1630
|
+
serverId?: string;
|
|
1631
|
+
/** Machine callers name their decision source ('subscription-gc',
|
|
1632
|
+
* 'housekeeping') so the durable record carries honest provenance.
|
|
1633
|
+
* Honored ONLY for module-origin dispatch — tool input is untrusted,
|
|
1634
|
+
* so a model-origin call carrying this field is still recorded as
|
|
1635
|
+
* 'agent-tool' (the resident may close their own channel; they may
|
|
1636
|
+
* not attribute the act to housekeeping). */
|
|
1637
|
+
source?: string;
|
|
1638
|
+
/** A machine close aimed at an explicitly-opened channel is refused
|
|
1639
|
+
* unless the caller certifies an explicit idle lease (a configured
|
|
1640
|
+
* per-channel budget is consent to close at that budget). Module
|
|
1641
|
+
* origin only, like `source`. */
|
|
1642
|
+
overrideExplicitOpen?: boolean;
|
|
1643
|
+
},
|
|
1644
|
+
origin?: ChannelToolOrigin,
|
|
1645
|
+
): Promise<ToolResult> {
|
|
1592
1646
|
const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
|
|
1593
1647
|
const entry = resolved.entry;
|
|
1594
1648
|
if (!entry) {
|
|
@@ -1607,7 +1661,47 @@ export class ChannelRegistry {
|
|
|
1607
1661
|
};
|
|
1608
1662
|
}
|
|
1609
1663
|
|
|
1610
|
-
|
|
1664
|
+
// Provenance comes from trusted dispatch, not from self-description:
|
|
1665
|
+
// only module-origin calls may record a machine source, and only from
|
|
1666
|
+
// the closed vocabulary. Agent-origin (or origin-less — the safe
|
|
1667
|
+
// default for any legacy caller) records 'agent-tool' and has its
|
|
1668
|
+
// machine fields ignored entirely.
|
|
1669
|
+
const isModuleOrigin = origin?.kind === 'module';
|
|
1670
|
+
let closeSource = 'agent-tool';
|
|
1671
|
+
if (isModuleOrigin && input.source !== undefined) {
|
|
1672
|
+
if (!MACHINE_CLOSE_SOURCES.has(input.source)) {
|
|
1673
|
+
return {
|
|
1674
|
+
success: false,
|
|
1675
|
+
isError: true,
|
|
1676
|
+
error:
|
|
1677
|
+
`Unknown machine close source '${input.source}'. Machine closes must use one of: ` +
|
|
1678
|
+
`${[...MACHINE_CLOSE_SOURCES].join(', ')}.`,
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
closeSource = input.source;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// Housekeeping must not override stated intent (issue #5: GC closes wore
|
|
1685
|
+
// the agent's badge and reset explicitly-opened doors). A machine-sourced
|
|
1686
|
+
// close of a channel whose current desired state is an agent/operator
|
|
1687
|
+
// 'open' is refused — structurally, so the caller can stand down rather
|
|
1688
|
+
// than retry — unless it certifies an explicit idle lease.
|
|
1689
|
+
if (isModuleOrigin && closeSource !== 'agent-tool' && !input.overrideExplicitOpen) {
|
|
1690
|
+
const current = this.desiredStates.get(this.lifecycleKey(entry.serverId, input.channelId));
|
|
1691
|
+
if (current?.state === 'open' && current.source === 'agent-tool') {
|
|
1692
|
+
return {
|
|
1693
|
+
success: false,
|
|
1694
|
+
isError: false,
|
|
1695
|
+
error:
|
|
1696
|
+
`Channel ${input.channelId} was explicitly opened (source: agent-tool); ` +
|
|
1697
|
+
`a '${closeSource}' close does not override stated intent. Machine closes ` +
|
|
1698
|
+
`of explicit opens require an explicit idle lease (overrideExplicitOpen).`,
|
|
1699
|
+
data: { refusal: 'explicit-open', channelId: input.channelId },
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
|
|
1611
1705
|
this.stopTyping(input.channelId);
|
|
1612
1706
|
|
|
1613
1707
|
const server = this.serverRegistry.getServer(entry.serverId);
|
package/src/mcpl/transport.ts
CHANGED
|
@@ -200,8 +200,26 @@ export class WebSocketTransport extends McplTransport {
|
|
|
200
200
|
* when `config.token` is set. Rejects on connect error / timeout (and tears
|
|
201
201
|
* the socket down) so a failed dial never leaks an open handle.
|
|
202
202
|
*/
|
|
203
|
-
static open(config: McplServerConfig): Promise<WebSocketTransport> {
|
|
204
|
-
|
|
203
|
+
static async open(config: McplServerConfig): Promise<WebSocketTransport> {
|
|
204
|
+
// Resolve the per-dial credential first: the provider runs on every
|
|
205
|
+
// connect AND reconnect, so rotation happens at the transport layer and
|
|
206
|
+
// nothing above it ever holds a credential.
|
|
207
|
+
let token: string | null = config.token ?? null;
|
|
208
|
+
if (config.accessProvider) {
|
|
209
|
+
try {
|
|
210
|
+
token = (await config.accessProvider()) ?? token;
|
|
211
|
+
} catch (err) {
|
|
212
|
+
// Fall back to the static token (if any) rather than failing the dial
|
|
213
|
+
// outright — the server will refuse a stale credential loudly anyway.
|
|
214
|
+
console.error(
|
|
215
|
+
`[mcpl] server "${config.id}": access provider failed (${err instanceof Error ? err.message : err}) — dialing with configured fallback`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const url = buildWebSocketUrl(config, token);
|
|
220
|
+
// Error strings travel: traces, logs, tool results the model reads. They
|
|
221
|
+
// get the endpoint, never the credential.
|
|
222
|
+
const redacted = buildWebSocketUrl({ ...config, token: undefined }, null);
|
|
205
223
|
const ws = new WebSocket(url);
|
|
206
224
|
|
|
207
225
|
return new Promise<WebSocketTransport>((resolve, reject) => {
|
|
@@ -211,7 +229,7 @@ export class WebSocketTransport extends McplTransport {
|
|
|
211
229
|
settled = true;
|
|
212
230
|
ws.removeAllListeners();
|
|
213
231
|
try { ws.terminate(); } catch { /* already gone */ }
|
|
214
|
-
reject(new Error(`MCPL server "${config.id}" WebSocket connect timed out after ${WS_OPEN_TIMEOUT_MS}ms (${
|
|
232
|
+
reject(new Error(`MCPL server "${config.id}" WebSocket connect timed out after ${WS_OPEN_TIMEOUT_MS}ms (${redacted})`));
|
|
215
233
|
}, WS_OPEN_TIMEOUT_MS);
|
|
216
234
|
|
|
217
235
|
ws.once('open', () => {
|
|
@@ -228,7 +246,7 @@ export class WebSocketTransport extends McplTransport {
|
|
|
228
246
|
clearTimeout(timer);
|
|
229
247
|
ws.removeAllListeners();
|
|
230
248
|
try { ws.terminate(); } catch { /* already gone */ }
|
|
231
|
-
reject(new Error(`MCPL server "${config.id}" WebSocket connect failed: ${err.message} (${
|
|
249
|
+
reject(new Error(`MCPL server "${config.id}" WebSocket connect failed: ${err.message} (${redacted})`));
|
|
232
250
|
});
|
|
233
251
|
});
|
|
234
252
|
}
|
|
@@ -261,7 +279,7 @@ export class WebSocketTransport extends McplTransport {
|
|
|
261
279
|
* Build the dial URL: validate it's a ws(s):// URL and append the auth token as
|
|
262
280
|
* a `token` query param when provided (preserving any existing query).
|
|
263
281
|
*/
|
|
264
|
-
export function buildWebSocketUrl(config: McplServerConfig): string {
|
|
282
|
+
export function buildWebSocketUrl(config: McplServerConfig, tokenOverride?: string | null): string {
|
|
265
283
|
if (!config.url) {
|
|
266
284
|
throw new Error(`MCPL server "${config.id}": websocket transport requires "url" (none provided)`);
|
|
267
285
|
}
|
|
@@ -276,8 +294,9 @@ export function buildWebSocketUrl(config: McplServerConfig): string {
|
|
|
276
294
|
`MCPL server "${config.id}": websocket url must be ws:// or wss://, got "${parsed.protocol}"`,
|
|
277
295
|
);
|
|
278
296
|
}
|
|
279
|
-
|
|
280
|
-
|
|
297
|
+
const token = tokenOverride !== undefined ? tokenOverride : config.token;
|
|
298
|
+
if (token) {
|
|
299
|
+
parsed.searchParams.set('token', token);
|
|
281
300
|
}
|
|
282
301
|
return parsed.toString();
|
|
283
302
|
}
|
package/src/mcpl/types.ts
CHANGED
|
@@ -198,6 +198,17 @@ export interface McplServerConfig {
|
|
|
198
198
|
*/
|
|
199
199
|
token?: string;
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Lazily resolves the connection credential at EVERY dial — first connect
|
|
203
|
+
* and each background reconnect — overriding `token` when it returns a
|
|
204
|
+
* value (null/throw falls back to `token`). Host-attached plumbing, never
|
|
205
|
+
* serialized: the point is that credentials stay out of the agent's
|
|
206
|
+
* context entirely (the agent asks for access by name; the host fetches
|
|
207
|
+
* something fresh each redial, which also makes short-lived credentials
|
|
208
|
+
* viable where a static `token` forced long ones).
|
|
209
|
+
*/
|
|
210
|
+
accessProvider?: () => Promise<string | null>;
|
|
211
|
+
|
|
201
212
|
/** Feature sets to enable on connect */
|
|
202
213
|
enabledFeatureSets?: string[];
|
|
203
214
|
|
package/src/module-registry.ts
CHANGED
|
@@ -75,6 +75,15 @@ export class ModuleRegistry {
|
|
|
75
75
|
private pushEventFn: (event: ProcessEvent) => void;
|
|
76
76
|
private onTraceFn: (listener: TraceEventListener) => () => void;
|
|
77
77
|
private callToolFn: (call: ToolCall) => Promise<ToolResult>;
|
|
78
|
+
/** Framework ops-alert channel, surfaced to modules via
|
|
79
|
+
* ModuleContext.notifyOps. Readonly-public so ModuleContextImpl can
|
|
80
|
+
* delegate without another positional constructor parameter. */
|
|
81
|
+
readonly notifyOpsFn?: (
|
|
82
|
+
kind: string,
|
|
83
|
+
agentName: string,
|
|
84
|
+
message: string,
|
|
85
|
+
data?: Record<string, unknown>,
|
|
86
|
+
) => void;
|
|
78
87
|
|
|
79
88
|
constructor(
|
|
80
89
|
store: JsStore,
|
|
@@ -89,6 +98,7 @@ export class ModuleRegistry {
|
|
|
89
98
|
pushEvent: (event: ProcessEvent) => void;
|
|
90
99
|
onTrace: (listener: TraceEventListener) => () => void;
|
|
91
100
|
callTool: (call: ToolCall) => Promise<ToolResult>;
|
|
101
|
+
notifyOps?: (kind: string, agentName: string, message: string, data?: Record<string, unknown>) => void;
|
|
92
102
|
}
|
|
93
103
|
) {
|
|
94
104
|
this.store = store;
|
|
@@ -102,6 +112,7 @@ export class ModuleRegistry {
|
|
|
102
112
|
this.pushEventFn = options.pushEvent;
|
|
103
113
|
this.onTraceFn = options.onTrace;
|
|
104
114
|
this.callToolFn = options.callTool;
|
|
115
|
+
this.notifyOpsFn = options.notifyOps;
|
|
105
116
|
}
|
|
106
117
|
|
|
107
118
|
/**
|
|
@@ -230,6 +241,25 @@ export class ModuleRegistry {
|
|
|
230
241
|
return tools;
|
|
231
242
|
}
|
|
232
243
|
|
|
244
|
+
/**
|
|
245
|
+
* All utilities from all modules, namespaced exactly like tools
|
|
246
|
+
* (`module--name`). See Module.getUtilities — these are deliberately kept
|
|
247
|
+
* out of the model-facing tool list and surfaced through the framework's
|
|
248
|
+
* `utils` meta-tool; dispatch goes through the same handleToolCall.
|
|
249
|
+
*/
|
|
250
|
+
getAllUtilities(): ToolDefinition[] {
|
|
251
|
+
const utilities: ToolDefinition[] = [];
|
|
252
|
+
for (const module of this.modules.values()) {
|
|
253
|
+
for (const util of module.getUtilities?.() ?? []) {
|
|
254
|
+
utilities.push({
|
|
255
|
+
...util,
|
|
256
|
+
name: `${module.name}--${util.name}`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return utilities;
|
|
261
|
+
}
|
|
262
|
+
|
|
233
263
|
/**
|
|
234
264
|
* Handle a tool call by routing to the appropriate module.
|
|
235
265
|
*/
|
|
@@ -556,6 +586,10 @@ class ModuleContextImpl implements ModuleContext {
|
|
|
556
586
|
return this.callToolFn(call);
|
|
557
587
|
}
|
|
558
588
|
|
|
589
|
+
notifyOps(kind: string, agentName: string, message: string, data?: Record<string, unknown>): void {
|
|
590
|
+
this.registry.notifyOpsFn?.(kind, agentName, message, data);
|
|
591
|
+
}
|
|
592
|
+
|
|
559
593
|
addMessage(
|
|
560
594
|
participant: string,
|
|
561
595
|
content: ContentBlock[],
|
|
@@ -34,7 +34,7 @@ import type {
|
|
|
34
34
|
} from './types.js';
|
|
35
35
|
import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
|
|
36
36
|
import { MountWatcher, type FsChange } from './watcher.js';
|
|
37
|
-
import { syncFromFs, materializeToFs, hashContent, DEFAULT_MAX_FILE_SIZE, type ConflictInfo } from './sync.js';
|
|
37
|
+
import { syncFromFs, materializeToFs, hashContent, isBinary, DEFAULT_MAX_FILE_SIZE, type ConflictInfo } from './sync.js';
|
|
38
38
|
|
|
39
39
|
export type {
|
|
40
40
|
WorkspaceConfig,
|
|
@@ -525,6 +525,11 @@ export class WorkspaceModule implements Module {
|
|
|
525
525
|
private config: WorkspaceConfig;
|
|
526
526
|
private mounts = new Map<string, MountState>();
|
|
527
527
|
private watchers = new Map<string, MountWatcher>();
|
|
528
|
+
/** Persisted state decoded in start(), held until mounts exist to apply it
|
|
529
|
+
* to — in the Host ordering start() runs before initStore() creates the
|
|
530
|
+
* mounts, so restoration must be second-callback-safe like watcher
|
|
531
|
+
* startup already is (issue #72). Cleared after application. */
|
|
532
|
+
private savedState: WorkspaceModuleState | null = null;
|
|
528
533
|
|
|
529
534
|
constructor(config: WorkspaceConfig) {
|
|
530
535
|
// Detect overlapping mount paths: if mount A contains mount B,
|
|
@@ -593,32 +598,47 @@ export class WorkspaceModule implements Module {
|
|
|
593
598
|
this.mounts.set(mount.name, mountState);
|
|
594
599
|
}
|
|
595
600
|
|
|
601
|
+
this.applySavedState();
|
|
596
602
|
this.ensureRunning();
|
|
597
603
|
}
|
|
598
604
|
|
|
599
605
|
async start(ctx: ModuleContext): Promise<void> {
|
|
600
606
|
this.ctx = ctx;
|
|
601
607
|
|
|
602
|
-
//
|
|
608
|
+
// Decode persisted state if restarting; application waits until mounts
|
|
609
|
+
// exist (see applySavedState) — in the Host ordering they don't yet.
|
|
603
610
|
if (ctx.isRestart) {
|
|
604
|
-
|
|
605
|
-
if (saved) {
|
|
606
|
-
for (const [name, meta] of Object.entries(saved.mounts)) {
|
|
607
|
-
const mount = this.mounts.get(name);
|
|
608
|
-
if (mount) {
|
|
609
|
-
mount.lastMaterializedSeq = meta.lastMaterializedSeq;
|
|
610
|
-
mount.lastMaterializedBranchId = meta.lastMaterializedBranchId ?? null;
|
|
611
|
-
// watcherReadyAt intentionally not restored — each session must
|
|
612
|
-
// observe its own watcher attach, otherwise a stale timestamp
|
|
613
|
-
// would hide a new-session attach failure.
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
}
|
|
611
|
+
this.savedState = ctx.getState<WorkspaceModuleState>() ?? null;
|
|
617
612
|
}
|
|
618
613
|
|
|
614
|
+
this.applySavedState();
|
|
619
615
|
this.ensureRunning();
|
|
620
616
|
}
|
|
621
617
|
|
|
618
|
+
/**
|
|
619
|
+
* Apply persisted per-mount state once both halves of the lifecycle have
|
|
620
|
+
* happened. Like watcher startup, either callback may fire second, so both
|
|
621
|
+
* start() and initStore() call this; it runs to effect exactly once — the
|
|
622
|
+
* saved payload is cleared after application so stale persisted values can
|
|
623
|
+
* never overwrite newer runtime state (e.g. a materialization that already
|
|
624
|
+
* happened this session).
|
|
625
|
+
*/
|
|
626
|
+
private applySavedState(): void {
|
|
627
|
+
if (!this.savedState || this.mounts.size === 0) return;
|
|
628
|
+
|
|
629
|
+
for (const [name, meta] of Object.entries(this.savedState.mounts)) {
|
|
630
|
+
const mount = this.mounts.get(name);
|
|
631
|
+
if (mount) {
|
|
632
|
+
mount.lastMaterializedSeq = meta.lastMaterializedSeq;
|
|
633
|
+
mount.lastMaterializedBranchId = meta.lastMaterializedBranchId ?? null;
|
|
634
|
+
// watcherReadyAt intentionally not restored — each session must
|
|
635
|
+
// observe its own watcher attach, otherwise a stale timestamp
|
|
636
|
+
// would hide a new-session attach failure.
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
this.savedState = null;
|
|
640
|
+
}
|
|
641
|
+
|
|
622
642
|
/**
|
|
623
643
|
* Start chokidar watchers and emit workspace:mounted events.
|
|
624
644
|
* Idempotent: safe to call from both start() and initStore(), fires once
|
|
@@ -725,6 +745,15 @@ export class WorkspaceModule implements Module {
|
|
|
725
745
|
this.ctx = null;
|
|
726
746
|
}
|
|
727
747
|
|
|
748
|
+
/** Mount names + modes, for peer callers (framework spill/journal paths)
|
|
749
|
+
* that need a writable mount without reaching into private state. */
|
|
750
|
+
getMounts(): Array<{ name: string; mode: 'read-write' | 'read-only' }> {
|
|
751
|
+
return [...this.mounts.values()].map((m) => ({
|
|
752
|
+
name: m.config.name,
|
|
753
|
+
mode: m.config.mode === 'read-only' ? 'read-only' : 'read-write',
|
|
754
|
+
}));
|
|
755
|
+
}
|
|
756
|
+
|
|
728
757
|
// ==========================================================================
|
|
729
758
|
// Tool Definitions
|
|
730
759
|
// ==========================================================================
|
|
@@ -1308,6 +1337,11 @@ export class WorkspaceModule implements Module {
|
|
|
1308
1337
|
if (!fileStat.isFile() || fileStat.size > maxSize) return;
|
|
1309
1338
|
|
|
1310
1339
|
const buffer = await readFile(absolutePath);
|
|
1340
|
+
// Binaries are never synced into the tree (mirrors syncFromFs): the old
|
|
1341
|
+
// utf-8 round-trip here silently replaced non-UTF-8 bytes with U+FFFD,
|
|
1342
|
+
// permanently mangling images in the blob store. Binary reads are served
|
|
1343
|
+
// straight from disk (readImageFromFilesystem / read_image fallback).
|
|
1344
|
+
if (isBinary(buffer)) return;
|
|
1311
1345
|
const content = buffer.toString('utf-8');
|
|
1312
1346
|
const blobHash = store.storeBlob(Buffer.from(content, 'utf-8'), 'text/plain');
|
|
1313
1347
|
|
|
@@ -40,7 +40,7 @@ export function hashContent(content: string | Buffer): string {
|
|
|
40
40
|
/**
|
|
41
41
|
* Check if content appears to be binary.
|
|
42
42
|
*/
|
|
43
|
-
function isBinary(buffer: Buffer): boolean {
|
|
43
|
+
export function isBinary(buffer: Buffer): boolean {
|
|
44
44
|
// Check for null bytes in first 8KB
|
|
45
45
|
const check = buffer.subarray(0, 8192);
|
|
46
46
|
for (let i = 0; i < check.length; i++) {
|
package/src/types/framework.ts
CHANGED
|
@@ -58,6 +58,16 @@ export interface CodeExecutionConfig {
|
|
|
58
58
|
* 0 disables reclaim.
|
|
59
59
|
*/
|
|
60
60
|
idleReclaimMs?: number;
|
|
61
|
+
/** Max concurrent background (daemon) scripts per agent (default 3). */
|
|
62
|
+
maxBackgroundScripts?: number;
|
|
63
|
+
/** Background script lifetime ceiling, ms (default 86_400_000 = 24h). */
|
|
64
|
+
backgroundMaxLifetimeMs?: number;
|
|
65
|
+
/** Minimum interval between wake_agent() deliveries, ms (default 60_000).
|
|
66
|
+
* Early wakes are delayed, not dropped (backpressure inside the script). */
|
|
67
|
+
wakeMinIntervalMs?: number;
|
|
68
|
+
/** Max wake_agent() calls per background script (default 100); further
|
|
69
|
+
* calls raise RuntimeError inside the script. */
|
|
70
|
+
maxWakesPerScript?: number;
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
export interface FrameworkConfig {
|
package/src/types/module.ts
CHANGED
|
@@ -63,6 +63,18 @@ export interface Module {
|
|
|
63
63
|
*/
|
|
64
64
|
getTools(): ToolDefinition[];
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Utilities: agent-invocable operations that deliberately do NOT get a
|
|
68
|
+
* first-class tool slot. Every tool schema taxes every inference; a
|
|
69
|
+
* capability used twice a month shouldn't. Utilities are listed, described,
|
|
70
|
+
* and invoked through the framework's single `utils` tool instead — same
|
|
71
|
+
* ToolDefinition shape, same un-prefixed names, and dispatch arrives at the
|
|
72
|
+
* SAME handleToolCall as getTools() output, so a tool migrates between
|
|
73
|
+
* surfaces by moving its definition from one list to the other and nothing
|
|
74
|
+
* else. Optional; omitting it means the module contributes no utilities.
|
|
75
|
+
*/
|
|
76
|
+
getUtilities?(): ToolDefinition[];
|
|
77
|
+
|
|
66
78
|
/**
|
|
67
79
|
* Handle a tool call.
|
|
68
80
|
* Tool name is without module prefix.
|
|
@@ -298,6 +310,18 @@ export interface ModuleContext {
|
|
|
298
310
|
* leaking listeners across teardown/recreate (e.g. session switch).
|
|
299
311
|
*/
|
|
300
312
|
onTrace(listener: TraceEventListener): () => void;
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Raise an operator-facing ops alert through the framework's ops channel
|
|
316
|
+
* (failures.log + `ops:alert` trace + the ops webhook, when configured).
|
|
317
|
+
* For module actions that change durable state the operator would otherwise
|
|
318
|
+
* discover by surprise — e.g. subscription GC closing a channel. Keep the
|
|
319
|
+
* message privacy-minimal: identifiers and thresholds, not content.
|
|
320
|
+
*
|
|
321
|
+
* Optional so modules built against this version degrade gracefully on an
|
|
322
|
+
* older framework: call as `ctx.notifyOps?.(...)`.
|
|
323
|
+
*/
|
|
324
|
+
notifyOps?(kind: string, agentName: string, message: string, data?: Record<string, unknown>): void;
|
|
301
325
|
}
|
|
302
326
|
|
|
303
327
|
/**
|