@mono-agent/agent-runtime 0.18.0 → 0.18.2
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/MIGRATION.md +49 -1
- package/README.md +73 -10
- package/package.json +1 -1
- package/src/agent/tools/agent-tool.js +16 -4
- package/src/agent/tools/web-controller.js +97 -7
- package/src/agent/tools/web-search.js +296 -60
- package/src/ai/providers/acp-client.js +38 -13
- package/src/ai/providers/acp-session-tokens.js +198 -45
- package/src/ai/providers/acp-transport.js +97 -0
- package/src/ai/providers/acp.js +22 -6
- package/src/ai/providers/claude-cli.js +77 -22
- package/src/ai/providers/claude-sdk.js +54 -11
- package/src/ai/providers/claude-subagent-activity.js +719 -0
- package/src/ai/providers/codex-app.js +1039 -105
- package/src/ai/runtime/router.js +42 -0
- package/src/ai/types.js +101 -11
- package/src/runtime.js +1 -0
- package/types/agent/tools/web-controller.d.ts +5 -1
- package/types/agent/tools/web-search.d.ts +13 -0
- package/types/ai/providers/acp-client.d.ts +4 -0
- package/types/ai/providers/acp-session-tokens.d.ts +16 -9
- package/types/ai/providers/acp-transport.d.ts +13 -0
- package/types/ai/providers/claude-subagent-activity.d.ts +53 -0
- package/types/ai/runtime/router.d.ts +9 -0
- package/types/ai/types.d.ts +243 -22
package/MIGRATION.md
CHANGED
|
@@ -17,6 +17,31 @@ the configuration schema.
|
|
|
17
17
|
|
|
18
18
|
---
|
|
19
19
|
|
|
20
|
+
## 0.18.2
|
|
21
|
+
|
|
22
|
+
- **Normalized native-subagent activity:** Claude SDK/CLI and Codex app-server
|
|
23
|
+
children now emit the exact `subagent_activity` lifecycle. Treat
|
|
24
|
+
`subagent.id` as the parent attachment key, `nativeId` only as provider
|
|
25
|
+
correlation metadata, and `phase: "message"` as child prose rather than
|
|
26
|
+
parent answer text or a completed tool call.
|
|
27
|
+
- **Explicit native configuration trust:** Claude SDK filesystem settings stay
|
|
28
|
+
disabled unless `settingSources` opts into `user`, `project`, or `local`.
|
|
29
|
+
Those sources may execute hooks and plugins, not only load agents. Codex
|
|
30
|
+
repository instructions remain disabled unless `codexLoadProjectDocs` is
|
|
31
|
+
true; explicit app-server arguments remain authoritative.
|
|
32
|
+
- **Provider-owned Codex agents:** non-empty caller-defined `nativeSubagents`
|
|
33
|
+
teammates now fail direct Codex startup with `skipped_capability_mismatch` so
|
|
34
|
+
a router may continue to Claude. Do not synthesize `collaborationMode` or
|
|
35
|
+
assume Claude profile definitions are portable to Codex.
|
|
36
|
+
- **Per-attempt policy projection:** `resolveAttempt().policyOptions` may replace
|
|
37
|
+
only `allowedTools`, `disallowedTools`, and `permissionMode` for the active
|
|
38
|
+
route. General resolver `options` still cannot replace protected request
|
|
39
|
+
fields.
|
|
40
|
+
- **Pi inline helper ceiling:** the runtime-owned `general-purpose` profile is
|
|
41
|
+
limited to its read-only defaults intersected with `subagents.inline.allowedTools`.
|
|
42
|
+
An empty intersection disables that fallback profile rather than restoring
|
|
43
|
+
wider defaults.
|
|
44
|
+
|
|
20
45
|
## 0.15.2
|
|
21
46
|
|
|
22
47
|
- **Tool-policy capability discovery:** built-in bridge capabilities now report
|
|
@@ -63,7 +88,23 @@ the configuration schema.
|
|
|
63
88
|
authoritative and the runtime emits a bounded
|
|
64
89
|
`live_input_callback_failed` warning.
|
|
65
90
|
|
|
66
|
-
## 0.18.
|
|
91
|
+
## 0.18.1
|
|
92
|
+
|
|
93
|
+
- ACP provider-session ids and session-list cursors are now confidential,
|
|
94
|
+
authenticated v2 handles. Hosts must persist one exact 32-byte binary
|
|
95
|
+
`acpSessionTokenKey` and pass it to ACP task runs, list/delete helpers, and
|
|
96
|
+
`validateAcpProviderSessionId(value, expectedProfileId, key)`. A changed or
|
|
97
|
+
missing key fails before profile resolution or process spawn. Existing v1
|
|
98
|
+
handles are rejected; discard them and obtain fresh v2 handles. Preserve the
|
|
99
|
+
complete returned value for resume, pagination, validation, and delete, but
|
|
100
|
+
do not compare ciphertexts for equality or parse/substitute the remote
|
|
101
|
+
agent's raw session id or cursor.
|
|
102
|
+
- Payload-bearing diagnostics from the pinned ACP SDK are scoped to the owned
|
|
103
|
+
ACP receive loop and reduced to content-free labels. Malformed or hostile
|
|
104
|
+
agent notifications cannot copy elicitation values or URL secrets into
|
|
105
|
+
process-wide console diagnostics.
|
|
106
|
+
|
|
107
|
+
## 0.18.0
|
|
67
108
|
|
|
68
109
|
- `acp:<profile-id>` is now a canonical runtime model reference when paired
|
|
69
110
|
with `executionMode: "acp"`. Hosts must provide `resolveAcpProfile`; profiles
|
|
@@ -266,6 +307,13 @@ a `supports_native_subagents` requirement when a run passes
|
|
|
266
307
|
native-subagent runs, ensure at least one entry supports native subagents, or the
|
|
267
308
|
run reports exhausted instead of degrading silently.
|
|
268
309
|
|
|
310
|
+
Caller-defined `nativeSubagents.teammates` are a Claude-only projection. Codex
|
|
311
|
+
still advertises and reports its provider-owned native collaboration surface,
|
|
312
|
+
but a direct Codex attempt now rejects configured teammate/profile definitions
|
|
313
|
+
with `codex_native_subagent_definitions_unsupported` before transport; a router
|
|
314
|
+
may then continue to Claude. Use `codexLoadProjectDocs: true` to enable Codex's
|
|
315
|
+
repository instructions, not to define Codex collaboration profiles.
|
|
316
|
+
|
|
269
317
|
### 6. Diagnostics & internal behavior changes (no API change)
|
|
270
318
|
|
|
271
319
|
- **Pi multimodal**: image inputs are delivered to the model as image content
|
package/README.md
CHANGED
|
@@ -615,19 +615,30 @@ transport frame is too structurally complex for the bounded host sanitizer,
|
|
|
615
615
|
the turn fails explicitly as `provider_protocol` instead of emitting a partial
|
|
616
616
|
tool, plan, or message event.
|
|
617
617
|
|
|
618
|
-
ACP provider-session ids and list cursors are
|
|
619
|
-
handles
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
618
|
+
ACP provider-session ids and list cursors are confidential, authenticated v2
|
|
619
|
+
handles bound to their token kind and profile. The host must supply an exact
|
|
620
|
+
32-byte binary `acpSessionTokenKey` for every task run, list, validation, and
|
|
621
|
+
delete operation. Call
|
|
622
|
+
`validateAcpProviderSessionId(handle, expectedProfileId, key)` at untrusted
|
|
623
|
+
ingress. Keep the key stable and secret across host restarts; changing it
|
|
624
|
+
invalidates every outstanding handle. Legacy `acp:v1:` and `acp-cursor:v1:`
|
|
625
|
+
values are rejected.
|
|
626
|
+
|
|
627
|
+
Preserve each returned handle byte-for-byte and pass it back only to the
|
|
628
|
+
matching high-level resume, list, validation, or delete operation. Encryption
|
|
629
|
+
uses a fresh nonce, so two handles for the same remote id are not equality
|
|
630
|
+
keys. Raw protocol session ids, cursors, token keys, and transport connections
|
|
631
|
+
remain private runtime state and are omitted from profile resolver context,
|
|
632
|
+
callbacks, and diagnostics. Under the default `auto` recovery policy, the
|
|
633
|
+
client prefers `session/resume`, then `session/load`, and finally a fresh
|
|
634
|
+
session when neither capability is advertised. Explicit `resume` or `load`
|
|
635
|
+
policies fail closed if missing. Stable usage comes from the latest typed
|
|
636
|
+
`usage_update` notification; unstable `PromptResponse.usage` is ignored.
|
|
627
637
|
|
|
628
638
|
### `createRuntime(host)`
|
|
629
639
|
|
|
630
|
-
Pass host-level integration once at boot.
|
|
640
|
+
Pass host-level integration once at boot. Keys are optional unless the selected
|
|
641
|
+
backend contract requires them.
|
|
631
642
|
|
|
632
643
|
```js
|
|
633
644
|
createRuntime({
|
|
@@ -636,6 +647,7 @@ createRuntime({
|
|
|
636
647
|
resolvePiApiKey, // async (provider) => string | undefined
|
|
637
648
|
resolveAcpProfile, // async (profileId, context) => AcpProfileDescriptor
|
|
638
649
|
onAcpInteractionRequest, // async permission/elicitation fallback callback
|
|
650
|
+
acpSessionTokenKey, // Uint8Array(32), required for ACP task/session-handle operations
|
|
639
651
|
persistArtifact, // ({ filename, buffer, toolName, toolUseId }) => path | null
|
|
640
652
|
onCompactionRecorded, // (compactionRow) => void — fired when the pi bridge
|
|
641
653
|
// runs an automatic compaction (proactive or reactive
|
|
@@ -738,6 +750,9 @@ Per-call options (a non-exhaustive selection):
|
|
|
738
750
|
| `cwd` | `string` | Working directory for the agent's tools. |
|
|
739
751
|
| `allowedTools` | `string[]` | Built-in tool allowlist. Default: all. |
|
|
740
752
|
| `disallowedTools` | `string[]` | Block list. |
|
|
753
|
+
| `nativeSubagents` | `object` | Caller-defined Claude native `Task` profiles. Direct Codex rejects configured teammate definitions because Codex owns its collaboration agents. |
|
|
754
|
+
| `settingSources` | `("user" \| "project" \| "local")[]` | Claude Agent SDK filesystem settings opt-in. Omitted/empty disables those three sources; Anthropic managed settings still apply. |
|
|
755
|
+
| `codexLoadProjectDocs` | `boolean` | Codex app-server repository-instruction opt-in. Omitted/false sets `project_doc_max_bytes=0`; true restores Codex defaults. Explicit `codexAppServerArgs` wins. |
|
|
741
756
|
| `mcpServers` | `Record<string, McpServerConfig>` | Configured MCP servers (stdio / sse / http); on direct Codex, each forwarded server authorizes its own tool calls. |
|
|
742
757
|
| `sandboxPolicy` | `SandboxPolicy` | Optional fail-closed sandbox policy for built-in tools and stdio MCP process startup. |
|
|
743
758
|
| `webSearchConfig` | `{ backend?, endpoint? }` | Run-scoped local SearXNG/keyless WebSearch backend selection. |
|
|
@@ -751,6 +766,7 @@ Per-call options (a non-exhaustive selection):
|
|
|
751
766
|
| `onEvent` | `(event) => void` | Fired for every runtime event (assistant text, tool calls/results, applied live input, runtime warnings, structured output). |
|
|
752
767
|
| `runId` | `string` | Tag this run for downstream callbacks (e.g. `onCompactionRecorded`). |
|
|
753
768
|
| `providerSessionId` | `string` | Resume a prior provider session. |
|
|
769
|
+
| `acpSessionTokenKey` | `Uint8Array(32)` | Required for ACP task runs when not bound at `createRuntime()`; keep it secret and stable across restarts. |
|
|
754
770
|
| `runArtifactDir` | `string` | Used by some providers as the Playwright MCP filename target. |
|
|
755
771
|
| `codexAppServerCommand` | `string` | Override the Codex CLI binary. |
|
|
756
772
|
| `codexAppServerArgs` | `string[]` | Override the Codex CLI arguments. |
|
|
@@ -776,6 +792,49 @@ second applied event. A throwing host `acknowledge` or `reject` callback cannot
|
|
|
776
792
|
change the native steering outcome; the Codex bridge reports it as a bounded
|
|
777
793
|
`live_input_callback_failed` runtime warning.
|
|
778
794
|
|
|
795
|
+
### Provider-native subagents and project instructions
|
|
796
|
+
|
|
797
|
+
Claude SDK runs are filesystem-isolated by default: mono-agent passes
|
|
798
|
+
`settingSources: []`, which disables user, project, and local settings sources,
|
|
799
|
+
including their `CLAUDE.md`, hooks, plugins, and `.claude/agents` profiles.
|
|
800
|
+
Anthropic managed settings remain in force and may still configure hooks or
|
|
801
|
+
plugins; `settingSources` is not a managed-policy bypass. Opt into only the
|
|
802
|
+
needed sources, for example `settingSources: ["project"]`. User, project, and
|
|
803
|
+
local settings may execute configured hooks and plugins, so enable only trusted
|
|
804
|
+
settings and avoid opting in while running in an untrusted checkout. This
|
|
805
|
+
option is SDK only. The Claude Code CLI performs its own settings discovery,
|
|
806
|
+
and mono-agent does not pass it a `--setting-sources` value.
|
|
807
|
+
|
|
808
|
+
Codex app-server owns its native collaboration agents and their profiles. The
|
|
809
|
+
bridge observes and normalizes their lifecycle, but it does not synthesize a
|
|
810
|
+
`collaborationMode` payload or inject caller-defined `nativeSubagents`
|
|
811
|
+
teammates. A non-empty configured teammate list fails before app-server startup
|
|
812
|
+
with `skipped_capability_mismatch`, allowing a fallback router to continue to a
|
|
813
|
+
Claude route.
|
|
814
|
+
|
|
815
|
+
Codex app-server runs disable automatic repository-instruction discovery by
|
|
816
|
+
default with `project_doc_max_bytes=0`. Set `codexLoadProjectDocs: true` when
|
|
817
|
+
Codex and its own collaboration agents should load repository instructions. If
|
|
818
|
+
`codexAppServerArgs` is supplied, that explicit argument vector is authoritative
|
|
819
|
+
and `codexLoadProjectDocs` does not alter it.
|
|
820
|
+
|
|
821
|
+
Provider-native and in-process delegation share `subagent_activity` telemetry.
|
|
822
|
+
`subagent.id` is the canonical parent attachment key: the initiating parent
|
|
823
|
+
tool-use id whenever the provider exposes it, or a stable synthetic key for an
|
|
824
|
+
orphan lifecycle record. `nativeId` is an optional provider task/thread id for
|
|
825
|
+
correlation only and never replaces that key.
|
|
826
|
+
The normalized phases are `agent_started`, `started`, `completed`, `message`,
|
|
827
|
+
and `agent_completed`. A `message` belongs to the child and must not be treated
|
|
828
|
+
as parent answer text or as a completed tool call.
|
|
829
|
+
|
|
830
|
+
Restrictive allowlists must still authorize the delegation surface. Include
|
|
831
|
+
`Agent` for the in-process built-in. Claude-native teammate definitions add
|
|
832
|
+
`Task` to an explicit allowed list automatically; filesystem profiles enabled
|
|
833
|
+
only through `settingSources` require callers to include `Task` themselves.
|
|
834
|
+
An explicit deny still wins. Direct Codex remains an allow-all-only bridge, so
|
|
835
|
+
a named restrictive allowlist fails before provider startup rather than being
|
|
836
|
+
silently widened.
|
|
837
|
+
|
|
779
838
|
Returns:
|
|
780
839
|
|
|
781
840
|
```ts
|
|
@@ -942,6 +1001,10 @@ Behaviour:
|
|
|
942
1001
|
- `resolveAttempt` runs once per attempt — including every same-model retry — and
|
|
943
1002
|
receives `{ attemptIndex, retryIndex }`, where `attemptIndex` stays the chain
|
|
944
1003
|
index. Its `cleanup` runs after each attempt.
|
|
1004
|
+
- `resolveAttempt().policyOptions` is the narrow host seam for translating one
|
|
1005
|
+
logical tool policy into the active provider's representation. It may replace
|
|
1006
|
+
only `allowedTools`, `disallowedTools`, and `permissionMode`; the resolver's
|
|
1007
|
+
general `options` bag still cannot replace protected request fields.
|
|
945
1008
|
|
|
946
1009
|
Chain entries can require backend capabilities via `requires: { structured_output: true, supports_mcp: true, ... }`; entries that don't satisfy the requirements are skipped (logged in `failoverHistory` as `failureKind: "skipped_capability_mismatch"`).
|
|
947
1010
|
|
package/package.json
CHANGED
|
@@ -84,7 +84,7 @@ State exactly what you want back ("return a bullet list of file:line and a one-l
|
|
|
84
84
|
/**
|
|
85
85
|
* @param {RuntimeSubagentsOptions} subagents
|
|
86
86
|
* @param {ReadonlyArray<RuntimeSubagentDefinition>} definitions
|
|
87
|
-
* @param {ReadonlyArray<string>|null} ceiling Tools
|
|
87
|
+
* @param {ReadonlyArray<string>|null} ceiling Tools available to the runtime-owned general-purpose or authored profiles, or null when authoring is off.
|
|
88
88
|
* @returns {string}
|
|
89
89
|
*/
|
|
90
90
|
function toolDescription(subagents, definitions, ceiling) {
|
|
@@ -309,7 +309,7 @@ export function createAgentTool(subagents, context = {}) {
|
|
|
309
309
|
}
|
|
310
310
|
const { profile, droppedTools } = authored
|
|
311
311
|
? buildInlineProfile(params, ceiling, names)
|
|
312
|
-
: { profile: resolveProfile(definitions, params?.name), droppedTools: [] };
|
|
312
|
+
: { profile: resolveProfile(definitions, params?.name, ceiling), droppedTools: [] };
|
|
313
313
|
if (profile === null) {
|
|
314
314
|
const available = [...names, GENERAL_PURPOSE_SUBAGENT].join(", ");
|
|
315
315
|
throw new Error(`Error: unknown subagent "${params?.name}". Available: ${available}.`);
|
|
@@ -482,7 +482,8 @@ export function createAgentTool(subagents, context = {}) {
|
|
|
482
482
|
}
|
|
483
483
|
|
|
484
484
|
/**
|
|
485
|
-
* The
|
|
485
|
+
* The ceiling for the runtime-owned general-purpose helper and authored
|
|
486
|
+
* subagents, or null when authoring is off.
|
|
486
487
|
*
|
|
487
488
|
* A host that enables authoring without stating a ceiling gets the read-only
|
|
488
489
|
* default rather than every built-in: the same reasoning as `normalizeProfile`,
|
|
@@ -548,14 +549,25 @@ function buildInlineProfile(params, ceiling, configuredNames) {
|
|
|
548
549
|
/**
|
|
549
550
|
* @param {ReadonlyArray<RuntimeSubagentDefinition>} definitions
|
|
550
551
|
* @param {string|undefined} name
|
|
552
|
+
* @param {ReadonlyArray<string>|null} ceiling Tool ceiling for the runtime-owned general-purpose profile.
|
|
551
553
|
* @returns {RuntimeSubagentDefinition|null}
|
|
552
554
|
*/
|
|
553
|
-
function resolveProfile(definitions, name) {
|
|
555
|
+
function resolveProfile(definitions, name, ceiling) {
|
|
554
556
|
if (name === undefined || name === null || name === GENERAL_PURPOSE_SUBAGENT) {
|
|
557
|
+
const allowedTools = ceiling === null
|
|
558
|
+
? DEFAULT_SUBAGENT_TOOLS
|
|
559
|
+
: DEFAULT_SUBAGENT_TOOLS.filter((tool) => ceiling.includes(tool));
|
|
560
|
+
// `normalizeProfile` treats an empty allow-list as "use the read-only
|
|
561
|
+
// default", which would silently widen this runtime-owned helper past a
|
|
562
|
+
// parent-policy ceiling that contains only write-capable tools.
|
|
563
|
+
if (allowedTools.length === 0) {
|
|
564
|
+
throw new Error(`Error: no read-only tools available to ${GENERAL_PURPOSE_SUBAGENT} within this agent's subagent ceiling (${ceiling?.join(", ") || "none"}). Use a configured profile, or do this yourself.`);
|
|
565
|
+
}
|
|
555
566
|
return normalizeProfile({
|
|
556
567
|
name: GENERAL_PURPOSE_SUBAGENT,
|
|
557
568
|
description: "Read-only researcher inheriting the main model.",
|
|
558
569
|
systemPrompt: "You are a focused research subagent. Work only from the task you were given — you cannot see the parent conversation and cannot ask anyone anything. Investigate with the tools you have, then finish with a written answer in exactly the shape the task requested. Cite file:line where relevant. Never modify files.",
|
|
570
|
+
allowedTools,
|
|
559
571
|
});
|
|
560
572
|
}
|
|
561
573
|
const found = definitions.find((definition) => definition.name === name);
|
|
@@ -1,14 +1,32 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { readToolRuntime } from "./shared/runtime-context.js";
|
|
5
|
+
import { resolveSandboxPolicy } from "./shared/tool-context.js";
|
|
4
6
|
import { performWebFetch } from "./web-fetch.js";
|
|
5
7
|
import { performWebSearch } from "./web-search.js";
|
|
6
8
|
|
|
7
9
|
const MAX_CACHE_ENTRIES = 64;
|
|
10
|
+
const MAX_SHARED_SEARCH_ENTRIES = 256;
|
|
11
|
+
const SHARED_SEARCH_TTL_MS = 15 * 60_000;
|
|
12
|
+
|
|
13
|
+
// Search results are shared process-wide rather than per model run. A run-scoped
|
|
14
|
+
// cache dies at the end of every turn and is invisible to sibling subagents, so
|
|
15
|
+
// an agent that keeps circling the same topic re-hits the network every time —
|
|
16
|
+
// which is exactly the request volume that gets keyless engines to rate-limit.
|
|
17
|
+
// One process is one agent instance, so the sharing stays inside one tenant.
|
|
18
|
+
//
|
|
19
|
+
// Only completed results are shared. In-flight promises stay per-controller on
|
|
20
|
+
// purpose: they carry the originating run's AbortSignal, and handing that to a
|
|
21
|
+
// sibling would let one turn's cancellation fail another turn's search.
|
|
22
|
+
/** @type {Map<string, {value: any, expiresAt: number}>} */
|
|
23
|
+
const sharedSearchCache = new Map();
|
|
8
24
|
|
|
9
25
|
/**
|
|
10
26
|
* One ephemeral web-tool controller for one model run. It owns in-memory
|
|
11
|
-
* deduplication, result
|
|
27
|
+
* deduplication, the fetch result cache, anonymous browser namespaces, and
|
|
28
|
+
* cleanup. Search results are the exception: they live in the process-wide
|
|
29
|
+
* cache above so sibling subagents and later turns can reuse them.
|
|
12
30
|
*
|
|
13
31
|
* @param {{searchConfig?: any, fetchConfig?: any, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, fetchImpl?: typeof fetch, browserRenderer?: any}} [options]
|
|
14
32
|
*/
|
|
@@ -22,7 +40,6 @@ export function createWebToolController({
|
|
|
22
40
|
browserRenderer,
|
|
23
41
|
} = {}) {
|
|
24
42
|
const namespace = `mono-agent-web-${randomUUID()}`;
|
|
25
|
-
const searchCache = new Map();
|
|
26
43
|
const fetchCache = new Map();
|
|
27
44
|
const searchInFlight = new Map();
|
|
28
45
|
const fetchInFlight = new Map();
|
|
@@ -66,15 +83,59 @@ export function createWebToolController({
|
|
|
66
83
|
}
|
|
67
84
|
}
|
|
68
85
|
|
|
86
|
+
/**
|
|
87
|
+
* @param {string} key
|
|
88
|
+
* @param {() => Promise<any>} execute
|
|
89
|
+
*/
|
|
90
|
+
async function cachedSearch(key, execute) {
|
|
91
|
+
if (closed) return closedResult();
|
|
92
|
+
const cached = readSharedSearch(key);
|
|
93
|
+
if (cached) return withCacheHit(cached);
|
|
94
|
+
const active = searchInFlight.get(key);
|
|
95
|
+
if (active) return withCacheHit(await active);
|
|
96
|
+
const task = Promise.resolve().then(execute);
|
|
97
|
+
searchInFlight.set(key, task);
|
|
98
|
+
try {
|
|
99
|
+
const result = await task;
|
|
100
|
+
// Failures stay uncached; the backend cooldown in web-search.js is what
|
|
101
|
+
// stops a rate-limited engine from being hammered again.
|
|
102
|
+
if (!result.error) writeSharedSearch(key, cloneResult(result));
|
|
103
|
+
return result;
|
|
104
|
+
} finally {
|
|
105
|
+
searchInFlight.delete(key);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
69
109
|
return {
|
|
70
110
|
namespace,
|
|
71
111
|
|
|
72
112
|
async search(params, execution = {}) {
|
|
73
|
-
|
|
74
|
-
|
|
113
|
+
// The key must pin the backend, the endpoint AND the network policy the
|
|
114
|
+
// search actually ran under. A params-only key was safe while the cache
|
|
115
|
+
// lived and died with one run; process-wide it would let controllers with
|
|
116
|
+
// different backends read each other's results.
|
|
117
|
+
//
|
|
118
|
+
// The policy has to be the RESOLVED one, computed exactly as
|
|
119
|
+
// performWebSearch computes it: the effective policy is the context
|
|
120
|
+
// policy merged with the request policy, so keying on the request half
|
|
121
|
+
// alone would let a run whose context denies network read entries a
|
|
122
|
+
// network-allowed run had populated. Resolved per call because
|
|
123
|
+
// readToolRuntime() is mutable process state.
|
|
124
|
+
//
|
|
125
|
+
// The snapshot is then handed to performWebSearch as the request policy
|
|
126
|
+
// rather than letting it re-resolve. Execution is deferred by a microtask
|
|
127
|
+
// and updateToolContext mutates the context in place by design, so a
|
|
128
|
+
// re-resolve could enforce a policy the key never described — caching a
|
|
129
|
+
// network-allowed result under a denied key. Merging is monotonic, so
|
|
130
|
+
// passing the snapshot back in means enforcement can only be at least as
|
|
131
|
+
// strict as the key claims.
|
|
132
|
+
const resolvedCtx = ctx ?? readToolRuntime();
|
|
133
|
+
const policy = resolveSandboxPolicy(resolvedCtx, sandboxPolicy);
|
|
134
|
+
const key = stableKey({ params, searchConfig, policy });
|
|
135
|
+
return cachedSearch(key, async () => performWebSearch(params, {
|
|
75
136
|
searchConfig,
|
|
76
|
-
sandboxPolicy,
|
|
77
|
-
ctx,
|
|
137
|
+
sandboxPolicy: policy,
|
|
138
|
+
ctx: resolvedCtx,
|
|
78
139
|
fetchImpl,
|
|
79
140
|
signal: execution.signal,
|
|
80
141
|
}));
|
|
@@ -101,7 +162,9 @@ export function createWebToolController({
|
|
|
101
162
|
const pending = [...cleanups];
|
|
102
163
|
cleanups.clear();
|
|
103
164
|
await Promise.allSettled(pending.map((cleanup) => Promise.resolve().then(cleanup)));
|
|
104
|
-
|
|
165
|
+
// The shared search cache deliberately outlives the controller; only
|
|
166
|
+
// run-owned state (browser namespaces, fetch results, in-flight work) is
|
|
167
|
+
// dropped here.
|
|
105
168
|
fetchCache.clear();
|
|
106
169
|
searchInFlight.clear();
|
|
107
170
|
fetchInFlight.clear();
|
|
@@ -109,6 +172,33 @@ export function createWebToolController({
|
|
|
109
172
|
};
|
|
110
173
|
}
|
|
111
174
|
|
|
175
|
+
function readSharedSearch(key) {
|
|
176
|
+
const entry = sharedSearchCache.get(key);
|
|
177
|
+
if (!entry) return null;
|
|
178
|
+
if (Date.now() >= entry.expiresAt) {
|
|
179
|
+
sharedSearchCache.delete(key);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
return entry.value;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function writeSharedSearch(key, value) {
|
|
186
|
+
sharedSearchCache.set(key, { value, expiresAt: Date.now() + SHARED_SEARCH_TTL_MS });
|
|
187
|
+
// Drop expired entries before falling back to insertion-order eviction, so a
|
|
188
|
+
// burst of stale keys cannot push a live one out.
|
|
189
|
+
for (const [entryKey, entry] of sharedSearchCache) {
|
|
190
|
+
if (Date.now() >= entry.expiresAt) sharedSearchCache.delete(entryKey);
|
|
191
|
+
}
|
|
192
|
+
while (sharedSearchCache.size > MAX_SHARED_SEARCH_ENTRIES) {
|
|
193
|
+
sharedSearchCache.delete(sharedSearchCache.keys().next().value);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Test hook: the shared cache is module state and would leak between cases. */
|
|
198
|
+
export function __resetSharedSearchCacheForTests() {
|
|
199
|
+
sharedSearchCache.clear();
|
|
200
|
+
}
|
|
201
|
+
|
|
112
202
|
function stableKey(value) {
|
|
113
203
|
return JSON.stringify(sortValue(value));
|
|
114
204
|
}
|