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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@ All notable changes to `yoagent` are documented here. The format loosely
follows [Keep a Changelog](https://keepachangelog.com/), and the project
adheres to [Semantic Versioning](https://semver.org/).

## Unreleased

### Added

- **Serializable event stream** — `AgentEvent` and `StreamDelta` now derive
`Serialize`, `Deserialize`, and `PartialEq`, so external frontends
(websocket fanout servers, TypeScript clients, JSONL pipes) can consume
the agent's event stream as JSON. The wire format is internally tagged
camelCase — `{"type":"toolExecutionEnd","toolCallId":...,"isError":false}`
— and is a **frozen public contract** guarded by snapshot tests: variant
tags, field names, and the tagging scheme will not change in minor
releases. Additive only: no variant, field, or signature changes.

### Changed

- **Message payload serialization normalized to camelCase** — the five
remaining snake_case fields in serialized messages now match the rest of
the wire format: `usage.cacheRead`/`cacheWrite`/`totalTokens`,
`errorMessage`, and `providerMetadata`. Session files and `save_messages`
blobs written by older versions still load (`serde` aliases accept the old
names); files **written** by 0.13 use the new names, so they are not
readable by yoagent < 0.13. The full nested payload shape (message,
content blocks, usage) is now frozen by an exact-JSON snapshot test.
- `serde` minimum version is now `1.0.181` (needed for `rename_all_fields`
on the event wire format). Released July 2023; no practical impact.

## 0.12.0

### Added
Expand Down
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ exclude = ["docs/images/*", "docs/theme/*", ".github/*", "scripts/*"]
tokio = { version = "1", features = ["rt", "sync", "fs", "macros", "process", "time", "io-util"] }
tokio-util = "0.7"
tokio-stream = "0.1"
serde = { version = "1", features = ["derive"] }
# 1.0.181 floor: rename_all_fields on the AgentEvent wire format
serde = { version = "1.0.181", features = ["derive"] }
serde_json = "1"
async-trait = "0.1"
thiserror = "2"
Expand Down
20 changes: 20 additions & 0 deletions docs/concepts/messages-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,26 @@ Events emitted during the agent loop for real-time UI updates:
| `ProgressMessage { tool_call_id, tool_name, text }` | User-facing progress text from a tool |
| `InputRejected { reason }` | Input filter rejected the user's message |

### Wire format

`AgentEvent` and `StreamDelta` serialize as internally-tagged camelCase JSON, so
external frontends (a websocket fanout server, a TypeScript client, a JSONL
pipe) can consume the event stream directly:

```json
{"type":"messageUpdate","message":{...},"delta":{"type":"text","delta":"hi"}}
{"type":"toolExecutionEnd","toolCallId":"tc_1","toolName":"bash","result":{...},"isError":false}
```

This shape is a **public contract** frozen by snapshot tests — variant tags,
field names, and the tagging scheme won't change in minor releases.

Streaming semantics: clients accumulate text from each `MessageUpdate`'s
`delta`; the `message` field during streaming is a placeholder (its `content`
fills in only when the message completes). The full message arrives with
`MessageEnd` — a client that misses events (e.g. a lagged websocket
subscriber) resyncs from the next `MessageEnd` without replay.

## StreamDelta

Deltas within `MessageUpdate`:
Expand Down
56 changes: 49 additions & 7 deletions src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ pub enum Content {
/// Provider-specific metadata (e.g. Gemini thought signatures).
/// Not passed to tool execution; used by providers when building
/// the next request.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "providerMetadata",
alias = "provider_metadata"
)]
provider_metadata: Option<serde_json::Value>,
},
}
Expand Down Expand Up @@ -124,7 +129,11 @@ pub enum Message {
provider: String,
usage: Usage,
timestamp: u64,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(
skip_serializing_if = "Option::is_none",
rename = "errorMessage",
alias = "error_message"
)]
error_message: Option<String>,
},
#[serde(rename = "toolResult")]
Expand Down Expand Up @@ -300,11 +309,13 @@ pub enum StopReason {
pub struct Usage {
pub input: u64,
pub output: u64,
#[serde(default)]
// camelCase on the wire (AgentEvent contract); `alias` keeps session
// files written by yoagent < 0.13 loadable.
#[serde(default, rename = "cacheRead", alias = "cache_read")]
pub cache_read: u64,
#[serde(default)]
#[serde(default, rename = "cacheWrite", alias = "cache_write")]
pub cache_write: u64,
#[serde(default)]
#[serde(default, rename = "totalTokens", alias = "total_tokens")]
pub total_tokens: u64,
}

Expand Down Expand Up @@ -518,7 +529,29 @@ pub enum ToolError {
// Agent events (for streaming UI updates)
// ---------------------------------------------------------------------------

#[derive(Debug, Clone)]
/// Events emitted by the agent loop for streaming UI updates.
///
/// # Wire format (stability contract)
///
/// `AgentEvent` and [`StreamDelta`] serialize as internally-tagged JSON —
/// `{"type": "<camelCase variant>", ...camelCase fields}` — so external
/// frontends (websocket fanout servers, TypeScript clients, JSONL pipes) can
/// consume the event stream directly:
///
/// ```json
/// {"type":"messageUpdate","message":{...},"delta":{"type":"text","delta":"hi"}}
/// {"type":"toolExecutionEnd","toolCallId":"tc_1","toolName":"bash","result":{...},"isError":false}
/// ```
///
/// This shape is a **public contract**: variant tags, field names, and the
/// internal tagging are frozen by snapshot tests. Renaming a variant or field
/// is a breaking change for wire clients, not just for Rust callers.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum AgentEvent {
AgentStart,
AgentEnd {
Expand Down Expand Up @@ -565,7 +598,16 @@ pub enum AgentEvent {
},
}

#[derive(Debug, Clone)]
/// Incremental content delta carried by [`AgentEvent::MessageUpdate`].
///
/// Serializes internally tagged (`{"type":"text","delta":"..."}`); see the
/// wire-format contract on [`AgentEvent`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum StreamDelta {
Text { delta: String },
Thinking { delta: String },
Expand Down
Loading
Loading