@animalabs/agent-framework 0.22.4 → 0.23.1
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/README.md +105 -3
- package/dist/src/agent.d.ts +36 -10
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +76 -32
- package/dist/src/agent.js.map +1 -1
- 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 +34 -2
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +72 -16
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +3 -2
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +47 -7
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts +12 -7
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +59 -11
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/config-checks.d.ts +2 -2
- package/dist/src/config-checks.d.ts.map +1 -1
- package/dist/src/config-checks.js +6 -2
- package/dist/src/config-checks.js.map +1 -1
- package/dist/src/framework.d.ts +744 -120
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +3546 -906
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +13 -6
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/held-writes.d.ts +16 -0
- package/dist/src/held-writes.d.ts.map +1 -0
- package/dist/src/held-writes.js +56 -0
- package/dist/src/held-writes.js.map +1 -0
- package/dist/src/index.d.ts +7 -3
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +8 -4
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts +4 -0
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
- package/dist/src/mcpl/capability-grant.js +1 -1
- package/dist/src/mcpl/capability-grant.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +95 -18
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +578 -150
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/convert-block.d.ts +2 -2
- package/dist/src/mcpl/convert-block.d.ts.map +1 -1
- package/dist/src/mcpl/convert-block.js +6 -7
- package/dist/src/mcpl/convert-block.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +5 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -1
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/inference-router.d.ts +4 -2
- package/dist/src/mcpl/inference-router.d.ts.map +1 -1
- package/dist/src/mcpl/inference-router.js +6 -7
- package/dist/src/mcpl/inference-router.js.map +1 -1
- package/dist/src/mcpl/log-value.d.ts +7 -0
- package/dist/src/mcpl/log-value.d.ts.map +1 -1
- package/dist/src/mcpl/log-value.js +9 -0
- package/dist/src/mcpl/log-value.js.map +1 -1
- package/dist/src/mcpl/modern-connection.d.ts +227 -0
- package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
- package/dist/src/mcpl/modern-connection.js +1088 -0
- package/dist/src/mcpl/modern-connection.js.map +1 -0
- package/dist/src/mcpl/prefix-rule.d.ts +18 -0
- package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
- package/dist/src/mcpl/prefix-rule.js +25 -0
- package/dist/src/mcpl/prefix-rule.js.map +1 -0
- package/dist/src/mcpl/protocol-family.d.ts +179 -0
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
- package/dist/src/mcpl/protocol-family.js +697 -0
- package/dist/src/mcpl/protocol-family.js.map +1 -0
- package/dist/src/mcpl/push-coalescer.d.ts +38 -19
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +52 -23
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +243 -76
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +63 -18
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +117 -32
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +326 -30
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +995 -212
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts +25 -5
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +51 -12
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
- package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
- package/dist/src/mcpl/tool-result-normalize.js +232 -0
- package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +56 -13
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +206 -70
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +66 -20
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/mcpl/visible-content.d.ts +2 -1
- package/dist/src/mcpl/visible-content.d.ts.map +1 -1
- package/dist/src/mcpl/visible-content.js +2 -1
- package/dist/src/mcpl/visible-content.js.map +1 -1
- package/dist/src/module-registry.d.ts +16 -6
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +65 -6
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/api/index.d.ts +1 -1
- package/dist/src/modules/api/index.d.ts.map +1 -1
- package/dist/src/modules/api/index.js +3 -2
- package/dist/src/modules/api/index.js.map +1 -1
- package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
- package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
- package/dist/src/modules/discord/discord-js-client.js +268 -65
- package/dist/src/modules/discord/discord-js-client.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts +27 -5
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +366 -76
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/discord/refusal.d.ts +14 -0
- package/dist/src/modules/discord/refusal.d.ts.map +1 -0
- package/dist/src/modules/discord/refusal.js +14 -0
- package/dist/src/modules/discord/refusal.js.map +1 -0
- package/dist/src/modules/discord/types.d.ts +17 -7
- package/dist/src/modules/discord/types.d.ts.map +1 -1
- package/dist/src/modules/health/index.d.ts.map +1 -1
- package/dist/src/modules/health/index.js +6 -2
- package/dist/src/modules/health/index.js.map +1 -1
- package/dist/src/modules/index.d.ts +1 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.js +45 -5
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
- package/dist/src/modules/workspace/effects.d.ts +2 -1
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +19 -9
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +214 -42
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +667 -113
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +240 -8
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +554 -24
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +10 -1
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts +42 -0
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +130 -25
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/silent-heartbeat.d.ts +112 -39
- package/dist/src/silent-heartbeat.d.ts.map +1 -1
- package/dist/src/silent-heartbeat.js +100 -29
- package/dist/src/silent-heartbeat.js.map +1 -1
- package/dist/src/tool-args.d.ts +40 -0
- package/dist/src/tool-args.d.ts.map +1 -0
- package/dist/src/tool-args.js +207 -0
- package/dist/src/tool-args.js.map +1 -0
- package/dist/src/tool-presentation.d.ts.map +1 -1
- package/dist/src/tool-presentation.js +9 -2
- package/dist/src/tool-presentation.js.map +1 -1
- package/dist/src/types/agent.d.ts +27 -17
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +8 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +17 -2
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +54 -10
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +2 -2
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +3 -4
- package/src/agent.ts +99 -36
- package/src/code-execution/index.ts +1 -0
- package/src/code-execution/py-runner.ts +104 -17
- package/src/code-execution/runtime-py.ts +47 -7
- package/src/code-execution/tool-definition.ts +65 -11
- package/src/config-checks.ts +4 -2
- package/src/framework.ts +3633 -956
- package/src/gate/event-gate.ts +14 -6
- package/src/held-writes.ts +59 -0
- package/src/index.ts +19 -4
- package/src/mcpl/capability-grant.ts +1 -1
- package/src/mcpl/channel-registry.ts +613 -160
- package/src/mcpl/convert-block.ts +15 -7
- package/src/mcpl/index.ts +14 -1
- package/src/mcpl/inference-router.ts +8 -7
- package/src/mcpl/log-value.ts +10 -0
- package/src/mcpl/modern-connection.ts +1182 -0
- package/src/mcpl/prefix-rule.ts +32 -0
- package/src/mcpl/protocol-family.ts +691 -0
- package/src/mcpl/push-coalescer.ts +67 -29
- package/src/mcpl/reference-fetcher.ts +288 -82
- package/src/mcpl/references.ts +123 -33
- package/src/mcpl/server-connection.ts +1050 -221
- package/src/mcpl/server-registry.ts +50 -12
- package/src/mcpl/tool-result-normalize.ts +294 -0
- package/src/mcpl/transport.ts +204 -62
- package/src/mcpl/types.ts +67 -20
- package/src/mcpl/visible-content.ts +2 -1
- package/src/module-registry.ts +68 -13
- package/src/modules/api/index.ts +3 -2
- package/src/modules/discord/discord-js-client.ts +288 -72
- package/src/modules/discord/index.ts +380 -77
- package/src/modules/discord/refusal.ts +13 -0
- package/src/modules/discord/types.ts +17 -9
- package/src/modules/health/index.ts +6 -2
- package/src/modules/index.ts +1 -1
- package/src/modules/workspace/disk-agreement.ts +46 -5
- package/src/modules/workspace/effects.ts +18 -7
- package/src/modules/workspace/index.ts +722 -124
- package/src/modules/workspace/observe.ts +591 -22
- package/src/modules/workspace/types.ts +10 -1
- package/src/modules/workspace/watcher.ts +126 -25
- package/src/silent-heartbeat.ts +184 -59
- package/src/tool-args.ts +213 -0
- package/src/tool-presentation.ts +9 -2
- package/src/types/agent.ts +27 -17
- package/src/types/events.ts +8 -0
- package/src/types/framework.ts +17 -2
- package/src/types/module.ts +55 -10
- package/src/types/trace.ts +2 -2
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { McplServerConfig, McplHostCapabilities } from './types.js';
|
|
8
|
-
import { McplServerConnection } from './server-connection.js';
|
|
8
|
+
import { McplServerConnection, McplUnreapedLaunchError } from './server-connection.js';
|
|
9
9
|
import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, waitForShutdown } from '../shutdown.js';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -26,32 +26,65 @@ export type McplCapabilityQuery =
|
|
|
26
26
|
*/
|
|
27
27
|
export class McplServerRegistry {
|
|
28
28
|
private servers = new Map<string, McplServerConnection>();
|
|
29
|
+
/** Ids whose addServer is still connecting. A second add for one is
|
|
30
|
+
* refused, so a failed launch's closed connection can always be registered
|
|
31
|
+
* as the owner of a child it couldn't reap. */
|
|
32
|
+
private adding = new Set<string>();
|
|
29
33
|
private closing = new Map<McplServerConnection, Promise<void>>();
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
36
|
* Connect to an MCPL server and register it.
|
|
33
37
|
*
|
|
34
38
|
* Throws if a server with the same id is already registered, or if
|
|
35
|
-
* the connection/handshake fails.
|
|
39
|
+
* the connection/handshake fails. A failed launch whose child couldn't be
|
|
40
|
+
* confirmed exited stays registered all the same, as the closed connection
|
|
41
|
+
* that owns it ({@link McplUnreapedLaunchError}), so the id stays held
|
|
42
|
+
* and removeServer can retry the reap.
|
|
43
|
+
*
|
|
44
|
+
* `onRegister` runs with the connection the connect resolved with, a
|
|
45
|
+
* reconnecting stub's included, synchronously just before it is
|
|
46
|
+
* registered, so what it records about the connection is in place before
|
|
47
|
+
* anything that reads the registry can see it. A failed launch registered
|
|
48
|
+
* as its child's owner doesn't run it. It must not throw: it runs before
|
|
49
|
+
* the registry owns the connection, so nothing would close it.
|
|
36
50
|
*/
|
|
37
51
|
async addServer(
|
|
38
52
|
config: McplServerConfig,
|
|
39
53
|
hostCapabilities: McplHostCapabilities,
|
|
54
|
+
signal?: AbortSignal,
|
|
55
|
+
onRegister?: (connection: McplServerConnection) => void,
|
|
40
56
|
): Promise<McplServerConnection> {
|
|
41
|
-
if (this.servers.has(config.id)) {
|
|
57
|
+
if (this.servers.has(config.id) || this.adding.has(config.id)) {
|
|
42
58
|
throw new Error(`MCPL server "${config.id}" is already registered`);
|
|
43
59
|
}
|
|
44
60
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
61
|
+
this.adding.add(config.id);
|
|
62
|
+
let connection: McplServerConnection;
|
|
63
|
+
try {
|
|
64
|
+
connection = config.reconnect === true
|
|
65
|
+
? await McplServerConnection.connectWithReconnect(config, hostCapabilities, signal)
|
|
66
|
+
: await McplServerConnection.connect(config, hostCapabilities, signal);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error instanceof McplUnreapedLaunchError) this.register(config, error.connection);
|
|
69
|
+
throw error;
|
|
70
|
+
} finally {
|
|
71
|
+
this.adding.delete(config.id);
|
|
72
|
+
}
|
|
73
|
+
onRegister?.(connection);
|
|
74
|
+
this.register(config, connection);
|
|
75
|
+
return connection;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private register(config: McplServerConfig, connection: McplServerConnection): void {
|
|
48
79
|
this.servers.set(config.id, connection);
|
|
49
80
|
|
|
50
81
|
// Auto-remove on close unless the connection will reconnect. That was
|
|
51
82
|
// decided once, from `reconnect === true` at connect, and a close() the
|
|
52
83
|
// host asks for disarms it first, so a server closed for good goes too.
|
|
84
|
+
// A connection that still owns a child it couldn't reap keeps its id, so
|
|
85
|
+
// nothing launches beside that child; removeServer retries the reap.
|
|
53
86
|
connection.on('close', () => {
|
|
54
|
-
if (!connection.willReconnect) {
|
|
87
|
+
if (!connection.willReconnect && this.servers.get(config.id) === connection && !connection.holdsUnreapedChild) {
|
|
55
88
|
this.servers.delete(config.id);
|
|
56
89
|
}
|
|
57
90
|
});
|
|
@@ -60,20 +93,22 @@ export class McplServerRegistry {
|
|
|
60
93
|
connection.on('reconnect', () => {
|
|
61
94
|
// Connection already in the map — capabilities may have changed
|
|
62
95
|
});
|
|
63
|
-
|
|
64
|
-
return connection;
|
|
65
96
|
}
|
|
66
97
|
|
|
67
98
|
/**
|
|
68
99
|
* Disconnect and remove a server by id.
|
|
69
100
|
*
|
|
70
|
-
* No-op if the server is not registered.
|
|
101
|
+
* No-op if the server is not registered. If its close can't confirm the
|
|
102
|
+
* child exited, it stays registered and the failure is thrown: removing it
|
|
103
|
+
* again retries the reap.
|
|
71
104
|
*/
|
|
72
105
|
async removeServer(id: string): Promise<void> {
|
|
73
106
|
const connection = this.servers.get(id);
|
|
74
107
|
if (!connection) {
|
|
75
108
|
return;
|
|
76
109
|
}
|
|
110
|
+
// The id stays held until the close settles, so nothing launches beside
|
|
111
|
+
// a child still being reaped; it's freed only once the exit is confirmed.
|
|
77
112
|
await this.closeServer(id, connection);
|
|
78
113
|
}
|
|
79
114
|
|
|
@@ -138,8 +173,11 @@ export class McplServerRegistry {
|
|
|
138
173
|
}
|
|
139
174
|
|
|
140
175
|
/**
|
|
141
|
-
* Close
|
|
142
|
-
*
|
|
176
|
+
* Close every server connection. Each one whose close confirms its exit
|
|
177
|
+
* is removed. One whose close fails or is still pending stays registered,
|
|
178
|
+
* holding its id, and every failure is thrown; removeServer or a later
|
|
179
|
+
* closeAll retries it. An explicit timeout bounds the caller's wait
|
|
180
|
+
* without cancelling cleanup.
|
|
143
181
|
*/
|
|
144
182
|
async closeAll(timeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS): Promise<void> {
|
|
145
183
|
validateShutdownTimeout(timeoutMs);
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a standard MCP tool result becomes for the framework. Standard means
|
|
3
|
+
* the server did not negotiate MCPL: a legacy MCP-only peer or a modern
|
|
4
|
+
* server. MCPL peers keep their RFC-005 reading (flat `resource` and
|
|
5
|
+
* uri-form media as references), which is not this module's job.
|
|
6
|
+
*
|
|
7
|
+
* An MCP result has two views, and each consumer gets its own:
|
|
8
|
+
* - **The content array is the server's model-facing view.** Text stays text,
|
|
9
|
+
* and an inline image stays native when a provider can take it: one of the
|
|
10
|
+
* types providers accept, within their per-image size. Anything else the
|
|
11
|
+
* provider format can't carry (audio, binary blobs, other images) is saved
|
|
12
|
+
* to the workspace, and a bounded stub says where it went. A stub tells the
|
|
13
|
+
* reader where the payload is; nothing is dropped silently. If a payload
|
|
14
|
+
* can't be saved, the result is a failure to materialize it, not a
|
|
15
|
+
* success. A `resource_link` is shown as a link, with its name and URI,
|
|
16
|
+
* and never fetched. An embedded resource shows its text, or is saved like
|
|
17
|
+
* a blob.
|
|
18
|
+
* - **`structuredContent` is the machine-readable view.** It is kept by
|
|
19
|
+
* presence on `ToolResult.structured`. The model sees it rendered as JSON
|
|
20
|
+
* only when the server's content has no text block of its own, because
|
|
21
|
+
* otherwise the server already chose what the model reads.
|
|
22
|
+
*
|
|
23
|
+
* A failed result is read by the model only as its error text, so there an
|
|
24
|
+
* inline image is saved and stubbed too, like any other payload.
|
|
25
|
+
*
|
|
26
|
+
* This is the model's reading. The direct path returns the server's raw
|
|
27
|
+
* content instead. A script receives the server's result whole only when it
|
|
28
|
+
* carries structuredContent; an unstructured one reaches a script as this
|
|
29
|
+
* reading, its payloads saved to the workspace and stubbed (framework.ts).
|
|
30
|
+
*/
|
|
31
|
+
import type { ToolResult } from '../types/index.js';
|
|
32
|
+
|
|
33
|
+
/** What a result whose content is present but isn't a list of blocks reads
|
|
34
|
+
* as, on the model's path and a script's alike. */
|
|
35
|
+
export const MALFORMED_CONTENT_NOTICE =
|
|
36
|
+
'[malformed result: its content is not a list of blocks; the tool may already have completed]';
|
|
37
|
+
|
|
38
|
+
/** Saves a payload under a host-chosen name; resolves to its workspace path,
|
|
39
|
+
* or null when there is nowhere writable to put it. */
|
|
40
|
+
export type SaveToolPayload = (fileName: string, bytes: Buffer, mimeType: string) => Promise<string | null>;
|
|
41
|
+
|
|
42
|
+
/** Stub fields are bounded independently of anything the server sends. */
|
|
43
|
+
const STUB_FIELD_POINTS = 200;
|
|
44
|
+
/** A URI is the reference itself, so it's bounded far more loosely than a
|
|
45
|
+
* stub field: a real one survives, and a pathological one can't flood the
|
|
46
|
+
* model's view. */
|
|
47
|
+
const URI_POINTS = 2048;
|
|
48
|
+
/** The image types providers take inline. Any other image is saved. */
|
|
49
|
+
const NATIVE_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
|
50
|
+
/** The decoded size providers take per inline image: the same 5 MB ceiling
|
|
51
|
+
* the framework's read_image tool guards. A larger image is saved. */
|
|
52
|
+
const NATIVE_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
|
53
|
+
|
|
54
|
+
/** Why a payload isn't shown inline, unless said otherwise. */
|
|
55
|
+
const UNSUPPORTED = "the model can't take this type";
|
|
56
|
+
|
|
57
|
+
type Block = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string };
|
|
58
|
+
|
|
59
|
+
/** The first `max` code points of `text`, never splitting a surrogate pair,
|
|
60
|
+
* and how many code points were left out. */
|
|
61
|
+
function headPoints(text: string, max: number): { head: string; omitted: number } {
|
|
62
|
+
let end = 0;
|
|
63
|
+
let points = 0;
|
|
64
|
+
while (end < text.length && points < max) {
|
|
65
|
+
end += text.codePointAt(end)! > 0xffff ? 2 : 1;
|
|
66
|
+
points++;
|
|
67
|
+
}
|
|
68
|
+
let omitted = 0;
|
|
69
|
+
for (let i = end; i < text.length; omitted++) i += text.codePointAt(i)! > 0xffff ? 2 : 1;
|
|
70
|
+
return { head: text.slice(0, end), omitted };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Any value as JSON (a string quoted too). Never throws. */
|
|
74
|
+
function asJson(value: unknown): string {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.stringify(value) ?? 'null';
|
|
77
|
+
} catch {
|
|
78
|
+
return Object.prototype.toString.call(value);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Any value as text: a string as itself, anything else as JSON. Never throws. */
|
|
83
|
+
function asText(value: unknown): string {
|
|
84
|
+
return typeof value === 'string' ? value : asJson(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function bounded(value: unknown, max = STUB_FIELD_POINTS): string {
|
|
88
|
+
const { head, omitted } = headPoints(asText(value), max);
|
|
89
|
+
return omitted > 0 ? `${head}…` : head;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A payload's decoded size, its base64 read without the whitespace a
|
|
93
|
+
* wrapped encoding carries. */
|
|
94
|
+
function decodedSize(base64: string): number {
|
|
95
|
+
const compact = base64.replace(/\s+/g, '');
|
|
96
|
+
const padding = compact.endsWith('==') ? 2 : compact.endsWith('=') ? 1 : 0;
|
|
97
|
+
return Math.max(0, Math.floor((compact.length * 3) / 4) - padding);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function humanSize(bytes: number): string {
|
|
101
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
102
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
103
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const KNOWN_EXTENSIONS: Record<string, string> = { 'octet-stream': 'bin', 'mpeg': 'mp3', 'plain': 'txt', 'x-wav': 'wav' };
|
|
107
|
+
|
|
108
|
+
function extensionFor(mimeType: string): string {
|
|
109
|
+
const subtype = mimeType.split('/')[1]?.split(';')[0]?.trim().toLowerCase() ?? '';
|
|
110
|
+
// The table's own keys only: a subtype such as 'constructor' must not find
|
|
111
|
+
// what Object.prototype holds.
|
|
112
|
+
const ext = Object.hasOwn(KNOWN_EXTENSIONS, subtype) ? KNOWN_EXTENSIONS[subtype]! : subtype.replace(/[^a-z0-9]/g, '');
|
|
113
|
+
return ext.length > 0 && ext.length <= 10 ? ext : 'bin';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The image type a provider takes inline, in its canonical spelling, or
|
|
117
|
+
* null when it takes none of these. */
|
|
118
|
+
function nativeImageType(mimeType: unknown): string | null {
|
|
119
|
+
if (typeof mimeType !== 'string') return null;
|
|
120
|
+
const base = mimeType.split(';')[0]!.trim().toLowerCase();
|
|
121
|
+
return NATIVE_IMAGE_TYPES.has(base) ? base : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface StandardToolResult {
|
|
125
|
+
content?: unknown;
|
|
126
|
+
isError?: boolean;
|
|
127
|
+
structuredContent?: unknown;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Normalize a standard MCP tool result into the framework's `ToolResult`.
|
|
132
|
+
* `label` names saved payloads (one per call; payloads get an index suffix).
|
|
133
|
+
* A payload that can't be retained (no `save`, or saving failed) makes the
|
|
134
|
+
* result a failure to materialize, never a success with part of it lost.
|
|
135
|
+
* Content that is present but isn't a list of blocks makes it a malformed
|
|
136
|
+
* result, never an empty success. A tool's error result with no words, or
|
|
137
|
+
* only whitespace, reads as `noWords`, which the framework words for its
|
|
138
|
+
* server and tool.
|
|
139
|
+
*/
|
|
140
|
+
export async function normalizeStandardToolResult(
|
|
141
|
+
result: StandardToolResult,
|
|
142
|
+
label: string,
|
|
143
|
+
save: SaveToolPayload | null,
|
|
144
|
+
noWords = 'Tool call failed',
|
|
145
|
+
): Promise<ToolResult> {
|
|
146
|
+
const malformed = result.content !== undefined && !Array.isArray(result.content);
|
|
147
|
+
const raw: unknown[] = Array.isArray(result.content) ? result.content : [];
|
|
148
|
+
const blocks: Block[] = [];
|
|
149
|
+
let saved = 0;
|
|
150
|
+
/** Payloads that could be neither shown nor retained. */
|
|
151
|
+
let lost = 0;
|
|
152
|
+
|
|
153
|
+
const payloadStub = async (
|
|
154
|
+
kind: string,
|
|
155
|
+
base64: string,
|
|
156
|
+
mimeType: string,
|
|
157
|
+
opts: { origin?: string; why?: string } = {},
|
|
158
|
+
): Promise<string> => {
|
|
159
|
+
const why = opts.why ?? UNSUPPORTED;
|
|
160
|
+
const size = humanSize(decodedSize(base64));
|
|
161
|
+
const what = `[${kind}${opts.origin ? ` ${bounded(opts.origin, URI_POINTS)}` : ''}: ${bounded(mimeType)}, ${size}`;
|
|
162
|
+
if (!save) {
|
|
163
|
+
lost++;
|
|
164
|
+
return `${what}. Not retained: ${why}, and no workspace is mounted to save it]`;
|
|
165
|
+
}
|
|
166
|
+
const fileName = `${label}-${++saved}.${extensionFor(mimeType)}`;
|
|
167
|
+
const path = await save(fileName, Buffer.from(base64, 'base64'), mimeType).catch((error: unknown) => {
|
|
168
|
+
// The stub says only that saving failed; why goes to the host's log.
|
|
169
|
+
console.error(`[mcp] saving tool payload ${fileName} (${kind}, ${JSON.stringify(bounded(mimeType))}, ${size}) failed:`, error);
|
|
170
|
+
return null;
|
|
171
|
+
});
|
|
172
|
+
if (path) return `${what}, saved to workspace file ${path}${why === UNSUPPORTED ? '' : `; not shown inline: ${why}`}]`;
|
|
173
|
+
lost++;
|
|
174
|
+
return `${what}. Not retained: ${why}, and saving it to the workspace failed]`;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
for (const entry of raw) {
|
|
178
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
179
|
+
blocks.push({ type: 'text', text: `[malformed content entry, left out: ${bounded(entry)}]` });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const block = entry as Record<string, unknown>;
|
|
183
|
+
switch (block.type) {
|
|
184
|
+
case 'text':
|
|
185
|
+
blocks.push(typeof block.text === 'string'
|
|
186
|
+
? { type: 'text', text: block.text }
|
|
187
|
+
: { type: 'text', text: '[malformed text block, left out: its text is not a string]' });
|
|
188
|
+
break;
|
|
189
|
+
case 'image':
|
|
190
|
+
case 'audio': {
|
|
191
|
+
const kind = block.type;
|
|
192
|
+
if (typeof block.data !== 'string') {
|
|
193
|
+
blocks.push({ type: 'text', text: `[${kind} without inline data, not fetched${typeof block.uri === 'string' ? `: ${bounded(block.uri, URI_POINTS)}` : ''}]` });
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
// Inline data with no type is still a payload, of no known type.
|
|
197
|
+
const mimeType = typeof block.mimeType === 'string' ? block.mimeType : 'application/octet-stream';
|
|
198
|
+
const native = kind === 'image' ? nativeImageType(block.mimeType) : null;
|
|
199
|
+
if (native && decodedSize(block.data) <= NATIVE_IMAGE_MAX_BYTES) {
|
|
200
|
+
// Passed on without a wrapped encoding's whitespace, which a
|
|
201
|
+
// provider may refuse, and with it the whole request.
|
|
202
|
+
blocks.push({ type: 'image', data: block.data.replace(/\s+/g, ''), mimeType: native });
|
|
203
|
+
} else {
|
|
204
|
+
const why = native ? `over the ${humanSize(NATIVE_IMAGE_MAX_BYTES)} a model takes per image` : UNSUPPORTED;
|
|
205
|
+
blocks.push({ type: 'text', text: await payloadStub(kind, block.data, mimeType, { why }) });
|
|
206
|
+
}
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
case 'resource_link': {
|
|
210
|
+
const name = typeof block.name === 'string' ? block.name : undefined;
|
|
211
|
+
const mime = typeof block.mimeType === 'string' ? `, ${bounded(block.mimeType)}` : '';
|
|
212
|
+
blocks.push({ type: 'text', text: `[resource link${name ? ` "${bounded(name)}"` : ''}: ${bounded(block.uri, URI_POINTS)}${mime}; not fetched]` });
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
case 'resource': {
|
|
216
|
+
const resource = (block.resource && typeof block.resource === 'object' ? block.resource : {}) as Record<string, unknown>;
|
|
217
|
+
const uri = typeof resource.uri === 'string' ? resource.uri : undefined;
|
|
218
|
+
const mime = typeof resource.mimeType === 'string' ? resource.mimeType : undefined;
|
|
219
|
+
if (typeof resource.text === 'string') {
|
|
220
|
+
blocks.push({ type: 'text', text: `[resource ${uri === undefined ? '(no uri)' : bounded(uri, URI_POINTS)}${mime ? `, ${bounded(mime)}` : ''}]\n${resource.text}` });
|
|
221
|
+
} else if (typeof resource.blob === 'string') {
|
|
222
|
+
blocks.push({ type: 'text', text: await payloadStub('resource', resource.blob, mime ?? 'application/octet-stream', { origin: uri }) });
|
|
223
|
+
} else {
|
|
224
|
+
blocks.push({ type: 'text', text: `[resource ${uri === undefined ? '(no uri)' : bounded(uri, URI_POINTS)} with no contents]` });
|
|
225
|
+
}
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
default: {
|
|
229
|
+
// Unknown to this client: shown as data rather than dropped, bounded
|
|
230
|
+
// like a stub field, so a large or binary payload can't flood the
|
|
231
|
+
// model's view.
|
|
232
|
+
const { head, omitted } = headPoints(asText(entry), STUB_FIELD_POINTS);
|
|
233
|
+
blocks.push({ type: 'text', text: omitted > 0 ? `${head}… [${omitted} more characters of this block left out]` : head });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const failing = !!result.isError || lost > 0 || malformed;
|
|
239
|
+
if (failing) {
|
|
240
|
+
// A failed result is read only as its text: an inline image becomes a
|
|
241
|
+
// payload like any other, saved and named, or marked not retained.
|
|
242
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
243
|
+
const block = blocks[i]!;
|
|
244
|
+
if (block.type === 'image') {
|
|
245
|
+
blocks[i] = { type: 'text', text: await payloadStub('image', block.data, block.mimeType, { why: 'a failed result is read as its text alone' }) };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const hasStructured = Object.prototype.hasOwnProperty.call(result, 'structuredContent');
|
|
251
|
+
// Only the server's own text blocks count as its presentation for the
|
|
252
|
+
// model. Stubs this module wrote are notices, not the server's text.
|
|
253
|
+
const serverText = raw.some((entry) => {
|
|
254
|
+
const block = entry as { type?: unknown; text?: unknown } | null;
|
|
255
|
+
return !!block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string' && block.text.trim().length > 0;
|
|
256
|
+
});
|
|
257
|
+
if (hasStructured && !serverText) {
|
|
258
|
+
blocks.push({ type: 'text', text: asJson(result.structuredContent) });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const text = blocks.filter((b): b is Extract<Block, { type: 'text' }> => b.type === 'text').map((b) => b.text).join('\n');
|
|
262
|
+
const structured = hasStructured ? { structured: result.structuredContent } : {};
|
|
263
|
+
if (result.isError) {
|
|
264
|
+
return { success: false, error: text.trim() ? text : noWords, isError: true, ...structured };
|
|
265
|
+
}
|
|
266
|
+
if (malformed) {
|
|
267
|
+
return {
|
|
268
|
+
success: false,
|
|
269
|
+
error: `${MALFORMED_CONTENT_NOTICE}${text ? `\n${text}` : ''}`,
|
|
270
|
+
isError: true,
|
|
271
|
+
...structured,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
if (lost > 0) {
|
|
275
|
+
// The server answered, but part of its answer is gone: report that as a
|
|
276
|
+
// failure to materialize the result, never as a success.
|
|
277
|
+
return {
|
|
278
|
+
success: false,
|
|
279
|
+
error: `[result incomplete: ${lost} payload(s) could not be retained, so this result is not complete; ` +
|
|
280
|
+
`the tool may already have completed]\n${text}`,
|
|
281
|
+
isError: true,
|
|
282
|
+
...structured,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
const hasImage = blocks.some((b) => b.type === 'image');
|
|
286
|
+
return {
|
|
287
|
+
success: true,
|
|
288
|
+
// Text-only results keep the joined-string convention callers expect.
|
|
289
|
+
// An image keeps the array, so the model sees it natively.
|
|
290
|
+
data: hasImage ? blocks : (text || undefined),
|
|
291
|
+
isError: false,
|
|
292
|
+
...structured,
|
|
293
|
+
};
|
|
294
|
+
}
|