@arnilo/prism 0.5.6 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -5
- package/README.md +10 -10
- package/dist/agent-approval.js +7 -6
- package/dist/agent-definitions.js +1 -0
- package/dist/agent-loops.js +51 -12
- package/dist/agent-run-lifecycle.js +11 -0
- package/dist/agent-run-state.d.ts +6 -0
- package/dist/agent-run-state.js +29 -9
- package/dist/agent-session/session/assemble.js +33 -2
- package/dist/agent-session/session/persist.js +6 -2
- package/dist/agent-session/session/tool-round.js +1 -0
- package/dist/agent-session/session/types.d.ts +10 -0
- package/dist/agent-session/session.d.ts +15 -0
- package/dist/agent-session/session.js +59 -4
- package/dist/agent-tool-dispatch.js +5 -4
- package/dist/artifacts.d.ts +39 -1
- package/dist/artifacts.js +73 -0
- package/dist/attention-compiler.d.ts +121 -0
- package/dist/attention-compiler.js +479 -0
- package/dist/cli-init.js +20 -6
- package/dist/content.d.ts +3 -16
- package/dist/content.js +9 -99
- package/dist/context-budget.d.ts +32 -2
- package/dist/context-budget.js +51 -19
- package/dist/contracts-core/agent.d.ts +18 -0
- package/dist/contracts-core/agent.js +4 -1
- package/dist/contracts-core/attention.d.ts +66 -0
- package/dist/contracts-core/attention.js +2 -0
- package/dist/contracts-core/compaction.d.ts +59 -0
- package/dist/contracts-core/compaction.js +77 -1
- package/dist/contracts-core/provider.d.ts +4 -0
- package/dist/contracts-core.d.ts +1 -0
- package/dist/contracts-core.js +1 -0
- package/dist/contracts-protocol.d.ts +29 -0
- package/dist/contracts-run-state.d.ts +6 -0
- package/dist/host-composition.d.ts +78 -0
- package/dist/host-composition.js +248 -0
- package/dist/index.d.ts +11 -8
- package/dist/index.js +6 -5
- package/dist/input.d.ts +19 -1
- package/dist/input.js +52 -2
- package/dist/media-types.d.ts +34 -0
- package/dist/media-types.js +158 -0
- package/dist/pinned-fetch.d.ts +2 -2
- package/dist/pinned-fetch.js +11 -12
- package/dist/redaction.js +74 -1
- package/dist/secure-agent.d.ts +2 -0
- package/dist/secure-agent.js +6 -1
- package/dist/session-stores.d.ts +11 -0
- package/dist/session-stores.js +23 -8
- package/dist/tool-result-fold.d.ts +12 -0
- package/dist/tool-result-fold.js +13 -6
- package/dist/tools.d.ts +10 -0
- package/dist/tools.js +41 -0
- package/docs/acp-agent.md +42 -11
- package/docs/acp.md +3 -2
- package/docs/ag-ui.md +9 -5
- package/docs/agent-definitions.md +9 -1
- package/docs/agent-events.md +6 -1
- package/docs/agent-loops.md +1 -1
- package/docs/agent-session-runtime.md +9 -7
- package/docs/attention-compiler.md +272 -0
- package/docs/browser-automation.md +5 -2
- package/docs/cli-rpc.md +4 -2
- package/docs/coding-agent-tools.md +1 -1
- package/docs/coding-security.md +5 -3
- package/docs/coding-tools.md +1 -1
- package/docs/coding-workspaces.md +22 -0
- package/docs/compaction-and-retry.md +36 -4
- package/docs/compaction-observational-memory.md +62 -9
- package/docs/context-and-skills.md +4 -2
- package/docs/contributing.md +37 -0
- package/docs/conversations.md +1 -1
- package/docs/core.md +2 -0
- package/docs/dev-inspector.md +4 -0
- package/docs/device-adapters.md +1 -0
- package/docs/document-reader.md +12 -2
- package/docs/documents.md +11 -3
- package/docs/enterprise-postgres-state.md +2 -2
- package/docs/evaluations.md +168 -4
- package/docs/execution-timeline.md +180 -0
- package/docs/graft.md +3 -1
- package/docs/history/0.7.0-primitive-review.md +254 -0
- package/docs/history/migration-0.0.md +2 -2
- package/docs/history/release-handoffs.md +70 -1
- package/docs/host-compositions.md +147 -0
- package/docs/host-security.md +2 -2
- package/docs/hosted-sandboxes.md +94 -0
- package/docs/index.md +73 -41
- package/docs/input-and-prompt-assembly.md +5 -4
- package/docs/knowledge-sync.md +84 -0
- package/docs/language-intelligence.md +2 -2
- package/docs/live-testing.md +4 -1
- package/docs/mcp-tools.md +2 -1
- package/docs/memory-fabric.md +416 -0
- package/docs/migrate-to-0.5.md +8 -3
- package/docs/migrate-to-0.6.md +90 -0
- package/docs/migrate-to-0.7.md +345 -0
- package/docs/migration.md +43 -1
- package/docs/model-registry.md +1 -1
- package/docs/model-routing.md +79 -4
- package/docs/multi-agent-patterns.md +20 -6
- package/docs/multimodal-content.md +1 -1
- package/docs/obscura.md +3 -1
- package/docs/observability.md +52 -1
- package/docs/operations.md +13 -1
- package/docs/options-index.md +298 -0
- package/docs/peer-dependencies.md +96 -0
- package/docs/performance.md +34 -2
- package/docs/ponytail.md +2 -0
- package/docs/postgres-persistence.md +3 -1
- package/docs/process-sessions.md +3 -1
- package/docs/prompt-registry.md +1 -1
- package/docs/provider-caching.md +4 -2
- package/docs/provider-conformance.md +2 -2
- package/docs/provider-packages.md +23 -23
- package/docs/provider-primitives.md +2 -1
- package/docs/providers/ai-sdk.md +5 -2
- package/docs/providers/bedrock.md +71 -7
- package/docs/providers/openai.md +1 -1
- package/docs/public-contracts.md +2 -2
- package/docs/rag.md +24 -8
- package/docs/realtime-voice.md +87 -0
- package/docs/release-and-install.md +78 -56
- package/docs/runs-and-usage.md +3 -2
- package/docs/server.md +6 -4
- package/docs/session-stores.md +3 -1
- package/docs/speech.md +2 -0
- package/docs/sqlite-persistence.md +2 -0
- package/docs/supervisors.md +33 -5
- package/docs/testing.md +38 -0
- package/docs/thinking-and-reasoning.md +3 -1
- package/docs/tools.md +7 -6
- package/docs/web-tools.md +2 -1
- package/docs/wiki.md +1 -1
- package/docs/work-artifacts-and-review.md +14 -4
- package/docs/work-connectors.md +3 -1
- package/docs/work-tools.md +14 -4
- package/docs/workflows.md +69 -1
- package/docs/working-and-semantic-memory.md +25 -14
- package/package.json +5 -5
- package/templates/README.md +2 -0
- package/templates/business-worker/README.md.tmpl +19 -0
- package/templates/business-worker/env.example.tmpl +1 -0
- package/templates/business-worker/gitignore.tmpl +11 -0
- package/templates/business-worker/manifest.json +11 -0
- package/templates/business-worker/package.json.tmpl +23 -0
- package/templates/business-worker/src/agent.ts.tmpl +92 -0
- package/templates/business-worker/src/index.ts.tmpl +13 -0
- package/templates/business-worker/src/tests/agent.test.ts.tmpl +77 -0
- package/templates/business-worker/tsconfig.json.tmpl +15 -0
- package/templates/personal-assistant/README.md.tmpl +18 -0
- package/templates/personal-assistant/env.example.tmpl +1 -0
- package/templates/personal-assistant/gitignore.tmpl +11 -0
- package/templates/personal-assistant/manifest.json +11 -0
- package/templates/personal-assistant/package.json.tmpl +23 -0
- package/templates/personal-assistant/src/agent.ts.tmpl +65 -0
- package/templates/personal-assistant/src/index.ts.tmpl +13 -0
- package/templates/personal-assistant/src/tests/agent.test.ts.tmpl +28 -0
- package/templates/personal-assistant/tsconfig.json.tmpl +15 -0
package/dist/input.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { compileAttention, createAttentionCompiler } from "./attention-compiler.js";
|
|
1
2
|
import { assertMessagesSupportModelCapabilities } from "./content.js";
|
|
2
3
|
import { applyContextBudget, CONTEXT_BUDGET_REPORT_METADATA_KEY } from "./context-budget.js";
|
|
3
4
|
import { runInstructionInjectors } from "./instruction-injection.js";
|
|
@@ -8,6 +9,12 @@ import { skillMessages as buildSkillMessages } from "./skill-disclosure.js";
|
|
|
8
9
|
import { composeSystemPrompt } from "./system-prompts.js";
|
|
9
10
|
import { foldToolResultHistory, foldToolResults } from "./tool-result-fold.js";
|
|
10
11
|
import { selectDisclosedTools } from "./tool-search.js";
|
|
12
|
+
/**
|
|
13
|
+
* Wire payload for a tool result that carries no `value`, no `type:text` content, and no error.
|
|
14
|
+
* Every provider route serializes a tool result from this block, so the empty case must be a
|
|
15
|
+
* constant non-empty string instead of an absent payload (strict providers reject empty results).
|
|
16
|
+
*/
|
|
17
|
+
export const EMPTY_TOOL_RESULT_TEXT = "(tool completed with no output)";
|
|
11
18
|
export function createDefaultInputBuilder() {
|
|
12
19
|
return {
|
|
13
20
|
name: "default-input",
|
|
@@ -127,7 +134,41 @@ export async function assembleProviderInput(options) {
|
|
|
127
134
|
}
|
|
128
135
|
let demotedSkillBodies;
|
|
129
136
|
let budgetReport;
|
|
130
|
-
if (options.
|
|
137
|
+
if (options.attentionCompiler) {
|
|
138
|
+
if (options.contextBudget) {
|
|
139
|
+
throw new TypeError("attentionCompiler and contextBudget are mutually exclusive: the compiler raises AttentionBudgetError instead of evicting");
|
|
140
|
+
}
|
|
141
|
+
// Same default-groups path as the budget branch: the compiler needs the assembled groups
|
|
142
|
+
// (and the provider context blocks) to measure one cost for the whole request.
|
|
143
|
+
const groups = await buildDefaultInputMessageGroups(options.input, buildContext);
|
|
144
|
+
context = await resolveContextProviders({
|
|
145
|
+
providers: options.contextProviders,
|
|
146
|
+
messages: flattenInputGroups(groups, layout),
|
|
147
|
+
injectedBlocks: injectorContribs.contextBlocks.length ? injectorContribs.contextBlocks : undefined,
|
|
148
|
+
middleware: options.middleware,
|
|
149
|
+
...baseContext,
|
|
150
|
+
});
|
|
151
|
+
const compiled = await compileAttention({
|
|
152
|
+
compiler: resolveAttentionCompiler(options.attentionCompiler, options.model),
|
|
153
|
+
groups,
|
|
154
|
+
context,
|
|
155
|
+
skills,
|
|
156
|
+
tools,
|
|
157
|
+
fold: options.toolResultFold,
|
|
158
|
+
frontier: options.attentionSticky,
|
|
159
|
+
redactor: options.redactor,
|
|
160
|
+
signal: options.signal,
|
|
161
|
+
turn,
|
|
162
|
+
sessionId: options.sessionId,
|
|
163
|
+
runId: options.runId,
|
|
164
|
+
});
|
|
165
|
+
if (compiled.mutated)
|
|
166
|
+
options.onAttentionReport?.(compiled.report);
|
|
167
|
+
messages = flattenInputGroups(compiled.groups, layout);
|
|
168
|
+
if (buildContext.middleware)
|
|
169
|
+
messages = await buildContext.middleware.run("input_assembly", messages);
|
|
170
|
+
}
|
|
171
|
+
else if (options.contextBudget) {
|
|
131
172
|
// ponytail: budget path always uses default groups so eviction kinds stay deterministic;
|
|
132
173
|
// custom inputBuilder still honored when contextBudget is absent.
|
|
133
174
|
const groups = await buildDefaultInputMessageGroups(options.input, buildContext);
|
|
@@ -211,6 +252,10 @@ export async function assembleProviderInput(options) {
|
|
|
211
252
|
signal: options.signal,
|
|
212
253
|
}, { sessionId: options.sessionId });
|
|
213
254
|
}
|
|
255
|
+
/** A resolved handle is reused as-is; raw options are validated for this call only. */
|
|
256
|
+
function resolveAttentionCompiler(value, model) {
|
|
257
|
+
return "inputCap" in value ? value : createAttentionCompiler(value, { model });
|
|
258
|
+
}
|
|
214
259
|
async function buildDefaultInputMessageGroups(input, context) {
|
|
215
260
|
return {
|
|
216
261
|
instructions: [
|
|
@@ -328,7 +373,12 @@ function toolResultPayload(result) {
|
|
|
328
373
|
.map((block) => block.text)
|
|
329
374
|
.filter(Boolean)
|
|
330
375
|
.join("\n");
|
|
331
|
-
|
|
376
|
+
if (text.length > 0)
|
|
377
|
+
return text;
|
|
378
|
+
// An error already carries the outcome; without one, send the constant sentinel so no route
|
|
379
|
+
// (JSON string content, a content string, a function response, or a typed output part) emits
|
|
380
|
+
// an empty or absent payload.
|
|
381
|
+
return result.error === undefined || result.error === null ? EMPTY_TOOL_RESULT_TEXT : undefined;
|
|
332
382
|
}
|
|
333
383
|
function toolResultMessages(results) {
|
|
334
384
|
return (results ?? []).map(toToolResultMessage);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface SsrfPolicy {
|
|
2
|
+
/** When true (default), deny private/link-local/metadata hostnames and IPs. */
|
|
3
|
+
readonly denyPrivateHosts?: boolean;
|
|
4
|
+
/** Optional hostname allow-list. When set, only listed hosts are permitted. */
|
|
5
|
+
readonly allowedHostnames?: readonly string[];
|
|
6
|
+
/**
|
|
7
|
+
* Optional IP-literal CIDR allow-list (IPv4 + IPv6, e.g. `"10.0.0.0/8"`). Checked
|
|
8
|
+
* after the hostname allow-list and the denied-name list, and applied to both URL
|
|
9
|
+
* literals and resolved DNS candidates. Membership bypasses **only** the private-IP
|
|
10
|
+
* block: metadata-style hostnames (`metadata.google.internal`), loopback names, and
|
|
11
|
+
* embedded credentials stay denied, and a hostname in the list can never match.
|
|
12
|
+
* An unparseable entry fails the check closed. Explicit host trust override — see
|
|
13
|
+
* `docs/multimodal-content.md` / `docs/host-security.md`.
|
|
14
|
+
*/
|
|
15
|
+
readonly allowedCidrs?: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
export interface MediaHostAddress {
|
|
18
|
+
readonly address: string;
|
|
19
|
+
readonly family: 4 | 6;
|
|
20
|
+
}
|
|
21
|
+
export type MediaHostnameResolver = (hostname: string, signal: AbortSignal) => Promise<readonly MediaHostAddress[]>;
|
|
22
|
+
export declare class MediaContentError extends Error {
|
|
23
|
+
readonly code: "ambiguous_source" | "missing_source" | "item_too_large" | "request_too_large" | "too_many_items" | "audio_too_long" | "invalid_base64" | "ssrf_denied" | "redirect" | "fetch_failed" | "fetch_timeout" | "resource_required" | "mime_mismatch" | "unsupported_url_scheme";
|
|
24
|
+
constructor(code: MediaContentError["code"], message: string, options?: ErrorOptions);
|
|
25
|
+
}
|
|
26
|
+
export declare function assertSsrfAllowedUrl(url: string, policy?: SsrfPolicy): void;
|
|
27
|
+
export declare function normalizeHostname(value: string): string;
|
|
28
|
+
export declare function isBlockedIp(hostname: string): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Membership test for `SsrfPolicy.allowedCidrs`. Non-IP hostnames can never match; an
|
|
31
|
+
* entry that does not parse as `address/prefix` throws `ssrf_denied` (fail closed,
|
|
32
|
+
* including entries of the other address family than the one being tested).
|
|
33
|
+
*/
|
|
34
|
+
export declare function isAllowedByCidr(hostname: string, allowedCidrs: readonly string[] | undefined): boolean;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF policy, host/address types, `MediaContentError`, and the URL gate shared by the
|
|
3
|
+
* media content pipeline (`content.ts`) and the DNS-pinned fetch primitive
|
|
4
|
+
* (`pinned-fetch.ts`).
|
|
5
|
+
*
|
|
6
|
+
* Leaf module: it imports nothing from either consumer, which is what keeps the two off
|
|
7
|
+
* each other's import graph (plan 070 Task 10, shipped in 0.6.0 — the pair previously formed
|
|
8
|
+
* a deliberate ESM cycle where each module referenced the other's exports only inside
|
|
9
|
+
* function bodies). Declarations moved here verbatim; `assertSsrfAllowedUrl` is
|
|
10
|
+
* re-exported from `content.ts` so every import path and the class identity stay put.
|
|
11
|
+
*/
|
|
12
|
+
import { isIP } from "node:net";
|
|
13
|
+
export class MediaContentError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
constructor(code, message, options) {
|
|
16
|
+
super(message, options);
|
|
17
|
+
this.name = "MediaContentError";
|
|
18
|
+
this.code = code;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function assertSsrfAllowedUrl(url, policy = {}) {
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = new URL(url);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new MediaContentError("ssrf_denied", "Media URL is not a valid absolute URL");
|
|
28
|
+
}
|
|
29
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
30
|
+
throw new MediaContentError("unsupported_url_scheme", `Media URL scheme ${parsed.protocol} is not allowed`);
|
|
31
|
+
}
|
|
32
|
+
if (parsed.username || parsed.password) {
|
|
33
|
+
throw new MediaContentError("ssrf_denied", "Media URL must not embed credentials");
|
|
34
|
+
}
|
|
35
|
+
const hostname = normalizeHostname(parsed.hostname);
|
|
36
|
+
// Parsed up front so a malformed policy entry always fails closed, even when another
|
|
37
|
+
// allow-list short-circuits below. Membership only matters after the denied-name list.
|
|
38
|
+
const cidrAllowed = isAllowedByCidr(hostname, policy.allowedCidrs);
|
|
39
|
+
if (policy.allowedHostnames?.length) {
|
|
40
|
+
if (!policy.allowedHostnames.some((allowed) => hostname === normalizeHostname(allowed))) {
|
|
41
|
+
throw new MediaContentError("ssrf_denied", `Media URL host ${hostname} is not allow-listed`);
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (policy.denyPrivateHosts === false)
|
|
46
|
+
return;
|
|
47
|
+
if (hostname === "localhost" ||
|
|
48
|
+
hostname.endsWith(".localhost") ||
|
|
49
|
+
hostname.endsWith(".local") ||
|
|
50
|
+
hostname === "metadata" ||
|
|
51
|
+
hostname === "metadata.google.internal" ||
|
|
52
|
+
hostname === "instance-data") {
|
|
53
|
+
throw new MediaContentError("ssrf_denied", `Media URL host ${hostname} is not allowed`);
|
|
54
|
+
}
|
|
55
|
+
// Validates the CIDR list even for a denied/absent IP: an unparseable entry fails closed.
|
|
56
|
+
if (cidrAllowed)
|
|
57
|
+
return;
|
|
58
|
+
if (isBlockedIp(hostname)) {
|
|
59
|
+
throw new MediaContentError("ssrf_denied", `Media URL host ${hostname} is not allowed`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function normalizeHostname(value) {
|
|
63
|
+
return value
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
.replace(/^\[|\]$/g, "")
|
|
66
|
+
.replace(/\.$/, "");
|
|
67
|
+
}
|
|
68
|
+
export function isBlockedIp(hostname) {
|
|
69
|
+
const normalized = normalizeHostname(hostname);
|
|
70
|
+
const family = isIP(normalized);
|
|
71
|
+
if (family === 4)
|
|
72
|
+
return isBlockedIpv4(normalized);
|
|
73
|
+
if (family === 6)
|
|
74
|
+
return isBlockedIpv6(normalized);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Membership test for `SsrfPolicy.allowedCidrs`. Non-IP hostnames can never match; an
|
|
79
|
+
* entry that does not parse as `address/prefix` throws `ssrf_denied` (fail closed,
|
|
80
|
+
* including entries of the other address family than the one being tested).
|
|
81
|
+
*/
|
|
82
|
+
export function isAllowedByCidr(hostname, allowedCidrs) {
|
|
83
|
+
if (!allowedCidrs?.length)
|
|
84
|
+
return false;
|
|
85
|
+
const ranges = allowedCidrs.map((entry) => {
|
|
86
|
+
const range = parseCidr(entry);
|
|
87
|
+
if (!range)
|
|
88
|
+
throw new MediaContentError("ssrf_denied", `SSRF policy CIDR '${entry}' is not a valid range`);
|
|
89
|
+
return range;
|
|
90
|
+
});
|
|
91
|
+
const address = normalizeHostname(hostname);
|
|
92
|
+
const family = isIP(address);
|
|
93
|
+
if (family !== 4 && family !== 6)
|
|
94
|
+
return false;
|
|
95
|
+
const bits = family === 4 ? 32 : 128;
|
|
96
|
+
const target = addressToBigInt(address, family);
|
|
97
|
+
return ranges.some((range) => range.bits === bits && target >> BigInt(bits - range.prefix) === range.base >> BigInt(bits - range.prefix));
|
|
98
|
+
}
|
|
99
|
+
function parseCidr(value) {
|
|
100
|
+
const [address, prefixText, ...rest] = value.split("/");
|
|
101
|
+
if (rest.length > 0 || address === undefined || prefixText === undefined)
|
|
102
|
+
return undefined;
|
|
103
|
+
const family = isIP(normalizeHostname(address));
|
|
104
|
+
if (family !== 4 && family !== 6)
|
|
105
|
+
return undefined;
|
|
106
|
+
const prefix = Number(prefixText);
|
|
107
|
+
const bits = family === 4 ? 32 : 128;
|
|
108
|
+
if (!Number.isInteger(prefix) || prefix < 0 || prefix > bits)
|
|
109
|
+
return undefined;
|
|
110
|
+
return { base: addressToBigInt(normalizeHostname(address), family), bits, prefix };
|
|
111
|
+
}
|
|
112
|
+
function addressToBigInt(address, family) {
|
|
113
|
+
const words = family === 4 ? address.split(".").map(Number) : (parseIpv6Words(address) ?? []);
|
|
114
|
+
return words.reduce((accumulator, word) => (accumulator << BigInt(family === 4 ? 8 : 16)) | BigInt(word), 0n);
|
|
115
|
+
}
|
|
116
|
+
function isBlockedIpv4(address) {
|
|
117
|
+
const [a, b] = address.split(".").map(Number);
|
|
118
|
+
return (a === 0 ||
|
|
119
|
+
a === 10 ||
|
|
120
|
+
a === 127 ||
|
|
121
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
122
|
+
(a === 169 && b === 254) ||
|
|
123
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
124
|
+
(a === 192 && (b === 0 || b === 168)) ||
|
|
125
|
+
(a === 198 && (b === 18 || b === 19 || b === 51)) ||
|
|
126
|
+
(a === 203 && b === 0) ||
|
|
127
|
+
a >= 224);
|
|
128
|
+
}
|
|
129
|
+
function isBlockedIpv6(address) {
|
|
130
|
+
const words = parseIpv6Words(address);
|
|
131
|
+
if (!words)
|
|
132
|
+
return true;
|
|
133
|
+
if (words.every((word) => word === 0) || (words.slice(0, 7).every((word) => word === 0) && words[7] === 1))
|
|
134
|
+
return true;
|
|
135
|
+
if ((words[0] & 0xfe00) === 0xfc00)
|
|
136
|
+
return true;
|
|
137
|
+
if ((words[0] & 0xffc0) === 0xfe80 || (words[0] & 0xffc0) === 0xfec0)
|
|
138
|
+
return true;
|
|
139
|
+
if ((words[0] & 0xff00) === 0xff00)
|
|
140
|
+
return true;
|
|
141
|
+
if (words[0] === 0x2001 && words[1] === 0x0db8)
|
|
142
|
+
return true;
|
|
143
|
+
const mapped = words.slice(0, 5).every((word) => word === 0) && (words[5] === 0 || words[5] === 0xffff);
|
|
144
|
+
return mapped && isBlockedIpv4(`${words[6] >> 8}.${words[6] & 0xff}.${words[7] >> 8}.${words[7] & 0xff}`);
|
|
145
|
+
}
|
|
146
|
+
function parseIpv6Words(address) {
|
|
147
|
+
const parts = address.split("::");
|
|
148
|
+
if (parts.length > 2)
|
|
149
|
+
return undefined;
|
|
150
|
+
const left = parts[0] ? parts[0].split(":") : [];
|
|
151
|
+
const right = parts[1] ? parts[1].split(":") : [];
|
|
152
|
+
const missing = 8 - left.length - right.length;
|
|
153
|
+
if (missing < 0 || (parts.length === 1 && missing !== 0))
|
|
154
|
+
return undefined;
|
|
155
|
+
const words = [...left, ...Array.from({ length: missing }, () => "0"), ...right].map((part) => Number.parseInt(part, 16));
|
|
156
|
+
return words.length === 8 && words.every((word) => Number.isInteger(word) && word >= 0 && word <= 0xffff) ? words : undefined;
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=media-types.js.map
|
package/dist/pinned-fetch.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type MediaHostAddress, type MediaHostnameResolver, type SsrfPolicy } from "./
|
|
1
|
+
import { type MediaHostAddress, type MediaHostnameResolver, normalizeHostname, type SsrfPolicy } from "./media-types.js";
|
|
2
|
+
export { normalizeHostname };
|
|
2
3
|
export interface PinnedFetchOptions {
|
|
3
4
|
/** Prefix for request-level error messages ("redirects are not allowed", "response exceeds", ...). Default "Request". */
|
|
4
5
|
readonly errorPrefix?: string;
|
|
@@ -20,6 +21,5 @@ export declare function defaultResolver(hostname: string): Promise<readonly Medi
|
|
|
20
21
|
export declare function requestPinned(url: URL, address: MediaHostAddress, init: RequestInit | undefined, errorPrefix?: string): Promise<Response>;
|
|
21
22
|
export declare function boundResponse(response: Response, maxBytes: number, errorPrefix?: string): Response;
|
|
22
23
|
export declare function raceAbort<T>(promise: Promise<T>, signal: AbortSignal | null | undefined): Promise<T>;
|
|
23
|
-
export declare function normalizeHostname(value: string): string;
|
|
24
24
|
export declare function isLoopbackHostname(value: string): boolean;
|
|
25
25
|
export declare function isLoopbackAddress(value: string): boolean;
|
package/dist/pinned-fetch.js
CHANGED
|
@@ -11,15 +11,17 @@
|
|
|
11
11
|
* for 3xx). Error messages are parameterized by `errorPrefix` so each caller
|
|
12
12
|
* (MCP, OIDC, OPA, content) keeps its own taxonomy and message text.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
* media
|
|
16
|
-
*
|
|
14
|
+
* The SSRF gate, `MediaContentError`, and the host/address types come from the leaf
|
|
15
|
+
* module `./media-types.js` — shared with `content.ts`, which routes its default media
|
|
16
|
+
* fetch through here. This module transitively imports nothing from `content.ts` (plan
|
|
17
|
+
* 070 Task 10 broke the deliberate ESM cycle that used to sit between the two).
|
|
17
18
|
*/
|
|
18
19
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
19
20
|
import { request as httpRequest } from "node:http";
|
|
20
21
|
import { request as httpsRequest } from "node:https";
|
|
21
22
|
import { isIP } from "node:net";
|
|
22
|
-
import { assertSsrfAllowedUrl, MediaContentError } from "./
|
|
23
|
+
import { assertSsrfAllowedUrl, MediaContentError, normalizeHostname, } from "./media-types.js";
|
|
24
|
+
export { normalizeHostname };
|
|
23
25
|
/** One DNS-pinned, redirect-free, byte-bounded fetch. See module comment. */
|
|
24
26
|
export async function pinnedFetch(url, init, options) {
|
|
25
27
|
const errorPrefix = options?.errorPrefix ?? "Request";
|
|
@@ -72,8 +74,11 @@ export async function resolvePinnedAddress(url, resolver, signal, allowLoopback,
|
|
|
72
74
|
}
|
|
73
75
|
const literal = candidate.family === 6 ? `[${normalized}]` : normalized;
|
|
74
76
|
// Fail closed on resolved candidates: an explicit hostname allow-list is honored
|
|
75
|
-
// for the URL itself, but every resolved address is still private-checked.
|
|
76
|
-
|
|
77
|
+
// for the URL itself, but every resolved address is still private-checked. An
|
|
78
|
+
// allowed CIDR is a range rule, so it does apply to resolved addresses.
|
|
79
|
+
const candidatePolicy = ssrf?.allowedHostnames?.length
|
|
80
|
+
? { denyPrivateHosts: ssrf.denyPrivateHosts, ...(ssrf.allowedCidrs ? { allowedCidrs: ssrf.allowedCidrs } : {}) }
|
|
81
|
+
: ssrf;
|
|
77
82
|
try {
|
|
78
83
|
assertSsrfAllowedUrl(`${url.protocol}//${literal}`, candidatePolicy);
|
|
79
84
|
}
|
|
@@ -250,12 +255,6 @@ export async function raceAbort(promise, signal) {
|
|
|
250
255
|
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
251
256
|
});
|
|
252
257
|
}
|
|
253
|
-
export function normalizeHostname(value) {
|
|
254
|
-
return value
|
|
255
|
-
.toLowerCase()
|
|
256
|
-
.replace(/^\[|\]$/g, "")
|
|
257
|
-
.replace(/\.$/, "");
|
|
258
|
-
}
|
|
259
258
|
export function isLoopbackHostname(value) {
|
|
260
259
|
const hostname = normalizeHostname(value);
|
|
261
260
|
return hostname === "localhost" || hostname.endsWith(".localhost") || isLoopbackAddress(hostname);
|
package/dist/redaction.js
CHANGED
|
@@ -3,6 +3,18 @@ const REDACTED = "[REDACTED]";
|
|
|
3
3
|
// Depth bound matching agent-run-state.ts; hostile deep structures yield a placeholder
|
|
4
4
|
// instead of a stack overflow.
|
|
5
5
|
const MAX_REDACT_DEPTH = 32;
|
|
6
|
+
// Plan 070 Task 9: single-pass fast path. One left-to-right alternation replaces every
|
|
7
|
+
// occurrence of every needle in a single scan, instead of one split/join pass per needle.
|
|
8
|
+
// It is only equivalent to the ordered reduce below when no needle occurrence can overlap
|
|
9
|
+
// another needle's occurrence or a produced placeholder, so `singlePassMatcher` returns
|
|
10
|
+
// null for such sets and the loop stays authoritative. Below this length the set check
|
|
11
|
+
// costs more than the passes it saves (measured crossover ~4 KB, so the fast path only
|
|
12
|
+
// engages with a wide margin).
|
|
13
|
+
const SINGLE_PASS_MIN_CHARS = 16 * 1024;
|
|
14
|
+
// ponytail: bound the fast path to this needle count. The set check is O(k²) and the
|
|
15
|
+
// alternation compile grows with k, so the loop is no slower beyond it. Raise if a host
|
|
16
|
+
// redacts with much larger secret sets.
|
|
17
|
+
const SINGLE_PASS_MAX_NEEDLES = 32;
|
|
6
18
|
export function createSecretRedactor(secrets) {
|
|
7
19
|
return { redact: (value) => redactSecrets(value, secrets) };
|
|
8
20
|
}
|
|
@@ -35,7 +47,18 @@ export function redactSecrets(value, secrets) {
|
|
|
35
47
|
const needles = secrets.filter((secret) => Boolean(secret));
|
|
36
48
|
if (needles.length === 0)
|
|
37
49
|
return value;
|
|
38
|
-
|
|
50
|
+
// Decided lazily on the first large string, and only once per call: a redaction of many
|
|
51
|
+
// small strings never pays the set check and never regresses against the loop.
|
|
52
|
+
let singlePass;
|
|
53
|
+
const redactString = (text) => {
|
|
54
|
+
if (text.length >= SINGLE_PASS_MIN_CHARS) {
|
|
55
|
+
if (singlePass === undefined)
|
|
56
|
+
singlePass = singlePassMatcher(needles);
|
|
57
|
+
if (singlePass)
|
|
58
|
+
return text.replace(singlePass, REDACTED);
|
|
59
|
+
}
|
|
60
|
+
return needles.reduce((current, secret) => current.split(secret).join(REDACTED), text);
|
|
61
|
+
};
|
|
39
62
|
const redactKey = (key) => {
|
|
40
63
|
if (typeof key === "string")
|
|
41
64
|
return redactString(key);
|
|
@@ -90,6 +113,56 @@ export function redactSecrets(value, secrets) {
|
|
|
90
113
|
};
|
|
91
114
|
return redact(value);
|
|
92
115
|
}
|
|
116
|
+
function escapeRegExpLiteral(literal) {
|
|
117
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Compiled single-pass matcher for `needles`, or null when the set is not provably
|
|
121
|
+
* equivalent to the ordered reduce/split/join in `redactSecrets`. Equivalence holds when
|
|
122
|
+
* no needle occurrence can overlap another needle's occurrence (overlap would make the
|
|
123
|
+
* result depend on which needle is mentioned first rather than on position) and no needle
|
|
124
|
+
* can occur inside or across the edges of a produced "[REDACTED]" placeholder (a later
|
|
125
|
+
* pass would then redact text the single scan never sees). Both checks are conservative:
|
|
126
|
+
* a false negative only costs the fast path.
|
|
127
|
+
*/
|
|
128
|
+
function singlePassMatcher(needles) {
|
|
129
|
+
if (needles.length < 2 || needles.length > SINGLE_PASS_MAX_NEEDLES)
|
|
130
|
+
return null;
|
|
131
|
+
for (const needle of needles) {
|
|
132
|
+
if (needleTouchesPlaceholder(needle))
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
for (const left of needles) {
|
|
136
|
+
for (const right of needles) {
|
|
137
|
+
if (left !== right && needlesOverlap(left, right))
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return new RegExp(needles.map(escapeRegExpLiteral).join("|"), "g");
|
|
142
|
+
}
|
|
143
|
+
/** A placeholder-relative occurrence: needle inside "[REDACTED]", or a needle prefix equal
|
|
144
|
+
* to a placeholder suffix / needle suffix equal to a placeholder prefix (a match spanning
|
|
145
|
+
* the placeholder's edge that the ordered passes would create). */
|
|
146
|
+
function needleTouchesPlaceholder(needle) {
|
|
147
|
+
if (REDACTED.includes(needle))
|
|
148
|
+
return true;
|
|
149
|
+
for (let n = 1; n < needle.length; n += 1) {
|
|
150
|
+
if (REDACTED.endsWith(needle.slice(0, n)) || REDACTED.startsWith(needle.slice(n)))
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
/** One occurrence of `left` overlapping one of `right`: containment either way, or a proper
|
|
156
|
+
* suffix of `left` equal to a proper prefix of `right` (callers check both directions). */
|
|
157
|
+
function needlesOverlap(left, right) {
|
|
158
|
+
if (right.includes(left))
|
|
159
|
+
return true;
|
|
160
|
+
for (let n = 1; n < left.length && n < right.length; n += 1) {
|
|
161
|
+
if (right.startsWith(left.slice(left.length - n)))
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
93
166
|
export function errorToErrorInfo(error, secrets = []) {
|
|
94
167
|
const code = readErrorCode(error);
|
|
95
168
|
const retry = readRetryAfterMs(error);
|
package/dist/secure-agent.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import type { Agent, SecureAgentOptions } from "./contracts.js";
|
|
2
|
+
import { assertHostCompositionReadiness, HostCompositionError, type HostCompositionGovernance, type HostCompositionOptions, type HostCompositionProfile, type HostCompositionReport, type HostCompositionToolReport, inspectHostComposition } from "./host-composition.js";
|
|
3
|
+
export { assertHostCompositionReadiness, HostCompositionError, type HostCompositionGovernance, type HostCompositionOptions, type HostCompositionProfile, type HostCompositionReport, type HostCompositionToolReport, inspectHostComposition, };
|
|
2
4
|
/** Build an opt-in agent whose security-critical defaults cannot be replaced per run. */
|
|
3
5
|
export declare function createSecureAgent(options: SecureAgentOptions): Agent;
|
package/dist/secure-agent.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { validateRunStateOptions } from "./agent-run-state.js";
|
|
2
2
|
import { createAgent } from "./agents.js";
|
|
3
|
+
import { assertHostCompositionReadiness, HostCompositionError, inspectHostComposition, } from "./host-composition.js";
|
|
3
4
|
import { assertIdentityActive, assertIdentityMatchesOwnership } from "./identity.js";
|
|
4
5
|
import { resolveRunLimits } from "./run-limits.js";
|
|
5
6
|
import { createToolParameterValidator, createToolRegistry } from "./tools.js";
|
|
7
|
+
export { assertHostCompositionReadiness, HostCompositionError, inspectHostComposition, };
|
|
6
8
|
/** Build an opt-in agent whose security-critical defaults cannot be replaced per run. */
|
|
7
9
|
export function createSecureAgent(options) {
|
|
8
10
|
if (!options.id.trim())
|
|
@@ -32,6 +34,9 @@ export function createSecureAgent(options) {
|
|
|
32
34
|
assertIdentityActive(options.identity);
|
|
33
35
|
assertIdentityMatchesOwnership(options.identity, options.ownership);
|
|
34
36
|
}
|
|
37
|
+
if (options.composition) {
|
|
38
|
+
assertHostCompositionReadiness({ ...options.composition, agent: options });
|
|
39
|
+
}
|
|
35
40
|
const runState = Object.freeze({ ...options.runState, definitionRevision: options.definitionRevision, interruptBeforeTool: true });
|
|
36
41
|
validateRunStateOptions(runState);
|
|
37
42
|
const config = Object.freeze({
|
|
@@ -52,7 +57,7 @@ export function createSecureAgent(options) {
|
|
|
52
57
|
return createAgent(config);
|
|
53
58
|
}
|
|
54
59
|
function withoutSecureFields(options) {
|
|
55
|
-
const { tools: _tools, toolArgumentValidator: _validator, redactor: _redactor, permission: _permission, trust: _trust, ownership: _ownership, identity: _identity, limits: _limits, guardrails: _guardrails, definitionRevision: _revision, runState: _runState, ...config } = options;
|
|
60
|
+
const { tools: _tools, toolArgumentValidator: _validator, redactor: _redactor, permission: _permission, trust: _trust, ownership: _ownership, identity: _identity, limits: _limits, guardrails: _guardrails, definitionRevision: _revision, runState: _runState, composition: _composition, ...config } = options;
|
|
56
61
|
return config;
|
|
57
62
|
}
|
|
58
63
|
function freezeGuardrails(guardrails) {
|
package/dist/session-stores.d.ts
CHANGED
|
@@ -28,5 +28,16 @@ export type MemorySessionSearchMode = "linear" | "unsupported";
|
|
|
28
28
|
export interface CreateMemorySessionStoreOptions {
|
|
29
29
|
/** Default `"linear"`: capped in-process scan. `"unsupported"`: typed throw. */
|
|
30
30
|
readonly sessionSearchMode?: MemorySessionSearchMode;
|
|
31
|
+
/**
|
|
32
|
+
* Optional overrides for the capped in-process scan. Hosts with a small session set can
|
|
33
|
+
* raise the caps (bounded by the contract `HARD_MAX_SESSION_SEARCH_LINEAR_*` values);
|
|
34
|
+
* defaults are the contract `DEFAULT_MAX_SESSION_SEARCH_LINEAR_*` caps. Values below 1
|
|
35
|
+
* or above the hard cap fail store construction closed with a `TypeError`.
|
|
36
|
+
*/
|
|
37
|
+
readonly search?: {
|
|
38
|
+
readonly maxLinearSessions?: number;
|
|
39
|
+
readonly maxLinearEntries?: number;
|
|
40
|
+
readonly maxLinearBytes?: number;
|
|
41
|
+
};
|
|
31
42
|
}
|
|
32
43
|
export declare function createMemorySessionStore(initialEntries?: readonly SessionEntry[], options?: CreateMemorySessionStoreOptions): SessionStore;
|
package/dist/session-stores.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEFAULT_MAX_SESSION_SEARCH_LINEAR_BYTES, DEFAULT_MAX_SESSION_SEARCH_LINEAR_ENTRIES, DEFAULT_MAX_SESSION_SEARCH_LINEAR_SESSIONS, DEFAULT_MAX_SESSION_SEARCH_SNIPPET_BYTES, resolveSessionSearchQuery, SESSION_APPEND_CONFLICT_CODE, SESSION_SEARCH_WORKSPACE_METADATA_KEY, SessionAppendConflictError, SessionSearchUnsupportedError, } from "./contracts.js";
|
|
1
|
+
import { DEFAULT_MAX_SESSION_SEARCH_LINEAR_BYTES, DEFAULT_MAX_SESSION_SEARCH_LINEAR_ENTRIES, DEFAULT_MAX_SESSION_SEARCH_LINEAR_SESSIONS, DEFAULT_MAX_SESSION_SEARCH_SNIPPET_BYTES, HARD_MAX_SESSION_SEARCH_LINEAR_BYTES, HARD_MAX_SESSION_SEARCH_LINEAR_ENTRIES, HARD_MAX_SESSION_SEARCH_LINEAR_SESSIONS, resolveSessionSearchQuery, SESSION_APPEND_CONFLICT_CODE, SESSION_SEARCH_WORKSPACE_METADATA_KEY, SessionAppendConflictError, SessionSearchUnsupportedError, } from "./contracts.js";
|
|
2
2
|
import { createId } from "./ids.js";
|
|
3
3
|
export function createSessionEntry(options) {
|
|
4
4
|
const { createId, now, ...entry } = options;
|
|
@@ -96,12 +96,27 @@ function rebuildSessionContextCore(entries, options = {}) {
|
|
|
96
96
|
}
|
|
97
97
|
return { leafId: branch.at(-1)?.id, entries: branch, messages, summaries };
|
|
98
98
|
}
|
|
99
|
+
function resolveLinearSearchCaps(search) {
|
|
100
|
+
return {
|
|
101
|
+
sessions: assertLinearCap(search?.maxLinearSessions, "maxLinearSessions", DEFAULT_MAX_SESSION_SEARCH_LINEAR_SESSIONS, HARD_MAX_SESSION_SEARCH_LINEAR_SESSIONS),
|
|
102
|
+
entries: assertLinearCap(search?.maxLinearEntries, "maxLinearEntries", DEFAULT_MAX_SESSION_SEARCH_LINEAR_ENTRIES, HARD_MAX_SESSION_SEARCH_LINEAR_ENTRIES),
|
|
103
|
+
bytes: assertLinearCap(search?.maxLinearBytes, "maxLinearBytes", DEFAULT_MAX_SESSION_SEARCH_LINEAR_BYTES, HARD_MAX_SESSION_SEARCH_LINEAR_BYTES),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function assertLinearCap(value, name, fallback, hardMax) {
|
|
107
|
+
const cap = value ?? fallback;
|
|
108
|
+
if (!Number.isSafeInteger(cap) || cap < 1 || cap > hardMax) {
|
|
109
|
+
throw new TypeError(`CreateMemorySessionStoreOptions.search.${name} must be a safe integer from 1 to ${hardMax}`);
|
|
110
|
+
}
|
|
111
|
+
return cap;
|
|
112
|
+
}
|
|
99
113
|
export function createMemorySessionStore(initialEntries = [], options = {}) {
|
|
100
114
|
const byId = new Map();
|
|
101
115
|
const bySession = new Map();
|
|
102
116
|
const leafBySession = new Map();
|
|
103
117
|
const idempotencySeen = new Set();
|
|
104
118
|
const mode = options.sessionSearchMode ?? "linear";
|
|
119
|
+
const searchCaps = resolveLinearSearchCaps(options.search);
|
|
105
120
|
for (const entry of initialEntries)
|
|
106
121
|
add(entry);
|
|
107
122
|
return {
|
|
@@ -118,7 +133,7 @@ export function createMemorySessionStore(initialEntries = [], options = {}) {
|
|
|
118
133
|
async searchSessions(query) {
|
|
119
134
|
if (mode === "unsupported")
|
|
120
135
|
throw new SessionSearchUnsupportedError();
|
|
121
|
-
return searchMemorySessionsLinear(bySession, leafBySession, query);
|
|
136
|
+
return searchMemorySessionsLinear(bySession, leafBySession, query, searchCaps);
|
|
122
137
|
},
|
|
123
138
|
};
|
|
124
139
|
function add(entry, options) {
|
|
@@ -160,7 +175,7 @@ export function createMemorySessionStore(initialEntries = [], options = {}) {
|
|
|
160
175
|
leafBySession.set(entry.sessionId, entry.id);
|
|
161
176
|
}
|
|
162
177
|
}
|
|
163
|
-
function searchMemorySessionsLinear(bySession, leafBySession, query) {
|
|
178
|
+
function searchMemorySessionsLinear(bySession, leafBySession, query, caps) {
|
|
164
179
|
const q = resolveSessionSearchQuery(query);
|
|
165
180
|
q.signal?.throwIfAborted();
|
|
166
181
|
let sessionsScanned = 0;
|
|
@@ -168,11 +183,11 @@ function searchMemorySessionsLinear(bySession, leafBySession, query) {
|
|
|
168
183
|
let bytesScanned = 0;
|
|
169
184
|
const matches = [];
|
|
170
185
|
for (const [sessionId, entries] of bySession) {
|
|
171
|
-
if (sessionsScanned >=
|
|
186
|
+
if (sessionsScanned >= caps.sessions)
|
|
172
187
|
break;
|
|
173
|
-
if (entriesScanned >=
|
|
188
|
+
if (entriesScanned >= caps.entries)
|
|
174
189
|
break;
|
|
175
|
-
if (bytesScanned >=
|
|
190
|
+
if (bytesScanned >= caps.bytes)
|
|
176
191
|
break;
|
|
177
192
|
q.signal?.throwIfAborted();
|
|
178
193
|
sessionsScanned += 1;
|
|
@@ -190,9 +205,9 @@ function searchMemorySessionsLinear(bySession, leafBySession, query) {
|
|
|
190
205
|
let matchedModel = false;
|
|
191
206
|
let snippetSource;
|
|
192
207
|
for (const entry of entries) {
|
|
193
|
-
if (entriesScanned >=
|
|
208
|
+
if (entriesScanned >= caps.entries)
|
|
194
209
|
break;
|
|
195
|
-
if (bytesScanned >=
|
|
210
|
+
if (bytesScanned >= caps.bytes)
|
|
196
211
|
break;
|
|
197
212
|
entriesScanned += 1;
|
|
198
213
|
const text = entrySearchText(entry);
|
|
@@ -38,3 +38,15 @@ export declare function foldToolResultHistory(history: readonly Message[], optio
|
|
|
38
38
|
export declare function foldToolResults(results: readonly ToolResult[], options: ResolvedToolResultFoldOptions, context: FoldToolResultsContext): Promise<readonly ToolResult[]>;
|
|
39
39
|
export declare function formatFoldedToolResult(summary: string): string;
|
|
40
40
|
export declare function foldedToolResultHeader(toolName: string, toolCallId: string, summary: string): string;
|
|
41
|
+
/** Text the fold summarizes for one tool result: the payload JSON, then any text blocks.
|
|
42
|
+
* Shared with the attention compiler so both hash/summarize the same bytes. */
|
|
43
|
+
export declare function toolResultFoldText(result: unknown, error: unknown, extra?: readonly {
|
|
44
|
+
readonly type: string;
|
|
45
|
+
}[]): string;
|
|
46
|
+
/** UTF-8 cap that never splits a multi-byte character. Shared with the attention
|
|
47
|
+
* compiler's host-summarize path so both cap host text identically. */
|
|
48
|
+
export declare function capToolResultSummary(summary: string, maxBytes: number): string;
|
|
49
|
+
/** Provider turn per history index: assistant messages advance the turn, tool rows use
|
|
50
|
+
* their `prismToolResultTurn` stamp when present and the last assistant turn otherwise.
|
|
51
|
+
* Shared with the attention compiler so age gates match the fold exactly. */
|
|
52
|
+
export declare function inferToolResultTurns(history: readonly Message[]): readonly number[];
|
package/dist/tool-result-fold.js
CHANGED
|
@@ -58,7 +58,7 @@ async function foldToolResultMessage(message, options, context) {
|
|
|
58
58
|
const block = message.content.find((part) => part.type === "tool_result");
|
|
59
59
|
if (block?.type !== "tool_result")
|
|
60
60
|
return message;
|
|
61
|
-
const text =
|
|
61
|
+
const text = toolResultFoldText(block.result, block.error, message.content);
|
|
62
62
|
const folded = await maybeFold({
|
|
63
63
|
options,
|
|
64
64
|
context,
|
|
@@ -80,7 +80,7 @@ async function foldToolResultMessage(message, options, context) {
|
|
|
80
80
|
return folded ?? message;
|
|
81
81
|
}
|
|
82
82
|
async function foldToolResultValue(result, options, context) {
|
|
83
|
-
const text =
|
|
83
|
+
const text = toolResultFoldText(result.value, result.error, result.content);
|
|
84
84
|
const folded = await maybeFold({
|
|
85
85
|
options,
|
|
86
86
|
context,
|
|
@@ -112,7 +112,7 @@ async function maybeFold(input) {
|
|
|
112
112
|
toolName: input.toolName,
|
|
113
113
|
text: input.text,
|
|
114
114
|
});
|
|
115
|
-
return input.apply(
|
|
115
|
+
return input.apply(capToolResultSummary(String(summary), input.options.maxSummaryBytes));
|
|
116
116
|
}
|
|
117
117
|
catch {
|
|
118
118
|
return undefined;
|
|
@@ -124,7 +124,9 @@ export function formatFoldedToolResult(summary) {
|
|
|
124
124
|
export function foldedToolResultHeader(toolName, toolCallId, summary) {
|
|
125
125
|
return `Tool result ${toolName} [${toolCallId}]: ${summary}`;
|
|
126
126
|
}
|
|
127
|
-
|
|
127
|
+
/** Text the fold summarizes for one tool result: the payload JSON, then any text blocks.
|
|
128
|
+
* Shared with the attention compiler so both hash/summarize the same bytes. */
|
|
129
|
+
export function toolResultFoldText(result, error, extra) {
|
|
128
130
|
const parts = [JSON.stringify(error ?? result ?? null)];
|
|
129
131
|
for (const block of extra ?? []) {
|
|
130
132
|
if (block.type === "text" && "text" in block && typeof block.text === "string")
|
|
@@ -132,7 +134,9 @@ function toolResultText(result, error, extra) {
|
|
|
132
134
|
}
|
|
133
135
|
return parts.join("\n");
|
|
134
136
|
}
|
|
135
|
-
|
|
137
|
+
/** UTF-8 cap that never splits a multi-byte character. Shared with the attention
|
|
138
|
+
* compiler's host-summarize path so both cap host text identically. */
|
|
139
|
+
export function capToolResultSummary(summary, maxBytes) {
|
|
136
140
|
const bytes = estimateTextBytes(summary);
|
|
137
141
|
if (bytes <= maxBytes)
|
|
138
142
|
return summary;
|
|
@@ -143,7 +147,10 @@ function capSummaryBytes(summary, maxBytes) {
|
|
|
143
147
|
end--;
|
|
144
148
|
return `${new TextDecoder().decode(encoded.slice(0, end))}…`;
|
|
145
149
|
}
|
|
146
|
-
|
|
150
|
+
/** Provider turn per history index: assistant messages advance the turn, tool rows use
|
|
151
|
+
* their `prismToolResultTurn` stamp when present and the last assistant turn otherwise.
|
|
152
|
+
* Shared with the attention compiler so age gates match the fold exactly. */
|
|
153
|
+
export function inferToolResultTurns(history) {
|
|
147
154
|
const turns = new Array(history.length).fill(1);
|
|
148
155
|
let providerTurn = 0;
|
|
149
156
|
let toolTurn = 1;
|