From a11bb5b98b0eeef590978cec9b4cc08963317c55 Mon Sep 17 00:00:00 2001 From: Jared Lunde Date: Mon, 29 Jun 2026 22:49:32 -0700 Subject: [PATCH] feat(agent): scaffold the Beyond agent harness core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two workspace crates that lay down the harness, modeled on Pi (badlogic/pi-mono) and ported to Rust: - beyond-ai-agent-core (lib `agent_core`): runtime- and network-agnostic core. The dialect-agnostic message model (Role/ContentBlock/Message/ ToolDef/StreamEvent/StopReason), the `Tool` + `ToolRegistry` extensibility seam, the `ModelTransport` network seam, `Session` state, and errors. No HTTP/provider/executor code, so it unit-tests with no network. - beyond-ai-agent (bin `beyond-ai-agent`): thin CLI shell. Targets the one-shot `run` and headless `serve` surface (both scaffolded); `tools` is a working no-network demo of the registry + async tool seam. The Beyond twist: the model layer never manages provider keys/endpoints — it speaks OpenAI/Anthropic wire to the gateway. The agent crate has no dependency on the gateway crate; its only contract is the HTTP wire. Workspace: add `async-trait` + `futures` to [workspace.dependencies] (gateway's async-trait now inherits from the workspace). mise: build:agent, run:agent, test:unit:agent. Verified: build · clippy -D warnings · cargo fmt --all --check · dprint check · 9 agent-core unit tests · gateway 99 unchanged · `beyond-ai-agent tools` demo. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 26 ++++ Cargo.toml | 3 + crates/agent-core/ARCHITECTURE.md | 38 ++++++ crates/agent-core/Cargo.toml | 29 ++++ crates/agent-core/src/error.rs | 39 ++++++ crates/agent-core/src/lib.rs | 31 +++++ crates/agent-core/src/message.rs | 208 +++++++++++++++++++++++++++++ crates/agent-core/src/session.rs | 66 +++++++++ crates/agent-core/src/tool.rs | 170 +++++++++++++++++++++++ crates/agent-core/src/transport.rs | 65 +++++++++ crates/agent/Cargo.toml | 24 ++++ crates/agent/src/main.rs | 100 ++++++++++++++ crates/gateway/Cargo.toml | 2 +- mise.toml | 11 ++ 14 files changed, 811 insertions(+), 1 deletion(-) create mode 100644 crates/agent-core/ARCHITECTURE.md create mode 100644 crates/agent-core/Cargo.toml create mode 100644 crates/agent-core/src/error.rs create mode 100644 crates/agent-core/src/lib.rs create mode 100644 crates/agent-core/src/message.rs create mode 100644 crates/agent-core/src/session.rs create mode 100644 crates/agent-core/src/tool.rs create mode 100644 crates/agent-core/src/transport.rs create mode 100644 crates/agent/Cargo.toml create mode 100644 crates/agent/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 1a9bf79..0d772f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -339,6 +339,32 @@ dependencies = [ "zeroize", ] +[[package]] +name = "beyond-ai-agent" +version = "0.1.0" +dependencies = [ + "async-trait", + "beyond-ai-agent-core", + "clap", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "beyond-ai-agent-core" +version = "0.1.0" +dependencies = [ + "async-trait", + "futures", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "beyond-slipstream" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 2c00731..d22c422 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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`). +futures = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" diff --git a/crates/agent-core/ARCHITECTURE.md b/crates/agent-core/ARCHITECTURE.md new file mode 100644 index 0000000..8702b93 --- /dev/null +++ b/crates/agent-core/ARCHITECTURE.md @@ -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). diff --git a/crates/agent-core/Cargo.toml b/crates/agent-core/Cargo.toml new file mode 100644 index 0000000..0659680 --- /dev/null +++ b/crates/agent-core/Cargo.toml @@ -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 diff --git a/crates/agent-core/src/error.rs b/crates/agent-core/src/error.rs new file mode 100644 index 0000000..d8be72f --- /dev/null +++ b/crates/agent-core/src/error.rs @@ -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 = std::result::Result; diff --git a/crates/agent-core/src/lib.rs b/crates/agent-core/src/lib.rs new file mode 100644 index 0000000..1d01a88 --- /dev/null +++ b/crates/agent-core/src/lib.rs @@ -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}; diff --git a/crates/agent-core/src/message.rs b/crates/agent-core/src/message.rs new file mode 100644 index 0000000..804df22 --- /dev/null +++ b/crates/agent-core/src/message.rs @@ -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, +} + +impl Message { + /// A user turn carrying a single text block. + pub fn user(text: impl Into) -> 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) -> 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, + content: impl Into, + 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 { + 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" + ); + } +} diff --git a/crates/agent-core/src/session.rs b/crates/agent-core/src/session.rs new file mode 100644 index 0000000..3e10d8b --- /dev/null +++ b/crates/agent-core/src/session.rs @@ -0,0 +1,66 @@ +//! Per-run agent state. +//! +//! A [`Session`] is the evolving state of one agent run: the full message history plus token/step +//! counters. It's `serde`-serializable so a headless run (`serve`) can persist it and a client can +//! reattach to a running session later — the foundation for the attach-later remote-control model. + +use serde::{Deserialize, Serialize}; + +use crate::message::Message; + +/// The state of one agent run. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Session { + /// Full conversation, oldest first. + pub messages: Vec, + /// Completed loop iterations (model turn + tool dispatch). + pub steps: u32, + /// Cumulative input tokens reported by the model. + pub input_tokens: u64, + /// Cumulative output tokens reported by the model. + pub output_tokens: u64, +} + +impl Session { + /// A fresh, empty session. + pub fn new() -> Self { + Self::default() + } + + /// Append a message to the history. + pub fn push(&mut self, message: Message) { + self.messages.push(message); + } + + /// Seed (or continue) the conversation with a user text turn. + pub fn user(&mut self, text: impl Into) -> &mut Self { + self.push(Message::user(text)); + self + } + + /// Fold a turn's token usage into the running totals. + pub fn record_usage(&mut self, input_tokens: u32, output_tokens: u32) { + self.input_tokens += u64::from(input_tokens); + self.output_tokens += u64::from(output_tokens); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_through_json() { + let mut s = Session::new(); + s.user("hello"); + s.steps = 2; + s.record_usage(10, 5); + s.record_usage(3, 7); + let json = serde_json::to_string(&s).unwrap(); + let back: Session = serde_json::from_str(&json).unwrap(); + assert_eq!(back.messages.len(), 1); + assert_eq!(back.steps, 2); + assert_eq!(back.input_tokens, 13); + assert_eq!(back.output_tokens, 12); + } +} diff --git a/crates/agent-core/src/tool.rs b/crates/agent-core/src/tool.rs new file mode 100644 index 0000000..f0d7bc1 --- /dev/null +++ b/crates/agent-core/src/tool.rs @@ -0,0 +1,170 @@ +//! The tool seam: capabilities the model can invoke. +//! +//! A [`Tool`] is a value; the agent is configured by registering tools in a [`ToolRegistry`]. This +//! is the harness's primary extensibility point (Pi's Extensions/Skills/Tools): the core four +//! (Read/Write/Edit/Bash) and the Beyond primitives (fork/sync/logs) are all just registered tools, +//! and tests register a mock tool to exercise the loop without a real capability. + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::Value; + +use crate::error::ToolError; +use crate::message::ToolDef; + +/// A capability the model can call. Implementors are cheap, `Send + Sync` values stored behind an +/// `Arc` in the registry. +#[async_trait] +pub trait Tool: Send + Sync { + /// Stable identifier the model uses to call this tool. + fn name(&self) -> &str; + + /// One-line description shown to the model — what the tool does and when to use it. + fn description(&self) -> &str; + + /// JSON Schema for the tool's input arguments object. + fn input_schema(&self) -> Value; + + /// Run the tool against a (schema-conformant) arguments object, returning text for the model. + /// Return [`ToolError`] on failure; the loop surfaces it as an error `tool_result` rather than + /// aborting the run. + async fn run(&self, input: Value) -> Result; + + /// The advertised definition sent to the model. Derived from the accessors; override only if a + /// tool needs a non-default shape. + fn definition(&self) -> ToolDef { + ToolDef { + name: self.name().to_string(), + description: self.description().to_string(), + input_schema: self.input_schema(), + } + } +} + +/// A name-keyed set of tools available to one agent. +#[derive(Default, Clone)] +pub struct ToolRegistry { + tools: HashMap>, +} + +impl ToolRegistry { + /// An empty registry. + pub fn new() -> Self { + Self::default() + } + + /// Register a tool, keyed by its `name()`. A later registration with the same name replaces the + /// earlier one (last-wins), which is how an extension overrides a built-in. + pub fn register(&mut self, tool: Arc) -> &mut Self { + self.tools.insert(tool.name().to_string(), tool); + self + } + + /// Look up a tool by the name the model called. + pub fn get(&self, name: &str) -> Option> { + self.tools.get(name).cloned() + } + + /// The definitions to advertise to the model, in no particular order. + pub fn definitions(&self) -> Vec { + self.tools.values().map(|t| t.definition()).collect() + } + + /// Number of registered tools. + pub fn len(&self) -> usize { + self.tools.len() + } + + /// Whether no tools are registered. + pub fn is_empty(&self) -> bool { + self.tools.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// A trivial tool that echoes its `text` argument back — the standard stand-in for exercising + /// the registry and (later) the loop without a real capability. + struct EchoTool; + + #[async_trait] + impl Tool for EchoTool { + fn name(&self) -> &str { + "echo" + } + fn description(&self) -> &str { + "Echo the `text` argument back." + } + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { "text": { "type": "string" } }, + "required": ["text"], + }) + } + async fn run(&self, input: Value) -> Result { + input + .get("text") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| ToolError::InvalidInput("missing `text`".into())) + } + } + + #[test] + fn register_and_get() { + let mut reg = ToolRegistry::new(); + assert!(reg.is_empty()); + reg.register(Arc::new(EchoTool)); + assert_eq!(reg.len(), 1); + assert!(reg.get("echo").is_some()); + assert!(reg.get("nope").is_none()); + } + + #[test] + fn definition_derives_from_accessors() { + let def = EchoTool.definition(); + assert_eq!(def.name, "echo"); + assert_eq!(def.input_schema["required"][0], "text"); + } + + #[test] + fn last_registration_wins() { + struct Other; + #[async_trait] + impl Tool for Other { + fn name(&self) -> &str { + "echo" + } + fn description(&self) -> &str { + "different impl, same name" + } + fn input_schema(&self) -> Value { + json!({ "type": "object" }) + } + async fn run(&self, _: Value) -> Result { + Ok("other".into()) + } + } + let mut reg = ToolRegistry::new(); + reg.register(Arc::new(EchoTool)).register(Arc::new(Other)); + assert_eq!(reg.len(), 1); + assert_eq!( + reg.get("echo").unwrap().description(), + "different impl, same name" + ); + } + + #[tokio::test] + async fn echo_runs() { + let out = EchoTool.run(json!({ "text": "hi" })).await.unwrap(); + assert_eq!(out, "hi"); + let err = EchoTool.run(json!({})).await; + assert!(matches!(err, Err(ToolError::InvalidInput(_)))); + } +} diff --git a/crates/agent-core/src/transport.rs b/crates/agent-core/src/transport.rs new file mode 100644 index 0000000..3cb0d27 --- /dev/null +++ b/crates/agent-core/src/transport.rs @@ -0,0 +1,65 @@ +//! The model-transport seam. +//! +//! The agent loop depends only on [`ModelTransport`] — never on `reqwest` or a dialect. Two things +//! implement it (in later milestones): the real HTTP client that POSTs OpenAI/Anthropic wire to the +//! Beyond gateway, and a `MockTransport` that replays scripted events so the loop is testable with +//! no network. Keeping the trait here, free of any client, is what makes that possible. + +use async_trait::async_trait; +use futures::stream::BoxStream; + +use crate::error::Result; +use crate::message::{Message, StreamEvent, ToolDef}; + +/// One model request: the conversation so far plus the tools the model may call this turn. +#[derive(Debug, Clone)] +pub struct ModelRequest { + /// Model identifier (e.g. `claude-opus-4-8`); the client maps it to a dialect + gateway path. + pub model: String, + /// Optional system prompt (kept separate from `messages` — both wire dialects treat it specially). + pub system: Option, + /// Conversation history. + pub messages: Vec, + /// Tools advertised to the model this turn. + pub tools: Vec, + /// Output token ceiling for the turn. + pub max_tokens: u32, +} + +impl ModelRequest { + /// A request with no system prompt and no tools — the minimal shape. + pub fn new(model: impl Into, messages: Vec, max_tokens: u32) -> Self { + Self { + model: model.into(), + system: None, + messages, + tools: Vec::new(), + max_tokens, + } + } + + /// Builder-style: attach the tools advertised for this turn. + pub fn with_tools(mut self, tools: Vec) -> Self { + self.tools = tools; + self + } + + /// Builder-style: attach a system prompt. + pub fn with_system(mut self, system: impl Into) -> Self { + self.system = Some(system.into()); + self + } +} + +/// Streamed events from a single model turn. `'static` so the stream can outlive the call (it's +/// driven to completion by the loop, not the transport). +pub type EventStream = BoxStream<'static, Result>; + +/// The boundary between the agent loop and the network. Implementors turn a [`ModelRequest`] into a +/// stream of normalized [`StreamEvent`]s. +#[async_trait] +pub trait ModelTransport: Send + Sync { + /// Issue the request and return its event stream. Errors here are connection/setup failures; + /// per-event failures surface as `Err` items within the stream. + async fn stream(&self, req: ModelRequest) -> Result; +} diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml new file mode 100644 index 0000000..146b2cb --- /dev/null +++ b/crates/agent/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "beyond-ai-agent" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +description = "Beyond agent harness — CLI (one-shot `run` and headless `serve`)" + +[[bin]] +name = "beyond-ai-agent" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +# The harness core. Depended on by package name; imported as `agent_core` (its lib name). +async-trait.workspace = true +beyond-ai-agent-core = { path = "../agent-core" } +clap.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true diff --git a/crates/agent/src/main.rs b/crates/agent/src/main.rs new file mode 100644 index 0000000..dc7aa33 --- /dev/null +++ b/crates/agent/src/main.rs @@ -0,0 +1,100 @@ +//! Beyond agent harness — CLI. +//! +//! Scaffold. The command surface is the one the harness targets: a one-shot `run` and a headless +//! `serve` (with an attachable control API, for remote control over SSH). Neither is wired to the +//! agent loop yet — that arrives with the loop (M4), the coding tools (M6), and the control API +//! (M7). `tools` is a working, no-network demo that the core links and the async `Tool` seam runs. + +use std::sync::Arc; + +use agent_core::error::ToolError; +use agent_core::{Tool, ToolRegistry}; +use clap::{Parser, Subcommand}; +use serde_json::{Value, json}; + +#[derive(Parser)] +#[command(name = "beyond-ai-agent", version, about = "Beyond agent harness")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Run a one-shot agent task to completion. (Agent loop lands in a later milestone.) + Run { + /// The task prompt for the agent. + task: String, + }, + /// Run the headless agent server exposing an attachable control API. (Later milestone.) + Serve, + /// List the tools the agent advertises to the model, and run one (no-network scaffold demo). + Tools, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .try_init(); + + match Cli::parse().command { + Command::Tools => demo_tools().await?, + Command::Run { task } => { + println!("scaffold: `run` is not wired yet (agent loop = M4, CLI = M6)."); + println!("would drive the agent loop to completion for task: {task:?}"); + } + Command::Serve => { + println!("scaffold: `serve` is not wired yet (headless control API = M7)."); + } + } + Ok(()) +} + +/// Build the agent's tool registry. As milestones land, the core Read/Write/Edit/Bash and the Beyond +/// fork/sync/logs tools register here; today it carries a single placeholder. +fn registry() -> ToolRegistry { + let mut reg = ToolRegistry::new(); + reg.register(Arc::new(EchoTool)); + reg +} + +async fn demo_tools() -> Result<(), Box> { + let reg = registry(); + println!("{} tool(s) registered:\n", reg.len()); + println!("{}", serde_json::to_string_pretty(®.definitions())?); + // Prove the async tool seam runs end to end — no network, no model. + if let Some(echo) = reg.get("echo") { + let out = echo.run(json!({ "text": "harness online" })).await?; + println!("\necho.run -> {out:?}"); + } + Ok(()) +} + +/// Placeholder built-in so the scaffold demonstrates the registry + async tool path. Replaced by the +/// real coding tools (Read/Write/Edit/Bash) in M6. +struct EchoTool; + +#[async_trait::async_trait] +impl Tool for EchoTool { + fn name(&self) -> &str { + "echo" + } + fn description(&self) -> &str { + "Echo the `text` argument back (scaffold placeholder)." + } + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { "text": { "type": "string" } }, + "required": ["text"], + }) + } + async fn run(&self, input: Value) -> Result { + input + .get("text") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| ToolError::InvalidInput("missing `text`".into())) + } +} diff --git a/crates/gateway/Cargo.toml b/crates/gateway/Cargo.toml index 4314b6f..516e62b 100644 --- a/crates/gateway/Cargo.toml +++ b/crates/gateway/Cargo.toml @@ -31,7 +31,7 @@ pingora-proxy = "0.8" arc-swap = "1" arrayvec = "0.7" -async-trait = "0.1" +async-trait.workspace = true base64.workspace = true bytes.workspace = true clap.workspace = true diff --git a/mise.toml b/mise.toml index 16c137b..463007e 100644 --- a/mise.toml +++ b/mise.toml @@ -17,6 +17,17 @@ run = "cargo build --release" description = "Mint a deterministic DEV-ONLY signing pubkey + bai_v1 token (fixed seed) for wiring up the dev config. Not for production." run = "cargo run -p beyond-ai --example mint_dev_key" +[tasks."build:agent"] +run = "cargo build -p beyond-ai-agent" + +[tasks."run:agent"] +description = "Run the agent harness CLI (e.g. `mise run run:agent -- tools`)." +run = "cargo run -p beyond-ai-agent --" + +[tasks."test:unit:agent"] +description = "Unit tests for the agent harness core (messages, tool registry, session)." +run = "cargo test -p beyond-ai-agent-core" + [tasks."check:rs"] run = "cargo clippy --all-targets -- -D warnings"