Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/reference/contracts/sse-event-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export type RunEvent =
| `agent:tool_call` | An agent invoked a tool. | `nodeId`, `model` (the invoking model — so a tool call is attributable across a failover), `toolId`, `toolInput` (sanitized — no secrets), `attemptNumber?` (1-based, matches `cost:updated`) |
| `agent:tool_result` | A tool returned. | `nodeId`, `toolId`, `success`, `outputSummary` (truncated for UI), `attemptNumber?` |
| `agent:file_patch_proposed` | An agent proposed a file change (**gated — no write until the user accepts**; e.g. the VS Code inline-diff review). | `nodeId`, `patches: [{ uri, unifiedDiff }]` (≥1 — an empty proposal is meaningless), `attemptNumber?` |
| `cost:updated` | A node's token cost was tallied (drives the cost waterfall). | `nodeId`, `model`, `inputTokens`, `outputTokens`, `costMicrocents`, `cumulativeCostMicrocents` (integer micro-cents — canonical unit in [llm-provider-seam.md](../shared-core/llm-provider-seam.md#6-usage)), `mediaUnits?` (1.AF — a **disjoint** media usage axis, never folded into tokens; ADR-0031 A6 / ADR-0044), `attemptNumber?` (1-based **within-chain** FallbackChain attempt — resets per node-retry re-dispatch; **distinct** from `node:*.attemptNumber`, see the [two attemptNumber families](#two-attemptnumber-families) note) |
| `cost:updated` | A node's token cost was tallied (drives the cost waterfall). | `nodeId`, `model`, `inputTokens`, `outputTokens`, `costMicrocents`, `cumulativeCostMicrocents` (integer micro-cents — canonical unit in [llm-provider-seam.md](../shared-core/llm-provider-seam.md#6-usage); **includes realized media spend**, folded as a disjoint addend per [ADR-0044](../../decisions/0044-media-access-governance-read-media-save-to-cost.md) §3 — the per-unit `Usage.mediaUnits` axis is **not yet a field on this event**, deferred, see [deferred-tasks.md](../../roadmap/deferred-tasks.md)), `attemptNumber?` (1-based **within-chain** FallbackChain attempt — resets per node-retry re-dispatch; **distinct** from `node:*.attemptNumber`, see the [two attemptNumber families](#two-attemptnumber-families) note) |
| `node:completed` | A node finished successfully. | `nodeId`, `output`, `tokensUsed: {input, output, model?}` (`model` only for LLM nodes), `durationMs`, `selected?` (a `condition`'s chosen target ids — the authoritative branch record checkpoint/resume restores from, 1.R; **may be an empty array** when the condition routes to no branch, dimming all downstream), `attemptNumber?` (1-based **node-retry** dispatch attempt — 1.S; absent ⇒ attempt 1) |
| `node:failed` | A node failed (TERMINAL — exactly one per node; emitted when the node-retry budget is exhausted, on a fatal / `retry_on`-excluded failure, **or** when a pending retry is abandoned by a cancel or a sibling abort — see 1.S). | `nodeId`, `error: {code, message, retryable, correlationId?}` (`code` is an [`ErrorCode`](#error-code-taxonomy); `correlationId` is a secret-free id joined to the internal log — ADR-0036), `attemptNumber?` (the last attempt, when a retry budget was spent — 1.S) |
| `node:retrying` | A retryable node attempt failed and the engine will re-dispatch the whole node (1.S, [ADR-0040](../../decisions/0040-node-retry-budget-above-the-chain.md)) — **non-terminal** (the node continues; `node:failed` is the terminal). | `nodeId`, `attemptNumber` (the attempt that just failed, 1-based), `error: {code, message, retryable}` (the `NodeFailure` shape — **no** `correlationId`; that anchors the terminal failure), `delayMs` (backoff before the next attempt) |
Expand Down Expand Up @@ -117,8 +117,12 @@ export interface CostUpdatedEvent extends BaseEvent {
inputTokens: number;
outputTokens: number;
costMicrocents: number; // integer micro-cents (canonical unit defined in llm-provider-seam.md); this attempt, from Relavium's pricing table (never the provider)
cumulativeCostMicrocents: number; // integer micro-cents running total for the whole run
mediaUnits?: MediaUnitsEntry[]; // (1.AF) a DISJOINT media observability/billing axis — image per-count, audio/video per-second (or a raw token count, unit:'count', for a token-based provider's audio); NEVER folded into the token counts above (ADR-0031 A6 / ADR-0044). Present only when a provider reports media usage (OpenAI audio today; nil otherwise)
cumulativeCostMicrocents: number; // integer micro-cents running total for the whole run — INCLUDES realized media spend, folded as a disjoint addend (ADR-0044 §3)
// NOTE (1.AF): the per-unit `Usage.mediaUnits` axis (image per-count, audio/video per-second; a token-based
// provider's audio rides as unit:'count') is NOT yet a field on this event. Realized media spend already
// folds into `cumulativeCostMicrocents`; surfacing the disjoint per-unit counts here needs `MediaUnitsEntry`
// relocated to `@relavium/shared` first (run-event.ts cannot import the `@relavium/llm` seam type). Deferred —
// see deferred-tasks.md.
attemptNumber?: number; // 1-based WITHIN-CHAIN attempt; resets per node-retry re-dispatch — distinct from node:*.attemptNumber (see "Two attemptNumber families")
}

Expand Down
1 change: 1 addition & 0 deletions docs/reference/shared-core/built-in-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ A tool declaration in YAML has a `type` discriminator:
| `notify` | Send a native desktop notification (title, body, optional action buttons). | `void` | Lets an agent request attention mid-run without blocking the run. |
| `mcp_call` | Connect to a registered MCP server and invoke one of its tools by name. | the MCP tool result as JSON | Server resolved from config; see [mcp-integration.md](mcp-integration.md). |
| `invoke_agent` | Call another agent node in the same workflow by node id, with explicit input. | that agent's output | The dynamic-dispatch mechanism used by orchestrator agents to delegate. |
| `read_media` | Read a produced/received media asset's bytes by its durable `media://sha256-…` handle (optional byte `Range`), so a media-capable model can consume it inline. | a gated `tool_result` media part (the bytes as an in-flight source under the inline ceiling) | **Scope-set authz** — the requesting `session`/`workspace` must be in the handle's `allowedScopes` ([ADR-0044](../../decisions/0044-media-access-governance-read-media-save-to-cost.md) §1); knowing a sha256 is **not** authorization; read-only (bypasses the action gate), fail-closed `Range`, secret-free `media_scope_denied`. *(Engine-pure policy landed at 1.AF; the host `MediaReadAccess` mechanism + scope population are wired at 1.AH — see [deferred-tasks.md](../../roadmap/deferred-tasks.md).)* |

> **Mandatory guardrails (security tightenings — [ADR-0029](../../decisions/0029-tool-policy-hardening.md)):** `run_command` only executes commands that **exactly match** the workflow's `allowedCommands` allowlist (opt-in glob via `allowedCommandGlobs`; defined under `spec.tools.allowedCommands` — see [workflow-yaml-spec.md](../contracts/workflow-yaml-spec.md#tool-policy-spectools); empty or absent ⇒ disabled). `http_request` is **HTTPS-only**, matches `allowedDomains` by **exact FQDN** (empty/absent ⇒ disabled), and **blocks private/loopback/link-local/metadata IP ranges** — the same vetted SSRF primitive applied to provider base URLs and MCP server URLs ([security-review.md](../../standards/security-review.md)). And `git_commit` is gated behind human approval in automated workflows. All are enforced by the engine, not by convention. `run_command` is the **only** sandboxed shell-exec built-in — there is no separate `run_javascript` tool (sandboxed JS evaluation is the engine's job for `condition`/`transform` expressions, not a callable tool).

Expand Down
6 changes: 3 additions & 3 deletions docs/reference/shared-core/tool-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
- **Canonical home**: the contract for the engine-side `ToolRegistry` + dispatch in `packages/core` (`@relavium/core`) and the host-injected `ToolHost` capability seam it performs side effects through — workstream **1.T**
- **Related**: [../../decisions/0037-engine-tool-execution-boundary.md](../../decisions/0037-engine-tool-execution-boundary.md) (the decision), [../../decisions/0029-tool-policy-hardening.md](../../decisions/0029-tool-policy-hardening.md) (the guardrails), [built-in-tools.md](built-in-tools.md) (the tool catalog + the concrete result-bounding / config-only-param shapes), [node-types.md](node-types.md) (the `tool_config` / `agent_config` blocks that carry `parameters` / `input_mapping` / `output_mapping`), [mcp-integration.md](mcp-integration.md) (the MCP tool transport, 2.R), [llm-provider-seam.md](llm-provider-seam.md) (the canonical `ToolDef` the `ToolNormalizer` lowers), [../contracts/sse-event-schema.md](../contracts/sse-event-schema.md#error-code-taxonomy) (`agent:tool_call` / `agent:tool_result` payloads + the `tool_denied` / `tool_failed` codes), [../../standards/security-review.md](../../standards/security-review.md#prompt-injection-posture) (the untrusted-data + SSRF + secret rules), [../../standards/error-handling.md](../../standards/error-handling.md) (the retryable/fatal mapping), [../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md) (the sibling `ExecutionHost` seam + the single event-translation point), [../../decisions/0031-llm-seam-shape-amendment-multimodal-io.md](../../decisions/0031-llm-seam-shape-amendment-multimodal-io.md) (durable media handles)

This page is the **one canonical home** for the tool-execution *contract* — how the engine registers a tool, resolves it, assembles and validates its effective arguments, enforces [ADR-0029](../../decisions/0029-tool-policy-hardening.md)'s guardrails, performs the side effect through the host, bounds the model-facing result, and marks it untrusted. The *why* (the engine↔host boundary, the policy/mechanism split) lives in [ADR-0037](../../decisions/0037-engine-tool-execution-boundary.md); the *tool catalog* (the twelve built-ins, their return shapes, FS scope tiers, and the concrete result-bounding thresholds) lives in [built-in-tools.md](built-in-tools.md). This file is the dry reference its consumers (the 1.T registry, the 1.O `AgentRunner`, the 1.V `AgentSession`, each surface's `ToolHost` wiring) bind to. Where any other doc names a tool-dispatch rule it links here and never restates it.
This page is the **one canonical home** for the tool-execution *contract* — how the engine registers a tool, resolves it, assembles and validates its effective arguments, enforces [ADR-0029](../../decisions/0029-tool-policy-hardening.md)'s guardrails, performs the side effect through the host, bounds the model-facing result, and marks it untrusted. The *why* (the engine↔host boundary, the policy/mechanism split) lives in [ADR-0037](../../decisions/0037-engine-tool-execution-boundary.md); the *tool catalog* (the thirteen built-ins, their return shapes, FS scope tiers, and the concrete result-bounding thresholds) lives in [built-in-tools.md](built-in-tools.md). This file is the dry reference its consumers (the 1.T registry, the 1.O `AgentRunner`, the 1.V `AgentSession`, each surface's `ToolHost` wiring) bind to. Where any other doc names a tool-dispatch rule it links here and never restates it.

> **The one rule that shapes everything here.** `@relavium/core` has **zero platform-specific imports** (CLAUDE.md rule 5). Eleven of the twelve built-in tools need filesystem / process / network / OS I/O the engine therefore cannot perform. The engine owns **policy + dispatch** (pure); the **mechanism** is injected as a `ToolHost`, the same purity seam as `ResolverCapabilities.readFile` (1.L2) and the injected HTTP transport ([ADR-0018](../../decisions/0018-desktop-execution-and-rust-egress.md)). The TypeScript shapes below are the canonical interface the implementation mirrors.
> **The one rule that shapes everything here.** `@relavium/core` has **zero platform-specific imports** (CLAUDE.md rule 5). Twelve of the thirteen built-in tools need filesystem / process / network / OS / media-store I/O the engine therefore cannot perform (only `invoke_agent` is engine-internal). The engine owns **policy + dispatch** (pure); the **mechanism** is injected as a `ToolHost`, the same purity seam as `ResolverCapabilities.readFile` (1.L2) and the injected HTTP transport ([ADR-0018](../../decisions/0018-desktop-execution-and-rust-egress.md)). The TypeScript shapes below are the canonical interface the implementation mirrors.

## The `ToolDef` — a registered tool

Expand Down Expand Up @@ -257,7 +257,7 @@ codes by [sse-event-schema.md](../contracts/sse-event-schema.md#error-code-taxon
| Error | When | Run `ErrorCode` | Class |
|-------|------|-----------------|-------|
| `UnknownToolError` | id not an exact match | `tool_failed` | fatal (loop-correctable first) |
| `ToolPolicyError` | a guardrail / grant denial — `not_granted`, `provider_executed`, `command_not_allowed`, `domain_not_allowed`, `insecure_url`, `gate_required` (the full `ToolPolicyDenyReason` union) | `tool_denied` | **fatal** (never retried) |
| `ToolPolicyError` | a guardrail / grant denial — `not_granted`, `provider_executed`, `command_not_allowed`, `domain_not_allowed`, `insecure_url`, `gate_required`, `media_scope_denied` (the full `ToolPolicyDenyReason` union; `media_scope_denied` is `read_media`'s scope-set denial, [ADR-0044](../../decisions/0044-media-access-governance-read-media-save-to-cost.md) §1) | `tool_denied` | **fatal** (never retried) |
| `ToolArgsInvalidError` | effective args fail `parseArgs` / secret-taint | `validation` | fatal (loop-correctable first) |
| `ToolUnavailableError` | the required `ToolHost` capability is absent (host/config gap, not the model's fault) | `internal` | fatal |
| `ToolExecutionError` | the host capability threw a non-cancel error (cause kept off the message, for logs) | `tool_failed` | retryable (node budget) |
Expand Down
15 changes: 15 additions & 0 deletions docs/roadmap/deferred-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,21 @@ Severity is the review's verified rating. Check an item off in the PR that resol
- [ ] **`save_to` url double-fetch** — a `url`-sourced media part in a save_to output is fetched twice (the
save_to de-inline + the node:completed emit de-inline; the put dedupes the bytes). Thread one de-inlined
result into both paths to fetch once. *(low · packages/core/src/engine/engine.ts `#performSaveTo`)*
- [ ] **Keychain no-raw-key IPC test (ADR-0044 §4 acceptance gate)** — ADR-0044 §4 makes "the keychain bridge
never returns a raw key from an IPC command" an **explicit 1.AF test deliverable**, bundled with the media
IPC/byte-delivery review surface. That IPC surface is the desktop/Tauri command layer, which is **unbuilt at
1.AF** — there is no keychain IPC command to assert against yet — so the test is deferred to the 1.AH host
bridge that introduces it. The Node-side keychain seam (ADR-0006) exists, but the *no-raw-key-over-IPC* gate
is meaningful only once the IPC command exists. **Owner: 1.AH (the keychain/media IPC bridge).** Recorded so
the ADR-0044 §4 acceptance is not silently dropped. *(apps/desktop keychain IPC + a direct test; 1.AH)*
- [ ] **Surface `Usage.mediaUnits` on `cost:updated` (the disjoint per-unit observability axis)** — ADR-0031 A6
/ ADR-0044 §3 intend the per-unit media usage (image per-count, audio/video per-second; a token-based
provider's audio as `unit:'count'`) to be observable on the `cost:updated` event. Realized media **spend**
already folds into `cumulativeCostMicrocents` (D17), but the per-unit **counts** are not surfaced: `CostUpdatedEventSchema`
lives in `@relavium/shared`, which cannot import `MediaUnitsEntry` from `@relavium/llm` (the layering forbids
shared→llm). Surfacing it requires **relocating `MediaUnitsEntrySchema` to `@relavium/shared`** (llm re-exports
it; `UsageSchema` keeps using it) — a seam-shape move that wants its own PR. The canonical docs (sse-event-schema.md)
now reflect the current state (field deferred). *(low · @relavium/shared seam move + run-event.ts + agent-turn.ts emit; a later PR)*

> **2026-06-19 — second (Sonnet) review pass on PR #35.** A full re-review (9 dimensions, double-verified)
> on top of the first review's fixes confirmed **0 blockers/highs in reachable code**; ~17 small fixes
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/tools/bounding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,19 @@ function isPlainObject(value: object): value is Record<string, unknown> {
return proto === Object.prototype || proto === null;
}

/**
* Byte-free projection of an arbitrary value for an **observability event field** (I3) — strips inline
* media base64 (canonical `{ kind:'base64', data }` sources, base64 `data:` URIs) and raw binary buffers
* at any nesting, leaving the surrounding structure intact. The symmetric twin of the `outputSummary`
* redaction in {@link toText}, exposed for the `agent:tool_call.toolInput` field: a model can emit a
* base64 `data:` URI (or a `{ kind:'base64', data }` object) as a tool argument, and that field rides the
* event/IPC/log stream (an I3 boundary the emit-time `deInlineMedia` choke point cannot catch, since it
* sees only a flat string). Display-only — the dispatch already ran on the real args. Cycle-safe.
*/
export function redactInlineMedia(value: unknown): unknown {
return redactInlineMediaForText(value, new WeakSet<object>());
}

function redactInlineMediaForText(value: unknown, seen: WeakSet<object>): unknown {
if (typeof value === 'string') {
return isBase64DataUri(value) ? '[base64 data URI omitted]' : value;
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/tools/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,28 @@ describe('ToolRegistry — config-only params and I/O mapping', () => {
expect(req?.credentialRef).toBe('ref-1');
expect(out.events.call.toolInput).toEqual({ query: 'hello world' }); // endpoint/credentialRef stripped
});

it('redacts inline media base64 from agent:tool_call.toolInput (I3 — symmetric to the result-side outputSummary redaction)', async () => {
// A model can emit a base64 `data:` URI as a free-form string tool argument (here http_request's `body`
// and a header value). toolInput rides the event/IPC/log stream (an I3 boundary the emit-time
// deInlineMedia choke point cannot catch in a flat string), so the bytes must be redacted there exactly
// as the result side redacts outputSummary — the dispatch still ran on the real (un-redacted) args.
const dataUri = 'data:image/png;base64,aGVsbG8gd29ybGQgdGhpcyBpcyBub3QgYSByZWFsIGltYWdl';
const out = await registry().dispatch(
call('http_request', {
url: 'https://api.example.com/x',
body: dataUri,
headers: { 'x-thumb': dataUri },
}),
ctx({ toolPolicy: { allowedDomains: ['api.example.com'] } }),
);
expect(out.events.call.toolInput).toMatchObject({
body: '[base64 data URI omitted]',
headers: { 'x-thumb': '[base64 data URI omitted]' }, // the walk recurses into nested objects
});
// No fragment of the payload survives anywhere in the event field.
expect(JSON.stringify(out.events.call.toolInput)).not.toContain('aGVsbG8');
});
});

/* --- capability availability + execution + cancellation --- */
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import { extractHttpsHost } from '@relavium/shared';

import { boundForModel } from './bounding.js';
import { boundForModel, redactInlineMedia } from './bounding.js';
import {
ToolArgsInvalidError,
ToolCancelledError,
Expand Down Expand Up @@ -425,6 +425,13 @@ function sanitizeInput(
delete out[key];
}
}
// Redact inline media bytes from every surviving arg value before it becomes `agent:tool_call.toolInput`:
// that field rides the event/IPC/log stream (an I3 boundary), and a model can emit a base64 `data:` URI
// or a `{ kind:'base64', data }` object as a tool argument. Symmetric to the `outputSummary` redaction on
// the result side — display-only, the dispatch already ran on the real args.
for (const key of Object.keys(out)) {
out[key] = redactInlineMedia(out[key]);
}
return out;
}

Expand Down
Loading
Loading