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
5 changes: 5 additions & 0 deletions .changeset/quiet-sandboxes-stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": minor
---

Allow authored hooks, tools, and channel callbacks to stop their active sandbox through `ctx.getSandbox().stop()`. Every built-in backend preserves the durable session for a later callback, and custom sandbox backend handles must now implement `stop()`.
28 changes: 25 additions & 3 deletions docs/guides/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,38 @@ A hook file declares stream-event subscribers under the `events` map, keyed by e

## Hook structure and context

Every handler receives the same `HookContext`:
Every handler receives the same `HookContext`, including the shared session
helpers documented in [Session context](./session-context):

```ts
interface HookContext {
interface HookContext extends SessionContext {
readonly agent: { readonly name: string; readonly nodeId?: string };
readonly channel: { readonly kind?: string; readonly continuationToken?: string };
readonly session: { readonly id: string };
}
```

That means a hook can access the current sandbox and release its backing
compute at an application-defined boundary:

```ts title="agent/hooks/stop-after-turn.ts"
import { defineHook } from "eve/hooks";

export default defineHook({
events: {
async "turn.completed"(_event, ctx) {
const sandbox = await ctx.getSandbox();
await sandbox.stop();
},
},
});
```

Every built-in backend stops its underlying compute while preserving the
durable session and filesystem for the next callback. On Vercel, the current
handle can also automatically resume on later I/O. A hook failure, including a
failed stop, follows the normal
[hook failure behavior](#what-happens-when-a-hook-throws).

### Narrowing tool results

`toolResultFrom` narrows an `action.result` event to a specific authored tool or MCP connection and returns typed output. Import it from `eve/tools`:
Expand Down
18 changes: 18 additions & 0 deletions docs/guides/session-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,24 @@ Behavior:
- It is async because eve binds or restores sandbox state lazily.
- It only works when sandbox access is attached to the active runtime path.
- Visibility is node-local. A subagent sees its own sandbox, not the parent's.
- The returned `RuntimeSandboxSession` extends the ordinary sandbox I/O surface
with `stop()`. It is exported from `eve/sandbox`.

Call `stop()` to release sandbox compute while preserving the durable session
and its filesystem:

```ts
const sandbox = await ctx.getSandbox();
await sandbox.stop();
```

Each backend implements this with its native lifecycle operation. Treat the
stop as the end of sandbox work in the current callback; a later callback calls
`ctx.getSandbox()` normally and eve reopens the same durable session. Vercel
also supports using the same handle again: its next command or file operation
automatically resumes the sandbox, just as it would after an inactivity
timeout. No separate eve reconnect state is created, and provider failures
reject the returned promise.

`SandboxSession` also exposes `resolvePath(path)`, which returns the live backend-native path for a logical `/workspace/...` location. Use it when authored code needs that path before passing it to shell code or a child process.

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/typescript-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ A few non-`define*` helpers round out the set: `disableTool`, `experimental_work
| Member | Use |
| --------------------------- | ---------------------------------------------------------------------------- |
| `ctx.session` | Current session, turn, auth, and optional parent lineage (read-only) |
| `ctx.getSandbox()` | Live sandbox handle for the current agent |
| `ctx.getSandbox()` | Live sandbox handle; `stop()` releases compute but preserves durable state |
| `ctx.getSkill(identifier)` | Handle for a named skill visible to the current agent |
| `ctx.getToken(provider)` | Resolve a bearer token for an inline auth provider such as `connect("...")` |
| `ctx.requireAuth(provider)` | Evict and re-authorize an inline provider, commonly after a downstream `401` |
Expand Down
20 changes: 19 additions & 1 deletion docs/sandbox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,27 @@ export default defineSandbox({

Sessions are persistent, and how the underlying runtime idles out depends on the backend. On the Vercel backend, the VM times out after a period of inactivity (default 30 minutes); eve preserves the filesystem and resumes the sandbox on the next message as if nothing happened, even days later. The Docker backend keeps a long-lived container per durable session and persists `/workspace` across turns without that timeout, and the just-bash backend stores its virtual filesystem under `.eve/sandbox-cache/`. In every case, `/workspace` survives between turns for the same session.

Authored runtime callbacks can stop compute sooner through the handle returned
by `ctx.getSandbox()`:

```ts
const sandbox = await ctx.getSandbox();
await sandbox.stop();
```

Every built-in backend uses its native lifecycle operation without deleting the
durable session. Treat the stop as the end of sandbox work in the current
callback. On the next callback, `ctx.getSandbox()` reopens the same Docker
container, microsandbox VM or snapshot, or just-bash filesystem and environment.
Vercel can also automatically resume the same handle on its next I/O operation,
just as it would after an inactivity timeout. No separate reconnect step or
stop-specific state is needed. Lifecycle `use()` calls return the I/O-only
`SandboxSession` because bootstrap and session initialization do not own runtime
teardown.

Session sandboxes are keyed per durable session, not per deployment, so redeploying your app does not discard them. A session gets a replacement sandbox only when the sandbox definition itself changes — the authored sandbox source, workspace seed content, or `revalidationKey` — in which case the next turn starts from the rebuilt template and `onSession` runs again.

When the eve server stops, no sandbox compute outlives it. `eve dev` stops the sandboxes it started when the dev server closes, and a self-hosted production server stops every open sandbox on shutdown (`SIGTERM`/`SIGINT`). Session state persists across the stop — the next server start reattaches each durable session from its stopped container, VM, or snapshot. Custom `SandboxBackend` adapters participate through the handle's `shutdown()` method: stop the underlying compute, keeping the session reattachable from persisted state where the backend supports it.
When the eve server stops, no sandbox compute outlives it. `eve dev` stops the sandboxes it started when the dev server closes, and a self-hosted production server stops every open sandbox on shutdown (`SIGTERM`/`SIGINT`). Session state persists across the stop — the next server start reattaches each durable session from its stopped container, VM, or snapshot. Custom `SandboxBackend` adapters implement `stop()` for authored runtime calls and `shutdown()` for server teardown. Both stop the underlying compute while keeping the session reattachable from persisted state where the backend supports it; authored `stop()` failures reject, while process-wide shutdown collects and logs failures without blocking teardown.

## Network policy

Expand Down
19 changes: 19 additions & 0 deletions e2e/fixtures/agent-tools-sandbox/agent/hooks/stop-sandbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { defineHook } from "eve/hooks";

const STOP_SANDBOX_TOKEN = "sandbox-stop-hook-ready-R7V";
const STOP_SANDBOX_MARKER_PATH = "/workspace/stopped-by-hook.txt";

export default defineHook({
events: {
async "message.completed"(event, ctx) {
if (!event.data.message?.includes(STOP_SANDBOX_TOKEN)) return;

const sandbox = await ctx.getSandbox();
await sandbox.writeTextFile({
content: STOP_SANDBOX_TOKEN,
path: STOP_SANDBOX_MARKER_PATH,
});
await sandbox.stop();
},
},
});
25 changes: 25 additions & 0 deletions e2e/fixtures/agent-tools-sandbox/evals/sandbox/hook-stop.eval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { defineEval } from "eve/evals";
import { equals } from "eve/evals/expect";

const STOP_SANDBOX_TOKEN = "sandbox-stop-hook-ready-R7V";
const STOP_SANDBOX_MARKER_PATH = "/workspace/stopped-by-hook.txt";

// The first response activates an authored hook that writes a marker and
// stops compute. Reading that marker on the next turn proves the configured
// backend reopens the same durable sandbox state.
export default defineEval({
description: "Sandbox: an authored hook can stop compute and the next turn reopens it.",
async test(t) {
const first = await t.send(`Reply with this exact token: ${STOP_SANDBOX_TOKEN}`);
first.expectOk();

const second = await t.send(
`Run the bash command \`cat ${STOP_SANDBOX_MARKER_PATH}\` and reply with the file contents verbatim.`,
);

await t.require(second.sessionId, equals(first.sessionId));
t.succeeded();
t.calledTool("bash", { output: new RegExp(STOP_SANDBOX_TOKEN) });
t.messageIncludes(STOP_SANDBOX_TOKEN);
},
});
11 changes: 11 additions & 0 deletions packages/eve/extension-contracts/compatibility/connection/v3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineMcpClientConnection } from "#public/connections/index.js";

export default defineMcpClientConnection({
description: "Tenant-aware MCP service",
toolCall: {
providedArguments: {
tenantId: ({ session, toolName }) => `${session.id}:${toolName}`,
},
},
url: "https://example.com/mcp",
});
14 changes: 14 additions & 0 deletions packages/eve/extension-contracts/compatibility/dynamicTool/v11.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { z as z3 } from "zod/v3";

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

export default defineDynamic({
events: {
"session.started": (_event, ctx) =>
defineTool({
description: "Return the active session identifier.",
inputSchema: z3.object({ prefix: z3.string() }),
execute: ({ prefix }) => ({ sessionId: `${prefix}:${ctx.session.id}` }),
}),
},
});
13 changes: 13 additions & 0 deletions packages/eve/extension-contracts/compatibility/hook/v8.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,
});
},
},
});
10 changes: 10 additions & 0 deletions packages/eve/extension-contracts/compatibility/state/v2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineState } from "#public/context/index.js";

export const budget = defineState("compatibility.budget", () => ({
count: 0,
limit: 10,
}));

export function recordUsage(): void {
budget.update((current) => ({ ...current, count: current.count + 1 }));
}
11 changes: 11 additions & 0 deletions packages/eve/extension-contracts/compatibility/tool/v10.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { z as z3 } from "zod/v3";

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

export default defineTool({
description: "Look up a report.",
inputSchema: z3.object({ reportId: z3.string() }),
execute(input, ctx) {
return { callId: ctx.callId, reportId: input.reportId };
},
});
15 changes: 15 additions & 0 deletions packages/eve/extension-contracts/reports/connection/v4.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"kind": "eve-extension-capability-contract",
"capability": "connection",
"epoch": 4,
"sha256": "b2c48c160b9c57b8661c661398a2931c30a08e5f43c41acf9ccc678922f550fa",
"exports": [
"ConnectionAuthorizationFailedError",
"ConnectionAuthorizationRequiredError",
"defineInteractiveAuthorization",
"defineMcpClientConnection",
"defineOpenAPIConnection",
"isConnectionAuthorizationFailedError",
"isConnectionAuthorizationRequiredError"
]
}
13 changes: 13 additions & 0 deletions packages/eve/extension-contracts/reports/dynamicTool/v12.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"kind": "eve-extension-capability-contract",
"capability": "dynamicTool",
"epoch": 12,
"sha256": "d0113501077e6a36820fc997121992d5c1039410c821752623d66fa5dd8f0812",
"exports": [
"DynamicToolEntry",
"DynamicToolEvents",
"DynamicToolResult",
"DynamicToolSet",
"defineDynamic"
]
}
7 changes: 7 additions & 0 deletions packages/eve/extension-contracts/reports/hook/v9.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"kind": "eve-extension-capability-contract",
"capability": "hook",
"epoch": 9,
"sha256": "afb6444059c947d8d2a864b46a7f3c6bccf18343e4718ffefeeda1b9e60f9195",
"exports": ["defineHook"]
}
15 changes: 15 additions & 0 deletions packages/eve/extension-contracts/reports/state/v3.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"kind": "eve-extension-capability-contract",
"capability": "state",
"epoch": 3,
"sha256": "09122994045bc69eef9e795c372a37b41afd0e9094e0dca68419321bacedabaa",
"exports": [
"Session",
"SessionAuth",
"SessionAuthContext",
"SessionContext",
"SessionParent",
"SessionTurn",
"defineState"
]
}
22 changes: 22 additions & 0 deletions packages/eve/extension-contracts/reports/tool/v11.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"kind": "eve-extension-capability-contract",
"capability": "tool",
"epoch": 11,
"sha256": "94c69226291735f01403a42e52fb2dca4cfb1f98581d233c297defc3fa051786",
"exports": [
"defineBashTool",
"defineGlobTool",
"defineGrepTool",
"defineReadFileTool",
"defineTool",
"defineWriteFileTool",
"disableTool",
"experimental_workflow",
"isDisabledToolSentinel",
"isExperimentalWorkflowToolDefinition",
"toolOutput",
"toolOutputPart",
"toolResultFrom",
"webSearch"
]
}
10 changes: 5 additions & 5 deletions packages/eve/src/compiler/extension-compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,16 @@ interface ExtensionCapabilityContract {

const EXTENSION_CAPABILITY_CONTRACTS = {
extension: { current: 1, supported: [1], dropped: {} },
tool: { current: 10, supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], dropped: {} },
dynamicTool: { current: 11, supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], dropped: {} },
connection: { current: 3, supported: [1, 2, 3], dropped: {} },
hook: { current: 8, supported: [1, 2, 3, 4, 5, 6, 7, 8], dropped: {} },
tool: { current: 11, supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], dropped: {} },
dynamicTool: { current: 12, supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], dropped: {} },
connection: { current: 4, supported: [1, 2, 3, 4], dropped: {} },
hook: { current: 9, supported: [1, 2, 3, 4, 5, 6, 7, 8, 9], dropped: {} },
skill: { current: 1, supported: [1], dropped: {} },
dynamicSkill: { current: 7, supported: [1, 2, 3, 4, 5, 6, 7], dropped: {} },
instructions: { current: 1, supported: [1], dropped: {} },
dynamicInstructions: { current: 7, supported: [1, 2, 3, 4, 5, 6, 7], dropped: {} },
config: { current: 1, supported: [1], dropped: {} },
state: { current: 2, supported: [1, 2], dropped: {} },
state: { current: 3, supported: [1, 2, 3], dropped: {} },
} as const satisfies Record<string, ExtensionCapabilityContract>;

/** One independently versioned extension-facing contract. */
Expand Down
19 changes: 18 additions & 1 deletion packages/eve/src/context/accessors.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { buildCallbackContext } from "#context/build-callback-context.js";
import { createTestRuntime } from "#internal/testing/app-harness.js";
import { mockSandbox } from "#internal/testing/mocks/mock-sandbox.js";
import { mockSkill } from "#internal/testing/mocks/mock-skill.js";
import type { SandboxSession } from "#public/definitions/sandbox.js";
import type { RuntimeSandboxSession, SandboxSession } from "#public/definitions/sandbox.js";

/**
* Integration coverage for {@link buildCallbackContext} — the single
Expand Down Expand Up @@ -122,6 +122,23 @@ describe("buildCallbackContext – getSandbox", () => {
expect(sandbox.removedPaths).toEqual(["/workspace/note.txt"]);
expect(sandbox.files.has("/workspace/note.txt")).toBe(false);
});

it("stops the active sandbox through the runtime session", async () => {
let stops = 0;
const sandbox = mockSandbox({
stop: () => {
stops += 1;
},
});
const runtime = createTestRuntime();

await runtime.runAsSession({ sandbox }, async () => {
const live: RuntimeSandboxSession = await buildCallbackContext().getSandbox();
await live.stop();
});

expect(stops).toBe(1);
});
});

describe("buildCallbackContext – getSkill", () => {
Expand Down
Loading