Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/tool-output-sandbox-overflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Agents can now opt into a model-facing tool output limit that writes oversized results to the session sandbox while preserving the full `action.result` for hooks, channels, clients, and observability. Successful spills emit a durable `tool.output.spilled` event and bounded trace metadata.
50 changes: 49 additions & 1 deletion docs/agent-config.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: "Agents"
description: "Configure an eve agent's model, reasoning effort, compaction, limits, and runtime behavior in agent.ts."
description: "Configure an eve agent's model, reasoning effort, compaction, tool output, limits, and runtime behavior in agent.ts."
---

An eve app has one root agent assembled from the files under `agent/`. Its optional `agent.ts` calls `defineAgent` (from `eve`) when you need to configure the model or other runtime behavior. Declared [subagents](./subagents) have their own `agent.ts` and capabilities; this page covers the configuration shared by root agents and subagents.
Expand Down Expand Up @@ -147,6 +147,53 @@ export default defineAgent({

See [Default harness](./concepts/default-harness#compaction) for how the loop applies it.

## Spill oversized tool results

Set `toolOutput` to keep one tool result from filling model history. The policy
applies across authored tools, framework tools, generated connection tools,
client-supplied tools, and subagent results that eve sends to the model:

```ts title="agent/agent.ts"
export default defineAgent({
model: "anthropic/claude-opus-4.8",
toolOutput: {
maxInlineBytes: 64 * 1024,
overflow: "sandbox",
},
});
```

Both fields are required. `maxInlineBytes` must be a positive integer. The
policy is disabled when `toolOutput` is omitted. eve measures text as UTF-8
bytes and JSON by its compact serialized value. A result at or below
`maxInlineBytes` stays unchanged. A larger result is written under
`/workspace/.eve/tool-results` in the session sandbox: text as `.txt`, JSON as
readable `.json`. Model history receives a small object with the file path,
original byte count, and tool name. Replaying the same call with the same
result overwrites the same path; a changed result receives a different path.

The policy runs after a tool's `toModelOutput`, so use that callback when you
can give the model a smaller semantic summary. The full execution result still
appears in `action.result` for hooks, channels, clients, and observability;
only model history receives the file reference. Framework control results that
eve must reread to reconstruct capabilities, such as `connection_search`, and
approval denials remain inline.

After the sandbox write succeeds, eve emits a durable `tool.output.spilled`
stream event with the call id, tool name, original byte count, configured
limit, file path, and deterministic `spillId`. Hooks, channels, and raw stream
clients can observe the spill without inspecting or changing `action.result`.
OpenTelemetry instrumentation records the same bounded metadata on an
`agent.tool.output` span. Durable retries may physically deliver the event
more than once; deduplicate notifications by `spillId`, not the stream event
id.

Provider-executed server tools are outside this boundary: the provider consumes
their output before eve receives it, so `toolOutput` cannot limit that first
provider call. Sandbox references follow the [sandbox lifecycle](./sandbox#lifecycle);
persist important artifacts outside the sandbox when they must survive
provider-side sandbox replacement.

## Runtime limits

Use `limits` for framework-owned runtime caps. Session token limits stop the
Expand Down Expand Up @@ -255,6 +302,7 @@ installed package must stay external in hosted output, list it in
| `limits` | `AgentLimitsDefinition` | field-specific | Framework-owned runtime limits. Sessions complete after 30 days by default; token-limit defaults and inheritance are described above. Set a limit to `false` to disable it. |
| `experimental` | `{ workflow?: { world?: string } }` | unset | Opt-in settings that can change or disappear in any release. Treat them as unstable. `workflow.world` selects the Workflow world package backing session state, queues, hooks, and streams on the root agent. |
| `outputSchema` | Standard Schema or a JSON Schema object | none | Structured return type for function-like invocations such as a subagent turn, schedule, or remote job. Ordinary interactive turns ignore it unless the client supplies a per-message schema. |
| `toolOutput` | `AgentToolOutputDefinition` | none | Optional agent-wide overflow policy for model-facing tool results. Oversized text and JSON are written to the session sandbox while `action.result` keeps the full output. |
| `build` | `{ externalDependencies?: string[] }` | none | Hosted-build packaging controls. `externalDependencies` keeps listed packages external while eve compiles authored modules such as tools and channels, and traces those packages into the hosted output. |

`externalDependencies` is a packaging control only. It keeps selected packages as runtime dependencies in the hosted output; it does not authorize, configure, or review any third-party service those packages may call.
Expand Down
6 changes: 6 additions & 0 deletions docs/concepts/sessions-runs-and-streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ The stream is newline-delimited JSON (NDJSON), one event per line:
| `actions.requested` | The model requested one or more actions, including tool calls; calls stream before execution. |
| `action.partial` | A locally executed tool generator yielded a preliminary output snapshot. |
| `action.result` | A tool call returned. |
| `tool.output.spilled` | An oversized model-facing tool result moved to the sandbox; carries bounded reference metadata. |
| `input.requested` | The run paused for human input ([HITL](/docs/human-in-the-loop) approval or `ask_question`); carries `requests`. |
| `input.resolved` | The server accepted terminal human-input outcomes; carries `resolutions` with responses when provided. |
| `subagent.called` | A subagent was delegated; carries `childSessionId` to attach to. |
Expand Down Expand Up @@ -83,6 +84,11 @@ When a streamed tool input becomes a validated call, its `action.input.appended`

`action.partial` carries one complete preliminary output snapshot from an authored async-generator tool. A later partial for the same `callId` replaces it, and `action.result` is the final snapshot. When the durable writer is busy, eve may keep only the newest adjacent partial for a call. Treat partials as last-write-wins: a durable step can retry and replay overlapping event runs. Provider-executed tool progress and MCP progress notifications are not projected as `action.partial` events.

`tool.output.spilled` leaves the complete `action.result` unchanged. Its data
contains `callId`, `toolName`, `bytes`, `maxInlineBytes`, `path`, and a
deterministic `spillId`. Durable step retries can repeat the notification with
a different `meta.id`; deduplicate spill handling by `spillId`.

Note: consider the privacy, confidentiality, and user-experience implications for displaying, storing, or transmitting reasoning events in your application.

`message.completed` can fire more than once in a turn: the agent often emits interim assistant text before a tool call. To tell tool-call narration from a terminal reply, check `message.completed.data.finishReason`. `step.completed.data.finishReason` mirrors the step outcome, and usage lives on `step.completed`.
Expand Down
31 changes: 16 additions & 15 deletions docs/guides/client/streaming.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -98,21 +98,22 @@ function handleEvent(event: MessageStreamEvent) {

The most common UI events are:

| Event | Use |
| ----------------------- | ------------------------------------------------------------------------------ |
| `message.received` | Confirm the user message landed; `data.parts` includes text and file metadata. |
| `reasoning.appended` | Render reasoning deltas when the model provides them. |
| `message.appended` | Render assistant text deltas. |
| `action.input.appended` | Accumulate raw tool-input deltas before validation completes. |
| `actions.requested` | Show tool calls as the model requests them, before execution. |
| `action.partial` | Update a generator tool's provisional output snapshot. |
| `action.result` | Show tool call results. |
| `input.requested` | Pause the UI for approval or a question answer. |
| `input.resolved` | Record the server-accepted outcome and response for each human-input request. |
| `result.completed` | Read structured output from an [output schema](./output-schema). |
| `session.waiting` | Enable the composer; the same fixed session handle accepts the next message. |
| `session.completed` | Mark the conversation terminal. |
| `session.failed` | Mark the conversation failed. |
| Event | Use |
| ----------------------- | ------------------------------------------------------------------------------- |
| `message.received` | Confirm the user message landed; `data.parts` includes text and file metadata. |
| `reasoning.appended` | Render reasoning deltas when the model provides them. |
| `message.appended` | Render assistant text deltas. |
| `action.input.appended` | Accumulate raw tool-input deltas before validation completes. |
| `actions.requested` | Show tool calls as the model requests them, before execution. |
| `action.partial` | Update a generator tool's provisional output snapshot. |
| `action.result` | Show tool call results. |
| `tool.output.spilled` | Observe that the model received a sandbox reference instead of the full result. |
| `input.requested` | Pause the UI for approval or a question answer. |
| `input.resolved` | Record the server-accepted outcome and response for each human-input request. |
| `result.completed` | Read structured output from an [output schema](./output-schema). |
| `session.waiting` | Enable the composer; the same fixed session handle accepts the next message. |
| `session.completed` | Mark the conversation terminal. |
| `session.failed` | Mark the conversation failed. |

For the complete event table, see [Sessions, runs & streaming](../../concepts/sessions-runs-and-streaming).

Expand Down
9 changes: 9 additions & 0 deletions docs/tools/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@ toModelOutput(output) {

Tool outputs must be JSON-serializable. Return plain objects, arrays, strings, numbers, booleans, or `null`; convert values like `Date`, `Map`, `Set`, `NaN`, and cyclic objects before returning them from `execute` or from a `{ type: "json" }` `toModelOutput`.

For an agent-wide safety bound, set `toolOutput` in
[`defineAgent`](/docs/agent-config#spill-oversized-tool-results). It applies
after `toModelOutput` and also covers generated connection tools that do not
have an authored projection. Oversized model-facing output moves to the
session sandbox while `action.result` keeps the full execution result.
Successful spills also emit a durable `tool.output.spilled` stream event, so
hooks, channels, clients, and traces can observe the model-facing projection
without changing the complete `action.result`.

### Send images to the model with content parts

A tool that produces an image — a screenshot, a rendered chart — can hand the pixels to a vision-capable model by returning a `content` output from `toModelOutput`. Build outputs with the `toolOutput` helpers and parts with the `toolOutputPart` helpers, both from `eve/tools`:
Expand Down
4 changes: 4 additions & 0 deletions e2e/fixtures/agent-tools-sandbox/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@ import { respond } from "./lib/mock-responder.js";
export default defineAgent({
...e2eAgentConfig({ mock: respond }),
reasoning: "high",
toolOutput: {
maxInlineBytes: 64 * 1024,
overflow: "sandbox",
},
});
54 changes: 54 additions & 0 deletions e2e/fixtures/agent-tools-sandbox/agent/lib/mock-responder.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { MockModelRequest, MockModelResponse } from "eve/evals";
import { OVERFLOW_PROBE_TOKEN } from "./overflow-probe.js";

const SUBAGENT_DIRECTIVE = /ask the `([^`]+)` subagent with message:\s*([\s\S]+)/iu;
const BASH_DIRECTIVE = /run the bash command `([^`]+)`/iu;
const SKILL_DIRECTIVE = /load the `([^`]+)` skill/iu;

const OVERFLOW_DIRECTIVE = /run the `overflow_probe` tool/iu;
const OVERFLOW_FILE_PATH = /^\/workspace\/\.eve\/tool-results\/[a-f0-9]{64}\.json$/u;
/**
* Scripted mock for the world suites: sandbox evals phrase every prompt as an
* explicit directive, so the responder executes exactly the requested tool
Expand All @@ -22,6 +25,9 @@ export function respond(request: MockModelRequest): MockModelResponse | string {
}
}
const turnHasToolResult = lastToolResultIndex > lastAuthoredUserIndex;
if (OVERFLOW_DIRECTIVE.test(message)) {
return overflowProbeResponse(request);
}

const subagent = SUBAGENT_DIRECTIVE.exec(message);
if (subagent?.[1] !== undefined && subagent[2] !== undefined) {
Expand Down Expand Up @@ -55,6 +61,54 @@ export function respond(request: MockModelRequest): MockModelResponse | string {
return `Mock reply: ${message}`;
}

function overflowProbeResponse(request: MockModelRequest): MockModelResponse | string {
const probe = request.toolResults.find((result) => result.id === "overflow-probe-source");
if (probe === undefined) {
return {
toolCalls: [{ id: "overflow-probe-source", input: {}, name: "overflow_probe" }],
};
}

if (request.toolResults.some((result) => result.id === "overflow-probe-read")) {
return bashStdout(request);
}

const path = overflowFilePath(probe.output);
if (path === undefined) {
return "overflow_probe did not produce an eve tool-output file reference";
}

return {
toolCalls: [
{
id: "overflow-probe-read",
input: { command: `grep -m 1 -o '${OVERFLOW_PROBE_TOKEN}' ${path}` },
name: "bash",
},
],
};
}

function overflowFilePath(output: unknown): string | undefined {
if (
typeof output !== "object" ||
output === null ||
!("kind" in output) ||
output.kind !== "eve-tool-output-file" ||
!("bytes" in output) ||
typeof output.bytes !== "number" ||
output.bytes <= 64 * 1024 ||
!("toolName" in output) ||
output.toolName !== "overflow_probe" ||
!("path" in output) ||
typeof output.path !== "string" ||
!OVERFLOW_FILE_PATH.test(output.path)
) {
return undefined;
}
return output.path;
}

function bashStdout(request: MockModelRequest): string {
const output = [...request.toolResults]
.reverse()
Expand Down
2 changes: 2 additions & 0 deletions e2e/fixtures/agent-tools-sandbox/agent/lib/overflow-probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const OVERFLOW_PROBE_PAYLOAD_BYTES = 128 * 1024;
export const OVERFLOW_PROBE_TOKEN = "tool-output-overflow-ok-R4V";
15 changes: 15 additions & 0 deletions e2e/fixtures/agent-tools-sandbox/agent/tools/overflow_probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { defineTool } from "eve/tools";
import { z } from "zod";
import { OVERFLOW_PROBE_PAYLOAD_BYTES, OVERFLOW_PROBE_TOKEN } from "../lib/overflow-probe.js";

export default defineTool({
description:
"Smoke-test fixture: returns deterministic oversized JSON. Only call when explicitly asked to use `overflow_probe`.",
inputSchema: z.object({}),
execute() {
return {
marker: OVERFLOW_PROBE_TOKEN,
payload: "x".repeat(OVERFLOW_PROBE_PAYLOAD_BYTES),
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { defineEval } from "eve/evals";

const OVERFLOW_PROBE_PAYLOAD_BYTES = 128 * 1024;
const OVERFLOW_PROBE_TOKEN = "tool-output-overflow-ok-R4V";

export default defineEval({
description:
"Tool output overflow: oversized model history becomes a readable sandbox reference while action.result stays complete.",
async test(t) {
await t.send(
[
"Run the `overflow_probe` tool exactly once.",
`Read the generated eve tool-output file by running the bash command \`grep -m 1 -o '${OVERFLOW_PROBE_TOKEN}' <reference path>\` with the actual reference path.`,
`Reply with exactly ${OVERFLOW_PROBE_TOKEN}.`,
].join("\n"),
);

t.succeeded();
t.calledTool("overflow_probe", {
count: 1,
output: hasCompleteProbeOutput,
});
t.calledTool("bash", {
count: 1,
output: new RegExp(OVERFLOW_PROBE_TOKEN),
});
t.messageIncludes(OVERFLOW_PROBE_TOKEN);
},
});

function hasCompleteProbeOutput(value: unknown): boolean {
if (typeof value !== "object" || value === null) return false;
const output = value as { marker?: unknown; payload?: unknown };
return (
output.marker === OVERFLOW_PROBE_TOKEN &&
typeof output.payload === "string" &&
output.payload.length === OVERFLOW_PROBE_PAYLOAD_BYTES
);
}
3 changes: 3 additions & 0 deletions packages/eve/extension-contracts/compatibility/channel/v10.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { disableRoute } from "#public/channels/index.js";

export default disableRoute();
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineDynamic, defineInstructions } from "#public/instructions/index.js";

export default defineDynamic({
events: {
"session.started": (_event, ctx) =>
defineInstructions({
markdown: `Use session ${ctx.session.id} when correlating evidence.`,
}),
},
});
11 changes: 11 additions & 0 deletions packages/eve/extension-contracts/compatibility/dynamicSkill/v13.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineDynamic, defineSkill } from "#public/skills/index.js";

export default defineDynamic({
events: {
"turn.started": (_event, ctx) =>
defineSkill({
description: `Review evidence for session ${ctx.session.id}.`,
markdown: "# Evidence review\n\nCheck every claim against its source.",
}),
},
});
19 changes: 19 additions & 0 deletions packages/eve/extension-contracts/compatibility/dynamicTool/v20.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { z } from "zod";

import { defineDynamic, defineTool } from "#public/tools/index.js";

export default defineDynamic({
events: {
"turn.started": (_event, ctx) =>
defineTool({
description: "Echo the tool call identifiers.",
inputSchema: z.object({ note: z.string() }),
execute: ({ note }, toolCtx) => ({
callId: toolCtx.callId,
note,
sessionId: ctx.session.id,
toolName: toolCtx.toolName,
}),
}),
},
});
13 changes: 13 additions & 0 deletions packages/eve/extension-contracts/compatibility/hook/v15.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { defineHook } from "#public/hooks/index.js";

export default defineHook({
events: {
"subagent.completed"(event, ctx) {
console.info("subagent completed", {
output: event.data.output,
sessionId: ctx.session.id,
subagentName: event.data.subagentName,
});
},
},
});
18 changes: 18 additions & 0 deletions packages/eve/extension-contracts/compatibility/schedule/v4.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import channel from "../channel/v7.js";
import { defineSchedule } from "#public/schedules/index.js";

export default defineSchedule({
cron: "0 0 * * *",
async run({ appAuth, to, waitUntil }) {
waitUntil(
(async () => {
const session = await to(channel, { sessionRef: "daily" }).send("Start review", {
auth: appAuth,
});
await session.respond([{ optionId: "approve", requestId: "approval-1" }], {
auth: appAuth,
});
})(),
);
},
});
7 changes: 7 additions & 0 deletions packages/eve/extension-contracts/compatibility/subagent/v5.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineAgent } from "#public/index.js";

export default defineAgent({
compaction: { thresholdPercent: 0.8 },
description: "Delegate research tasks.",
model: "anthropic/claude-sonnet-5",
});
1 change: 1 addition & 0 deletions packages/eve/extension-contracts/entrypoints/subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export {
type AgentDefinition,
type AgentModelDefinition,
type AgentStaticModelDefinition,
type AgentToolOutputDefinition,
type DefinedAgent,
type DynamicLocalSubagentDefinition,
type DynamicSubagentDefinition,
Expand Down
Loading