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 Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,13 @@ overflow-checks = true
# once for the whole tree. Only deps used (or expected to be used) by more than one crate live here;
# crate-specific deps stay in that crate's manifest.
[workspace.dependencies]
async-trait = "0.1"
base64 = "0.22"
bytes = "1"
clap = { version = "4", features = ["derive", "env"] }
figment = { version = "0.10", features = ["toml", "env"] }
# Stream combinators for the agent loop's streaming transport (`BoxStream<StreamEvent>`).
futures = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
Expand Down
38 changes: 38 additions & 0 deletions crates/agent-core/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Beyond Agent Harness — Core Architecture

`beyond-ai-agent-core` (lib `agent_core`) is the runtime-agnostic core of the Beyond agent harness,
modeled on [Pi](https://github.com/badlogic/pi-mono) (`pi-agent-core` + the dialect half of `pi-ai`)
and ported to Rust. It holds **no** HTTP, provider, or executor code, so it unit-tests without a
network or a live model — the same discipline the gateway uses to keep its logic testable without
Pingora.

## The Beyond twist

The model layer never manages provider keys or endpoints. It speaks OpenAI/Anthropic **wire** to the
Beyond gateway, which owns routing, auth, and metering. So the only part of `pi-ai` worth porting is
the **dialect-agnostic message model**; provider selection is the gateway's job. The agent crate has
**no dependency on the gateway crate** — its sole contract is HTTP wire to a base URL.

## Modules

| Module | Type | Role |
| ----------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `message` | data | Dialect-agnostic conversation model: `Role`, `ContentBlock` (`Text`/`ToolUse`/`ToolResult`), `Message`, `ToolDef`, `StreamEvent`, `StopReason`. The single internal representation; wire adapters map it to/from each provider's shape. |
| `tool` | seam (extensibility) | `Tool` trait + `ToolRegistry`. Capabilities are registered values — the core four (Read/Write/Edit/Bash) and Beyond primitives (fork/sync/logs) are all just tools. Last-registration-wins lets an extension override a built-in. |
| `transport` | seam (network) | `ModelRequest` + `ModelTransport` trait returning an `EventStream` of `StreamEvent`s. The loop depends only on this; the real gateway client and the test `MockTransport` both implement it. |
| `session` | data | `Session`: message history + token/step counters. `serde`-serializable so a headless run persists and a client can reattach. |
| `error` | data | `Error` (loop/transport) and `ToolError` (a tool's own failure → an error `tool_result`, not an aborted run). |

## The two seams

Everything above the wire is testable with mocks because of two trait boundaries:

- **`Tool`** — tests register a mock (`EchoTool`) to exercise dispatch without a real capability.
- **`ModelTransport`** — tests replay scripted `StreamEvent`s to exercise the loop without a network.

## Milestone status

Scaffolded: the type model, the `Tool`/`ToolRegistry` seam, the `ModelTransport` seam, `Session`,
and errors — all unit-tested. **Not yet built:** the wire dialect adapters (OpenAI + Anthropic), the
HTTP-to-gateway transport, the agent loop itself, and the `serve` control API. See the repo plan for
the milestone sequence (M2–M8).
29 changes: 29 additions & 0 deletions crates/agent-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[package]
name = "beyond-ai-agent-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "Beyond agent harness — runtime-agnostic core: messages, tools, sessions, the model-transport seam"

[lib]
# Short, ergonomic extern name (`use agent_core::…`); the published/depended-on package stays
# `beyond-ai-agent-core`. Same split the gateway uses (package `beyond-ai` / lib `beyond_ai`).
name = "agent_core"
path = "src/lib.rs"

[lints]
workspace = true

[dependencies]
async-trait.workspace = true
futures.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true

[dev-dependencies]
# Async test runtime for the `Tool`/loop tests. The library itself is runtime-agnostic — it pulls in
# no executor, only the `futures` stream traits — so the runtime lives in dev-deps.
tokio.workspace = true
39 changes: 39 additions & 0 deletions crates/agent-core/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//! Error types for the agent core. Loop/transport failures are [`Error`]; a tool's own failure is
//! [`ToolError`], which the loop converts into a `tool_result` with `is_error = true` rather than
//! aborting the run — a failed tool is a value the model can react to, not a dead turn.

use thiserror::Error;

/// A failure inside a [`crate::Tool`].
#[derive(Debug, Error)]
pub enum ToolError {
/// The arguments object didn't match the tool's schema / expectations.
#[error("invalid tool input: {0}")]
InvalidInput(String),
/// The tool ran but failed (command non-zero, file missing, etc.).
#[error("tool execution failed: {0}")]
Execution(String),
/// An underlying IO error.
#[error(transparent)]
Io(#[from] std::io::Error),
}

/// A failure in the agent loop or its transport.
#[derive(Debug, Error)]
pub enum Error {
/// The model transport failed (network, decode, gateway error).
#[error("transport error: {0}")]
Transport(String),
/// The model asked for a tool that isn't registered.
#[error("unknown tool: {name}")]
UnknownTool { name: String },
/// The loop hit its step ceiling without the model ending its turn.
#[error("reached max steps ({0}) without completion")]
MaxSteps(u32),
/// A streamed tool call carried malformed JSON arguments.
#[error("malformed tool-call arguments: {0}")]
MalformedToolInput(String),
}

/// Crate result alias.
pub type Result<T> = std::result::Result<T, Error>;
31 changes: 31 additions & 0 deletions crates/agent-core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//! Beyond agent harness — core.
//!
//! A runtime-agnostic, network-agnostic agent loop, modeled on Pi (`pi-agent-core` + the dialect
//! half of `pi-ai`). The pieces here are deliberately free of HTTP, providers, and any executor so
//! they unit-test without a network or a live model — the same split the gateway uses to keep its
//! load-bearing logic testable without Pingora.
//!
//! Two seams keep everything above the wire testable with mocks:
//! - [`Tool`] — capabilities are values in a [`ToolRegistry`]; tests register a mock tool.
//! - [`ModelTransport`] — the loop talks to this, never to `reqwest`. The real HTTP-to-gateway
//! client and the test `MockTransport` both implement it (added in later milestones).
//!
//! **The Beyond twist:** the model layer never manages provider keys or endpoints. It speaks
//! OpenAI/Anthropic *wire* to the Beyond gateway, which owns routing, auth, and metering. So the
//! only part of `pi-ai` worth porting is the dialect-agnostic message model in [`message`].

// Production code in this crate is panic-free (see `[workspace.lints.clippy]`); a unit test's whole
// job is to assert a precondition holds, so `.unwrap()` *is* the assertion — allow it under `test`.
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]

pub mod error;
pub mod message;
pub mod session;
pub mod tool;
pub mod transport;

pub use error::{Error, Result, ToolError};
pub use message::{ContentBlock, Message, Role, StopReason, StreamEvent, ToolDef};
pub use session::Session;
pub use tool::{Tool, ToolRegistry};
pub use transport::{ModelRequest, ModelTransport};
208 changes: 208 additions & 0 deletions crates/agent-core/src/message.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
//! The dialect-agnostic conversation model.
//!
//! These types are the single internal representation of a conversation. The wire adapters (added
//! with the transport client) map them to and from the OpenAI (`/v1/chat/completions`) and Anthropic
//! (`/v1/messages`) shapes — the gateway relays bytes verbatim, so the harness owns the dialect.
//!
//! The vocabulary leans Anthropic (content blocks, `tool_use`/`tool_result`) because that's the
//! default dialect for Claude; the OpenAI adapter folds its flatter shape into these blocks.

use serde::{Deserialize, Serialize};
use serde_json::Value;

/// Who authored a message.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
System,
User,
Assistant,
}

/// One piece of message content. A message is a sequence of these.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlock {
/// Plain text — a prompt fragment or assistant prose.
Text { text: String },
/// The model's request to invoke a tool. `input` is the (already-complete) JSON arguments
/// object; during streaming it's assembled from `StreamEvent::InputJsonDelta` fragments.
ToolUse {
id: String,
name: String,
input: Value,
},
/// The result of running a tool, fed back to the model. Carried on a `User` message (Anthropic
/// convention). `is_error` lets the model see a failure as a value rather than a dead turn.
ToolResult {
tool_use_id: String,
content: String,
#[serde(default)]
is_error: bool,
},
}

/// A single turn in the conversation.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Message {
pub role: Role,
pub content: Vec<ContentBlock>,
}

impl Message {
/// A user turn carrying a single text block.
pub fn user(text: impl Into<String>) -> Self {
Self {
role: Role::User,
content: vec![ContentBlock::Text { text: text.into() }],
}
}

/// An assistant turn from already-assembled content blocks.
pub fn assistant(content: Vec<ContentBlock>) -> Self {
Self {
role: Role::Assistant,
content,
}
}

/// A user turn carrying one tool result (how a tool's output is returned to the model).
pub fn tool_result(
tool_use_id: impl Into<String>,
content: impl Into<String>,
is_error: bool,
) -> Self {
Self {
role: Role::User,
content: vec![ContentBlock::ToolResult {
tool_use_id: tool_use_id.into(),
content: content.into(),
is_error,
}],
}
}

/// The `ToolUse` blocks in this message, if any (what the loop dispatches each step).
pub fn tool_uses(&self) -> impl Iterator<Item = (&str, &str, &Value)> {
self.content.iter().filter_map(|b| match b {
ContentBlock::ToolUse { id, name, input } => Some((id.as_str(), name.as_str(), input)),
_ => None,
})
}
}

/// A tool advertised to the model: name, description, and a JSON Schema for its input object.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolDef {
pub name: String,
pub description: String,
/// JSON Schema describing the tool's input arguments.
pub input_schema: Value,
}

/// Why the model ended a turn.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StopReason {
/// Natural end of the assistant's turn.
EndTurn,
/// The model wants to call one or more tools; the loop should dispatch and continue.
ToolUse,
/// Hit the output token ceiling.
MaxTokens,
/// Hit a configured stop sequence.
StopSequence,
/// Anything else / dialect-specific.
Other,
}

/// An incremental event from a streaming model response. The dialect adapters normalize both
/// providers' SSE shapes into this sequence; the loop consumes it to assemble assistant messages.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum StreamEvent {
/// The assistant turn has begun.
MessageStart,
/// A chunk of assistant text.
TextDelta { text: String },
/// A tool-call block opened; `id` and `name` are known before its arguments stream in.
ToolUseStart { id: String, name: String },
/// A chunk of the in-progress tool call's JSON arguments.
InputJsonDelta { partial_json: String },
/// The current content block finished (text or tool-call).
ContentBlockStop,
/// Token accounting. May arrive at end-of-stream (OpenAI) or alongside other events (Anthropic).
Usage {
input_tokens: u32,
output_tokens: u32,
},
/// The assistant turn finished, with the reason it stopped.
MessageStop { stop_reason: StopReason },
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

#[test]
fn content_block_tool_use_round_trips() {
let block = ContentBlock::ToolUse {
id: "tu_1".into(),
name: "read".into(),
input: json!({ "path": "README.md" }),
};
let wire = serde_json::to_value(&block).unwrap();
// Internally-tagged on `type`, snake_case — the shape the dialect adapters key on.
assert_eq!(wire["type"], "tool_use");
assert_eq!(wire["name"], "read");
let back: ContentBlock = serde_json::from_value(wire).unwrap();
assert_eq!(back, block);
}

#[test]
fn tool_result_defaults_is_error_false_when_absent() {
let wire = json!({ "type": "tool_result", "tool_use_id": "tu_1", "content": "ok" });
let block: ContentBlock = serde_json::from_value(wire).unwrap();
assert_eq!(
block,
ContentBlock::ToolResult {
tool_use_id: "tu_1".into(),
content: "ok".into(),
is_error: false
}
);
}

#[test]
fn tool_uses_extracts_only_tool_calls() {
let msg = Message::assistant(vec![
ContentBlock::Text {
text: "let me look".into(),
},
ContentBlock::ToolUse {
id: "a".into(),
name: "read".into(),
input: json!({}),
},
ContentBlock::ToolUse {
id: "b".into(),
name: "bash".into(),
input: json!({}),
},
]);
let calls: Vec<_> = msg.tool_uses().map(|(id, name, _)| (id, name)).collect();
assert_eq!(calls, vec![("a", "read"), ("b", "bash")]);
}

#[test]
fn stream_event_tag_is_snake_case() {
let ev = StreamEvent::InputJsonDelta {
partial_json: "{\"p\":".into(),
};
assert_eq!(
serde_json::to_value(&ev).unwrap()["type"],
"input_json_delta"
);
}
}
Loading
Loading