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/stream-tool-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Tool inputs now stream through the durable event protocol as `action.input.appended` before the matching validated `actions.requested` event. Each event stores only its raw delta and UTF-16 offset, while the default message reducer exposes cumulative raw input on `dynamic-tool.inputText` in the `input-streaming` state. This advances the stream protocol to version 24; when assistant text precedes a tool call, `message.completed` now arrives before that call's streamed input events.
7 changes: 5 additions & 2 deletions docs/concepts/sessions-runs-and-streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ The stream is newline-delimited JSON (NDJSON), one event per line:
| `turn.started` | A new turn began; carries the active `trace` when the runtime is traced. |
| `message.received` | An inbound user message was accepted; carries flattened text plus structured text/file parts. |
| `step.started` | A model step began. |
| `action.input.appended` | A raw tool-input text delta, its character offset, and tool-call identity. |
| `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. |
Expand Down Expand Up @@ -76,7 +77,9 @@ The stream is newline-delimited JSON (NDJSON), one event per line:

The optional `data.trace` on session and turn starts contains eve-owned W3C trace coordinates: `traceId`, `spanId`, and `traceFlags`. Use it to correlate stream consumers such as eval reporters with an observability backend. An uninstrumented target omits it.

`reasoning.appended` and `message.appended` stream incremental output as it arrives. When the durable stream writer is busy, eve may coalesce adjacent deltas of the same type; the text remains in source order, and any other event forms an ordering barrier. Each append carries both the new delta and the cumulative text for the current block. The finalized block shows up on `message.completed` and `reasoning.completed`, which is the compatibility path for clients that don't render incremental streaming.
`reasoning.appended`, `message.appended`, and `action.input.appended` stream incremental output as it arrives. When the durable stream writer is busy, eve may coalesce adjacent deltas for the same text block or tool call; the text remains in source order, and a different event type, tool `callId`, or stream coordinate forms an ordering barrier. Text and reasoning appends carry both the new delta and the cumulative text for the current block. The finalized blocks show up on `message.completed` and `reasoning.completed`, which is the compatibility path for clients that don't render incremental streaming.

When a streamed tool input becomes a validated call, its `action.input.appended` events precede the matching `actions.requested` event. Each append carries `callId`, `toolName`, `inputTextDelta`, and `inputTextOffset`; the offset is the zero-based UTF-16 code-unit position where the delta begins. Storing only the delta and offset avoids repeating the cumulative input in every durable event. The default client reducer starts or restarts accumulation at offset `0`, ignores a nonzero offset that is not contiguous, and projects the potentially incomplete JSON as a `dynamic-tool` part with `state: "input-streaming"` and cumulative text in `inputText`. `actions.requested` replaces that part with `state: "input-available"` and the validated `input`. Excluded internal actions never publish their input stream.

`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.

Expand Down Expand Up @@ -111,7 +114,7 @@ Alongside `type` and `data`, every event carries a `meta` envelope:

`meta.id` is stable. eve mints it once, when the event is written to the durable stream, and stores it with the event. Reconnecting from a cursor, rewinding to `startIndex=0`, or replaying a finished session all return the same id for the same event.

`meta.at` has always been there; `meta.id` arrived in stream version 20. Events written by an earlier version are stored with the envelope but no id inside it, so rewinding into the part of a session that ran before you upgraded yields events whose `meta.id` is absent, even though the type says it is always a string. eve passes those events through rather than dropping them, and they cannot be deduplicated. The exposure ends when the sessions that predate your upgrade do.
`meta.at` has always been there; `meta.id` arrived in stream version 20, and `action.input.appended` arrived in version 24. Events written by an earlier version are stored with the envelope but no id inside it, so rewinding into the part of a session that ran before you upgraded yields events whose `meta.id` is absent, even though the type says it is always a string. eve passes those events through rather than dropping them, and they cannot be deduplicated. The exposure ends when the sessions that predate your upgrade do.

That makes it the key for ingesting a stream into a database without duplicating rows when you re-read it:

Expand Down
33 changes: 18 additions & 15 deletions docs/guides/client/streaming.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ for await (const event of response) {
}
```

`message.appended` and `reasoning.appended` are incremental delta events. eve may combine adjacent deltas of the same type while a durable stream write is in flight, but preserves their text and event ordering; any different event is a barrier. Their completed forms, `message.completed` and `reasoning.completed`, are the compatibility path for clients that don't render deltas.
`message.appended`, `reasoning.appended`, and `action.input.appended` are incremental delta events. eve may combine adjacent deltas for the same text block or tool call while a durable stream write is in flight, but preserves their text and event ordering. A different event type, tool `callId`, or stream coordinate is a barrier. The completed text forms, `message.completed` and `reasoning.completed`, are the compatibility path for clients that don't render deltas. A streamed tool input is complete when the matching validated call arrives in `actions.requested`.

## Handle event types

Expand All @@ -98,23 +98,26 @@ 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. |
| `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. |
| `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).

Each `action.input.appended` event stores `inputTextDelta` and its zero-based UTF-16 code-unit `inputTextOffset`, so consumers can reject a gap instead of concatenating corrupt input. The default message reducer starts or restarts at offset `0` and ignores a nonzero offset that is not contiguous. It accumulates accepted deltas into a `dynamic-tool` part with `state: "input-streaming"`; its `inputText` field contains cumulative raw text that may be incomplete JSON. The matching `actions.requested` event upgrades the same `toolCallId` to `state: "input-available"` and puts the validated value in `input`.

When a submitted message includes attachments, `message.received.data.message` stays the
flattened compatibility summary, while `message.received.data.parts` carries renderable text and
file metadata. File parts never include raw bytes or internal sandbox paths; `url` appears only for
Expand Down
3 changes: 3 additions & 0 deletions packages/eve/extension-contracts/compatibility/channel/v9.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/v12.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/v19.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/v14.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/v3.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/v4.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",
});
21 changes: 21 additions & 0 deletions packages/eve/extension-contracts/reports/channel/v10.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"kind": "eve-extension-capability-contract",
"capability": "channel",
"epoch": 10,
"sha256": "0b7359bd92a73d6d4c9c51f63d7e7af5b169338cab6f0e1429ce17f44e322c1a",
"exports": [
"DELETE",
"GET",
"HEAD",
"OPTIONS",
"PATCH",
"POST",
"PUT",
"WS",
"createWebSocketUpgradeServer",
"defineChannel",
"disableRoute",
"isChannel",
"isDisabledRouteSentinel"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"kind": "eve-extension-capability-contract",
"capability": "dynamicInstructions",
"epoch": 14,
"sha256": "9dea3c7c9cecd8c6136e6e6c38d1d21e74e7ee13427e04cd4aedb604d5ffebcd",
"exports": ["defineDynamic"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"kind": "eve-extension-capability-contract",
"capability": "dynamicSkill",
"epoch": 13,
"sha256": "e314c50148ec36066b8708c6407c3825afe6f141a2c86125c15c6cc63699d1dc",
"exports": ["defineDynamic"]
}
13 changes: 13 additions & 0 deletions packages/eve/extension-contracts/reports/dynamicTool/v20.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"kind": "eve-extension-capability-contract",
"capability": "dynamicTool",
"epoch": 20,
"sha256": "8767b4843b08e49833245ec0004d63cbe775263f349888dd075fa6f71ea6959d",
"exports": [
"DynamicToolEntry",
"DynamicToolEvents",
"DynamicToolResult",
"DynamicToolSet",
"defineDynamic"
]
}
7 changes: 7 additions & 0 deletions packages/eve/extension-contracts/reports/hook/v15.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"kind": "eve-extension-capability-contract",
"capability": "hook",
"epoch": 15,
"sha256": "9b4ffcb120e78affa01a862934dc641327c812958fb7da37d254b974c7d7ef9d",
"exports": ["defineHook"]
}
14 changes: 14 additions & 0 deletions packages/eve/extension-contracts/reports/schedule/v4.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"kind": "eve-extension-capability-contract",
"capability": "schedule",
"epoch": 4,
"sha256": "cc792aebecfd572ff328f0e5c05cd50ca662fcbaa61b59adec30aa023f5cfc67",
"exports": [
"ScheduleDefinition",
"ScheduleHandlerArgs",
"ScheduleRunHandler",
"ScheduleToFn",
"TypedReceiveTarget",
"defineSchedule"
]
}
20 changes: 20 additions & 0 deletions packages/eve/extension-contracts/reports/subagent/v5.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"kind": "eve-extension-capability-contract",
"capability": "subagent",
"epoch": 5,
"sha256": "b940181d78346c850ffb480856db39d21fecc4ebd9b8311c4f40dc4dd3366e2e",
"exports": [
"AgentCompactionDefinition",
"AgentDefinition",
"AgentModelDefinition",
"AgentStaticModelDefinition",
"DefinedAgent",
"DynamicLocalSubagentDefinition",
"DynamicSubagentDefinition",
"RemoteAgentDefinition",
"RemoteAgentDefinitionInput",
"defineAgent",
"defineDynamic",
"defineRemoteAgent"
]
}
81 changes: 81 additions & 0 deletions packages/eve/src/client/message-reducer-primitives.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { EveMessage, EveMessageData, EveMessagePart } from "#client/message-reducer-types.js";
import type { MessageReceivedPart } from "#protocol/message.js";

export function projectReceivedParts(
parts: readonly MessageReceivedPart[] | undefined,
message: string,
): readonly EveMessagePart[] {
return (
parts?.map((part) =>
part.type === "text"
? { state: "done", text: part.text, type: "text" }
: {
filename: part.filename,
mediaType: part.mediaType,
size: part.size,
type: "file",
url: part.url,
},
) ?? [{ state: "done", text: message, type: "text" }]
);
}

export function partKey(part: EveMessagePart): string {
switch (part.type) {
case "text":
return `text:${part.stepIndex ?? 0}`;
case "reasoning":
return `reasoning:${part.stepIndex ?? 0}`;
case "file":
return `file:${part.stepIndex ?? 0}:${part.filename ?? part.url ?? part.mediaType}`;
case "step-start":
return "step-start";
case "authorization":
return `authorization:${part.turnId}:${part.stepIndex}:${part.name}`;
case "dynamic-tool":
return `dynamic-tool:${part.toolCallId}`;
}
}

export function upsertMessage(data: EveMessageData, next: EveMessage): EveMessageData {
const index = data.messages.findIndex((message) => message.id === next.id);
if (index === -1) {
return { messages: [...data.messages, next] };
}

return {
messages: [...data.messages.slice(0, index), next, ...data.messages.slice(index + 1)],
};
}

export function removeStreamingToolPartsForTurn(
data: EveMessageData,
turnId: string,
): EveMessageData {
const index = data.messages.findIndex(
(message) => message.role === "assistant" && message.metadata?.turnId === turnId,
);
const message = data.messages[index];
if (message === undefined) return data;

return upsertMessage(data, {
...message,
parts: message.parts.filter(
(part) => part.type !== "dynamic-tool" || part.state !== "input-streaming",
),
});
}

export function optimisticUserMessageId(submissionId: string): string {
return `optimistic:${submissionId}:user`;
}

export function appendToolInputDelta(
inputText: string | undefined,
offset: number,
delta: string,
): string | undefined {
if (offset === 0) return delta;
if (inputText?.length !== offset) return undefined;
return inputText + delta;
}
2 changes: 2 additions & 0 deletions packages/eve/src/client/message-reducer-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ export type EveDynamicToolPart = {
readonly approval?: never;
readonly errorText?: never;
readonly input: unknown | undefined;
/** Accumulated raw tool input, which may be incomplete JSON. */
readonly inputText: string;
readonly output?: never;
readonly state: "input-streaming";
}
Expand Down
Loading
Loading