@omercnet/paseo-omp 0.2.1-next.90.1 → 0.3.0-next.100.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/CHANGELOG.md +26 -0
- package/README.md +2 -2
- package/SUPPORT.md +2 -2
- package/TESTING.md +4 -4
- package/docs/installation.md +1 -1
- package/package.json +1 -1
- package/server/provider/connection.ts +35 -8
- package/server/provider/host-tools.ts +223 -26
- package/server/provider/omp-rpc.ts +417 -58
- package/server/provider/security.ts +8 -10
- package/server/provider/subsessions.ts +21 -1
- package/server/provider/timeline-projector.ts +34 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.0](https://github.com/omercnet/paseo-plugins/compare/paseo-omp-v0.2.1...paseo-omp-v0.3.0) (2026-09-18)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **paseo-omp:** add first-class MCP management ([#64](https://github.com/omercnet/paseo-plugins/issues/64)) ([5a8fefa](https://github.com/omercnet/paseo-plugins/commit/5a8fefa9148e8d01f0461aa4a90619ec6ae2c2fa))
|
|
9
|
+
* **paseo-omp:** add workspace-scoped OMP management ([#59](https://github.com/omercnet/paseo-plugins/issues/59)) ([2807aec](https://github.com/omercnet/paseo-plugins/commit/2807aec6217ed2d52f8b4666f0a41a688109f8f5))
|
|
10
|
+
* **paseo-omp:** make composer pills configurable ([#76](https://github.com/omercnet/paseo-plugins/issues/76)) ([28e7501](https://github.com/omercnet/paseo-plugins/commit/28e75011a06a11cdc963187aa4106dfc21d561e6))
|
|
11
|
+
* **release:** publish plugins to npm ([#80](https://github.com/omercnet/paseo-plugins/issues/80)) ([3c93048](https://github.com/omercnet/paseo-plugins/commit/3c93048cfefda97d8c2bc1631e3428fb64bdad09))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
* **paseo-omp:** advertise nested subsession capability ([#62](https://github.com/omercnet/paseo-plugins/issues/62)) ([fcb4e82](https://github.com/omercnet/paseo-plugins/commit/fcb4e8243e9559d70f26ac6945e2f3c0bf05d575))
|
|
17
|
+
* **paseo-omp:** bound model catalogs and retain safe failure diagnostics ([#73](https://github.com/omercnet/paseo-plugins/issues/73)) ([e70afb7](https://github.com/omercnet/paseo-plugins/commit/e70afb72be782446e7ff585bad40940c7231bdc5))
|
|
18
|
+
* **paseo-omp:** correlate terminal events by request ([#81](https://github.com/omercnet/paseo-plugins/issues/81)) ([41f756e](https://github.com/omercnet/paseo-plugins/commit/41f756e9bebf5cbc275e5cbd3657c37d45a18997))
|
|
19
|
+
* **paseo-omp:** fail closed on unowned later-turn terminal events ([#69](https://github.com/omercnet/paseo-plugins/issues/69)) ([072cd09](https://github.com/omercnet/paseo-plugins/commit/072cd09e38e450376bcd3f4c7b5eae9a670b56b5))
|
|
20
|
+
* **paseo-omp:** preserve prompt scheduling error fidelity ([#67](https://github.com/omercnet/paseo-plugins/issues/67)) ([1c6d549](https://github.com/omercnet/paseo-plugins/commit/1c6d549fddca48be8fd86b4dd2c5faea526c8ac0))
|
|
21
|
+
* **paseo-omp:** reconcile incomplete terminal outcomes ([#68](https://github.com/omercnet/paseo-plugins/issues/68)) ([854f4e6](https://github.com/omercnet/paseo-plugins/commit/854f4e6daddf905c671bb39a94d06a2de0c6d51d))
|
|
22
|
+
* **paseo-omp:** replay failed turns from native transcript ([#79](https://github.com/omercnet/paseo-plugins/issues/79)) ([4a13b77](https://github.com/omercnet/paseo-plugins/commit/4a13b778a9ba413b61fe0cc85878d3c333c161d2))
|
|
23
|
+
* **paseo-omp:** scope providers and auxiliary state to named profiles ([#74](https://github.com/omercnet/paseo-plugins/issues/74)) ([b265ad3](https://github.com/omercnet/paseo-plugins/commit/b265ad317a47230ec76dba799197443664492729))
|
|
24
|
+
* **paseo-omp:** settle concurrent startup races ([#70](https://github.com/omercnet/paseo-plugins/issues/70)) ([9d4e6ef](https://github.com/omercnet/paseo-plugins/commit/9d4e6efb26041f9c88ab4ed6c02a9526520b83e9))
|
|
25
|
+
* **paseo-omp:** show immutable approval mode ([#66](https://github.com/omercnet/paseo-plugins/issues/66)) ([4c586dc](https://github.com/omercnet/paseo-plugins/commit/4c586dc7d012127ee7570077a0d12cc0d6415bd4))
|
|
26
|
+
* **paseo-omp:** support Paseo 0.9 beta ([#103](https://github.com/omercnet/paseo-plugins/issues/103)) ([0444d52](https://github.com/omercnet/paseo-plugins/commit/0444d52078a52befa62bb52f44545f22ab10e8c7))
|
|
27
|
+
* **paseo-omp:** support unkeyed terminal events ([#89](https://github.com/omercnet/paseo-plugins/issues/89)) ([a00a831](https://github.com/omercnet/paseo-plugins/commit/a00a83158993fdf648fc10e06f1d8f8ebc62fa6a))
|
|
28
|
+
|
|
3
29
|
## [0.2.1](https://github.com/omercnet/paseo-plugins/compare/paseo-omp-v0.2.0...paseo-omp-v0.2.1) (2026-09-15)
|
|
4
30
|
|
|
5
31
|
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Community OMP integration for Paseo. The plugin registers the distinct `omp-plug
|
|
|
6
6
|
|
|
7
7
|
## Quick start
|
|
8
8
|
|
|
9
|
-
Requirements: Paseo `>=0.8.0 <0.10.0`, OMP `18.1.15` or newer, and OMP RPC protocol v2. Paseo `0.9.0-beta.1` is recommended for npm-managed installation and the complete client integration.
|
|
9
|
+
Requirements: Paseo `>=0.8.0 <0.10.0`, OMP `18.1.15` or newer, and OMP RPC protocol v2. Paseo `0.9.0-beta.1` or newer is required for nested provider-subagent ancestry and spawning-tool links, and is recommended for npm-managed installation and the complete client integration.
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
paseo plugin install npm:@omercnet/paseo-omp@<version>
|
|
@@ -99,7 +99,7 @@ Existing agents whose provider is `omp` remain owned by the bundled provider. Ne
|
|
|
99
99
|
|
|
100
100
|
OMP `18.1.15` is the oldest version tested end to end. The direct provider's hard compatibility gate is `rpc-ui` protocol v2: metadata-free legacy ready frames and v1-only runtimes are rejected before a provider session opens because they cannot support the advertised persistence and conversation-rewind capabilities. Typed tool approvals remain capability-gated and fall back as described above. For terminal completion, a matching `agent_end.requestId` is authoritative and mismatches are ignored before state changes. Released OMP 18.2.x builds that omit that field use an ordered fallback requiring a fresh correlated native user entry followed by current-turn assistant activity, an idle non-compacting runtime, and no conflicting permission, tool, steer, or child-session work. Ambiguous events while OMP is active are ignored; unresolved ambiguity after confirmed idle fails only the Paseo turn. This trades a bounded residual same-agent stale-event risk for compatibility with released OMP instead of terminating and lazily restarting the runtime after every later prompt. Paseo clients, including mobile clients inside reconnect grace, do not own or terminate the daemon-managed OMP session.
|
|
101
101
|
|
|
102
|
-
Paseo 0.9.0-beta.1 is the canary target and provides npm-managed plugin sources, platform-owned external URL opening, and whole-item timeline transforms before Overview grouping. Paseo 0.8.x remains supported for provider
|
|
102
|
+
Paseo 0.9.0-beta.1 is the verified canary target and the first release that preserves nested provider-subagent ancestry and spawning-tool links. The required full Docker canary passes there with OMP 18.1.15 and 18.2.0. Paseo 0.9 also provides npm-managed plugin sources, platform-owned external URL opening, and whole-item timeline transforms before Overview grouping. Paseo 0.8.x remains supported for other provider operations and Git or directory installation; its clients do not provide the external URL opener, retain their older timeline presentation semantics, and flatten nested provider subagents.
|
|
103
103
|
|
|
104
104
|
Install, update, disable, or remove `paseo-omp` independently of the bundled provider. Verify the provider snapshot contains `omp-plugin` after installation; a bundled `omp` entry may remain present and is not modified by this plugin.
|
|
105
105
|
|
package/SUPPORT.md
CHANGED
|
@@ -16,8 +16,8 @@ Do not put credentials, private repository paths, session transcripts, or unreda
|
|
|
16
16
|
|
|
17
17
|
## Supported versions
|
|
18
18
|
|
|
19
|
-
- Paseo: `>=0.8.0 <0.10.0` on both the daemon and every app loading the client entry. The pinned SDK and controlled canary use `0.9.0-beta.1
|
|
20
|
-
- OMP: `18.1.15` is the oldest release
|
|
19
|
+
- Paseo: `>=0.8.0 <0.10.0` on both the daemon and every app loading the client entry. The pinned SDK and controlled canary use `0.9.0-beta.1`, the first release that preserves nested provider-subagent ancestry and spawning-tool links. Paseo 0.8.x remains supported for other provider operations.
|
|
20
|
+
- OMP: `18.1.15` is the oldest supported release. The required full Docker canary passes with 18.1.15 and 18.2.0; the hard runtime contract remains `rpc-ui` protocol v2, not the version string alone.
|
|
21
21
|
- Plugin release channel: alpha. Backward compatibility is best effort until stable `0.1.0`; every known migration requirement must be stated in the release notes.
|
|
22
22
|
- Typed approvals: optional. When both peers negotiate `typedToolApprovals: 1`, the plugin uses typed tool permissions. Otherwise it retains the bounded generic extension-question flow.
|
|
23
23
|
|
package/TESTING.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# OMP provider parity audit
|
|
2
2
|
|
|
3
|
-
Validated against the Paseo plugin SDK versions pinned in `package.json` and the real OMP compatibility matrix: high-use historical releases 17.2.15, 17.3.4, 18.0.11, and 18.1.10; minimum supported release 18.1.15; latest published 18.1 patch 18.1.22; and current release 18.2.0.
|
|
3
|
+
Validated against the Paseo plugin SDK versions pinned in `package.json` and the real OMP compatibility matrix: high-use historical releases 17.2.15, 17.3.4, 18.0.11, and 18.1.10; minimum supported release 18.1.15; latest published 18.1 patch 18.1.22; and current release 18.2.0. On 2026-09-18, the full controlled canary passed against OMP 18.1.15 and 18.2.0 on official Paseo image `0.9.0-beta.1@sha256:f75a0eb3547ad3cc6bbdeaa7277d2d50eb4d06a9dd669d480371d7adf1c911b5`.
|
|
4
4
|
|
|
5
5
|
Classifications:
|
|
6
6
|
|
|
@@ -101,11 +101,11 @@ Run the full local provider integration canary with:
|
|
|
101
101
|
npm run test:integration:canary
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
-
Set `PASEO_CANARY_OMP_VERSION` to exercise another pinned release. The local runner knows the SHA-256 values for every version in the compatibility matrix; an unlisted version requires explicit `PASEO_CANARY_OMP_SHA256_AMD64` and `PASEO_CANARY_OMP_SHA256_ARM64` values. It allocates isolated loopback ports, builds a version-specific image, waits for both services to become healthy, runs the deterministic provider scenarios, prints container logs on failure, and always removes its containers, volumes, and image namespace.
|
|
104
|
+
Set `PASEO_CANARY_OMP_VERSION` to exercise another pinned release. The local runner knows the SHA-256 values for every version in the compatibility matrix; an unlisted version requires explicit `PASEO_CANARY_OMP_SHA256_AMD64` and `PASEO_CANARY_OMP_SHA256_ARM64` values. It allocates isolated loopback ports, builds a version-specific image, waits for both services to become healthy, runs the deterministic provider scenarios, prints container logs on failure, and always removes its containers, volumes, and image namespace. CI runs this full canary for OMP 18.1.15 and 18.2.0 whenever `paseo-omp` changes; both matrix entries feed the required `CI sentinel` gate and must pass before the supported OMP floor changes.
|
|
105
105
|
|
|
106
106
|
The Linux real-OMP CI matrix downloads checksummed `omp-linux-x64` assets for OMP 17.2.15, 17.3.4, 18.0.11, 18.1.10, 18.1.15, 18.1.22, and 18.2.0, verifies each binary's pinned GitHub release SHA-256, and runs `PASEO_OMP_REAL_E2E=1 PASEO_OMP_VERSION=<version> npm test -- tests/provider.real.e2e.test.ts`. The historical entries are the four pre-floor releases with more than 4,000 downloads shown by npm for the seven days ending 2026-09-15; they are compatibility regression probes, not a support commitment. The remaining entries cover the supported floor, newest patch in that minor line, and current release. The test uses the real OMP binary and a local deterministic OpenAI-compatible model endpoint, including three sequential prompts on one native session, a Bash tool, and oversized-image transport.
|
|
107
107
|
|
|
108
|
-
The controlled canary
|
|
108
|
+
The controlled canary passed end to end with OMP 18.1.15 and 18.2.0 on 2026-09-18. In addition to runtime health, catalog, prompts, tools, MCP, permissions, steering, interruption, persistence, Hub, usage, rewind, and plugin RPC coverage, it verified that a direct child has `parentSubagentId: null` and the root task's `toolCallId`, a nested child has its direct parent's public subagent ID and the nested task's `toolCallId`, both children finish as `completed`, and both timelines remain independently fetchable without cross-contamination.
|
|
109
109
|
|
|
110
110
|
## OMP RPC compatibility intake
|
|
111
111
|
|
|
@@ -145,6 +145,6 @@ The go/no-go criteria, manual acceptance boundary, and alpha limitation list are
|
|
|
145
145
|
- `npm run test:integration:install`: npm package acquisition retains required production dependencies, and a fresh Git-style checkout runs frozen production-only preparation with lifecycle scripts disabled. Both installed trees resolve runtime dependencies, compile the client and server entries with Paseo's host compiler, and load the server contribution.
|
|
146
146
|
- `npm run test:integration:docker`: verifies the host/container ownership boundary.
|
|
147
147
|
- `npm run test:integration:wsl`: locally skips when `wsl.exe` is unavailable; Windows CI sets `PASEO_OMP_REQUIRE_WSL=1`, so this boundary remains required there.
|
|
148
|
-
- `
|
|
148
|
+
- `npm run test:integration:canary`: passed against OMP 18.1.15 and 18.2.0 on official Paseo `0.9.0-beta.1`; direct and nested ancestry, spawning-tool links, terminal status, and independently addressable child timelines passed.
|
|
149
149
|
- `zizmor .github/workflows`: no findings (offline audit; six repository-wide suppressions remain).
|
|
150
150
|
- Release Please 17.1.2 `config.json` and `manifest.json` schema validation: passed for `release-please-config.json` and `.release-please-manifest.json`.
|
package/docs/installation.md
CHANGED
|
@@ -4,7 +4,7 @@ Paseo plugins are trusted, unsandboxed code. Review this plugin and its producti
|
|
|
4
4
|
|
|
5
5
|
## Requirements
|
|
6
6
|
|
|
7
|
-
- Paseo daemon and apps: `>=0.8.0 <0.10.0`; `0.9.0-beta.1`
|
|
7
|
+
- Paseo daemon and apps: `>=0.8.0 <0.10.0`; use `0.9.0-beta.1` or newer for nested provider-subagent ancestry and spawning-tool links
|
|
8
8
|
- OMP: `18.1.15` or newer is the supported floor
|
|
9
9
|
- OMP RPC: protocol v2 must negotiate successfully
|
|
10
10
|
|
package/package.json
CHANGED
|
@@ -58,6 +58,7 @@ const MAX_CONNECTION_SESSIONS = 32;
|
|
|
58
58
|
const MAX_ACTIVE_OPERATIONS = 128;
|
|
59
59
|
const MAX_PROVIDER_INPUT_BYTES = 2 * 1024 * 1024;
|
|
60
60
|
const MAX_NESTED_OPTION_BYTES = 256 * 1024;
|
|
61
|
+
const MAX_ENV_ENTRIES = 256;
|
|
61
62
|
const MAX_NATIVE_SESSION_RESERVATIONS = 256;
|
|
62
63
|
|
|
63
64
|
function hasOwnEntries(value: unknown): boolean {
|
|
@@ -68,6 +69,19 @@ function hasOwnEntries(value: unknown): boolean {
|
|
|
68
69
|
return false;
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
function providerOptionsExceedPreflightLimits(value: unknown): boolean {
|
|
73
|
+
if (
|
|
74
|
+
boundedJsonBytes(value, MAX_NESTED_OPTION_BYTES, MAX_ENV_ENTRIES) === Number.POSITIVE_INFINITY
|
|
75
|
+
) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
79
|
+
const unrelatedOptions = Object.fromEntries(
|
|
80
|
+
Object.entries(value).filter(([key]) => key !== "env" && key !== "inheritEnv"),
|
|
81
|
+
);
|
|
82
|
+
return boundedJsonBytes(unrelatedOptions, MAX_NESTED_OPTION_BYTES) === Number.POSITIVE_INFINITY;
|
|
83
|
+
}
|
|
84
|
+
|
|
71
85
|
function preflightProviderInput(input: unknown): void {
|
|
72
86
|
if (!input || typeof input !== "object") throw new OmpPublicError("Invalid provider request");
|
|
73
87
|
const record = input as Record<string, unknown>;
|
|
@@ -82,7 +96,16 @@ function preflightProviderInput(input: unknown): void {
|
|
|
82
96
|
throw new OmpPublicError("Session persistence input is too large");
|
|
83
97
|
}
|
|
84
98
|
const config = record.config as Record<string, unknown> | undefined;
|
|
85
|
-
|
|
99
|
+
if (
|
|
100
|
+
(config?.env !== undefined &&
|
|
101
|
+
boundedJsonBytes(config.env, MAX_NESTED_OPTION_BYTES, MAX_ENV_ENTRIES) ===
|
|
102
|
+
Number.POSITIVE_INFINITY) ||
|
|
103
|
+
(config?.providerOptions !== undefined &&
|
|
104
|
+
providerOptionsExceedPreflightLimits(config.providerOptions))
|
|
105
|
+
) {
|
|
106
|
+
throw new OmpPublicError("Session configuration is too large");
|
|
107
|
+
}
|
|
108
|
+
for (const value of [config?.mcpServers, config?.settings]) {
|
|
86
109
|
if (
|
|
87
110
|
value !== undefined &&
|
|
88
111
|
boundedJsonBytes(value, MAX_NESTED_OPTION_BYTES) === Number.POSITIVE_INFINITY
|
|
@@ -109,13 +132,17 @@ function preflightProviderInput(input: unknown): void {
|
|
|
109
132
|
}
|
|
110
133
|
}
|
|
111
134
|
if (record.type === "catalog" || record.type === "sessions") {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
|
|
135
|
+
if (
|
|
136
|
+
record.providerOptions !== undefined &&
|
|
137
|
+
providerOptionsExceedPreflightLimits(record.providerOptions)
|
|
138
|
+
) {
|
|
139
|
+
throw new OmpPublicError("Provider configuration is too large");
|
|
140
|
+
}
|
|
141
|
+
if (
|
|
142
|
+
record.settings !== undefined &&
|
|
143
|
+
boundedJsonBytes(record.settings, MAX_NESTED_OPTION_BYTES) === Number.POSITIVE_INFINITY
|
|
144
|
+
) {
|
|
145
|
+
throw new OmpPublicError("Provider configuration is too large");
|
|
119
146
|
}
|
|
120
147
|
}
|
|
121
148
|
if (record.type === "session.prompt") {
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
ProviderMcpServerConfig,
|
|
4
4
|
ProviderSessionConfig,
|
|
5
5
|
} from "@getpaseo/plugin/server/provider";
|
|
6
|
+
import { isValidImagePayload } from "./image";
|
|
6
7
|
import {
|
|
7
8
|
type ConnectedMcpClient,
|
|
8
9
|
type ConnectedMcpTool,
|
|
@@ -37,7 +38,13 @@ const MAX_HOST_TOOL_DESCRIPTION_BYTES = 64 * 1024;
|
|
|
37
38
|
const MAX_HOST_TOOL_SCHEMA_BYTES = 256 * 1024;
|
|
38
39
|
const MAX_HOST_TOOL_CATALOG_BYTES = 768 * 1024;
|
|
39
40
|
const MAX_HOST_TOOL_RESULT_BYTES = 12 * 1024 * 1024;
|
|
41
|
+
const MAX_HOST_TOOL_CONTENT_BLOCKS = 512;
|
|
42
|
+
const MAX_HOST_TOOL_TEXT_BYTES = 1024 * 1024;
|
|
43
|
+
const MAX_HOST_TOOL_IMAGE_DATA_BYTES = 8 * 1024 * 1024;
|
|
40
44
|
const MAX_STRUCTURED_CONTENT_FALLBACK_BYTES = 1024 * 1024;
|
|
45
|
+
const CONTENT_TRUNCATED_NOTICE = "[MCP result content truncated: exceeded size limits]";
|
|
46
|
+
const DETAILS_OMITTED_NOTICE = "[MCP structured content omitted: exceeded size limits]";
|
|
47
|
+
const RESULT_TRUNCATED_NOTICE = "[MCP content truncated; details omitted]";
|
|
41
48
|
const MAX_PENDING_HOST_TOOL_CALLS = 64;
|
|
42
49
|
const MAX_PENDING_HOST_TOOL_BYTES = 8 * 1024 * 1024;
|
|
43
50
|
const DEFAULT_INITIALIZATION_TIMEOUT_MS = 20_000;
|
|
@@ -272,42 +279,171 @@ async function discoverMcpTools(
|
|
|
272
279
|
throw new OmpPublicError("MCP server tool-list pagination exceeds the supported limit");
|
|
273
280
|
}
|
|
274
281
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
282
|
+
type HostToolContentBlock = OmpHostToolResult["result"]["content"][number];
|
|
283
|
+
|
|
284
|
+
function hostToolContentIsBounded(content: readonly HostToolContentBlock[]): boolean {
|
|
285
|
+
return (
|
|
286
|
+
boundedJsonBytes(
|
|
287
|
+
content,
|
|
288
|
+
MAX_HOST_TOOL_RESULT_BYTES,
|
|
289
|
+
MAX_HOST_TOOL_CONTENT_BLOCKS,
|
|
290
|
+
MAX_HOST_TOOL_IMAGE_DATA_BYTES,
|
|
291
|
+
4_096,
|
|
292
|
+
) !== Number.POSITIVE_INFINITY
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function noticeBlock(text: string): HostToolContentBlock {
|
|
297
|
+
return { type: "text", text };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function appendBoundedNotice(
|
|
301
|
+
content: readonly HostToolContentBlock[],
|
|
302
|
+
notice: string,
|
|
303
|
+
): HostToolContentBlock[] {
|
|
304
|
+
const trailingNotice = content.at(-1)?.text;
|
|
305
|
+
const contentWasTruncated =
|
|
306
|
+
trailingNotice === CONTENT_TRUNCATED_NOTICE || trailingNotice === RESULT_TRUNCATED_NOTICE;
|
|
307
|
+
const prefix = (contentWasTruncated ? content.slice(0, -1) : content).slice(
|
|
308
|
+
0,
|
|
309
|
+
MAX_HOST_TOOL_CONTENT_BLOCKS - 1,
|
|
310
|
+
);
|
|
311
|
+
const markerText =
|
|
312
|
+
notice === DETAILS_OMITTED_NOTICE && contentWasTruncated ? RESULT_TRUNCATED_NOTICE : notice;
|
|
313
|
+
const marker = noticeBlock(markerText);
|
|
314
|
+
while (prefix.length > 0 && !hostToolContentIsBounded([...prefix, marker])) prefix.pop();
|
|
315
|
+
return [...prefix, marker];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function boundedTextPrefix(
|
|
319
|
+
prefix: readonly HostToolContentBlock[],
|
|
320
|
+
block: HostToolContentBlock,
|
|
321
|
+
): HostToolContentBlock | undefined {
|
|
322
|
+
if (block.type !== "text" || typeof block.text !== "string") return;
|
|
323
|
+
const marker = noticeBlock(CONTENT_TRUNCATED_NOTICE);
|
|
324
|
+
let low = 0;
|
|
325
|
+
let high = block.text.length;
|
|
326
|
+
let best = "";
|
|
327
|
+
while (low <= high) {
|
|
328
|
+
const middle = Math.floor((low + high) / 2);
|
|
329
|
+
let text = block.text.slice(0, middle);
|
|
330
|
+
if (/\p{Surrogate}$/u.test(text)) text = text.slice(0, -1);
|
|
331
|
+
const candidate = { type: "text", text } satisfies HostToolContentBlock;
|
|
332
|
+
if (hostToolContentIsBounded([...prefix, candidate, marker])) {
|
|
333
|
+
best = text;
|
|
334
|
+
low = middle + 1;
|
|
335
|
+
} else {
|
|
336
|
+
high = middle - 1;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return best ? { type: "text", text: best } : undefined;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function normalizeContent(content: readonly unknown[]): HostToolContentBlock[] {
|
|
343
|
+
const normalized: HostToolContentBlock[] = [];
|
|
344
|
+
for (const value of content) {
|
|
345
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
346
|
+
throw new Error("MCP tool returned invalid content");
|
|
347
|
+
}
|
|
348
|
+
const block = value as Record<string, unknown>;
|
|
349
|
+
if (typeof block.type !== "string" || !block.type || utf8Bytes(block.type) > 256) {
|
|
350
|
+
throw new Error("MCP tool returned invalid content");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
let candidate: HostToolContentBlock;
|
|
354
|
+
let blockWasTruncated = false;
|
|
355
|
+
if (block.type === "text") {
|
|
356
|
+
if (typeof block.text !== "string") throw new Error("MCP tool returned invalid text content");
|
|
357
|
+
const text = truncateUtf8(block.text, MAX_HOST_TOOL_TEXT_BYTES);
|
|
358
|
+
candidate = { ...block, type: "text", text } as HostToolContentBlock;
|
|
359
|
+
blockWasTruncated = text !== block.text;
|
|
360
|
+
} else if (block.type === "image") {
|
|
361
|
+
if (typeof block.data !== "string" || typeof block.mimeType !== "string") {
|
|
362
|
+
throw new Error("MCP tool returned invalid image content");
|
|
363
|
+
}
|
|
364
|
+
if (block.data.length > MAX_HOST_TOOL_IMAGE_DATA_BYTES) {
|
|
365
|
+
return appendBoundedNotice(normalized, CONTENT_TRUNCATED_NOTICE);
|
|
366
|
+
}
|
|
367
|
+
if (!isValidImagePayload(block.data, block.mimeType, MAX_HOST_TOOL_IMAGE_DATA_BYTES)) {
|
|
368
|
+
throw new Error("MCP tool returned invalid image content");
|
|
369
|
+
}
|
|
370
|
+
candidate = {
|
|
371
|
+
...block,
|
|
372
|
+
type: "image",
|
|
373
|
+
data: block.data,
|
|
374
|
+
mimeType: block.mimeType,
|
|
375
|
+
} as HostToolContentBlock;
|
|
376
|
+
} else {
|
|
377
|
+
candidate = block as HostToolContentBlock;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (normalized.length >= MAX_HOST_TOOL_CONTENT_BLOCKS) {
|
|
381
|
+
return appendBoundedNotice(normalized, CONTENT_TRUNCATED_NOTICE);
|
|
382
|
+
}
|
|
383
|
+
if (!hostToolContentIsBounded([...normalized, candidate])) {
|
|
384
|
+
const prefix = boundedTextPrefix(normalized, candidate);
|
|
385
|
+
return appendBoundedNotice(
|
|
386
|
+
prefix ? [...normalized, prefix] : normalized,
|
|
387
|
+
CONTENT_TRUNCATED_NOTICE,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
normalized.push(candidate);
|
|
391
|
+
if (blockWasTruncated) return appendBoundedNotice(normalized, CONTENT_TRUNCATED_NOTICE);
|
|
392
|
+
}
|
|
393
|
+
return normalized;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function structuredContentIsBounded(value: unknown): boolean {
|
|
397
|
+
return (
|
|
279
398
|
boundedJsonBytes(
|
|
280
|
-
|
|
399
|
+
value,
|
|
281
400
|
MAX_HOST_TOOL_RESULT_BYTES,
|
|
282
401
|
1_024,
|
|
283
402
|
MAX_HOST_TOOL_RESULT_BYTES,
|
|
284
403
|
4_096,
|
|
285
|
-
)
|
|
286
|
-
)
|
|
287
|
-
|
|
404
|
+
) !== Number.POSITIVE_INFINITY
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function normalizeResult(result: unknown): OmpHostToolResult["result"] {
|
|
409
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
410
|
+
throw new Error("MCP tool returned an invalid result");
|
|
288
411
|
}
|
|
289
412
|
const record = result as Record<string, unknown>;
|
|
290
413
|
if (Array.isArray(record.content)) {
|
|
291
414
|
const details = record.structuredContent;
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
415
|
+
const detailsAreBounded = details === undefined || structuredContentIsBounded(details);
|
|
416
|
+
let content = normalizeContent(record.content);
|
|
417
|
+
if (content.length === 0 && details !== undefined && detailsAreBounded) {
|
|
418
|
+
content = [
|
|
419
|
+
{
|
|
420
|
+
type: "text",
|
|
421
|
+
text: truncateUtf8(JSON.stringify(details), MAX_STRUCTURED_CONTENT_FALLBACK_BYTES),
|
|
422
|
+
},
|
|
423
|
+
];
|
|
424
|
+
}
|
|
425
|
+
if (!detailsAreBounded) content = appendBoundedNotice(content, DETAILS_OMITTED_NOTICE);
|
|
426
|
+
|
|
427
|
+
let normalized = parseOmpHostToolAgentResult({
|
|
302
428
|
content,
|
|
303
|
-
...(details !== undefined ? { details } : {}),
|
|
429
|
+
...(details !== undefined && detailsAreBounded ? { details } : {}),
|
|
304
430
|
...(typeof record.isError === "boolean" ? { isError: record.isError } : {}),
|
|
305
431
|
});
|
|
432
|
+
if (!structuredContentIsBounded(normalized)) {
|
|
433
|
+
normalized = parseOmpHostToolAgentResult({
|
|
434
|
+
content: appendBoundedNotice(content, DETAILS_OMITTED_NOTICE),
|
|
435
|
+
...(typeof record.isError === "boolean" ? { isError: record.isError } : {}),
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
return normalized;
|
|
306
439
|
}
|
|
307
440
|
if (Object.hasOwn(record, "toolResult")) {
|
|
441
|
+
const details = record.toolResult;
|
|
442
|
+
const detailsAreBounded = structuredContentIsBounded(details);
|
|
308
443
|
return parseOmpHostToolAgentResult({
|
|
309
|
-
content: [
|
|
310
|
-
details:
|
|
444
|
+
content: [noticeBlock(detailsAreBounded ? "MCP tool completed" : DETAILS_OMITTED_NOTICE)],
|
|
445
|
+
...(detailsAreBounded ? { details } : {}),
|
|
446
|
+
...(typeof record.isError === "boolean" ? { isError: record.isError } : {}),
|
|
311
447
|
});
|
|
312
448
|
}
|
|
313
449
|
throw new Error("MCP tool returned an unsupported result");
|
|
@@ -704,12 +840,73 @@ export class OmpHostToolsBridge {
|
|
|
704
840
|
result: OmpHostToolResult,
|
|
705
841
|
): OmpHostToolResult {
|
|
706
842
|
const limit = runtime.maxHostToolFrameBytes ?? 1024 * 1024;
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
843
|
+
const fits = (candidate: OmpHostToolResult) => {
|
|
844
|
+
try {
|
|
845
|
+
return Buffer.byteLength(`${JSON.stringify(candidate)}\n`) <= limit;
|
|
846
|
+
} catch {
|
|
847
|
+
return false;
|
|
848
|
+
}
|
|
849
|
+
};
|
|
850
|
+
if (fits(result)) return result;
|
|
851
|
+
|
|
852
|
+
const withoutDetails: OmpHostToolResult = {
|
|
853
|
+
...result,
|
|
854
|
+
result: {
|
|
855
|
+
content: appendBoundedNotice(result.result.content, DETAILS_OMITTED_NOTICE),
|
|
856
|
+
...(result.result.isError !== undefined ? { isError: result.result.isError } : {}),
|
|
857
|
+
},
|
|
858
|
+
};
|
|
859
|
+
if (result.result.details !== undefined && fits(withoutDetails)) return withoutDetails;
|
|
860
|
+
|
|
861
|
+
const trailingNotice = result.result.content.at(-1)?.text;
|
|
862
|
+
const detailsWereOmitted =
|
|
863
|
+
trailingNotice === DETAILS_OMITTED_NOTICE || trailingNotice === RESULT_TRUNCATED_NOTICE;
|
|
864
|
+
const contentWasTruncated =
|
|
865
|
+
trailingNotice === CONTENT_TRUNCATED_NOTICE || trailingNotice === RESULT_TRUNCATED_NOTICE;
|
|
866
|
+
const marker = noticeBlock(
|
|
867
|
+
result.result.details !== undefined || detailsWereOmitted
|
|
868
|
+
? RESULT_TRUNCATED_NOTICE
|
|
869
|
+
: CONTENT_TRUNCATED_NOTICE,
|
|
870
|
+
);
|
|
871
|
+
const sourceContent =
|
|
872
|
+
detailsWereOmitted || contentWasTruncated
|
|
873
|
+
? result.result.content.slice(0, -1)
|
|
874
|
+
: result.result.content;
|
|
875
|
+
const boundedContent: HostToolContentBlock[] = [];
|
|
876
|
+
const terminalWith = (content: HostToolContentBlock[]): OmpHostToolResult => ({
|
|
877
|
+
...result,
|
|
878
|
+
result: {
|
|
879
|
+
content,
|
|
880
|
+
...(result.result.isError !== undefined ? { isError: result.result.isError } : {}),
|
|
881
|
+
},
|
|
882
|
+
});
|
|
883
|
+
for (const block of sourceContent) {
|
|
884
|
+
const candidate = terminalWith([...boundedContent, block, marker]);
|
|
885
|
+
if (fits(candidate)) {
|
|
886
|
+
boundedContent.push(block);
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
890
|
+
let low = 0;
|
|
891
|
+
let high = block.text.length;
|
|
892
|
+
let best = "";
|
|
893
|
+
while (low <= high) {
|
|
894
|
+
const middle = Math.floor((low + high) / 2);
|
|
895
|
+
let text = block.text.slice(0, middle);
|
|
896
|
+
if (/\p{Surrogate}$/u.test(text)) text = text.slice(0, -1);
|
|
897
|
+
if (fits(terminalWith([...boundedContent, { type: "text", text }, marker]))) {
|
|
898
|
+
best = text;
|
|
899
|
+
low = middle + 1;
|
|
900
|
+
} else {
|
|
901
|
+
high = middle - 1;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
if (best) boundedContent.push({ type: "text", text: best });
|
|
905
|
+
}
|
|
906
|
+
break;
|
|
711
907
|
}
|
|
712
|
-
|
|
908
|
+
const bounded = terminalWith([...boundedContent, marker]);
|
|
909
|
+
return fits(bounded) ? bounded : errorResult(result.id, OMP_HOST_TOOL_FRAME_LIMIT_ERROR);
|
|
713
910
|
}
|
|
714
911
|
|
|
715
912
|
private sendTerminal(
|
|
@@ -4,7 +4,13 @@ import { isAbsolute, join } from "node:path";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { ompDataDir } from "../paths";
|
|
6
6
|
import { isValidImagePayload } from "./image";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
boundedJsonBytes,
|
|
9
|
+
boundedJsonMetrics,
|
|
10
|
+
OmpCleanupFailure,
|
|
11
|
+
OmpPublicError,
|
|
12
|
+
utf8Bytes,
|
|
13
|
+
} from "./security";
|
|
8
14
|
import {
|
|
9
15
|
listOmpSessionDescriptors,
|
|
10
16
|
type OmpSessionDescriptor,
|
|
@@ -56,6 +62,14 @@ const MAX_PENDING_ONE_WAY_WRITES = 256;
|
|
|
56
62
|
const MAX_PENDING_WRITE_BYTES = 8 * 1024 * 1024;
|
|
57
63
|
const MAX_LINE_PARTS = 4_096;
|
|
58
64
|
const MAX_ARRAY_ITEMS = 512;
|
|
65
|
+
// OMP read metadata can contain one source entry per displayed line. Bound this optional,
|
|
66
|
+
// opaque field separately so over-budget metadata can be omitted without losing completion events.
|
|
67
|
+
const MAX_OPTIONAL_METADATA_BYTES = MAX_TOOL_PAYLOAD_LENGTH;
|
|
68
|
+
const MAX_OPTIONAL_METADATA_ITEMS = 2_048;
|
|
69
|
+
const MAX_OPTIONAL_METADATA_NODES = 4_096;
|
|
70
|
+
const MAX_TASK_CORRELATION_BYTES = 256 * 1024;
|
|
71
|
+
const MAX_TASK_CORRELATION_ITEMS = 1_024;
|
|
72
|
+
const MAX_TASK_CORRELATION_NODES = 4_096;
|
|
59
73
|
// Tool-intensive OMP turns legitimately exceed 64 blocks; transport byte/node budgets remain the
|
|
60
74
|
// primary resource bounds.
|
|
61
75
|
export const OMP_MAX_CONTENT_PARTS = 4_096;
|
|
@@ -106,6 +120,76 @@ const IDENTIFIER = boundedString(MAX_ID_LENGTH, 1);
|
|
|
106
120
|
const NAME = boundedString(MAX_NAME_LENGTH, 1);
|
|
107
121
|
const OMP_PROVIDER_NAME = NAME.refine((provider) => !provider.includes("/"));
|
|
108
122
|
const TEXT = boundedString(MAX_TEXT_LENGTH);
|
|
123
|
+
const RAW_DISPLAY_TEXT = boundedString(MAX_IMAGE_DATA_LENGTH);
|
|
124
|
+
const DISPLAY_TRUNCATION_MARKER = "<truncated>";
|
|
125
|
+
|
|
126
|
+
function boundRawDisplayContent(value: unknown): unknown {
|
|
127
|
+
if (typeof value === "string") {
|
|
128
|
+
return utf8Bytes(value) <= MAX_IMAGE_DATA_LENGTH ? value : DISPLAY_TRUNCATION_MARKER;
|
|
129
|
+
}
|
|
130
|
+
if (!Array.isArray(value)) return value;
|
|
131
|
+
let totalBytes = 0;
|
|
132
|
+
for (const part of value) {
|
|
133
|
+
if (!part || typeof part !== "object" || Array.isArray(part)) continue;
|
|
134
|
+
const record = part as Record<string, unknown>;
|
|
135
|
+
if (typeof record.text === "string") totalBytes += utf8Bytes(record.text);
|
|
136
|
+
if (typeof record.thinking === "string") totalBytes += utf8Bytes(record.thinking);
|
|
137
|
+
}
|
|
138
|
+
if (totalBytes <= MAX_IMAGE_DATA_LENGTH) return value;
|
|
139
|
+
|
|
140
|
+
let retainedMarker = false;
|
|
141
|
+
return value.map((part) => {
|
|
142
|
+
if (!part || typeof part !== "object" || Array.isArray(part)) return part;
|
|
143
|
+
const copy = { ...(part as Record<string, unknown>) };
|
|
144
|
+
for (const key of ["text", "thinking"] as const) {
|
|
145
|
+
if (typeof copy[key] !== "string") continue;
|
|
146
|
+
if (retainedMarker) delete copy[key];
|
|
147
|
+
else {
|
|
148
|
+
copy[key] = DISPLAY_TRUNCATION_MARKER;
|
|
149
|
+
retainedMarker = true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return copy;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function sanitizeLiveMessageDisplay(value: unknown): unknown {
|
|
157
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
158
|
+
const message = value as Record<string, unknown>;
|
|
159
|
+
if (message.role === "assistant") {
|
|
160
|
+
const content = boundRawDisplayContent(message.content);
|
|
161
|
+
return content === message.content ? value : { ...message, content };
|
|
162
|
+
}
|
|
163
|
+
if (
|
|
164
|
+
message.role === "bashExecution" &&
|
|
165
|
+
typeof message.output === "string" &&
|
|
166
|
+
utf8Bytes(message.output) > MAX_IMAGE_DATA_LENGTH
|
|
167
|
+
) {
|
|
168
|
+
return { ...message, output: DISPLAY_TRUNCATION_MARKER };
|
|
169
|
+
}
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function sanitizeLiveDisplayFrame(value: unknown): unknown {
|
|
174
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
175
|
+
const frame = value as Record<string, unknown>;
|
|
176
|
+
if (
|
|
177
|
+
frame.type === "message_start" ||
|
|
178
|
+
frame.type === "message_update" ||
|
|
179
|
+
frame.type === "message_end"
|
|
180
|
+
) {
|
|
181
|
+
const message = sanitizeLiveMessageDisplay(frame.message);
|
|
182
|
+
return message === frame.message ? value : { ...frame, message };
|
|
183
|
+
}
|
|
184
|
+
if (frame.type !== "agent_end" || !Array.isArray(frame.messages)) return value;
|
|
185
|
+
let changed = false;
|
|
186
|
+
const messages = frame.messages.map((message) => {
|
|
187
|
+
const sanitized = sanitizeLiveMessageDisplay(message);
|
|
188
|
+
if (sanitized !== message) changed = true;
|
|
189
|
+
return sanitized;
|
|
190
|
+
});
|
|
191
|
+
return changed ? { ...frame, messages } : value;
|
|
192
|
+
}
|
|
109
193
|
const OmpThinkingLevelSchema = z.enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
110
194
|
|
|
111
195
|
function isBoundedJson(
|
|
@@ -118,6 +202,148 @@ function isBoundedJson(
|
|
|
118
202
|
boundedJsonBytes(value, maxBytes, maxItems, maxBytes, maxNodes) !== Number.POSITIVE_INFINITY
|
|
119
203
|
);
|
|
120
204
|
}
|
|
205
|
+
function optionalMetadataMetrics(value: unknown) {
|
|
206
|
+
return boundedJsonMetrics(
|
|
207
|
+
value,
|
|
208
|
+
MAX_OPTIONAL_METADATA_BYTES,
|
|
209
|
+
MAX_OPTIONAL_METADATA_ITEMS,
|
|
210
|
+
MAX_OPTIONAL_METADATA_BYTES,
|
|
211
|
+
MAX_OPTIONAL_METADATA_NODES,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function optionalMetadataIsBounded(value: unknown): boolean {
|
|
216
|
+
return optionalMetadataMetrics(value) !== undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function omitUnsafeOptionalDetails(value: unknown): unknown {
|
|
220
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
|
|
221
|
+
const record = value as Record<string, unknown>;
|
|
222
|
+
if (!Object.hasOwn(record, "details") || optionalMetadataIsBounded(record.details)) return value;
|
|
223
|
+
const { details: _details, ...safe } = record;
|
|
224
|
+
return safe;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const TASK_RESULT_STATUSES: Readonly<Record<string, true>> = {
|
|
228
|
+
pending: true,
|
|
229
|
+
running: true,
|
|
230
|
+
completed: true,
|
|
231
|
+
failed: true,
|
|
232
|
+
error: true,
|
|
233
|
+
aborted: true,
|
|
234
|
+
canceled: true,
|
|
235
|
+
cancelled: true,
|
|
236
|
+
};
|
|
237
|
+
const TASK_PROGRESS_STATUSES: Readonly<Record<string, true>> = {
|
|
238
|
+
pending: true,
|
|
239
|
+
running: true,
|
|
240
|
+
completed: true,
|
|
241
|
+
failed: true,
|
|
242
|
+
aborted: true,
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
function boundedTaskId(value: unknown): value is string {
|
|
246
|
+
return typeof value === "string" && value.length > 0 && utf8Bytes(value) <= MAX_ID_LENGTH;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function taskCorrelationDetails(value: unknown): unknown {
|
|
250
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return;
|
|
251
|
+
const details = value as Record<string, unknown>;
|
|
252
|
+
if (!Array.isArray(details.results) || details.results.length > MAX_TASK_CORRELATION_ITEMS)
|
|
253
|
+
return;
|
|
254
|
+
const results: Record<string, unknown>[] = [];
|
|
255
|
+
for (const value of details.results) {
|
|
256
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return;
|
|
257
|
+
const result = value as Record<string, unknown>;
|
|
258
|
+
if (!boundedTaskId(result.id)) return;
|
|
259
|
+
const safe: Record<string, unknown> = { id: result.id };
|
|
260
|
+
if (typeof result.status === "string" && Object.hasOwn(TASK_RESULT_STATUSES, result.status)) {
|
|
261
|
+
safe.status = result.status;
|
|
262
|
+
}
|
|
263
|
+
if (typeof result.aborted === "boolean") safe.aborted = result.aborted;
|
|
264
|
+
if (typeof result.exitCode === "number" && Number.isFinite(result.exitCode)) {
|
|
265
|
+
safe.exitCode = result.exitCode;
|
|
266
|
+
}
|
|
267
|
+
if (
|
|
268
|
+
result.error !== undefined &&
|
|
269
|
+
boundedJsonMetrics(result.error, 4_096, 32, 4_096, 64) !== undefined
|
|
270
|
+
) {
|
|
271
|
+
safe.error = result.error;
|
|
272
|
+
}
|
|
273
|
+
results.push(safe);
|
|
274
|
+
}
|
|
275
|
+
let progress: Record<string, unknown>[] | undefined;
|
|
276
|
+
if (details.progress !== undefined) {
|
|
277
|
+
if (!Array.isArray(details.progress) || details.progress.length > MAX_TASK_CORRELATION_ITEMS) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
progress = [];
|
|
281
|
+
for (const value of details.progress) {
|
|
282
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return;
|
|
283
|
+
const item = value as Record<string, unknown>;
|
|
284
|
+
if (
|
|
285
|
+
!boundedTaskId(item.id) ||
|
|
286
|
+
typeof item.index !== "number" ||
|
|
287
|
+
!Number.isInteger(item.index) ||
|
|
288
|
+
item.index < 0 ||
|
|
289
|
+
item.index >= MAX_TASK_CORRELATION_ITEMS ||
|
|
290
|
+
typeof item.status !== "string" ||
|
|
291
|
+
!Object.hasOwn(TASK_PROGRESS_STATUSES, item.status)
|
|
292
|
+
) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
progress.push({ id: item.id, index: item.index, status: item.status });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const correlation = { results, ...(progress ? { progress } : {}) };
|
|
299
|
+
return boundedJsonMetrics(
|
|
300
|
+
correlation,
|
|
301
|
+
MAX_TASK_CORRELATION_BYTES,
|
|
302
|
+
MAX_TASK_CORRELATION_ITEMS,
|
|
303
|
+
MAX_TASK_CORRELATION_BYTES,
|
|
304
|
+
MAX_TASK_CORRELATION_NODES,
|
|
305
|
+
)
|
|
306
|
+
? correlation
|
|
307
|
+
: undefined;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function omitOptionalDetails(value: unknown, preserveTaskCorrelation = false): unknown {
|
|
311
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
|
|
312
|
+
const record = value as Record<string, unknown>;
|
|
313
|
+
if (!Object.hasOwn(record, "details")) return value;
|
|
314
|
+
const { details: _details, ...structural } = record;
|
|
315
|
+
if (!preserveTaskCorrelation) return structural;
|
|
316
|
+
const correlation = taskCorrelationDetails(record.details);
|
|
317
|
+
return correlation === undefined ? structural : { ...structural, details: correlation };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function createOptionalMetadataSanitizer(): (value: unknown, taskResult?: boolean) => unknown {
|
|
321
|
+
let retainedBytes = 0;
|
|
322
|
+
let retainedNodes = 0;
|
|
323
|
+
return (value, taskResult = false) => {
|
|
324
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
|
|
325
|
+
const record = value as Record<string, unknown>;
|
|
326
|
+
if (!Object.hasOwn(record, "details")) return value;
|
|
327
|
+
const metrics = optionalMetadataMetrics(record.details);
|
|
328
|
+
if (
|
|
329
|
+
metrics &&
|
|
330
|
+
retainedBytes + metrics.bytes <= MAX_OPTIONAL_METADATA_BYTES &&
|
|
331
|
+
retainedNodes + metrics.nodes <= MAX_OPTIONAL_METADATA_NODES
|
|
332
|
+
) {
|
|
333
|
+
retainedBytes += metrics.bytes;
|
|
334
|
+
retainedNodes += metrics.nodes;
|
|
335
|
+
return value;
|
|
336
|
+
}
|
|
337
|
+
const correlation = taskResult ? taskCorrelationDetails(record.details) : undefined;
|
|
338
|
+
const { details: _details, ...safe } = record;
|
|
339
|
+
return correlation === undefined ? safe : { ...safe, details: correlation };
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const OmpOptionalMetadataSchema = z.preprocess(
|
|
344
|
+
(value) => (optionalMetadataIsBounded(value) ? value : undefined),
|
|
345
|
+
z.unknown().optional(),
|
|
346
|
+
);
|
|
121
347
|
|
|
122
348
|
const OmpContentPartSchema = z
|
|
123
349
|
.object({
|
|
@@ -147,6 +373,14 @@ const OmpContentPartSchema = z
|
|
|
147
373
|
context.addIssue({ code: "custom", message: "invalid image payload" });
|
|
148
374
|
}
|
|
149
375
|
});
|
|
376
|
+
const OmpAssistantContentPartSchema = OmpContentPartSchema.safeExtend({
|
|
377
|
+
text: RAW_DISPLAY_TEXT.optional(),
|
|
378
|
+
thinking: RAW_DISPLAY_TEXT.optional(),
|
|
379
|
+
});
|
|
380
|
+
const OmpAssistantDisplayContentSchema = z.preprocess(
|
|
381
|
+
boundRawDisplayContent,
|
|
382
|
+
z.union([RAW_DISPLAY_TEXT, z.array(OmpAssistantContentPartSchema).max(OMP_MAX_CONTENT_PARTS)]),
|
|
383
|
+
);
|
|
150
384
|
const OmpDisplayContentSchema = z.union([
|
|
151
385
|
TEXT,
|
|
152
386
|
z.array(OmpContentPartSchema).max(OMP_MAX_CONTENT_PARTS),
|
|
@@ -165,10 +399,7 @@ const OmpMessageIdentityShape = {
|
|
|
165
399
|
responseId: IDENTIFIER.optional(),
|
|
166
400
|
images: OmpImageArraySchema.optional(),
|
|
167
401
|
timestamp: z.number().finite().optional(),
|
|
168
|
-
details:
|
|
169
|
-
.unknown()
|
|
170
|
-
.refine((value) => isBoundedJson(value, MAX_SEMANTIC_FRAME_BYTES, 1_024, 4_096))
|
|
171
|
-
.optional(),
|
|
402
|
+
details: OmpOptionalMetadataSchema,
|
|
172
403
|
};
|
|
173
404
|
type OmpContentPart = z.infer<typeof OmpContentPartSchema>;
|
|
174
405
|
type OmpMessageIdentity = {
|
|
@@ -218,7 +449,7 @@ export type OmpMessage = OmpMessageIdentity &
|
|
|
218
449
|
const OmpMessageSchema: z.ZodType<OmpMessage> = z.union([
|
|
219
450
|
z.object({
|
|
220
451
|
role: z.literal("assistant"),
|
|
221
|
-
content:
|
|
452
|
+
content: OmpAssistantDisplayContentSchema.optional(),
|
|
222
453
|
...OmpMessageIdentityShape,
|
|
223
454
|
errorMessage: boundedString(4_096).nullable().optional(),
|
|
224
455
|
stopReason: boundedString(64).optional(),
|
|
@@ -241,7 +472,13 @@ const OmpMessageSchema: z.ZodType<OmpMessage> = z.union([
|
|
|
241
472
|
z.object({
|
|
242
473
|
role: z.literal("bashExecution"),
|
|
243
474
|
command: TEXT,
|
|
244
|
-
output:
|
|
475
|
+
output: z.preprocess(
|
|
476
|
+
(value) =>
|
|
477
|
+
typeof value === "string" && utf8Bytes(value) > MAX_IMAGE_DATA_LENGTH
|
|
478
|
+
? DISPLAY_TRUNCATION_MARKER
|
|
479
|
+
: value,
|
|
480
|
+
RAW_DISPLAY_TEXT.optional(),
|
|
481
|
+
),
|
|
245
482
|
exitCode: z.number().int().nullable().optional(),
|
|
246
483
|
cancelled: z.boolean().optional(),
|
|
247
484
|
truncated: z.boolean().optional(),
|
|
@@ -409,6 +646,16 @@ const JsonObjectSchema = z.record(z.string(), z.unknown());
|
|
|
409
646
|
const BoundedToolPayloadSchema = z
|
|
410
647
|
.unknown()
|
|
411
648
|
.refine((value) => isBoundedJson(value, MAX_SEMANTIC_FRAME_BYTES, 1_024, 4_096));
|
|
649
|
+
const OmpToolResultPayloadSchema = z.preprocess(
|
|
650
|
+
omitUnsafeOptionalDetails,
|
|
651
|
+
z
|
|
652
|
+
.unknown()
|
|
653
|
+
.refine(
|
|
654
|
+
(value) =>
|
|
655
|
+
isBoundedJson(value, MAX_SEMANTIC_FRAME_BYTES, MAX_OPTIONAL_METADATA_ITEMS, 8_192) &&
|
|
656
|
+
isBoundedJson(omitOptionalDetails(value), MAX_SEMANTIC_FRAME_BYTES, 1_024, 4_096),
|
|
657
|
+
),
|
|
658
|
+
);
|
|
412
659
|
const OmpHostToolDefinitionSchema = z.object({
|
|
413
660
|
name: NAME,
|
|
414
661
|
label: NAME.optional(),
|
|
@@ -616,13 +863,13 @@ const OmpAgentSessionEventSchema = z.discriminatedUnion("type", [
|
|
|
616
863
|
toolCallId: IDENTIFIER,
|
|
617
864
|
toolName: NAME,
|
|
618
865
|
args: BoundedToolPayloadSchema.optional(),
|
|
619
|
-
partialResult:
|
|
866
|
+
partialResult: OmpToolResultPayloadSchema,
|
|
620
867
|
}),
|
|
621
868
|
z.object({
|
|
622
869
|
type: z.literal("tool_execution_end"),
|
|
623
870
|
toolCallId: IDENTIFIER,
|
|
624
871
|
toolName: NAME,
|
|
625
|
-
result:
|
|
872
|
+
result: OmpToolResultPayloadSchema,
|
|
626
873
|
isError: z.boolean().optional(),
|
|
627
874
|
}),
|
|
628
875
|
OmpCompactionStartSchema,
|
|
@@ -873,6 +1120,102 @@ const OmpRuntimeEventSchema = z.discriminatedUnion("type", [
|
|
|
873
1120
|
OmpToolApprovalCancelSchema,
|
|
874
1121
|
z.object({ type: z.literal("advisor_yielded") }),
|
|
875
1122
|
]);
|
|
1123
|
+
type OptionalDetailsMapper = (value: unknown, taskResult?: boolean) => unknown;
|
|
1124
|
+
|
|
1125
|
+
function mapRecordField(
|
|
1126
|
+
record: Record<string, unknown>,
|
|
1127
|
+
key: string,
|
|
1128
|
+
map: OptionalDetailsMapper,
|
|
1129
|
+
taskResult = false,
|
|
1130
|
+
): Record<string, unknown> {
|
|
1131
|
+
if (!Object.hasOwn(record, key)) return record;
|
|
1132
|
+
const next = map(record[key], taskResult);
|
|
1133
|
+
return next === record[key] ? record : { ...record, [key]: next };
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function mapMessageList(value: unknown, map: OptionalDetailsMapper): unknown {
|
|
1137
|
+
if (!Array.isArray(value)) return value;
|
|
1138
|
+
let changed = false;
|
|
1139
|
+
const messages = value.map((message) => {
|
|
1140
|
+
const taskResult =
|
|
1141
|
+
message !== null &&
|
|
1142
|
+
typeof message === "object" &&
|
|
1143
|
+
!Array.isArray(message) &&
|
|
1144
|
+
(message as Record<string, unknown>).role === "toolResult" &&
|
|
1145
|
+
(message as Record<string, unknown>).toolName === "task";
|
|
1146
|
+
const next = map(message, taskResult);
|
|
1147
|
+
changed ||= next !== message;
|
|
1148
|
+
return next;
|
|
1149
|
+
});
|
|
1150
|
+
return changed ? messages : value;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function mapAgentEventDetails(
|
|
1154
|
+
frame: Record<string, unknown>,
|
|
1155
|
+
map: OptionalDetailsMapper,
|
|
1156
|
+
): Record<string, unknown> {
|
|
1157
|
+
switch (frame.type) {
|
|
1158
|
+
case "message_start":
|
|
1159
|
+
case "message_update":
|
|
1160
|
+
case "message_end": {
|
|
1161
|
+
const message = frame.message;
|
|
1162
|
+
const taskResult =
|
|
1163
|
+
message !== null &&
|
|
1164
|
+
typeof message === "object" &&
|
|
1165
|
+
!Array.isArray(message) &&
|
|
1166
|
+
(message as Record<string, unknown>).role === "toolResult" &&
|
|
1167
|
+
(message as Record<string, unknown>).toolName === "task";
|
|
1168
|
+
return mapRecordField(frame, "message", map, taskResult);
|
|
1169
|
+
}
|
|
1170
|
+
case "tool_execution_update":
|
|
1171
|
+
return mapRecordField(frame, "partialResult", map, frame.toolName === "task");
|
|
1172
|
+
case "tool_execution_end":
|
|
1173
|
+
return mapRecordField(frame, "result", map, frame.toolName === "task");
|
|
1174
|
+
case "agent_end":
|
|
1175
|
+
return mapRecordField(frame, "messages", (messages) => mapMessageList(messages, map));
|
|
1176
|
+
default:
|
|
1177
|
+
return frame;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function mapRuntimeFrameDetails(
|
|
1182
|
+
frame: Record<string, unknown>,
|
|
1183
|
+
map: OptionalDetailsMapper,
|
|
1184
|
+
): Record<string, unknown> {
|
|
1185
|
+
if (frame.type !== "subagent_event") return mapAgentEventDetails(frame, map);
|
|
1186
|
+
if (frame.payload === null || typeof frame.payload !== "object" || Array.isArray(frame.payload)) {
|
|
1187
|
+
return frame;
|
|
1188
|
+
}
|
|
1189
|
+
const payload = frame.payload as Record<string, unknown>;
|
|
1190
|
+
if (payload.event === null || typeof payload.event !== "object" || Array.isArray(payload.event)) {
|
|
1191
|
+
return frame;
|
|
1192
|
+
}
|
|
1193
|
+
const event = mapAgentEventDetails(payload.event as Record<string, unknown>, map);
|
|
1194
|
+
return event === payload.event ? frame : { ...frame, payload: { ...payload, event } };
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function sanitizeMessageListMetadata(value: unknown): unknown {
|
|
1198
|
+
return mapMessageList(value, createOptionalMetadataSanitizer());
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function sanitizeHistoryResponseData(value: unknown): unknown {
|
|
1202
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
|
|
1203
|
+
return mapRecordField(value as Record<string, unknown>, "messages", sanitizeMessageListMetadata);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function runtimeFrameCollectionLimit(frame: Record<string, unknown>): number {
|
|
1207
|
+
const type = frame.type;
|
|
1208
|
+
if (
|
|
1209
|
+
type === "message_start" ||
|
|
1210
|
+
type === "message_update" ||
|
|
1211
|
+
type === "message_end" ||
|
|
1212
|
+
type === "agent_end" ||
|
|
1213
|
+
type === "subagent_event"
|
|
1214
|
+
) {
|
|
1215
|
+
return OMP_MAX_CONTENT_PARTS;
|
|
1216
|
+
}
|
|
1217
|
+
return 1_024;
|
|
1218
|
+
}
|
|
876
1219
|
const OmpModelsResultSchema = z.object({
|
|
877
1220
|
models: z.array(OmpModelSchema).min(1).max(256),
|
|
878
1221
|
});
|
|
@@ -1988,21 +2331,7 @@ class OmpRpcProcess {
|
|
|
1988
2331
|
this.recordProtocolViolation();
|
|
1989
2332
|
return;
|
|
1990
2333
|
}
|
|
1991
|
-
|
|
1992
|
-
if (this.receiveDegradedAgentEnd(decoded, true)) return;
|
|
1993
|
-
if (
|
|
1994
|
-
boundedJsonBytes(decoded, MAX_SEMANTIC_FRAME_BYTES, 1_024, MAX_IMAGE_DATA_LENGTH, 4_096) ===
|
|
1995
|
-
Number.POSITIVE_INFINITY
|
|
1996
|
-
) {
|
|
1997
|
-
this.recordProtocolViolation();
|
|
1998
|
-
return;
|
|
1999
|
-
}
|
|
2000
|
-
const frame = JsonObjectSchema.safeParse(decoded);
|
|
2001
|
-
if (!frame.success) {
|
|
2002
|
-
this.recordProtocolViolation();
|
|
2003
|
-
return;
|
|
2004
|
-
}
|
|
2005
|
-
this.receiveFrame(frame.data);
|
|
2334
|
+
this.receiveDecodedFrame(decoded, payload.byteLength);
|
|
2006
2335
|
}
|
|
2007
2336
|
|
|
2008
2337
|
private receiveChunk(frame: ChunkFrame): void {
|
|
@@ -2072,26 +2401,33 @@ class OmpRpcProcess {
|
|
|
2072
2401
|
this.recordProtocolViolation();
|
|
2073
2402
|
return;
|
|
2074
2403
|
}
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2404
|
+
this.receiveDecodedFrame(decodedFrame, reassembled.byteLength);
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
private receiveDecodedFrame(value: unknown, rawByteLength: number): void {
|
|
2408
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
2409
|
+
const frame = value as Record<string, unknown>;
|
|
2410
|
+
const pending = typeof frame.id === "string" ? this.pending.get(frame.id) : undefined;
|
|
2411
|
+
if (
|
|
2412
|
+
frame.type === "response" &&
|
|
2413
|
+
(pending?.command === "get_messages" || pending?.command === "get_subagent_messages")
|
|
2414
|
+
) {
|
|
2415
|
+
this.receiveResponse(frame);
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
if (rawByteLength > MAX_SEMANTIC_FRAME_BYTES) {
|
|
2420
|
+
this.fail(new Error("OMP RPC frame exceeds the semantic byte limit"));
|
|
2087
2421
|
return;
|
|
2088
2422
|
}
|
|
2089
|
-
|
|
2090
|
-
|
|
2423
|
+
if (this.receiveKnownResponse(value)) return;
|
|
2424
|
+
const sanitized = sanitizeLiveDisplayFrame(value);
|
|
2425
|
+
const frame = JsonObjectSchema.safeParse(sanitized);
|
|
2426
|
+
if (!frame.success) {
|
|
2091
2427
|
this.recordProtocolViolation();
|
|
2092
2428
|
return;
|
|
2093
2429
|
}
|
|
2094
|
-
this.receiveFrame(
|
|
2430
|
+
this.receiveFrame(frame.data);
|
|
2095
2431
|
}
|
|
2096
2432
|
|
|
2097
2433
|
private receiveKnownResponse(value: unknown): boolean {
|
|
@@ -2130,6 +2466,11 @@ class OmpRpcProcess {
|
|
|
2130
2466
|
const isBranchHistory = pending.command === "get_branch_messages";
|
|
2131
2467
|
const isHistory =
|
|
2132
2468
|
pending.command === "get_messages" || pending.command === "get_subagent_messages";
|
|
2469
|
+
const responseData = isHistory
|
|
2470
|
+
? sanitizeHistoryResponseData(response.data.data)
|
|
2471
|
+
: response.data.data;
|
|
2472
|
+
const boundedFrame =
|
|
2473
|
+
responseData === response.data.data ? frame : { ...frame, data: responseData };
|
|
2133
2474
|
const responseItemLimit = isBranchHistory ? 1_024 : isHistory ? 100_000 : MAX_ARRAY_ITEMS;
|
|
2134
2475
|
const responseByteLimit =
|
|
2135
2476
|
isBranchHistory || isHistory
|
|
@@ -2146,7 +2487,7 @@ class OmpRpcProcess {
|
|
|
2146
2487
|
: 2_048;
|
|
2147
2488
|
if (
|
|
2148
2489
|
boundedJsonBytes(
|
|
2149
|
-
|
|
2490
|
+
boundedFrame,
|
|
2150
2491
|
responseByteLimit,
|
|
2151
2492
|
responseItemLimit,
|
|
2152
2493
|
MAX_IMAGE_DATA_LENGTH,
|
|
@@ -2162,7 +2503,7 @@ class OmpRpcProcess {
|
|
|
2162
2503
|
if (!settled) return;
|
|
2163
2504
|
if (response.data.success) {
|
|
2164
2505
|
try {
|
|
2165
|
-
settled.beforeResolve?.(
|
|
2506
|
+
settled.beforeResolve?.(responseData);
|
|
2166
2507
|
if (settled.command === "prompt") {
|
|
2167
2508
|
if (this.acceptedPromptIds.size >= MAX_PENDING_REQUESTS) {
|
|
2168
2509
|
const oldest = this.acceptedPromptIds.values().next().value;
|
|
@@ -2170,7 +2511,7 @@ class OmpRpcProcess {
|
|
|
2170
2511
|
}
|
|
2171
2512
|
this.acceptedPromptIds.add(response.data.id);
|
|
2172
2513
|
}
|
|
2173
|
-
settled.resolve(
|
|
2514
|
+
settled.resolve(responseData);
|
|
2174
2515
|
} catch {
|
|
2175
2516
|
settled.reject(new Error("OMP RPC response is invalid"));
|
|
2176
2517
|
}
|
|
@@ -2219,21 +2560,27 @@ class OmpRpcProcess {
|
|
|
2219
2560
|
this.fail(new Error("OMP emitted invalid terminal metadata"));
|
|
2220
2561
|
return true;
|
|
2221
2562
|
}
|
|
2563
|
+
const structuralFrame = mapRuntimeFrameDetails(frame, omitOptionalDetails);
|
|
2222
2564
|
const messagesAreSafe =
|
|
2223
2565
|
frame.messages === undefined ||
|
|
2224
2566
|
(Array.isArray(frame.messages) &&
|
|
2225
2567
|
frame.messages.length <= MAX_ARRAY_ITEMS &&
|
|
2226
2568
|
boundedJsonBytes(
|
|
2227
|
-
|
|
2569
|
+
structuralFrame.messages as unknown[],
|
|
2228
2570
|
MAX_SEMANTIC_FRAME_BYTES,
|
|
2229
|
-
|
|
2230
|
-
|
|
2571
|
+
OMP_MAX_CONTENT_PARTS,
|
|
2572
|
+
MAX_IMAGE_DATA_LENGTH,
|
|
2231
2573
|
4_096,
|
|
2232
2574
|
) !== Number.POSITIVE_INFINITY);
|
|
2233
2575
|
const payloadIsSafe =
|
|
2234
2576
|
messagesAreSafe &&
|
|
2235
|
-
boundedJsonBytes(
|
|
2236
|
-
|
|
2577
|
+
boundedJsonBytes(
|
|
2578
|
+
structuralFrame,
|
|
2579
|
+
MAX_SEMANTIC_FRAME_BYTES,
|
|
2580
|
+
OMP_MAX_CONTENT_PARTS,
|
|
2581
|
+
MAX_IMAGE_DATA_LENGTH,
|
|
2582
|
+
4_096,
|
|
2583
|
+
) !== Number.POSITIVE_INFINITY;
|
|
2237
2584
|
if (onlyUnsafePayload && payloadIsSafe) return false;
|
|
2238
2585
|
if (envelope.data.isTerminal === false) {
|
|
2239
2586
|
this.fail(new Error("OMP emitted an invalid nonterminal agent_end payload"));
|
|
@@ -2260,16 +2607,22 @@ class OmpRpcProcess {
|
|
|
2260
2607
|
this.recordProtocolViolation();
|
|
2261
2608
|
return;
|
|
2262
2609
|
}
|
|
2263
|
-
|
|
2610
|
+
const safeFrame = mapRuntimeFrameDetails(frame, createOptionalMetadataSanitizer());
|
|
2611
|
+
if (this.receiveDegradedAgentEnd(safeFrame, true)) return;
|
|
2264
2612
|
if (
|
|
2265
|
-
boundedJsonBytes(
|
|
2266
|
-
|
|
2613
|
+
boundedJsonBytes(
|
|
2614
|
+
mapRuntimeFrameDetails(safeFrame, omitOptionalDetails),
|
|
2615
|
+
MAX_SEMANTIC_FRAME_BYTES,
|
|
2616
|
+
runtimeFrameCollectionLimit(safeFrame),
|
|
2617
|
+
MAX_IMAGE_DATA_LENGTH,
|
|
2618
|
+
4_096,
|
|
2619
|
+
) === Number.POSITIVE_INFINITY
|
|
2267
2620
|
) {
|
|
2268
2621
|
this.recordProtocolViolation();
|
|
2269
2622
|
return;
|
|
2270
2623
|
}
|
|
2271
2624
|
if (type === "rpc_chunk") {
|
|
2272
|
-
const chunk = OmpChunkFrameSchema.safeParse(
|
|
2625
|
+
const chunk = OmpChunkFrameSchema.safeParse(safeFrame);
|
|
2273
2626
|
if (!chunk.success) this.rejectChunk();
|
|
2274
2627
|
else this.receiveChunk(chunk.data);
|
|
2275
2628
|
return;
|
|
@@ -2287,7 +2640,7 @@ class OmpRpcProcess {
|
|
|
2287
2640
|
this.recordProtocolViolation();
|
|
2288
2641
|
return;
|
|
2289
2642
|
}
|
|
2290
|
-
const ready = OmpReadyFrameSchema.safeParse(
|
|
2643
|
+
const ready = OmpReadyFrameSchema.safeParse(safeFrame);
|
|
2291
2644
|
if (!ready.success) {
|
|
2292
2645
|
this.recordProtocolViolation();
|
|
2293
2646
|
} else {
|
|
@@ -2297,13 +2650,13 @@ class OmpRpcProcess {
|
|
|
2297
2650
|
return;
|
|
2298
2651
|
}
|
|
2299
2652
|
if (type === "response") {
|
|
2300
|
-
this.receiveResponse(
|
|
2653
|
+
this.receiveResponse(safeFrame);
|
|
2301
2654
|
return;
|
|
2302
2655
|
}
|
|
2303
|
-
const event = OmpRuntimeEventSchema.safeParse(
|
|
2656
|
+
const event = OmpRuntimeEventSchema.safeParse(safeFrame);
|
|
2304
2657
|
if (!event.success) {
|
|
2305
|
-
this.rejectMatchingToolApproval(
|
|
2306
|
-
if (type === "agent_end" && this.receiveDegradedAgentEnd(
|
|
2658
|
+
this.rejectMatchingToolApproval(safeFrame);
|
|
2659
|
+
if (type === "agent_end" && this.receiveDegradedAgentEnd(safeFrame, false)) return;
|
|
2307
2660
|
this.recordProtocolViolation();
|
|
2308
2661
|
return;
|
|
2309
2662
|
}
|
|
@@ -2728,7 +3081,10 @@ export class OmpRpcRuntime implements OmpRuntime {
|
|
|
2728
3081
|
);
|
|
2729
3082
|
return {
|
|
2730
3083
|
...transcript,
|
|
2731
|
-
messages: z
|
|
3084
|
+
messages: z
|
|
3085
|
+
.array(OmpMessageSchema)
|
|
3086
|
+
.max(100_000)
|
|
3087
|
+
.parse(sanitizeMessageListMetadata(transcript.messages)),
|
|
2732
3088
|
};
|
|
2733
3089
|
}
|
|
2734
3090
|
async readPersistedSubagentTranscript(options: {
|
|
@@ -2745,7 +3101,10 @@ export class OmpRpcRuntime implements OmpRuntime {
|
|
|
2745
3101
|
);
|
|
2746
3102
|
return {
|
|
2747
3103
|
...transcript,
|
|
2748
|
-
messages: z
|
|
3104
|
+
messages: z
|
|
3105
|
+
.array(OmpMessageSchema)
|
|
3106
|
+
.max(100_000)
|
|
3107
|
+
.parse(sanitizeMessageListMetadata(transcript.messages)),
|
|
2749
3108
|
};
|
|
2750
3109
|
}
|
|
2751
3110
|
|
|
@@ -83,16 +83,14 @@ function truncateJsonString(value: string, maxBytes: number): string {
|
|
|
83
83
|
export function truncateUtf8(value: string, maxBytes: number): string {
|
|
84
84
|
if (utf8Bytes(value) <= maxBytes) return value;
|
|
85
85
|
const suffix = "<truncated>";
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
return `${output}${suffix}`;
|
|
86
|
+
const suffixBytes = utf8Bytes(suffix);
|
|
87
|
+
if (maxBytes <= 0) return "";
|
|
88
|
+
if (maxBytes < suffixBytes) return suffix.slice(0, Math.floor(maxBytes));
|
|
89
|
+
|
|
90
|
+
const bytes = Buffer.from(value, "utf8");
|
|
91
|
+
let end = Math.min(maxBytes - suffixBytes, bytes.byteLength);
|
|
92
|
+
while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1;
|
|
93
|
+
return `${bytes.subarray(0, end).toString("utf8")}${suffix}`;
|
|
96
94
|
}
|
|
97
95
|
|
|
98
96
|
export interface BoundedJsonMetrics {
|
|
@@ -94,6 +94,18 @@ const TaskResultDetailsSchema = z.object({
|
|
|
94
94
|
exitCode: z.number().optional(),
|
|
95
95
|
error: z.unknown().optional(),
|
|
96
96
|
aborted: z.boolean().optional(),
|
|
97
|
+
status: z
|
|
98
|
+
.enum([
|
|
99
|
+
"pending",
|
|
100
|
+
"running",
|
|
101
|
+
"completed",
|
|
102
|
+
"failed",
|
|
103
|
+
"error",
|
|
104
|
+
"aborted",
|
|
105
|
+
"canceled",
|
|
106
|
+
"cancelled",
|
|
107
|
+
])
|
|
108
|
+
.optional(),
|
|
97
109
|
}),
|
|
98
110
|
)
|
|
99
111
|
.max(MAX_CHILDREN),
|
|
@@ -192,8 +204,15 @@ function replayChildren(messages: readonly OmpMessage[]): ReplayChildRef[] {
|
|
|
192
204
|
const details = taskResultDetails(message);
|
|
193
205
|
const results = details?.results ?? [];
|
|
194
206
|
for (const result of results) {
|
|
207
|
+
const canceled =
|
|
208
|
+
result.aborted === true ||
|
|
209
|
+
result.status === "aborted" ||
|
|
210
|
+
result.status === "canceled" ||
|
|
211
|
+
result.status === "cancelled";
|
|
195
212
|
const failed =
|
|
196
213
|
message.isError === true ||
|
|
214
|
+
result.status === "failed" ||
|
|
215
|
+
result.status === "error" ||
|
|
197
216
|
Boolean(result.error) ||
|
|
198
217
|
(typeof result.exitCode === "number" && result.exitCode !== 0);
|
|
199
218
|
children.push({
|
|
@@ -201,7 +220,7 @@ function replayChildren(messages: readonly OmpMessage[]): ReplayChildRef[] {
|
|
|
201
220
|
agent: result.agent ?? call?.title,
|
|
202
221
|
description: call?.description,
|
|
203
222
|
parentToolCallId: message.toolCallId,
|
|
204
|
-
status:
|
|
223
|
+
status: canceled ? "canceled" : failed ? "failed" : "completed",
|
|
205
224
|
});
|
|
206
225
|
}
|
|
207
226
|
const resultIds = new Set(results.map((result) => result.id));
|
|
@@ -555,6 +574,7 @@ export class OmpSubsessionProjector {
|
|
|
555
574
|
type: "session.opened",
|
|
556
575
|
sessionId,
|
|
557
576
|
parentSessionId,
|
|
577
|
+
toolCallId: ref.parentToolCallId,
|
|
558
578
|
capabilities: ["session.subsession"],
|
|
559
579
|
restoration: "parent",
|
|
560
580
|
cwd: this.cwd,
|
|
@@ -12,11 +12,14 @@ import {
|
|
|
12
12
|
type JsonValue,
|
|
13
13
|
OmpPublicDataSerializer,
|
|
14
14
|
OmpPublicError,
|
|
15
|
+
truncateUtf8,
|
|
15
16
|
utf8Bytes,
|
|
16
17
|
} from "./security";
|
|
17
18
|
|
|
18
19
|
const STREAM_FRAME_MS = 32;
|
|
19
20
|
const MAX_STREAM_TEXT_LENGTH = 4 * 1024 * 1024;
|
|
21
|
+
const MAX_RAW_STREAM_TEXT_LENGTH = 8 * 1024 * 1024;
|
|
22
|
+
const MAX_REDACTED_STREAM_TEXT_LENGTH = MAX_RAW_STREAM_TEXT_LENGTH * 3;
|
|
20
23
|
const MAX_ACTIVE_TOOLS = 64;
|
|
21
24
|
const MAX_TODOS = 256;
|
|
22
25
|
const MAX_TURN_NATIVE_IDENTITIES = 1_024;
|
|
@@ -908,10 +911,7 @@ export class OmpTimelineProjector {
|
|
|
908
911
|
return;
|
|
909
912
|
}
|
|
910
913
|
if (message.role === "bashExecution") {
|
|
911
|
-
|
|
912
|
-
? `$ ${message.command}\n${message.output}`
|
|
913
|
-
: `$ ${message.command}`;
|
|
914
|
-
this.project({ type: "command_output", text }, this.replayTurnId);
|
|
914
|
+
this.publishCustomMessage(message);
|
|
915
915
|
this.finishTurn(this.replayTurnId);
|
|
916
916
|
this.replayTurnId = null;
|
|
917
917
|
}
|
|
@@ -1007,13 +1007,28 @@ export class OmpTimelineProjector {
|
|
|
1007
1007
|
if (!this.stream || this.closed || this.stream.dirtyBlocks.size === 0) return;
|
|
1008
1008
|
const stream = this.stream;
|
|
1009
1009
|
if (!stream.nativeIdentity && !finalizeFallback) return;
|
|
1010
|
-
const indexes = [...stream.
|
|
1010
|
+
const indexes = [...stream.blocks.keys()].sort((left, right) => left - right);
|
|
1011
|
+
let remainingTextBytes = MAX_STREAM_TEXT_LENGTH;
|
|
1011
1012
|
stream.dirtyBlocks.clear();
|
|
1012
1013
|
for (const contentIndex of indexes) {
|
|
1013
1014
|
const block = stream.blocks.get(contentIndex);
|
|
1014
|
-
if (!block
|
|
1015
|
-
const publicText =
|
|
1016
|
-
|
|
1015
|
+
if (!block) continue;
|
|
1016
|
+
const publicText =
|
|
1017
|
+
block.kind === "image"
|
|
1018
|
+
? block.text
|
|
1019
|
+
: truncateUtf8(
|
|
1020
|
+
this.dataFilter.text(block.text, MAX_REDACTED_STREAM_TEXT_LENGTH),
|
|
1021
|
+
remainingTextBytes,
|
|
1022
|
+
);
|
|
1023
|
+
if (block.kind !== "image") {
|
|
1024
|
+
remainingTextBytes = Math.max(0, remainingTextBytes - utf8Bytes(publicText));
|
|
1025
|
+
}
|
|
1026
|
+
if (
|
|
1027
|
+
block.publishedText === publicText ||
|
|
1028
|
+
(!publicText && block.publishedText === undefined)
|
|
1029
|
+
) {
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1017
1032
|
const nextPublishedBytes = utf8Bytes(publicText);
|
|
1018
1033
|
if (
|
|
1019
1034
|
stream.retainedBytes + stream.publishedBytes + nextPublishedBytes >
|
|
@@ -1267,7 +1282,7 @@ export class OmpTimelineProjector {
|
|
|
1267
1282
|
(snapshot.kind === "image" ? 0 : snapshotBytes);
|
|
1268
1283
|
if (
|
|
1269
1284
|
retainedBytes + stream.publishedBytes > MAX_STREAM_TOTAL_BYTES ||
|
|
1270
|
-
textBytes >
|
|
1285
|
+
textBytes > MAX_RAW_STREAM_TEXT_LENGTH
|
|
1271
1286
|
) {
|
|
1272
1287
|
return;
|
|
1273
1288
|
}
|
|
@@ -1291,7 +1306,7 @@ export class OmpTimelineProjector {
|
|
|
1291
1306
|
|
|
1292
1307
|
private publishCommand(turnId: string): void {
|
|
1293
1308
|
if (!this.commandText) return;
|
|
1294
|
-
const publicText = this.dataFilter.text(this.commandText);
|
|
1309
|
+
const publicText = this.dataFilter.text(this.commandText, MAX_STREAM_TEXT_LENGTH);
|
|
1295
1310
|
if (!publicText || publicText === this.commandPublishedText) return;
|
|
1296
1311
|
this.commandPublishedText = publicText;
|
|
1297
1312
|
this.publish({
|
|
@@ -1377,13 +1392,21 @@ export class OmpTimelineProjector {
|
|
|
1377
1392
|
type: "shell",
|
|
1378
1393
|
command,
|
|
1379
1394
|
...(firstString(details, "cwd") ? { cwd: firstString(details, "cwd") } : {}),
|
|
1380
|
-
...(output ? { output: this.dataFilter.text(output) } : {}),
|
|
1395
|
+
...(output ? { output: this.dataFilter.text(output, MAX_STREAM_TEXT_LENGTH) } : {}),
|
|
1381
1396
|
...(typeof message.exitCode === "number" || message.exitCode === null
|
|
1382
1397
|
? { exitCode: message.exitCode }
|
|
1383
1398
|
: typeof details?.exitCode === "number"
|
|
1384
1399
|
? { exitCode: details.exitCode }
|
|
1385
1400
|
: {}),
|
|
1386
1401
|
},
|
|
1402
|
+
...(message.cancelled !== undefined || message.truncated !== undefined
|
|
1403
|
+
? {
|
|
1404
|
+
metadata: {
|
|
1405
|
+
...(message.cancelled !== undefined ? { cancelled: message.cancelled } : {}),
|
|
1406
|
+
...(message.truncated !== undefined ? { truncated: message.truncated } : {}),
|
|
1407
|
+
},
|
|
1408
|
+
}
|
|
1409
|
+
: {}),
|
|
1387
1410
|
status: message.cancelled ? "canceled" : "completed",
|
|
1388
1411
|
error: null,
|
|
1389
1412
|
});
|