diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dba3501..9c03381 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -376,6 +376,13 @@ jobs: atomr-agents-coding-cli-vendor-antigravity atomr-agents-coding-cli-harness atomr-agents-coding-cli-harness-web + # --- Claude Agent SDK harness ---------------------------- + # core → harness (needs agent) → harness-web (needs harness). + # All publish before host/py-bindings/umbrella, which depend + # on agent-sdk-harness. + atomr-agents-agent-sdk-core + atomr-agents-agent-sdk-harness + atomr-agents-agent-sdk-harness-web # --- deep-research capability ---------------------------- atomr-agents-deep-research-core atomr-agents-deep-research-harness diff --git a/CHANGELOG.md b/CHANGELOG.md index d5e53da..5376917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,45 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Added — Claude Agent SDK harness + +Anthropic's **Claude Agent SDK** (`claude-agent-sdk` / +`@anthropic-ai/claude-agent-sdk`) — the programmable form of Claude Code — +wrapped as a first-class atomr-agents harness. It exposes Claude Code's full +feature set (slash commands, subagents, hooks, MCP, permission modes, +sessions, custom system prompts, built-in Read/Write/Edit/Bash/Grep/WebSearch +tools) and bills against your Anthropic API credits by driving the bundled +`claude` CLI. Distinct from the server-side "Managed Agents" API. + +- **`atomr-agents-agent-sdk-core`** — the contract: `AgentSdkConfig` mirroring + the SDK's `ClaudeAgentOptions` (round-trips to a JSON dict), a normalized + message/result/event schema, and the pluggable `AgentSdkBackend` / + `AgentSdkSession` trait seam with a deterministic in-memory `MockBackend` so + the whole surface is testable without the SDK, the `claude` CLI, or + network/credits. +- **`atomr-agents-agent-sdk-harness`** — orchestrator with a pluggable backend, + an event broadcast, a session registry under an atomically-reserved + concurrency quota, Anthropic-credit spend tracking via the shared + `SpendLedger`, a `.claude/` projection (skills / slash commands / MCP / + settings), and a `Callable` impl. Behind the `actor` feature it exposes the + bidirectional interactive session as an `atomr_core::actor::Actor`. The + default `permission_mode` is `bypassPermissions` (max autonomy — overridable + per request/spec). +- **`atomr-agents-agent-sdk-harness-web`** — axum REST + SSE companion (start + runs/sessions, post messages, interrupt, change mode/model, stream events). +- **PyO3 bridge** — `from atomr_agents.agent_sdk import harness`. The Rust + `PythonAgentSdkBackend` drives the real `claude-agent-sdk` over a reverse + async-iterator bridge (Rust drives the Python `__anext__`); the interactive + session is exposed as `AgentSdkSession` (the actor surface). atomr `@tool` + guests and Rust tools are bridged into the agent as in-process SDK MCP tools + via `create_sdk_mcp_server`. +- **Host loader** — `/agent-sdk//` (`harness.yaml` + `commands/` + + `skills/` + `mcp/`) → `AgentSdkHarnessSpec` + `.claude` projection, reusing + the host's on-disk conventions. +- **Umbrella** — new `agent-sdk` feature. +- **Packaging** — `pip install atomr-agents[agent-sdk]` pulls the SDK (which + auto-bundles the `claude` CLI); the native extension imports it lazily. + ### Added — MicroVM sandbox capability (local + dev tiers) Secure, instant-boot compute environments for executing untrusted agent diff --git a/Cargo.toml b/Cargo.toml index 7d64e6c..8003cd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,9 @@ members = [ "crates/coding-cli-vendor-antigravity", "crates/coding-cli-harness", "crates/coding-cli-harness-web", + "crates/agent-sdk-core", + "crates/agent-sdk-harness", + "crates/agent-sdk-harness-web", "crates/sandbox-core", "crates/sandbox-harness", "crates/sandbox-tool", @@ -125,6 +128,9 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" bytes = "1" +# Streams (broadcast→SSE, mpsc→Stream bridges) +tokio-stream = "0.1" + # Error handling thiserror = "1" anyhow = "1" @@ -237,6 +243,9 @@ atomr-agents-coding-cli-vendor-codex = { path = "crates/coding-cli-vendor-cod atomr-agents-coding-cli-vendor-antigravity = { path = "crates/coding-cli-vendor-antigravity", version = "0.19.0" } atomr-agents-coding-cli-harness = { path = "crates/coding-cli-harness", version = "0.19.0" } atomr-agents-coding-cli-harness-web = { path = "crates/coding-cli-harness-web", version = "0.19.0" } +atomr-agents-agent-sdk-core = { path = "crates/agent-sdk-core", version = "0.19.0" } +atomr-agents-agent-sdk-harness = { path = "crates/agent-sdk-harness", version = "0.19.0" } +atomr-agents-agent-sdk-harness-web = { path = "crates/agent-sdk-harness-web", version = "0.19.0" } atomr-agents-sandbox-core = { path = "crates/sandbox-core", version = "0.19.0" } atomr-agents-sandbox-harness = { path = "crates/sandbox-harness", version = "0.19.0" } atomr-agents-sandbox-tool = { path = "crates/sandbox-tool", version = "0.19.0" } diff --git a/crates/agent-sdk-core/Cargo.toml b/crates/agent-sdk-core/Cargo.toml new file mode 100644 index 0000000..180d922 --- /dev/null +++ b/crates/agent-sdk-core/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "atomr-agents-agent-sdk-core" +version = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } +authors = { workspace = true } +description = "Uniform contract for the Claude Agent SDK harness: config mirroring ClaudeAgentOptions, normalized message/result/event schema, and the pluggable AgentSdkBackend / AgentSdkSession trait seam." + +keywords = ["agents", "claude", "harness", "atomr"] +categories = ["asynchronous"] +readme = "README.md" + +[dependencies] +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +tokio = { workspace = true } +futures = { workspace = true } +parking_lot = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["full", "test-util"] } diff --git a/crates/agent-sdk-core/README.md b/crates/agent-sdk-core/README.md new file mode 100644 index 0000000..0a380f9 --- /dev/null +++ b/crates/agent-sdk-core/README.md @@ -0,0 +1,12 @@ +# atomr-agents-agent-sdk-core + +Contract layer for the **Claude Agent SDK** harness — config that mirrors the +SDK's `ClaudeAgentOptions`, a normalized message/result/event schema, and the +pluggable `AgentSdkBackend` / `AgentSdkSession` trait seam (with an in-memory +`MockBackend` for network-free tests). + +Wraps Anthropic's programmable Claude Code agent +(`claude-agent-sdk` / `@anthropic-ai/claude-agent-sdk`). Distinct from the +server-side "Managed Agents" API. + +See `docs/agent-sdk-harness.md` for the full design. diff --git a/crates/agent-sdk-core/src/backend.rs b/crates/agent-sdk-core/src/backend.rs new file mode 100644 index 0000000..e7ebe7a --- /dev/null +++ b/crates/agent-sdk-core/src/backend.rs @@ -0,0 +1,69 @@ +//! The pluggable backend abstraction. +//! +//! [`AgentSdkBackend`] is the seam between the harness and whatever actually +//! drives the Claude Agent SDK. [`MockBackend`](crate::MockBackend) keeps +//! Rust tests network-free; `PythonAgentSdkBackend` (in `py-bindings`) +//! drives the real `claude-agent-sdk`. A future pure-Rust backend that +//! spawns the `claude` CLI directly can implement the same traits. + +use std::pin::Pin; + +use async_trait::async_trait; +use futures::Stream; + +use crate::error::AgentSdkError; +use crate::message::AgentSdkMessage; +use crate::request::{AgentSessionId, QueryRequest, SessionSpec}; + +/// A stream of normalized messages. `Result<_, _>` items let a backend +/// surface a mid-stream error (e.g. a Python exception) as a stream error +/// rather than a silent truncation. +pub type MessageStream = + Pin> + Send>>; + +/// Drives the Claude Agent SDK — one-shot queries and stateful sessions. +#[async_trait] +pub trait AgentSdkBackend: Send + Sync { + /// Stable identifier used in logs (`mock`, `python`, `claude-cli`). + fn name(&self) -> &str; + + /// Whether this backend is usable on the current host (SDK installed, + /// `claude` CLI present, credentials configured…). + async fn available(&self) -> bool; + + /// Run a one-shot query (the SDK `query()` path), streaming normalized + /// messages to a terminal [`Result`](AgentSdkMessage::Result). + async fn query(&self, req: QueryRequest) -> Result; + + /// Open a stateful interactive session (the SDK `ClaudeSDKClient` path). + async fn create_session( + &self, + spec: SessionSpec, + ) -> Result, AgentSdkError>; +} + +/// A live interactive session. Maps 1:1 onto `ClaudeSDKClient`. +#[async_trait] +pub trait AgentSdkSession: Send + Sync { + /// The opaque session handle (registry / actor key). + fn session_id(&self) -> &AgentSessionId; + + /// Send a user prompt (`ClaudeSDKClient.query()`). + async fn send(&self, prompt: String) -> Result<(), AgentSdkError>; + + /// Stream the agent's response for the current turn + /// (`receive_response()`), ending at a terminal result. + async fn receive(&self) -> Result; + + /// Interrupt the in-flight turn. + async fn interrupt(&self) -> Result<(), AgentSdkError>; + + /// Change permission mode mid-conversation. + async fn set_permission_mode(&self, mode: String) -> Result<(), AgentSdkError>; + + /// Change model mid-conversation. + async fn set_model(&self, model: String) -> Result<(), AgentSdkError>; + + /// Tear down the session and its `claude` subprocess. + async fn close(&self) -> Result<(), AgentSdkError>; +} diff --git a/crates/agent-sdk-core/src/config.rs b/crates/agent-sdk-core/src/config.rs new file mode 100644 index 0000000..04c3341 --- /dev/null +++ b/crates/agent-sdk-core/src/config.rs @@ -0,0 +1,315 @@ +//! [`AgentSdkConfig`] — a serde mirror of the SDK's `ClaudeAgentOptions`. +//! +//! `serde_json::to_value(&AgentSdkConfig)` produces a dict the Python +//! wrapper turns back into `ClaudeAgentOptions(**dict)` (after popping the +//! fields that carry live callbacks, which can't cross JSON — see below). +//! +//! **Callbacks are carried as data descriptors only.** `can_use_tool`, +//! hooks, in-process MCP servers, and subagents reference Python closures +//! that cannot serialize. The config carries [`PermissionPolicy`], +//! [`HookHandlerRef`], [`McpServerConfig::InProcess`], and [`SubagentDef`] +//! as plain data; the Python wrapper resolves them to live closures +//! host-side. This keeps `AgentSdkConfig` a pure round-tripping dict. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// Mirror of `ClaudeAgentOptions`. Every field is optional so a minimal +/// `{"prompt": "..."}` request deserializes; omitted fields fall back to +/// the SDK's own defaults on the Python side. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AgentSdkConfig { + /// Custom system prompt: a bare string, or the `claude_code` preset + /// (optionally appended to). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + + /// Tools auto-approved without a permission prompt. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_tools: Vec, + + /// Tools removed from the available set entirely. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub disallowed_tools: Vec, + + /// Claude Code's tool-approval behavior. Defaults to + /// [`PermissionMode::BypassPermissions`] (max autonomy — override per + /// run/spec; see the harness safety note). + #[serde(default)] + pub permission_mode: PermissionMode, + + /// Working directory the agent operates in. Required at run time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + + /// Additional directories granted filesystem access beyond `cwd`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub add_dirs: Vec, + + /// Environment variables passed to the `claude` subprocess. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub env: BTreeMap, + + /// MCP servers (external stdio/SSE/HTTP, or an in-process marker the + /// wrapper resolves to an SDK MCP server). + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub mcp_servers: BTreeMap, + + /// Lifecycle hooks (data descriptors; live callbacks injected host-side). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hooks: Vec, + + /// Programmatically-defined subagents, keyed by name. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub agents: BTreeMap, + + /// Which filesystem settings to load (controls `.claude/` config and + /// slash-command / `CLAUDE.md` discovery). Defaults to `["project"]`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub setting_sources: Vec, + + /// Model id (alias like `sonnet`/`opus` or a full id). When unset, the + /// harness applies its spec default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// Fallback model the SDK uses if the primary is overloaded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fallback_model: Option, + + /// Cap on agent turns (SDK-enforced hard limit). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_turns: Option, + + /// Resume most-recent session in `cwd`. + #[serde(default, skip_serializing_if = "is_false")] + pub continue_conversation: bool, + + /// Resume a specific session by id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resume: Option, + + /// Fork the resumed session into a fresh one with a copy of history. + #[serde(default, skip_serializing_if = "is_false")] + pub fork_session: bool, + + /// Emit partial (streaming-delta) assistant messages. + #[serde(default, skip_serializing_if = "is_false")] + pub include_partial_messages: bool, + + /// Reasoning-effort level. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort: Option, + + /// Extended-thinking configuration (opaque pass-through to the SDK). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thinking: Option, + + /// Data form of the SDK's `can_use_tool` callback. The wrapper turns + /// this into a live permission callback. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_policy: Option, +} + +fn is_false(b: &bool) -> bool { + !*b +} + +/// Claude Code's tool-approval behavior. Serializes to the SDK's camelCase +/// strings (`default`, `acceptEdits`, `plan`, `bypassPermissions`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum PermissionMode { + Default, + AcceptEdits, + Plan, + /// Auto-approve every tool. The harness default (max autonomy). + #[default] + BypassPermissions, +} + +impl PermissionMode { + /// The wire string the SDK expects. + pub fn as_sdk_str(&self) -> &'static str { + match self { + PermissionMode::Default => "default", + PermissionMode::AcceptEdits => "acceptEdits", + PermissionMode::Plan => "plan", + PermissionMode::BypassPermissions => "bypassPermissions", + } + } +} + +/// System prompt: a bare string, or the `claude_code` preset. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum SystemPromptConfig { + Text(String), + Preset(SystemPromptPreset), +} + +/// The `{"type":"preset","preset":"claude_code","append":...}` shape. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemPromptPreset { + #[serde(rename = "type")] + pub kind: String, + pub preset: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub append: Option, +} + +impl SystemPromptPreset { + /// The default `claude_code` preset, optionally appended to. + pub fn claude_code(append: Option) -> Self { + Self { + kind: "preset".into(), + preset: "claude_code".into(), + append, + } + } +} + +/// One external or in-process MCP server. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "transport", rename_all = "snake_case")] +pub enum McpServerConfig { + Stdio { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: BTreeMap, + }, + Sse { + url: String, + #[serde(default)] + headers: BTreeMap, + }, + Http { + url: String, + #[serde(default)] + headers: BTreeMap, + }, + /// Marker the wrapper resolves to an SDK in-process MCP server (the + /// atomr tool bridge). + InProcess { name: String }, +} + +/// A lifecycle hook descriptor. The `handler` names a callback the wrapper +/// resolves to a live Python hook. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HookConfig { + pub event: HookEvent, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub matcher: Option, + pub handler: HookHandlerRef, +} + +/// Named reference to a host-registered hook callback. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HookHandlerRef { + pub name: String, +} + +/// SDK hook events. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum HookEvent { + PreToolUse, + PostToolUse, + UserPromptSubmit, + Stop, + SubagentStop, + PreCompact, +} + +/// A programmatically-defined subagent (maps to the SDK's `AgentDefinition`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubagentDef { + pub description: String, + pub prompt: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tools: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +/// Which filesystem settings the SDK loads. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SettingSource { + User, + Project, + Local, +} + +/// Reasoning-effort level. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Effort { + Low, + Medium, + High, + Xhigh, + Max, +} + +/// Data form of the SDK's `can_use_tool` callback. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(tag = "mode", rename_all = "snake_case")] +pub enum PermissionPolicy { + /// Allow every tool (the wrapper returns "allow" unconditionally). + #[default] + AllowAll, + /// Deny every tool. + DenyAll, + /// Allow only the listed tools, deny the rest. + AllowList { tools: Vec }, + /// Defer to the SDK's interactive ask flow. + Ask, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn permission_mode_default_is_bypass() { + assert_eq!(PermissionMode::default(), PermissionMode::BypassPermissions); + let j = serde_json::to_string(&PermissionMode::AcceptEdits).unwrap(); + assert_eq!(j, "\"acceptEdits\""); + } + + #[test] + fn minimal_config_round_trips() { + let cfg: AgentSdkConfig = serde_json::from_str("{}").unwrap(); + assert_eq!(cfg.permission_mode, PermissionMode::BypassPermissions); + assert!(cfg.allowed_tools.is_empty()); + let back = serde_json::to_value(&cfg).unwrap(); + // Empty collections + Nones are skipped; default permission mode stays. + assert_eq!(back["permission_mode"], "bypassPermissions"); + } + + #[test] + fn system_prompt_preset_shape() { + let p = SystemPromptConfig::Preset(SystemPromptPreset::claude_code(Some("extra".into()))); + let v = serde_json::to_value(&p).unwrap(); + assert_eq!(v["type"], "preset"); + assert_eq!(v["preset"], "claude_code"); + assert_eq!(v["append"], "extra"); + } + + #[test] + fn system_prompt_text_is_bare_string() { + let p = SystemPromptConfig::Text("hi".into()); + let v = serde_json::to_value(&p).unwrap(); + assert_eq!(v, serde_json::json!("hi")); + } + + #[test] + fn setting_source_lowercases() { + let j = serde_json::to_string(&SettingSource::Project).unwrap(); + assert_eq!(j, "\"project\""); + } +} diff --git a/crates/agent-sdk-core/src/error.rs b/crates/agent-sdk-core/src/error.rs new file mode 100644 index 0000000..e27ee92 --- /dev/null +++ b/crates/agent-sdk-core/src/error.rs @@ -0,0 +1,51 @@ +//! Errors raised by an [`AgentSdkBackend`](crate::AgentSdkBackend). +//! +//! Kept atomr-core-free (like `coding-cli-core`): the harness crate owns a +//! local `HarnessError` that wraps this and converts to +//! `atomr_agents_core::AgentError` (the orphan rule requires the local +//! type to live there, not here). + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AgentSdkError { + /// The backend is not usable on this host (SDK not installed, `claude` + /// CLI missing, credentials absent…). + #[error("agent-sdk backend unavailable: {0}")] + Unavailable(String), + + /// The request/config could not be built into valid options. + #[error("invalid agent-sdk config: {0}")] + InvalidConfig(String), + + /// Referenced an interactive session that does not exist. + #[error("agent-sdk session not found: {0}")] + SessionNotFound(String), + + /// Concurrency quota for interactive sessions reached. + #[error("agent-sdk session quota reached ({0})")] + SessionQuota(usize), + + /// A configured budget cap was exceeded mid-run. + #[error("agent-sdk budget exhausted: {0}")] + BudgetExhausted(&'static str), + + /// A tool was denied by the configured permission policy. + #[error("agent-sdk policy denied: {0}")] + PolicyDenied(String), + + /// The underlying SDK / `claude` CLI raised an error. + #[error("agent-sdk error: {0}")] + Sdk(String), + + /// The message stream closed before yielding a terminal result. + #[error("agent-sdk stream closed before result")] + StreamClosed, + + /// The run was cancelled / interrupted. + #[error("agent-sdk run cancelled")] + Cancelled, + + #[error("serialization error: {0}")] + Serde(#[from] serde_json::Error), +} diff --git a/crates/agent-sdk-core/src/event.rs b/crates/agent-sdk-core/src/event.rs new file mode 100644 index 0000000..d441cc0 --- /dev/null +++ b/crates/agent-sdk-core/src/event.rs @@ -0,0 +1,116 @@ +//! Normalized broadcast event schema for the agent-sdk harness. +//! +//! The harness fans these out on a `tokio::sync::broadcast` channel; SSE in +//! the web companion and the Python async iterator both consume from it. +//! Mirrors `CodingCliEvent` in shape and `recv` semantics. + +use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast; + +use crate::request::AgentRunId; + +/// Why a run finished. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FinishReason { + Completed, + MaxTurns, + BudgetExhausted, + Interrupted, + Error, +} + +/// Normalized lifecycle events. Tagged enum — serializes as +/// `{"kind": "...", ...}` so the web client can switch on `kind`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AgentSdkEvent { + /// The harness started a run, before any SDK events. + RunStarted { + run_id: AgentRunId, + #[serde(default, skip_serializing_if = "Option::is_none")] + model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + }, + /// The SDK `system`/`init`: tools + MCP servers + session id. + SystemInit { + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + tools: Vec, + mcp_servers: Vec, + }, + /// Streaming assistant text (one per block / delta). + AssistantTextDelta { text: String }, + /// Streaming extended-thinking text. + ThinkingDelta { text: String }, + /// Agent invoked a tool. + ToolUse { + tool_use_id: String, + name: String, + input: serde_json::Value, + }, + /// A tool returned. + ToolResult { tool_use_id: String, is_error: bool }, + /// Token / cost accounting from a result. + Usage { + input_tokens: u64, + output_tokens: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + cost_usd: Option, + }, + /// Terminal event. + RunFinished { + reason: FinishReason, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + result_text: Option, + }, + /// Free-form diagnostic. + Note { message: String }, +} + +/// Subscriber handle backed by a `broadcast::Receiver`. Drops lagged events +/// silently; returns `None` once the channel closes. +pub struct AgentSdkEventStream { + rx: broadcast::Receiver, +} + +impl AgentSdkEventStream { + pub fn new(rx: broadcast::Receiver) -> Self { + Self { rx } + } + + pub async fn recv(&mut self) -> Option { + loop { + match self.rx.recv().await { + Ok(ev) => return Some(ev), + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => return None, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn event_round_trips_json() { + let ev = AgentSdkEvent::AssistantTextDelta { text: "Hi".into() }; + let j = serde_json::to_string(&ev).unwrap(); + assert!(j.contains("\"kind\":\"assistant_text_delta\"")); + let back: AgentSdkEvent = serde_json::from_str(&j).unwrap(); + assert!(matches!(back, AgentSdkEvent::AssistantTextDelta { .. })); + } + + #[test] + fn finish_reason_snake_case() { + assert_eq!( + serde_json::to_string(&FinishReason::MaxTurns).unwrap(), + "\"max_turns\"" + ); + } +} diff --git a/crates/agent-sdk-core/src/lib.rs b/crates/agent-sdk-core/src/lib.rs new file mode 100644 index 0000000..e9a034e --- /dev/null +++ b/crates/agent-sdk-core/src/lib.rs @@ -0,0 +1,48 @@ +//! Uniform contract for the **Claude Agent SDK** harness. +//! +//! This crate wraps Anthropic's programmable Claude Code agent +//! (`claude-agent-sdk` / `@anthropic-ai/claude-agent-sdk`) — the SDK that +//! exposes Claude Code's full harness (slash commands, subagents, hooks, +//! MCP, permission modes, sessions, custom system prompts, built-in +//! Read/Write/Edit/Bash/Grep/WebSearch tools) and bills against your +//! Anthropic API credits by shelling out to the bundled `claude` CLI. +//! +//! It is **distinct** from the server-side "Managed Agents" API. +//! +//! The crate is the contract layer only: +//! +//! * [`AgentSdkConfig`] mirrors the SDK's `ClaudeAgentOptions` and +//! round-trips to a JSON dict the Python wrapper turns back into +//! `ClaudeAgentOptions`. +//! * [`AgentSdkMessage`] is the normalized message protocol the backend +//! yields (the Python wrapper produces these dicts from SDK message +//! objects). +//! * [`AgentSdkBackend`] / [`AgentSdkSession`] are the pluggable seam. +//! [`MockBackend`] keeps Rust builds/tests network-free; the real +//! `PythonAgentSdkBackend` lives in `py-bindings`. A future pure-Rust +//! backend that spawns the `claude` CLI directly can slot in here too. +//! +//! See the workspace `docs/agent-sdk-harness.md` for the full design. + +#![forbid(unsafe_code)] + +mod backend; +mod config; +mod error; +mod event; +mod message; +mod mock; +mod request; +mod result; + +pub use backend::{AgentSdkBackend, AgentSdkSession, MessageStream}; +pub use config::{ + AgentSdkConfig, Effort, HookConfig, HookEvent, HookHandlerRef, McpServerConfig, PermissionMode, + PermissionPolicy, SettingSource, SubagentDef, SystemPromptConfig, SystemPromptPreset, +}; +pub use error::AgentSdkError; +pub use event::{AgentSdkEvent, AgentSdkEventStream, FinishReason}; +pub use message::{AgentSdkMessage, ContentBlock}; +pub use mock::{MockBackend, MockSession}; +pub use request::{AgentRunId, AgentSessionId, QueryRequest, SessionSpec}; +pub use result::{ResultSummary, UsageSummary}; diff --git a/crates/agent-sdk-core/src/message.rs b/crates/agent-sdk-core/src/message.rs new file mode 100644 index 0000000..8b73be2 --- /dev/null +++ b/crates/agent-sdk-core/src/message.rs @@ -0,0 +1,110 @@ +//! Normalized message protocol yielded by an [`AgentSdkBackend`]. +//! +//! The Python wrapper produces these as plain dicts from the SDK's message +//! objects (`SystemMessage` / `AssistantMessage` / `ResultMessage`), so the +//! `#[serde(tag = "type")]` discriminant and field names must stay in lock +//! step with `python/atomr_agents/agent_sdk.py::_normalize`. + +use serde::{Deserialize, Serialize}; + +use crate::result::ResultSummary; + +/// One normalized message from a run. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AgentSdkMessage { + /// The SDK `system`/`init` message — carries the conversation session id. + System { + #[serde(default, skip_serializing_if = "Option::is_none")] + subtype: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + model: Option, + #[serde(default)] + tools: Vec, + #[serde(default)] + mcp_servers: Vec, + }, + /// An assistant turn, decomposed into content blocks. + Assistant { + #[serde(default)] + blocks: Vec, + }, + /// The terminal `ResultMessage`. + Result(ResultSummary), + /// Any message the wrapper didn't map. Always safe to ignore. + #[serde(other)] + Unknown, +} + +/// One assistant content block. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ContentBlock { + Text { + #[serde(default)] + text: String, + }, + Thinking { + #[serde(default)] + text: String, + }, + ToolUse { + #[serde(default)] + id: String, + #[serde(default)] + name: String, + #[serde(default)] + input: serde_json::Value, + }, + ToolResult { + #[serde(default)] + tool_use_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + content: Option, + #[serde(default)] + is_error: bool, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn assistant_blocks_deserialize() { + let j = r#"{"type":"assistant","blocks":[ + {"kind":"text","text":"hi"}, + {"kind":"tool_use","id":"t1","name":"Read","input":{"path":"a"}} + ]}"#; + let m: AgentSdkMessage = serde_json::from_str(j).unwrap(); + match m { + AgentSdkMessage::Assistant { blocks } => { + assert_eq!(blocks.len(), 2); + assert!(matches!(blocks[0], ContentBlock::Text { .. })); + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn result_flattens_fields() { + let j = r#"{"type":"result","subtype":"success","result":"done","num_turns":2,"cost_usd":0.01}"#; + let m: AgentSdkMessage = serde_json::from_str(j).unwrap(); + match m { + AgentSdkMessage::Result(r) => { + assert_eq!(r.subtype, "success"); + assert_eq!(r.num_turns, 2); + assert_eq!(r.cost_micro_usd(), 10_000); + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn unknown_is_tolerated() { + let m: AgentSdkMessage = serde_json::from_str(r#"{"type":"weird","x":1}"#).unwrap(); + assert!(matches!(m, AgentSdkMessage::Unknown)); + } +} diff --git a/crates/agent-sdk-core/src/mock.rs b/crates/agent-sdk-core/src/mock.rs new file mode 100644 index 0000000..5f2052a --- /dev/null +++ b/crates/agent-sdk-core/src/mock.rs @@ -0,0 +1,200 @@ +//! `MockBackend` — an in-memory, deterministic backend. +//! +//! Lives in `-core` (like `MockBackend` in `sandbox-core`) so every +//! downstream crate — harness, web, pyo3 — can be unit-tested without the +//! `claude-agent-sdk`, the `claude` CLI, or network/credits. It emits a +//! scripted turn: `system` → `assistant` → optional tool round-trip → +//! `result`. + +use async_trait::async_trait; +use futures::StreamExt; +use parking_lot::Mutex; + +use crate::backend::{AgentSdkBackend, AgentSdkSession, MessageStream}; +use crate::config::AgentSdkConfig; +use crate::error::AgentSdkError; +use crate::message::{AgentSdkMessage, ContentBlock}; +use crate::request::{AgentSessionId, QueryRequest, SessionSpec}; +use crate::result::{ResultSummary, UsageSummary}; + +/// In-memory deterministic backend. Always available. +#[derive(Debug, Default, Clone)] +pub struct MockBackend { + /// If set, the assistant echoes this instead of the prompt. + pub canned_reply: Option, + /// If set, the scripted turn includes one tool round-trip with this name. + pub tool: Option, +} + +impl MockBackend { + pub fn new() -> Self { + Self::default() + } + + pub fn with_reply(mut self, reply: impl Into) -> Self { + self.canned_reply = Some(reply.into()); + self + } + + pub fn with_tool(mut self, tool: impl Into) -> Self { + self.tool = Some(tool.into()); + self + } + + fn scripted_turn(&self, prompt: &str, config: &AgentSdkConfig) -> Vec { + let session_id = "mock-session".to_string(); + let reply = self + .canned_reply + .clone() + .unwrap_or_else(|| format!("[mock] {prompt}")); + let mut msgs = vec![AgentSdkMessage::System { + subtype: Some("init".into()), + session_id: Some(session_id.clone()), + model: config.model.clone(), + tools: config.allowed_tools.clone(), + mcp_servers: config.mcp_servers.keys().cloned().collect(), + }]; + + let mut blocks = vec![ContentBlock::Text { text: reply.clone() }]; + if let Some(name) = &self.tool { + blocks.push(ContentBlock::ToolUse { + id: "mock-tool-1".into(), + name: name.clone(), + input: serde_json::json!({}), + }); + blocks.push(ContentBlock::ToolResult { + tool_use_id: "mock-tool-1".into(), + content: Some(serde_json::json!("ok")), + is_error: false, + }); + } + msgs.push(AgentSdkMessage::Assistant { blocks }); + + msgs.push(AgentSdkMessage::Result(ResultSummary { + subtype: "success".into(), + result: Some(reply), + session_id: Some(session_id), + num_turns: 1, + duration_ms: Some(0), + cost_usd: Some(0.0), + usage: UsageSummary { + input_tokens: 1, + output_tokens: 1, + ..Default::default() + }, + is_error: false, + })); + msgs + } + + fn stream_of(msgs: Vec) -> MessageStream { + futures::stream::iter(msgs.into_iter().map(Ok)).boxed() + } +} + +#[async_trait] +impl AgentSdkBackend for MockBackend { + fn name(&self) -> &str { + "mock" + } + + async fn available(&self) -> bool { + true + } + + async fn query(&self, req: QueryRequest) -> Result { + Ok(Self::stream_of(self.scripted_turn(&req.prompt, &req.config))) + } + + async fn create_session( + &self, + spec: SessionSpec, + ) -> Result, AgentSdkError> { + Ok(Box::new(MockSession { + backend: self.clone(), + id: AgentSessionId::new(), + config: spec.config, + last_prompt: Mutex::new(spec.initial_prompt.unwrap_or_default()), + })) + } +} + +/// A live mock session. +pub struct MockSession { + backend: MockBackend, + id: AgentSessionId, + config: AgentSdkConfig, + last_prompt: Mutex, +} + +#[async_trait] +impl AgentSdkSession for MockSession { + fn session_id(&self) -> &AgentSessionId { + &self.id + } + + async fn send(&self, prompt: String) -> Result<(), AgentSdkError> { + *self.last_prompt.lock() = prompt; + Ok(()) + } + + async fn receive(&self) -> Result { + let prompt = self.last_prompt.lock().clone(); + Ok(MockBackend::stream_of( + self.backend.scripted_turn(&prompt, &self.config), + )) + } + + async fn interrupt(&self) -> Result<(), AgentSdkError> { + Ok(()) + } + + async fn set_permission_mode(&self, _mode: String) -> Result<(), AgentSdkError> { + Ok(()) + } + + async fn set_model(&self, _model: String) -> Result<(), AgentSdkError> { + Ok(()) + } + + async fn close(&self) -> Result<(), AgentSdkError> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::StreamExt; + + #[tokio::test] + async fn mock_query_yields_terminal_result() { + let b = MockBackend::new().with_reply("hello"); + let mut s = b.query(QueryRequest::new("ignored")).await.unwrap(); + let mut saw_result = false; + while let Some(item) = s.next().await { + if let AgentSdkMessage::Result(r) = item.unwrap() { + assert_eq!(r.subtype, "success"); + assert_eq!(r.result.as_deref(), Some("hello")); + saw_result = true; + } + } + assert!(saw_result); + } + + #[tokio::test] + async fn mock_session_round_trips() { + let b = MockBackend::new().with_tool("Read"); + let sess = b.create_session(SessionSpec::default()).await.unwrap(); + sess.send("hi".into()).await.unwrap(); + let mut s = sess.receive().await.unwrap(); + let mut tool_seen = false; + while let Some(item) = s.next().await { + if let AgentSdkMessage::Assistant { blocks } = item.unwrap() { + tool_seen |= blocks.iter().any(|b| matches!(b, ContentBlock::ToolUse { .. })); + } + } + assert!(tool_seen); + sess.close().await.unwrap(); + } +} diff --git a/crates/agent-sdk-core/src/request.rs b/crates/agent-sdk-core/src/request.rs new file mode 100644 index 0000000..373b7c7 --- /dev/null +++ b/crates/agent-sdk-core/src/request.rs @@ -0,0 +1,110 @@ +//! Inputs accepted by the agent-sdk harness. + +use std::fmt; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::config::AgentSdkConfig; + +macro_rules! id_newtype { + ($name:ident, $prefix:literal) => { + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[serde(transparent)] + pub struct $name(String); + + impl $name { + pub fn new() -> Self { + Self(format!("{}-{}", $prefix, Uuid::new_v4())) + } + pub fn as_str(&self) -> &str { + &self.0 + } + } + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } + } + impl From for $name { + fn from(s: String) -> Self { + Self(s) + } + } + impl From<&str> for $name { + fn from(s: &str) -> Self { + Self(s.to_owned()) + } + } + }; +} + +id_newtype!(AgentRunId, "agent-run"); +// `AgentSessionId` is an opaque handle to a live interactive session (the +// registry / actor key). Distinct from the SDK's *conversation* `session_id` +// (surfaced on messages/results for resume). +id_newtype!(AgentSessionId, "agent-sess"); + +/// A one-shot headless query. `config` is flattened so a request reads as +/// `{"prompt": "...", "allowed_tools": [...], ...}`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryRequest { + pub prompt: String, + #[serde(flatten)] + pub config: AgentSdkConfig, + /// Soft cost cap (USD) enforced at turn boundaries. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_cost_usd: Option, +} + +impl QueryRequest { + pub fn new(prompt: impl Into) -> Self { + Self { + prompt: prompt.into(), + config: AgentSdkConfig::default(), + max_cost_usd: None, + } + } + + pub fn with_config(mut self, config: AgentSdkConfig) -> Self { + self.config = config; + self + } +} + +/// Spec for a stateful interactive session. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SessionSpec { + #[serde(flatten)] + pub config: AgentSdkConfig, + /// Optional first prompt sent on connect. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub initial_prompt: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn query_flattens_config() { + let j = r#"{"prompt":"hi","allowed_tools":["Read"],"model":"opus"}"#; + let q: QueryRequest = serde_json::from_str(j).unwrap(); + assert_eq!(q.prompt, "hi"); + assert_eq!(q.config.allowed_tools, vec!["Read".to_string()]); + assert_eq!(q.config.model.as_deref(), Some("opus")); + } + + #[test] + fn run_id_unique_and_prefixed() { + let a = AgentRunId::new(); + let b = AgentRunId::new(); + assert_ne!(a, b); + assert!(a.as_str().starts_with("agent-run-")); + } +} diff --git a/crates/agent-sdk-core/src/result.rs b/crates/agent-sdk-core/src/result.rs new file mode 100644 index 0000000..a0e92c4 --- /dev/null +++ b/crates/agent-sdk-core/src/result.rs @@ -0,0 +1,48 @@ +//! Terminal value produced by a run (mirrors the SDK's `ResultMessage`). + +use serde::{Deserialize, Serialize}; + +/// Token + cache usage reported by the SDK. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct UsageSummary { + #[serde(default)] + pub input_tokens: u64, + #[serde(default)] + pub output_tokens: u64, + #[serde(default)] + pub cache_creation_input_tokens: u64, + #[serde(default)] + pub cache_read_input_tokens: u64, +} + +/// The SDK `ResultMessage`, normalized. `cost_usd` is the SDK's +/// **client-side estimate**, not an invoice. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ResultSummary { + /// `"success"`, `"error_max_turns"`, `"error_during_execution"`, … + #[serde(default)] + pub subtype: String, + /// Final assistant text. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + /// SDK conversation session id (for resume). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default)] + pub num_turns: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost_usd: Option, + #[serde(default)] + pub usage: UsageSummary, + #[serde(default)] + pub is_error: bool, +} + +impl ResultSummary { + /// Cost in integer micro-USD (0 when the SDK reported no estimate). + pub fn cost_micro_usd(&self) -> u64 { + self.cost_usd.map(|c| (c * 1_000_000.0) as u64).unwrap_or(0) + } +} diff --git a/crates/agent-sdk-harness-web/Cargo.toml b/crates/agent-sdk-harness-web/Cargo.toml new file mode 100644 index 0000000..2d6e06a --- /dev/null +++ b/crates/agent-sdk-harness-web/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "atomr-agents-agent-sdk-harness-web" +version = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } +authors = { workspace = true } +description = "Axum REST + SSE web companion for the Claude Agent SDK harness: start runs/sessions, post messages, interrupt, and stream normalized agent events." + +keywords = ["agents", "claude", "harness", "atomr"] +categories = ["asynchronous", "web-programming"] +readme = "README.md" + +[dependencies] +atomr-agents-agent-sdk-core = { workspace = true } +atomr-agents-agent-sdk-harness = { workspace = true } + +axum = { workspace = true } +tower-http = { workspace = true } +tokio = { workspace = true, features = ["full"] } +futures = { workspace = true } +tokio-stream = { workspace = true, features = ["sync"] } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["full", "test-util"] } +tower = { version = "0.5", features = ["util"] } diff --git a/crates/agent-sdk-harness-web/README.md b/crates/agent-sdk-harness-web/README.md new file mode 100644 index 0000000..a8a580f --- /dev/null +++ b/crates/agent-sdk-harness-web/README.md @@ -0,0 +1,12 @@ +# atomr-agents-agent-sdk-harness-web + +Axum REST + SSE web companion for the Claude Agent SDK harness. Start headless +runs and interactive sessions, post messages, interrupt, change permission +mode / model, and stream normalized agent events over SSE. + +`cargo run -p atomr-agents-agent-sdk-harness-web --bin server` starts a +dev/demo server backed by the in-memory `MockBackend` (no `claude-agent-sdk` +needed). Production deployments build the harness with the Python-driven +backend and call `WebServer::serve`. + +See `docs/agent-sdk-harness.md`. diff --git a/crates/agent-sdk-harness-web/src/bin/server.rs b/crates/agent-sdk-harness-web/src/bin/server.rs new file mode 100644 index 0000000..57fd40a --- /dev/null +++ b/crates/agent-sdk-harness-web/src/bin/server.rs @@ -0,0 +1,28 @@ +//! Dev/demo server for the agent-sdk harness web companion. +//! +//! Serves the in-memory [`MockBackend`](atomr_agents_agent_sdk_core::MockBackend) +//! over REST + SSE — useful for exercising the HTTP surface without the +//! `claude-agent-sdk` / `claude` CLI. Production deployments construct the +//! harness with the Python-driven backend (via `py-bindings`) and call +//! [`WebServer::serve`] directly. + +use std::net::SocketAddr; +use std::sync::Arc; + +use atomr_agents_agent_sdk_harness::AgentSdkHarness; +use atomr_agents_agent_sdk_harness_web::{WebConfig, WebServer}; + +#[tokio::main] +async fn main() -> std::io::Result<()> { + tracing_subscriber::fmt::init(); + + let bind: SocketAddr = std::env::var("AGENT_SDK_WEB_BIND") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 8080))); + + let harness = Arc::new(AgentSdkHarness::local_default()); + tracing::info!(%bind, backend = harness.backend_name(), "agent-sdk web companion listening"); + + WebServer::new(WebConfig { bind }, harness).serve().await +} diff --git a/crates/agent-sdk-harness-web/src/lib.rs b/crates/agent-sdk-harness-web/src/lib.rs new file mode 100644 index 0000000..61d5203 --- /dev/null +++ b/crates/agent-sdk-harness-web/src/lib.rs @@ -0,0 +1,295 @@ +//! Axum REST + SSE companion for the [`AgentSdkHarness`]. +//! +//! Endpoints (all JSON): +//! - `POST /run` — headless query → `ResultSummary` +//! - `POST /sessions` — open an interactive session → `{ session_id }` +//! - `GET /sessions` — list live session ids +//! - `POST /sessions/:id/messages` — send a prompt (`{ prompt }`) +//! - `POST /sessions/:id/interrupt` — interrupt the in-flight turn +//! - `POST /sessions/:id/permission-mode` — change mode (`{ mode }`) +//! - `POST /sessions/:id/model` — change model (`{ model }`) +//! - `DELETE /sessions/:id` — close the session +//! - `GET /events` — SSE stream of `AgentSdkEvent` +//! - `GET /healthz` — liveness +//! +//! Mirrors `sandbox-harness-web`. + +#![forbid(unsafe_code)] + +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::sse::{Event as SseEvent, KeepAlive, Sse}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use futures::Stream; +use futures::StreamExt; +use serde::Deserialize; +use serde_json::json; +use tokio_stream::wrappers::BroadcastStream; + +use atomr_agents_agent_sdk_core::{AgentSessionId, QueryRequest, SessionSpec}; +use atomr_agents_agent_sdk_harness::{AgentSdkHarness, HarnessError}; + +/// Web server configuration. +#[derive(Debug, Clone)] +pub struct WebConfig { + pub bind: SocketAddr, +} + +impl Default for WebConfig { + fn default() -> Self { + Self { + bind: SocketAddr::from(([0, 0, 0, 0], 8080)), + } + } +} + +/// Shared handler state. +#[derive(Clone)] +pub struct AppState { + pub harness: Arc, +} + +/// HTTP error wrapper mapping [`HarnessError`] to status codes. +struct ApiError(HarnessError); + +impl From for ApiError { + fn from(e: HarnessError) -> Self { + ApiError(e) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let code = match &self.0 { + HarnessError::SessionNotFound(_) => StatusCode::NOT_FOUND, + HarnessError::InvalidRequest(_) + | HarnessError::InvalidWorkdir(_) + | HarnessError::SessionQuota(_) => StatusCode::BAD_REQUEST, + HarnessError::Budget(_) => StatusCode::PAYMENT_REQUIRED, + HarnessError::PolicyDenied(_) => StatusCode::FORBIDDEN, + HarnessError::StreamClosed | HarnessError::Sdk(_) => StatusCode::BAD_GATEWAY, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + (code, Json(json!({ "error": self.0.to_string() }))).into_response() + } +} + +#[derive(Debug, Deserialize)] +struct MessageRequest { + prompt: String, +} + +#[derive(Debug, Deserialize)] +struct PermissionModeRequest { + mode: String, +} + +#[derive(Debug, Deserialize)] +struct ModelRequest { + model: String, +} + +/// Build the router over the given state. +pub fn router(state: AppState) -> Router { + Router::new() + .route("/run", post(run_once)) + .route("/sessions", post(create_session).get(list_sessions)) + .route("/sessions/:id/messages", post(send_message)) + .route("/sessions/:id/interrupt", post(interrupt_session)) + .route("/sessions/:id/permission-mode", post(set_permission_mode)) + .route("/sessions/:id/model", post(set_model)) + .route("/sessions/:id", delete(stop_session)) + .route("/events", get(events)) + .route("/healthz", get(healthz)) + .with_state(state) +} + +async fn healthz(State(state): State) -> Json { + Json(json!({ + "status": "ok", + "backend": state.harness.backend_name(), + "live_sessions": state.harness.live_count(), + })) +} + +async fn run_once( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let res = state.harness.run(req).await?; + Ok(Json(serde_json::to_value(res).unwrap_or_default())) +} + +async fn create_session( + State(state): State, + Json(spec): Json, +) -> Result, ApiError> { + let session = state.harness.start_session(spec).await?; + Ok(Json(json!({ "session_id": session.id.to_string() }))) +} + +async fn list_sessions(State(state): State) -> Json { + let ids: Vec = state + .harness + .sessions() + .list() + .iter() + .map(|s| s.id.to_string()) + .collect(); + Json(json!({ "sessions": ids })) +} + +async fn send_message( + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result { + let session = state + .harness + .sessions() + .get(&AgentSessionId::from(id.clone())) + .ok_or(HarnessError::SessionNotFound(id))?; + session.send(req.prompt).await?; + Ok(StatusCode::ACCEPTED) +} + +async fn interrupt_session( + State(state): State, + Path(id): Path, +) -> Result { + let session = state + .harness + .sessions() + .get(&AgentSessionId::from(id.clone())) + .ok_or(HarnessError::SessionNotFound(id))?; + session.interrupt().await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn set_permission_mode( + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result { + let session = state + .harness + .sessions() + .get(&AgentSessionId::from(id.clone())) + .ok_or(HarnessError::SessionNotFound(id))?; + session.set_permission_mode(req.mode).await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn set_model( + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result { + let session = state + .harness + .sessions() + .get(&AgentSessionId::from(id.clone())) + .ok_or(HarnessError::SessionNotFound(id))?; + session.set_model(req.model).await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn stop_session( + State(state): State, + Path(id): Path, +) -> Result { + state.harness.stop_session(&AgentSessionId::from(id)).await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn events( + State(state): State, +) -> Sse>> { + let rx = state.harness.event_sender().subscribe(); + let stream = BroadcastStream::new(rx).filter_map(|item| async move { + match item { + Ok(ev) => Some(Ok(SseEvent::default() + .json_data(ev) + .unwrap_or_else(|_| SseEvent::default().data("serialize_error")))), + // Drop lagged notifications silently. + Err(_) => None, + } + }); + Sse::new(stream).keep_alive(KeepAlive::default()) +} + +/// A ready-to-serve web server. +pub struct WebServer { + config: WebConfig, + state: AppState, +} + +impl WebServer { + pub fn new(config: WebConfig, harness: Arc) -> Self { + Self { + config, + state: AppState { harness }, + } + } + + pub fn router(&self) -> Router { + router(self.state.clone()) + } + + pub fn bind_addr(&self) -> SocketAddr { + self.config.bind + } + + /// Bind and serve until the process is terminated. + pub async fn serve(self) -> std::io::Result<()> { + let listener = tokio::net::TcpListener::bind(self.config.bind).await?; + axum::serve(listener, self.router()).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::{to_bytes, Body}; + use axum::http::Request; + use tower::util::ServiceExt; // for `oneshot` + + fn app() -> Router { + router(AppState { + harness: Arc::new(AgentSdkHarness::local_default()), + }) + } + + #[tokio::test] + async fn healthz_ok() { + let res = app() + .oneshot(Request::builder().uri("/healthz").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = to_bytes(res.into_body(), 64 * 1024).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["backend"], "mock"); + } + + #[tokio::test] + async fn run_returns_result() { + let req = Request::builder() + .method("POST") + .uri("/run") + .header("content-type", "application/json") + .body(Body::from(r#"{"prompt":"hi"}"#)) + .unwrap(); + let res = app().oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = to_bytes(res.into_body(), 64 * 1024).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["subtype"], "success"); + } +} diff --git a/crates/agent-sdk-harness/Cargo.toml b/crates/agent-sdk-harness/Cargo.toml new file mode 100644 index 0000000..829216d --- /dev/null +++ b/crates/agent-sdk-harness/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "atomr-agents-agent-sdk-harness" +version = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } +authors = { workspace = true } +description = "Harness wrapping Anthropic's Claude Agent SDK (the programmable Claude Code agent) as an atomr-agents Callable: headless runs, interactive sessions, an optional actor, event streaming, and Anthropic-credit budget tracking." + +keywords = ["agents", "claude", "harness", "atomr"] +categories = ["asynchronous"] +readme = "README.md" + +[features] +default = [] +# The bidirectional interactive session as an `atomr_core::actor::Actor`. +actor = ["dep:atomr-core"] + +[dependencies] +atomr-agents-core = { workspace = true } +atomr-agents-callable = { workspace = true } +atomr-agents-observability = { workspace = true } +atomr-agents-agent = { workspace = true } +atomr-agents-agent-sdk-core = { workspace = true } + +atomr-core = { workspace = true, optional = true } + +async-trait = { workspace = true } +tokio = { workspace = true, features = ["full"] } +futures = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +parking_lot = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["full", "test-util"] } +tempfile = "3" diff --git a/crates/agent-sdk-harness/README.md b/crates/agent-sdk-harness/README.md new file mode 100644 index 0000000..2ccdea4 --- /dev/null +++ b/crates/agent-sdk-harness/README.md @@ -0,0 +1,18 @@ +# atomr-agents-agent-sdk-harness + +Harness wrapping Anthropic's **Claude Agent SDK** (the programmable Claude +Code agent) as an atomr-agents `Callable`. Headless one-shot runs and stateful +interactive sessions, normalized event streaming, a `.claude/` projection for +slash commands / skills / MCP, and Anthropic-credit spend tracking via the +shared `SpendLedger`. + +The actual SDK is driven behind the pluggable `AgentSdkBackend` trait +(`atomr-agents-agent-sdk-core`). A `MockBackend` keeps Rust tests network-free; +the production `PythonAgentSdkBackend` lives in `py-bindings`. + +> **Safety:** the default `permission_mode` is `bypassPermissions` (max +> autonomy — every tool auto-approved). Override per request/spec; run +> untrusted work inside the sandbox harness. + +Enable the `actor` feature to expose an interactive session as an +`atomr_core::actor::Actor`. See `docs/agent-sdk-harness.md`. diff --git a/crates/agent-sdk-harness/src/actor.rs b/crates/agent-sdk-harness/src/actor.rs new file mode 100644 index 0000000..bee33f3 --- /dev/null +++ b/crates/agent-sdk-harness/src/actor.rs @@ -0,0 +1,86 @@ +//! The interactive session as an `atomr_core::actor::Actor`. +//! +//! `ClaudeSDKClient` is a stateful, bidirectional session — a natural fit +//! for an actor. Mirrors `AgentHostActor` in `crates/host`. Gated behind +//! the `actor` feature so the default build, the web companion, and the +//! PyO3 layer stay free of the actor runtime dependency. + +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::{broadcast, oneshot}; + +use atomr_core::actor::{Actor, Context}; + +use atomr_agents_agent_sdk_core::{AgentSdkEvent, PermissionMode}; + +use crate::error::HarnessError; +use crate::session::InteractiveAgentSession; + +/// Messages the [`AgentSdkActor`] handles — a 1:1 map of the +/// `ClaudeSDKClient` surface. +pub enum AgentSdkMsg { + /// Send a user prompt and start streaming its response. + Query { + prompt: String, + reply: oneshot::Sender>, + }, + /// Interrupt the in-flight turn. + Interrupt(oneshot::Sender>), + /// Change permission mode mid-conversation. + SetPermissionMode { + mode: PermissionMode, + reply: oneshot::Sender>, + }, + /// Change model mid-conversation. + SetModel { + model: String, + reply: oneshot::Sender>, + }, + /// Hand back a fresh broadcast receiver for the event stream. + Subscribe(oneshot::Sender>), + /// Close the session and tear down its `claude` subprocess. + Shutdown, +} + +pub struct AgentSdkActor { + session: Arc, +} + +impl AgentSdkActor { + pub fn new(session: Arc) -> Self { + Self { session } + } +} + +#[async_trait] +impl Actor for AgentSdkActor { + type Msg = AgentSdkMsg; + + async fn handle(&mut self, _ctx: &mut Context, msg: Self::Msg) { + match msg { + AgentSdkMsg::Query { prompt, reply } => { + let _ = reply.send(self.session.send(prompt).await); + } + AgentSdkMsg::Interrupt(reply) => { + let _ = reply.send(self.session.interrupt().await); + } + AgentSdkMsg::SetPermissionMode { mode, reply } => { + let _ = reply.send( + self.session + .set_permission_mode(mode.as_sdk_str().to_string()) + .await, + ); + } + AgentSdkMsg::SetModel { model, reply } => { + let _ = reply.send(self.session.set_model(model).await); + } + AgentSdkMsg::Subscribe(reply) => { + let _ = reply.send(self.session.subscribe_receiver()); + } + AgentSdkMsg::Shutdown => { + let _ = self.session.close().await; + } + } + } +} diff --git a/crates/agent-sdk-harness/src/bridge.rs b/crates/agent-sdk-harness/src/bridge.rs new file mode 100644 index 0000000..935d85f --- /dev/null +++ b/crates/agent-sdk-harness/src/bridge.rs @@ -0,0 +1,93 @@ +//! Project a normalized [`AgentSdkMessage`] three ways: onto the broadcast +//! event stream, onto the core [`EventBus`], and onto the [`SpendLedger`]. +//! +//! Returns `Some(ResultSummary)` on the terminal `Result` message. + +use tokio::sync::broadcast; + +use atomr_agents_agent::SpendLedger; +use atomr_agents_agent_sdk_core::{ + AgentSdkEvent, AgentSdkMessage, ContentBlock, FinishReason, ResultSummary, +}; +use atomr_agents_core::{AgentId, Event, HarnessId}; +use atomr_agents_observability::EventBus; + +use crate::budget; + +pub fn project( + msg: &AgentSdkMessage, + harness_id: &HarnessId, + event_tx: &broadcast::Sender, + bus: &EventBus, + ledger: &SpendLedger, +) -> Option { + match msg { + AgentSdkMessage::System { + session_id, + tools, + mcp_servers, + .. + } => { + let _ = event_tx.send(AgentSdkEvent::SystemInit { + session_id: session_id.clone(), + tools: tools.clone(), + mcp_servers: mcp_servers.clone(), + }); + None + } + AgentSdkMessage::Assistant { blocks } => { + for b in blocks { + let ev = match b { + ContentBlock::Text { text } => AgentSdkEvent::AssistantTextDelta { text: text.clone() }, + ContentBlock::Thinking { text } => AgentSdkEvent::ThinkingDelta { text: text.clone() }, + ContentBlock::ToolUse { id, name, input } => AgentSdkEvent::ToolUse { + tool_use_id: id.clone(), + name: name.clone(), + input: input.clone(), + }, + ContentBlock::ToolResult { tool_use_id, is_error, .. } => AgentSdkEvent::ToolResult { + tool_use_id: tool_use_id.clone(), + is_error: *is_error, + }, + }; + let _ = event_tx.send(ev); + } + None + } + AgentSdkMessage::Result(r) => { + budget::record_result(ledger, r); + let _ = event_tx.send(AgentSdkEvent::Usage { + input_tokens: r.usage.input_tokens, + output_tokens: r.usage.output_tokens, + cost_usd: r.cost_usd, + }); + bus.emit(Event::AgentTurn { + agent_id: AgentId::from(harness_id.as_str()), + input_tokens: r.usage.input_tokens as u32, + output_tokens: r.usage.output_tokens as u32, + reasoning_tokens: 0, + cached_tokens: r.usage.cache_read_input_tokens as u32, + finish_reason: None, + elapsed_ms: r.duration_ms.unwrap_or(0), + }); + bus.emit(Event::HarnessIteration { + harness_id: harness_id.clone(), + iteration: r.num_turns as u64, + outcome: r.subtype.clone(), + budget_remaining_tokens: 0, + }); + let reason = if r.is_error { + FinishReason::Error + } else { + FinishReason::Completed + }; + let _ = event_tx.send(AgentSdkEvent::RunFinished { + reason, + session_id: r.session_id.clone(), + result_text: r.result.clone(), + }); + Some(r.clone()) + } + AgentSdkMessage::Unknown => None, + } +} diff --git a/crates/agent-sdk-harness/src/budget.rs b/crates/agent-sdk-harness/src/budget.rs new file mode 100644 index 0000000..e3f23b7 --- /dev/null +++ b/crates/agent-sdk-harness/src/budget.rs @@ -0,0 +1,19 @@ +//! Map SDK cost/usage into atomr's spend ledger. +//! +//! The SDK reports real spend on every `ResultMessage`. We charge it +//! directly to a [`SpendLedger`] (the `cost_usd` is the SDK's client-side +//! estimate; for Bedrock/Vertex it may be absent, in which case only the +//! token totals are recorded). + +use atomr_agents_agent::{Spend, SpendLedger}; +use atomr_agents_agent_sdk_core::ResultSummary; + +/// Record one result's cost + tokens onto the ledger. +pub fn record_result(ledger: &SpendLedger, r: &ResultSummary) { + let tokens = (r.usage.input_tokens + r.usage.output_tokens) as u32; + ledger.record(&Spend { + micro_usd: r.cost_micro_usd(), + tokens, + decision_key: None, + }); +} diff --git a/crates/agent-sdk-harness/src/error.rs b/crates/agent-sdk-harness/src/error.rs new file mode 100644 index 0000000..69b9821 --- /dev/null +++ b/crates/agent-sdk-harness/src/error.rs @@ -0,0 +1,53 @@ +use thiserror::Error; + +use atomr_agents_agent_sdk_core::AgentSdkError; + +#[derive(Debug, Error)] +pub enum HarnessError { + #[error("invalid request: {0}")] + InvalidRequest(String), + + #[error("workdir is missing or not a directory: {0}")] + InvalidWorkdir(String), + + #[error("session not found: {0}")] + SessionNotFound(String), + + #[error("session quota reached ({0})")] + SessionQuota(usize), + + #[error("budget exhausted: {0}")] + Budget(&'static str), + + #[error("policy denied: {0}")] + PolicyDenied(String), + + #[error("stream closed before result")] + StreamClosed, + + #[error(transparent)] + Sdk(#[from] AgentSdkError), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("serialization error: {0}")] + Serde(#[from] serde_json::Error), +} + +pub type Result = std::result::Result; + +impl From for atomr_agents_core::AgentError { + fn from(e: HarnessError) -> Self { + use atomr_agents_core::AgentError as A; + let msg = e.to_string(); + match e { + HarnessError::Budget(s) => A::BudgetExceeded(s), + HarnessError::PolicyDenied(s) => A::PolicyDenied(s), + HarnessError::Sdk(AgentSdkError::PolicyDenied(s)) => A::PolicyDenied(s), + HarnessError::Sdk(AgentSdkError::BudgetExhausted(s)) => A::BudgetExceeded(s), + HarnessError::Sdk(_) => A::Inference(msg), + _ => A::Harness(msg), + } + } +} diff --git a/crates/agent-sdk-harness/src/harness.rs b/crates/agent-sdk-harness/src/harness.rs new file mode 100644 index 0000000..2f0ff24 --- /dev/null +++ b/crates/agent-sdk-harness/src/harness.rs @@ -0,0 +1,287 @@ +//! The harness — wraps an [`AgentSdkBackend`] behind a single async surface +//! plus a [`Callable`] impl. + +use std::path::Path; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::broadcast; +use tracing::info; + +use atomr_agents_agent::SpendLedger; +use atomr_agents_agent_sdk_core::{ + AgentSdkBackend, AgentSdkConfig, AgentSdkEvent, AgentSdkEventStream, AgentRunId, MockBackend, + QueryRequest, ResultSummary, SessionSpec, +}; +use atomr_agents_callable::Callable; +use atomr_agents_core::{CallCtx, HarnessId, Result as CoreResult, Value}; +use atomr_agents_observability::EventBus; + +use crate::error::{HarnessError, Result}; +use crate::headless; +use crate::projection::Projection; +use crate::session::{InteractiveAgentSession, SessionRegistry}; +use crate::spec::AgentSdkHarnessSpec; + +pub struct AgentSdkHarness { + backend: Arc, + pub spec: AgentSdkHarnessSpec, + pub projection: Projection, + pub bus: EventBus, + event_tx: broadcast::Sender, + sessions: SessionRegistry, + ledger: SpendLedger, + harness_id: HarnessId, + live_sessions: AtomicUsize, +} + +impl AgentSdkHarness { + pub fn new(backend: Arc, spec: AgentSdkHarnessSpec) -> Self { + let (event_tx, _) = broadcast::channel(spec.event_channel_capacity); + let harness_id = HarnessId::from(spec.id.as_str()); + Self { + backend, + spec, + projection: Projection::default(), + bus: EventBus::new(), + event_tx, + sessions: SessionRegistry::new(), + ledger: SpendLedger::new(), + harness_id, + live_sessions: AtomicUsize::new(0), + } + } + + /// Shortcut: the in-memory [`MockBackend`] + default spec. Network-free. + pub fn local_default() -> Self { + Self::new(Arc::new(MockBackend::new()), AgentSdkHarnessSpec::default()) + } + + /// Attach a `.claude/` projection materialized before each run. + pub fn with_projection(mut self, projection: Projection) -> Self { + self.projection = projection; + self + } + + pub fn events(&self) -> AgentSdkEventStream { + AgentSdkEventStream::new(self.event_tx.subscribe()) + } + + pub fn event_sender(&self) -> broadcast::Sender { + self.event_tx.clone() + } + + pub fn sessions(&self) -> &SessionRegistry { + &self.sessions + } + + pub fn ledger(&self) -> &SpendLedger { + &self.ledger + } + + pub fn backend_name(&self) -> &str { + self.backend.name() + } + + pub fn live_count(&self) -> usize { + self.live_sessions.load(Ordering::SeqCst) + } + + /// Apply spec defaults to a config that didn't override them. + fn apply_defaults(&self, config: &mut AgentSdkConfig) { + if config.model.is_none() { + config.model = self.spec.default_model.clone(); + } + if config.fallback_model.is_none() { + config.fallback_model = self.spec.fallback_model.clone(); + } + if config.max_turns.is_none() { + config.max_turns = self.spec.default_max_turns; + } + if config.effort.is_none() { + config.effort = self.spec.default_effort; + } + if config.setting_sources.is_empty() { + config.setting_sources = self.spec.default_setting_sources.clone(); + } + } + + fn validate_cwd(config: &AgentSdkConfig) -> Result<()> { + if let Some(cwd) = &config.cwd { + if !cwd.is_dir() { + return Err(HarnessError::InvalidWorkdir(cwd.display().to_string())); + } + } + Ok(()) + } + + fn materialize(&self, config: &AgentSdkConfig) -> Result<()> { + if let Some(cwd) = &config.cwd { + crate::projection::materialize(cwd.as_path() as &Path, &self.projection)?; + } + Ok(()) + } + + fn reserve_slot(&self) -> Result<()> { + let prev = self.live_sessions.fetch_add(1, Ordering::SeqCst); + if prev >= self.spec.max_concurrent_sessions { + self.live_sessions.fetch_sub(1, Ordering::SeqCst); + return Err(HarnessError::SessionQuota(self.spec.max_concurrent_sessions)); + } + Ok(()) + } + + fn release_slot(&self) { + self.live_sessions.fetch_sub(1, Ordering::SeqCst); + } + + /// Drive one query to completion (headless). + pub async fn run(&self, mut req: QueryRequest) -> Result { + Self::validate_cwd(&req.config)?; + self.apply_defaults(&mut req.config); + if req.max_cost_usd.is_none() { + req.max_cost_usd = self.spec.default_max_cost_usd; + } + self.materialize(&req.config)?; + + let run_id = AgentRunId::new(); + info!(run_id = %run_id, model = ?req.config.model, "agent-sdk run starting"); + let _ = self.event_tx.send(AgentSdkEvent::RunStarted { + run_id, + model: req.config.model.clone(), + session_id: None, + }); + + let max_cost = req.max_cost_usd; + let stream = self.backend.query(req).await?; + headless::drive( + stream, + &self.harness_id, + &self.event_tx, + &self.bus, + &self.ledger, + max_cost, + ) + .await + } + + /// Open a stateful interactive session. + pub async fn start_session(&self, mut spec: SessionSpec) -> Result> { + Self::validate_cwd(&spec.config)?; + self.apply_defaults(&mut spec.config); + self.materialize(&spec.config)?; + + self.reserve_slot()?; + let initial = spec.initial_prompt.clone(); + let backend_session = match self.backend.create_session(spec).await { + Ok(s) => s, + Err(e) => { + self.release_slot(); + return Err(e.into()); + } + }; + let session = Arc::new(InteractiveAgentSession::new( + backend_session, + self.event_tx.clone(), + self.bus.clone(), + self.ledger.clone(), + self.harness_id.clone(), + )); + self.sessions.insert(session.clone()); + if let Some(prompt) = initial { + session.send(prompt).await?; + } + Ok(session) + } + + /// Close an interactive session and drop it from the registry. + pub async fn stop_session(&self, id: &atomr_agents_agent_sdk_core::AgentSessionId) -> Result<()> { + let s = self + .sessions + .get(id) + .ok_or_else(|| HarnessError::SessionNotFound(id.to_string()))?; + let _ = s.close().await; + self.sessions.remove(id); + self.release_slot(); + Ok(()) + } +} + +#[async_trait] +impl Callable for AgentSdkHarness { + async fn call(&self, input: Value, _ctx: CallCtx) -> CoreResult { + let req: QueryRequest = serde_json::from_value(input)?; + let result = self.run(req).await.map_err(atomr_agents_core::AgentError::from)?; + Ok(serde_json::to_value(result)?) + } + + fn label(&self) -> &str { + "agent-sdk-harness" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use atomr_agents_agent_sdk_core::MockBackend; + + fn ctx() -> CallCtx { + use atomr_agents_core::{IterationBudget, MoneyBudget, TimeBudget, TokenBudget}; + use std::time::Duration; + CallCtx { + agent_id: None, + tokens: TokenBudget::new(1000), + time: TimeBudget::new(Duration::from_secs(10)), + money: MoneyBudget::from_usd(1.0), + iterations: IterationBudget::new(10), + trace: vec![], + extensions: Default::default(), + } + } + + #[tokio::test] + async fn run_returns_result_summary() { + let h = AgentSdkHarness::new( + Arc::new(MockBackend::new().with_reply("hi there")), + AgentSdkHarnessSpec::default(), + ); + let r = h.run(QueryRequest::new("hello")).await.unwrap(); + assert_eq!(r.subtype, "success"); + assert_eq!(r.result.as_deref(), Some("hi there")); + // Ledger recorded a (zero-cost) spend entry. + assert_eq!(h.ledger().total_tokens(), 2); + } + + #[tokio::test] + async fn callable_round_trips() { + let h = AgentSdkHarness::local_default(); + let out = h + .call(serde_json::json!({"prompt": "ping"}), ctx()) + .await + .unwrap(); + assert_eq!(out["subtype"], "success"); + } + + #[tokio::test] + async fn session_lifecycle() { + let h = AgentSdkHarness::new( + Arc::new(MockBackend::new().with_reply("ok")), + AgentSdkHarnessSpec::default(), + ); + let sess = h.start_session(SessionSpec::default()).await.unwrap(); + assert_eq!(h.live_count(), 1); + let id = sess.id.clone(); + h.stop_session(&id).await.unwrap(); + assert_eq!(h.live_count(), 0); + } + + #[tokio::test] + async fn missing_cwd_rejected() { + let h = AgentSdkHarness::local_default(); + let mut req = QueryRequest::new("x"); + req.config.cwd = Some("/no/such/dir/atomr-test".into()); + let err = h.run(req).await.unwrap_err(); + assert!(matches!(err, HarnessError::InvalidWorkdir(_))); + } +} diff --git a/crates/agent-sdk-harness/src/headless.rs b/crates/agent-sdk-harness/src/headless.rs new file mode 100644 index 0000000..f7d6b9a --- /dev/null +++ b/crates/agent-sdk-harness/src/headless.rs @@ -0,0 +1,45 @@ +//! Drive a one-shot query's message stream to its terminal result. + +use futures::StreamExt; +use tokio::sync::broadcast; + +use atomr_agents_agent::SpendLedger; +use atomr_agents_agent_sdk_core::{AgentSdkEvent, MessageStream, ResultSummary}; +use atomr_agents_core::HarnessId; +use atomr_agents_observability::EventBus; + +use crate::bridge; +use crate::error::{HarnessError, Result}; + +/// Consume `stream`, projecting each message and capturing the terminal +/// result. Enforces `max_cost_usd` at turn boundaries. +pub async fn drive( + mut stream: MessageStream, + harness_id: &HarnessId, + event_tx: &broadcast::Sender, + bus: &EventBus, + ledger: &SpendLedger, + max_cost_usd: Option, +) -> Result { + let cap_micro = max_cost_usd.map(|c| (c * 1_000_000.0) as u64); + let mut result: Option = None; + + while let Some(item) = stream.next().await { + let msg = item?; // AgentSdkError -> HarnessError::Sdk + if let Some(r) = bridge::project(&msg, harness_id, event_tx, bus, ledger) { + result = Some(r); + } + if let Some(cap) = cap_micro { + if ledger.total_micro_usd() > cap { + let _ = event_tx.send(AgentSdkEvent::RunFinished { + reason: atomr_agents_agent_sdk_core::FinishReason::BudgetExhausted, + session_id: result.as_ref().and_then(|r| r.session_id.clone()), + result_text: result.as_ref().and_then(|r| r.result.clone()), + }); + return Err(HarnessError::Budget("money")); + } + } + } + + result.ok_or(HarnessError::StreamClosed) +} diff --git a/crates/agent-sdk-harness/src/lib.rs b/crates/agent-sdk-harness/src/lib.rs new file mode 100644 index 0000000..4a96026 --- /dev/null +++ b/crates/agent-sdk-harness/src/lib.rs @@ -0,0 +1,42 @@ +//! Harness wrapping Anthropic's **Claude Agent SDK** as an atomr-agents +//! [`Callable`](atomr_agents_callable::Callable). +//! +//! The harness composes an [`AgentSdkBackend`](atomr_agents_agent_sdk_core::AgentSdkBackend) +//! (mock for tests, the Python-driven SDK in production) with an event +//! broadcast, a session registry, an Anthropic-credit spend ledger, and a +//! `.claude/` projection. It runs one-shot queries (headless) and stateful +//! interactive sessions, and — behind the `actor` feature — exposes the +//! session as an `atomr_core::actor::Actor`. +//! +//! # Safety +//! +//! The default [`PermissionMode`](atomr_agents_agent_sdk_core::PermissionMode) +//! is `bypassPermissions` — the agent auto-approves every tool, including +//! `Bash`, `Write`, and network access. This is the configured default for +//! autonomy; it is overridable per request/spec. The harness validates +//! `cwd`/`add_dirs` as real directories and defaults `setting_sources` to +//! `["project"]` so the agent only sees atomr-materialized config. Run +//! untrusted work inside the sandbox harness. + +#![forbid(unsafe_code)] + +mod budget; +mod bridge; +mod error; +mod harness; +mod headless; +mod projection; +mod session; +mod spec; + +#[cfg(feature = "actor")] +pub mod actor; + +pub use error::{HarnessError, Result}; +pub use harness::AgentSdkHarness; +pub use projection::{CommandDoc, Projection, SkillDoc}; +pub use session::{InteractiveAgentSession, SessionRegistry}; +pub use spec::{AgentSdkHarnessSpec, AuthConfig, AuthProvider}; + +// Re-export the contract types so downstream crates have one import path. +pub use atomr_agents_agent_sdk_core as core; diff --git a/crates/agent-sdk-harness/src/projection.rs b/crates/agent-sdk-harness/src/projection.rs new file mode 100644 index 0000000..ae9ecc5 --- /dev/null +++ b/crates/agent-sdk-harness/src/projection.rs @@ -0,0 +1,237 @@ +//! Materialize atomr concepts into a `.claude/` directory the SDK reads. +//! +//! With `setting_sources = ["project"]`, the Claude Agent SDK loads +//! `.claude/skills/`, `.claude/commands/`, `.claude/settings.local.json`, +//! and `.mcp.json` from the working directory. This module writes those +//! deterministically before a run so the agent sees atomr-controlled +//! config. It mirrors the projection done by +//! `coding-cli-vendor-claude::mapper` but is self-contained (no +//! coding-cli dependency). + +use std::collections::BTreeMap; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use atomr_agents_agent_sdk_core::McpServerConfig; + +/// One skill rendered to `.claude/skills//SKILL.md`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SkillDoc { + pub id: String, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub allowed_tools: Vec, + #[serde(default)] + pub body: String, +} + +/// One slash command rendered to `.claude/commands/.md`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CommandDoc { + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub allowed_tools: Vec, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub body: String, +} + +/// Everything materialized into `cwd/.claude/` (+ `.mcp.json`) before a run. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Projection { + #[serde(default)] + pub skills: Vec, + #[serde(default)] + pub commands: Vec, + /// External MCP servers (in-process markers are skipped — those are + /// injected by the Python wrapper, not written to `.mcp.json`). + #[serde(default)] + pub mcp_servers: BTreeMap, + /// Written verbatim to `.claude/settings.local.json` when present. + #[serde(default)] + pub settings: Option, +} + +impl Projection { + pub fn is_empty(&self) -> bool { + self.skills.is_empty() + && self.commands.is_empty() + && self.mcp_servers.is_empty() + && self.settings.is_none() + } +} + +fn frontmatter_list(key: &str, items: &[String], out: &mut String) { + if items.is_empty() { + return; + } + out.push_str(key); + out.push_str(":\n"); + for it in items { + out.push_str(" - "); + out.push_str(it); + out.push('\n'); + } +} + +/// Write the projection into `cwd`. Idempotent — overwrites existing files. +pub fn materialize(cwd: &Path, p: &Projection) -> std::io::Result<()> { + if p.is_empty() { + return Ok(()); + } + let claude = cwd.join(".claude"); + + // Skills → .claude/skills//SKILL.md + for s in &p.skills { + let dir = claude.join("skills").join(&s.id); + std::fs::create_dir_all(&dir)?; + let mut md = String::from("---\n"); + md.push_str(&format!("name: {}\n", s.name.clone().unwrap_or_else(|| s.id.clone()))); + if let Some(d) = &s.description { + md.push_str(&format!("description: {d}\n")); + } + frontmatter_list("allowed-tools", &s.allowed_tools, &mut md); + md.push_str("---\n\n"); + md.push_str(&s.body); + if !s.body.ends_with('\n') { + md.push('\n'); + } + std::fs::write(dir.join("SKILL.md"), md)?; + } + + // Slash commands → .claude/commands/.md + if !p.commands.is_empty() { + let dir = claude.join("commands"); + std::fs::create_dir_all(&dir)?; + for c in &p.commands { + let mut md = String::new(); + let has_fm = c.description.is_some() || !c.allowed_tools.is_empty() || c.model.is_some(); + if has_fm { + md.push_str("---\n"); + if let Some(d) = &c.description { + md.push_str(&format!("description: {d}\n")); + } + if let Some(m) = &c.model { + md.push_str(&format!("model: {m}\n")); + } + frontmatter_list("allowed-tools", &c.allowed_tools, &mut md); + md.push_str("---\n\n"); + } + md.push_str(&c.body); + if !c.body.ends_with('\n') { + md.push('\n'); + } + std::fs::write(dir.join(format!("{}.md", c.name)), md)?; + } + } + + // External MCP servers → .mcp.json + let external: serde_json::Map = p + .mcp_servers + .iter() + .filter_map(|(name, cfg)| mcp_to_json(cfg).map(|v| (name.clone(), v))) + .collect(); + if !external.is_empty() { + let doc = serde_json::json!({ "mcpServers": external }); + std::fs::write(cwd.join(".mcp.json"), serde_json::to_string_pretty(&doc)?)?; + } + + // Settings → .claude/settings.local.json + if let Some(settings) = &p.settings { + std::fs::create_dir_all(&claude)?; + std::fs::write( + claude.join("settings.local.json"), + serde_json::to_string_pretty(settings)?, + )?; + } + + Ok(()) +} + +/// `.mcp.json` server shape. Returns `None` for in-process markers. +fn mcp_to_json(cfg: &McpServerConfig) -> Option { + match cfg { + McpServerConfig::Stdio { command, args, env } => Some(serde_json::json!({ + "command": command, + "args": args, + "env": env, + })), + McpServerConfig::Sse { url, headers } => Some(serde_json::json!({ + "type": "sse", + "url": url, + "headers": headers, + })), + McpServerConfig::Http { url, headers } => Some(serde_json::json!({ + "type": "http", + "url": url, + "headers": headers, + })), + McpServerConfig::InProcess { .. } => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn materialize_writes_claude_tree() { + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path(); + let mut mcp = BTreeMap::new(); + mcp.insert( + "github".to_string(), + McpServerConfig::Stdio { + command: "npx".into(), + args: vec!["-y".into(), "server".into()], + env: Default::default(), + }, + ); + mcp.insert("inproc".to_string(), McpServerConfig::InProcess { name: "atomr".into() }); + let p = Projection { + skills: vec![SkillDoc { + id: "review".into(), + name: Some("Review".into()), + description: Some("Reviews code".into()), + allowed_tools: vec!["Read".into()], + body: "Do a review.".into(), + }], + commands: vec![CommandDoc { + name: "echo".into(), + description: Some("Echo".into()), + allowed_tools: vec![], + model: None, + body: "Echo: $ARGUMENTS".into(), + }], + mcp_servers: mcp, + settings: Some(serde_json::json!({"permissions": {"allow": ["Bash"]}})), + }; + materialize(cwd, &p).unwrap(); + + assert!(cwd.join(".claude/skills/review/SKILL.md").is_file()); + assert!(cwd.join(".claude/commands/echo.md").is_file()); + assert!(cwd.join(".claude/settings.local.json").is_file()); + let mcp_doc = std::fs::read_to_string(cwd.join(".mcp.json")).unwrap(); + assert!(mcp_doc.contains("github")); + // In-process markers are not written to .mcp.json. + assert!(!mcp_doc.contains("inproc")); + + let skill = std::fs::read_to_string(cwd.join(".claude/skills/review/SKILL.md")).unwrap(); + assert!(skill.contains("name: Review")); + assert!(skill.contains("allowed-tools:")); + } + + #[test] + fn empty_projection_writes_nothing() { + let dir = tempfile::tempdir().unwrap(); + materialize(dir.path(), &Projection::default()).unwrap(); + assert!(!dir.path().join(".claude").exists()); + } +} diff --git a/crates/agent-sdk-harness/src/session.rs b/crates/agent-sdk-harness/src/session.rs new file mode 100644 index 0000000..7a7867d --- /dev/null +++ b/crates/agent-sdk-harness/src/session.rs @@ -0,0 +1,143 @@ +//! Long-lived interactive sessions (the `ClaudeSDKClient` surface). +//! +//! One [`InteractiveAgentSession`] wraps a backend session. `send()` queues +//! a prompt and spawns a pump task that drains the response stream, +//! projecting each message onto the broadcast event stream. Mirrors the +//! `SessionRegistry` shape from `coding-cli-harness`. + +use std::sync::Arc; + +use futures::StreamExt; +use parking_lot::RwLock; +use tokio::sync::broadcast; + +use atomr_agents_agent::SpendLedger; +use atomr_agents_agent_sdk_core::{ + AgentSdkEvent, AgentSdkEventStream, AgentSdkSession, AgentSessionId, +}; +use atomr_agents_core::HarnessId; +use atomr_agents_observability::EventBus; + +use crate::bridge; +use crate::error::Result; + +pub struct InteractiveAgentSession { + inner: Arc, + pub id: AgentSessionId, + event_tx: broadcast::Sender, + bus: EventBus, + ledger: SpendLedger, + harness_id: HarnessId, +} + +impl InteractiveAgentSession { + pub(crate) fn new( + inner: Box, + event_tx: broadcast::Sender, + bus: EventBus, + ledger: SpendLedger, + harness_id: HarnessId, + ) -> Self { + let inner: Arc = Arc::from(inner); + let id = inner.session_id().clone(); + Self { + inner, + id, + event_tx, + bus, + ledger, + harness_id, + } + } + + /// Queue a prompt and start streaming the agent's response onto the + /// event channel. + pub async fn send(&self, prompt: String) -> Result<()> { + self.inner.send(prompt).await?; + let inner = self.inner.clone(); + let event_tx = self.event_tx.clone(); + let bus = self.bus.clone(); + let ledger = self.ledger.clone(); + let hid = self.harness_id.clone(); + tokio::spawn(async move { + if let Ok(mut stream) = inner.receive().await { + while let Some(item) = stream.next().await { + match item { + Ok(msg) => { + let _ = bridge::project(&msg, &hid, &event_tx, &bus, &ledger); + } + Err(_) => break, + } + } + } + }); + Ok(()) + } + + /// Subscribe to this session's event stream. + pub fn subscribe(&self) -> AgentSdkEventStream { + AgentSdkEventStream::new(self.event_tx.subscribe()) + } + + /// Raw broadcast receiver (used by the actor `Subscribe` message). + pub fn subscribe_receiver(&self) -> broadcast::Receiver { + self.event_tx.subscribe() + } + + pub async fn interrupt(&self) -> Result<()> { + self.inner.interrupt().await?; + Ok(()) + } + + pub async fn set_permission_mode(&self, mode: String) -> Result<()> { + self.inner.set_permission_mode(mode).await?; + Ok(()) + } + + pub async fn set_model(&self, model: String) -> Result<()> { + self.inner.set_model(model).await?; + Ok(()) + } + + pub async fn close(&self) -> Result<()> { + self.inner.close().await?; + Ok(()) + } +} + +/// Concurrent registry of active sessions — shared by the harness and the +/// web companion. +#[derive(Default, Clone)] +pub struct SessionRegistry { + inner: Arc>>>, +} + +impl SessionRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&self, h: Arc) { + self.inner.write().push(h); + } + + pub fn get(&self, id: &AgentSessionId) -> Option> { + self.inner.read().iter().find(|s| &s.id == id).cloned() + } + + pub fn list(&self) -> Vec> { + self.inner.read().clone() + } + + pub fn remove(&self, id: &AgentSessionId) { + self.inner.write().retain(|s| &s.id != id); + } + + pub fn len(&self) -> usize { + self.inner.read().len() + } + + pub fn is_empty(&self) -> bool { + self.inner.read().is_empty() + } +} diff --git a/crates/agent-sdk-harness/src/spec.rs b/crates/agent-sdk-harness/src/spec.rs new file mode 100644 index 0000000..d51be4c --- /dev/null +++ b/crates/agent-sdk-harness/src/spec.rs @@ -0,0 +1,132 @@ +//! Top-level harness configuration. + +use serde::{Deserialize, Serialize}; + +use atomr_agents_agent_sdk_core::{Effort, PermissionMode, SettingSource}; + +/// How the harness selects Anthropic credentials at spawn. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum AuthProvider { + /// `ANTHROPIC_API_KEY` (first-party / Claude Platform on AWS). + #[default] + Anthropic, + /// `CLAUDE_CODE_USE_BEDROCK=1` + AWS credentials. + Bedrock, + /// `CLAUDE_CODE_USE_VERTEX=1` + GCP credentials. + Vertex, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AuthConfig { + #[serde(default)] + pub provider: AuthProvider, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentSdkHarnessSpec { + /// Stable harness id (used in telemetry / `Event::HarnessIteration`). + #[serde(default = "default_id")] + pub id: String, + + /// Model used when a request doesn't name one. + #[serde(default = "default_model")] + pub default_model: Option, + + /// Fallback model if the primary is overloaded. + #[serde(default)] + pub fallback_model: Option, + + /// Default tool-approval behavior. **`bypassPermissions`** (max + /// autonomy) — override per request/spec; see the safety note in the + /// crate docs. + #[serde(default)] + pub default_permission_mode: PermissionMode, + + /// Which filesystem settings the SDK loads. Defaults to `["project"]` + /// so the agent sees only harness-materialized `.claude/` config. + #[serde(default = "default_setting_sources")] + pub default_setting_sources: Vec, + + /// Default reasoning effort. + #[serde(default)] + pub default_effort: Option, + + /// Cap on simultaneous interactive sessions. + #[serde(default = "default_max_sessions")] + pub max_concurrent_sessions: usize, + + /// Capacity of the broadcast event channel. + #[serde(default = "default_channel_cap")] + pub event_channel_capacity: usize, + + /// Default turn cap (SDK-enforced). + #[serde(default)] + pub default_max_turns: Option, + + /// Default soft cost cap (USD) enforced at turn boundaries. + #[serde(default)] + pub default_max_cost_usd: Option, + + /// Credential selection. + #[serde(default)] + pub auth: AuthConfig, +} + +impl Default for AgentSdkHarnessSpec { + fn default() -> Self { + Self { + id: default_id(), + default_model: default_model(), + fallback_model: None, + default_permission_mode: PermissionMode::default(), + default_setting_sources: default_setting_sources(), + default_effort: None, + max_concurrent_sessions: default_max_sessions(), + event_channel_capacity: default_channel_cap(), + default_max_turns: None, + default_max_cost_usd: None, + auth: AuthConfig::default(), + } + } +} + +fn default_id() -> String { + "agent-sdk".to_string() +} +fn default_model() -> Option { + Some("claude-opus-4-8".to_string()) +} +fn default_setting_sources() -> Vec { + vec![SettingSource::Project] +} +fn default_max_sessions() -> usize { + 16 +} +fn default_channel_cap() -> usize { + 512 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_spec_is_bypass_and_project() { + let s = AgentSdkHarnessSpec::default(); + assert_eq!(s.default_permission_mode, PermissionMode::BypassPermissions); + assert_eq!(s.default_setting_sources, vec![SettingSource::Project]); + assert_eq!(s.default_model.as_deref(), Some("claude-opus-4-8")); + assert_eq!(s.auth.provider, AuthProvider::Anthropic); + } + + #[test] + fn spec_round_trips_yaml_like_json() { + let s: AgentSdkHarnessSpec = + serde_json::from_str(r#"{"id":"reviewer","default_model":"claude-sonnet-4-6"}"#).unwrap(); + assert_eq!(s.id, "reviewer"); + assert_eq!(s.default_model.as_deref(), Some("claude-sonnet-4-6")); + // Unspecified fields fall back to defaults. + assert_eq!(s.max_concurrent_sessions, 16); + } +} diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index 66b56e8..e791377 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -38,6 +38,7 @@ atomr-agents-memory = { workspace = true } atomr-agents-tool = { workspace = true } atomr-agents-channel-core = { workspace = true } atomr-agents-channel-harness = { workspace = true } +atomr-agents-agent-sdk-harness = { workspace = true } atomr-agents-observability = { workspace = true } atomr-agents-registry = { workspace = true } atomr-agents-eval = { workspace = true } diff --git a/crates/host/src/agent_sdk.rs b/crates/host/src/agent_sdk.rs new file mode 100644 index 0000000..baf8aa2 --- /dev/null +++ b/crates/host/src/agent_sdk.rs @@ -0,0 +1,249 @@ +//! Load a Claude Agent SDK harness spec + `.claude` projection from disk. +//! +//! Layout under `/agent-sdk//`: +//! +//! ```text +//! harness.yaml → AgentSdkHarnessSpec (optional; defaults if absent) +//! commands/.md → slash commands (frontmatter: description/model/allowed-tools) +//! skills//SKILL.md → skills (frontmatter: name/description/allowed-tools) +//! mcp/.yaml → external MCP servers (McpServerConfig) +//! ``` +//! +//! The result feeds +//! `AgentSdkHarness::new(backend, spec).with_projection(projection)` — the +//! projection materializes the `.claude/` tree into the run's `cwd` so the +//! SDK (with `setting_sources = ["project"]`) sees atomr-controlled config. + +use std::collections::BTreeMap; +use std::path::Path; + +use serde::Deserialize; + +use atomr_agents_agent_sdk_harness::core::McpServerConfig; +use atomr_agents_agent_sdk_harness::{AgentSdkHarnessSpec, CommandDoc, Projection, SkillDoc}; + +use crate::error::{HostError, HostResult}; + +/// A harness spec + its `.claude` projection. +#[derive(Debug, Clone)] +pub struct LoadedAgentSdk { + pub id: String, + pub spec: AgentSdkHarnessSpec, + pub projection: Projection, +} + +#[derive(Debug, Default, Deserialize)] +struct Frontmatter { + #[serde(default)] + name: Option, + #[serde(default)] + description: Option, + #[serde(default)] + model: Option, + #[serde(default, rename = "allowed-tools")] + allowed_tools: Vec, +} + +/// Split a `---\n\n---\n` markdown doc. Missing/invalid +/// frontmatter yields defaults + the whole text as the body. +fn split_frontmatter(text: &str) -> (Frontmatter, String) { + let t = text.trim_start_matches('\u{feff}'); + if let Some(after) = t.strip_prefix("---") { + if let Some(idx) = after.find("\n---") { + let fm_str = after[..idx].trim_start_matches('\n'); + let after_fence = &after[idx + 4..]; // skip "\n---" + let body = match after_fence.find('\n') { + Some(nl) => &after_fence[nl + 1..], + None => "", + }; + let fm: Frontmatter = serde_yaml::from_str(fm_str).unwrap_or_default(); + return (fm, body.to_string()); + } + } + (Frontmatter::default(), text.to_string()) +} + +/// Load the spec + projection for one agent-sdk harness directory. +pub fn load_agent_sdk(id: &str, dir: &Path) -> HostResult { + let spec = load_spec(dir)?; + let projection = Projection { + commands: load_commands(&dir.join("commands"))?, + skills: load_skills(&dir.join("skills"))?, + mcp_servers: load_mcp(&dir.join("mcp"))?, + settings: None, + }; + Ok(LoadedAgentSdk { + id: id.to_string(), + spec, + projection, + }) +} + +fn load_spec(dir: &Path) -> HostResult { + let p = dir.join("harness.yaml"); + if !p.is_file() { + return Ok(AgentSdkHarnessSpec::default()); + } + let s = std::fs::read_to_string(&p).map_err(|e| HostError::io(&p, e))?; + serde_yaml::from_str(&s).map_err(|e| HostError::yaml(&p, e)) +} + +fn load_commands(dir: &Path) -> HostResult> { + let mut cmds = Vec::new(); + for (name, content) in read_md_files(dir)? { + let (fm, body) = split_frontmatter(&content); + cmds.push(CommandDoc { + name, + description: fm.description, + allowed_tools: fm.allowed_tools, + model: fm.model, + body, + }); + } + Ok(cmds) +} + +fn load_skills(dir: &Path) -> HostResult> { + if !dir.is_dir() { + return Ok(Vec::new()); + } + let mut entries: Vec<_> = std::fs::read_dir(dir) + .map_err(|e| HostError::io(dir, e))? + .filter_map(|e| e.ok()) + .collect(); + entries.sort_by_key(|e| e.file_name()); + + let mut skills = Vec::new(); + for entry in entries { + let sdir = entry.path(); + if !sdir.is_dir() { + continue; + } + let skill_md = sdir.join("SKILL.md"); + if !skill_md.is_file() { + continue; + } + let id = sdir + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + let content = std::fs::read_to_string(&skill_md).map_err(|e| HostError::io(&skill_md, e))?; + let (fm, body) = split_frontmatter(&content); + skills.push(SkillDoc { + id, + name: fm.name, + description: fm.description, + allowed_tools: fm.allowed_tools, + body, + }); + } + Ok(skills) +} + +fn load_mcp(dir: &Path) -> HostResult> { + if !dir.is_dir() { + return Ok(BTreeMap::new()); + } + let mut entries: Vec<_> = std::fs::read_dir(dir) + .map_err(|e| HostError::io(dir, e))? + .filter_map(|e| e.ok()) + .collect(); + entries.sort_by_key(|e| e.file_name()); + + let mut map = BTreeMap::new(); + for entry in entries { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("yaml") { + continue; + } + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + let s = std::fs::read_to_string(&path).map_err(|e| HostError::io(&path, e))?; + let cfg: McpServerConfig = serde_yaml::from_str(&s).map_err(|e| HostError::yaml(&path, e))?; + map.insert(name, cfg); + } + Ok(map) +} + +/// Read every `*.md` under `dir` as `(file_stem, contents)`, sorted by stem. +fn read_md_files(dir: &Path) -> HostResult> { + if !dir.is_dir() { + return Ok(Vec::new()); + } + let mut out = Vec::new(); + for entry in std::fs::read_dir(dir).map_err(|e| HostError::io(dir, e))? { + let entry = entry.map_err(|e| HostError::io(dir, e))?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("md") { + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + let content = std::fs::read_to_string(&path).map_err(|e| HostError::io(&path, e))?; + out.push((stem, content)); + } + } + out.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn loads_spec_commands_skills_mcp() { + let tmp = tempdir().unwrap(); + let dir = tmp.path().join("reviewer"); + std::fs::create_dir_all(dir.join("commands")).unwrap(); + std::fs::create_dir_all(dir.join("skills/review")).unwrap(); + std::fs::create_dir_all(dir.join("mcp")).unwrap(); + + std::fs::write( + dir.join("harness.yaml"), + "id: reviewer\ndefault_model: claude-sonnet-4-6\n", + ) + .unwrap(); + std::fs::write( + dir.join("commands/echo.md"), + "---\ndescription: Echo input\nallowed-tools:\n - Read\n---\nEcho: $ARGUMENTS\n", + ) + .unwrap(); + std::fs::write( + dir.join("skills/review/SKILL.md"), + "---\nname: Review\ndescription: Reviews code\n---\nReview carefully.\n", + ) + .unwrap(); + std::fs::write( + dir.join("mcp/github.yaml"), + "transport: stdio\ncommand: npx\nargs:\n - -y\n - server-github\n", + ) + .unwrap(); + + let loaded = load_agent_sdk("reviewer", &dir).unwrap(); + assert_eq!(loaded.spec.id, "reviewer"); + assert_eq!(loaded.spec.default_model.as_deref(), Some("claude-sonnet-4-6")); + assert_eq!(loaded.projection.commands.len(), 1); + assert_eq!(loaded.projection.commands[0].name, "echo"); + assert_eq!(loaded.projection.commands[0].allowed_tools, vec!["Read"]); + assert_eq!(loaded.projection.skills.len(), 1); + assert_eq!(loaded.projection.skills[0].id, "review"); + assert_eq!(loaded.projection.skills[0].name.as_deref(), Some("Review")); + assert!(loaded.projection.skills[0].body.contains("Review carefully")); + assert!(loaded.projection.mcp_servers.contains_key("github")); + } + + #[test] + fn missing_dir_yields_defaults() { + let tmp = tempdir().unwrap(); + let loaded = load_agent_sdk("none", &tmp.path().join("none")).unwrap(); + assert_eq!(loaded.spec.id, "agent-sdk"); // default spec id + assert!(loaded.projection.is_empty()); + } +} diff --git a/crates/host/src/layout.rs b/crates/host/src/layout.rs index 69e8084..7a94936 100644 --- a/crates/host/src/layout.rs +++ b/crates/host/src/layout.rs @@ -93,6 +93,16 @@ impl HostPaths { self.root.join("mcp") } + /// Root for Claude Agent SDK harness definitions. + pub fn agent_sdk_dir(&self) -> PathBuf { + self.root.join("agent-sdk") + } + + /// Directory for one agent-sdk harness: `/agent-sdk//`. + pub fn agent_sdk(&self, id: &str) -> PathBuf { + self.agent_sdk_dir().join(id) + } + pub fn agent(&self, agent_id: &str) -> AgentPaths { AgentPaths { root: self.root.clone(), diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index c1f0c09..812ce62 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -21,6 +21,7 @@ #![allow(clippy::result_large_err)] pub mod actor; +pub mod agent_sdk; pub mod branching; pub mod chat; pub mod config; @@ -42,6 +43,7 @@ pub mod scheduler; pub mod skills_registry; pub mod triggers; +pub use agent_sdk::{load_agent_sdk, LoadedAgentSdk}; pub use config::{HostConfig, ProviderConfig}; pub use error::HostError; pub use layout::{default_root, AgentPaths, HostPaths}; diff --git a/crates/py-bindings/Cargo.toml b/crates/py-bindings/Cargo.toml index 5083c0c..41172e9 100644 --- a/crates/py-bindings/Cargo.toml +++ b/crates/py-bindings/Cargo.toml @@ -45,6 +45,8 @@ atomr-agents-stt-tool = { workspace = true } atomr-agents-stt-harness = { workspace = true, features = ["decode"] } atomr-agents-coding-cli-core = { workspace = true } atomr-agents-coding-cli-harness = { workspace = true } +atomr-agents-agent-sdk-core = { workspace = true } +atomr-agents-agent-sdk-harness = { workspace = true } atomr-agents-sandbox-core = { workspace = true } atomr-agents-sandbox-harness = { workspace = true } atomr-agents-channel-core = { workspace = true } diff --git a/crates/py-bindings/src/agent_sdk.rs b/crates/py-bindings/src/agent_sdk.rs new file mode 100644 index 0000000..1664530 --- /dev/null +++ b/crates/py-bindings/src/agent_sdk.rs @@ -0,0 +1,293 @@ +//! Python bindings for the Claude Agent SDK harness. +//! +//! Exposes `atomr_agents._native.agent_sdk`: +//! +//! - `AgentSdkHarness` — `from_python_backend(key, spec)` / `mock()` +//! builders, async `run()` (final result dict), `session()` (interactive +//! bidirectional session = the actor surface), `events()` (event +//! stream), `sessions()`. +//! - `AgentSdkSession` — `query`, `interrupt`, `set_permission_mode`, +//! `set_model`, `events`, `close`. +//! - `AgentSdkEventStream` — `__aiter__` / `__anext__` async iterator. +//! - `invoke_tool` — run a registered atomr `@tool` as an in-process tool. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use pyo3::exceptions::{PyStopAsyncIteration, PyValueError}; +use pyo3::prelude::*; +use tokio::sync::Mutex as AsyncMutex; + +use atomr_agents_agent_sdk_core::{ + AgentSdkEvent, AgentSdkEventStream, AgentSessionId, QueryRequest, SessionSpec, +}; +use atomr_agents_agent_sdk_harness::{AgentSdkHarness, AgentSdkHarnessSpec, InteractiveAgentSession}; + +use crate::conv::{json_to_py, py_to_json, py_to_value_or}; + +fn query_request_from_py(py: Python<'_>, request: &Bound<'_, PyAny>) -> PyResult { + if let Ok(s) = request.extract::() { + return Ok(QueryRequest::new(s)); + } + let value = py_to_json(py, request)?; + serde_json::from_value::(value) + .map_err(|e| PyValueError::new_err(format!("invalid request: {e}"))) +} + +// ----- AgentSdkEventStream ------------------------------------------------ + +#[pyclass(name = "AgentSdkEventStream", module = "atomr_agents._native.agent_sdk")] +pub struct PyAgentSdkEventStream { + inner: Arc>, + stop_on_finish: bool, + done: Arc, +} + +#[pymethods] +impl PyAgentSdkEventStream { + fn __aiter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __anext__<'py>(slf: PyRef<'py, Self>, py: Python<'py>) -> PyResult> { + let inner = slf.inner.clone(); + let stop_on_finish = slf.stop_on_finish; + let done = slf.done.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + if done.load(Ordering::Relaxed) { + return Err(PyStopAsyncIteration::new_err("")); + } + let next = { + let mut guard = inner.lock().await; + guard.recv().await + }; + match next { + None => Err(PyStopAsyncIteration::new_err("")), + Some(ev) => { + if stop_on_finish && matches!(ev, AgentSdkEvent::RunFinished { .. }) { + done.store(true, Ordering::Relaxed); + } + let value = serde_json::to_value(&ev).unwrap_or(serde_json::Value::Null); + Python::with_gil(|py| json_to_py(py, &value)) + } + } + }) + } + + /// Explicit `recv()` → dict, or `None` once the stream ends. + fn recv<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let next = { + let mut guard = inner.lock().await; + guard.recv().await + }; + Python::with_gil(|py| match next { + None => Ok(py.None()), + Some(ev) => { + let value = serde_json::to_value(&ev).unwrap_or(serde_json::Value::Null); + json_to_py(py, &value) + } + }) + }) + } +} + +// ----- AgentSdkSession ---------------------------------------------------- + +#[pyclass(name = "AgentSdkSession", module = "atomr_agents._native.agent_sdk")] +pub struct PyAgentSdkSession { + session: Arc, + harness: Arc, +} + +#[pymethods] +impl PyAgentSdkSession { + #[getter] + fn session_id(&self) -> String { + self.session.id.to_string() + } + + /// Send a prompt; the response streams onto `events()`. + fn query<'py>(&self, py: Python<'py>, prompt: String) -> PyResult> { + let session = self.session.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + session.send(prompt).await.map_err(crate::errors::map)?; + Ok(()) + }) + } + + /// Subscribe to the event stream. + fn events(&self) -> PyAgentSdkEventStream { + PyAgentSdkEventStream { + inner: Arc::new(AsyncMutex::new(self.session.subscribe())), + stop_on_finish: false, + done: Arc::new(AtomicBool::new(false)), + } + } + + fn interrupt<'py>(&self, py: Python<'py>) -> PyResult> { + let session = self.session.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + session.interrupt().await.map_err(crate::errors::map)?; + Ok(()) + }) + } + + fn set_permission_mode<'py>(&self, py: Python<'py>, mode: String) -> PyResult> { + let session = self.session.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + session.set_permission_mode(mode).await.map_err(crate::errors::map)?; + Ok(()) + }) + } + + fn set_model<'py>(&self, py: Python<'py>, model: String) -> PyResult> { + let session = self.session.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + session.set_model(model).await.map_err(crate::errors::map)?; + Ok(()) + }) + } + + fn close<'py>(&self, py: Python<'py>) -> PyResult> { + let harness = self.harness.clone(); + let id = self.session.id.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + harness.stop_session(&id).await.map_err(crate::errors::map)?; + Ok(()) + }) + } + + fn __repr__(&self) -> String { + format!("AgentSdkSession(id={})", self.session.id) + } +} + +// ----- AgentSdkHarness ---------------------------------------------------- + +#[pyclass(name = "AgentSdkHarness", module = "atomr_agents._native.agent_sdk")] +pub struct PyAgentSdkHarness { + inner: Arc, +} + +#[pymethods] +impl PyAgentSdkHarness { + /// Build a harness driving the registered Python `agent_sdk` backend. + #[staticmethod] + #[pyo3(signature = (backend_key, spec=None))] + fn from_python_backend( + py: Python<'_>, + backend_key: String, + spec: Option<&Bound<'_, PyAny>>, + ) -> PyResult { + let spec_val = py_to_value_or(py, spec, serde_json::json!({}))?; + let spec: AgentSdkHarnessSpec = + serde_json::from_value(spec_val).map_err(crate::errors::map)?; + let backend = crate::guest::build_agent_sdk_backend(&backend_key)?; + Ok(Self { + inner: Arc::new(AgentSdkHarness::new(backend, spec)), + }) + } + + /// Build a harness over the in-memory `MockBackend` (network-free). + #[staticmethod] + fn mock() -> Self { + Self { + inner: Arc::new(AgentSdkHarness::local_default()), + } + } + + #[getter] + fn backend_name(&self) -> String { + self.inner.backend_name().to_string() + } + + fn live_count(&self) -> usize { + self.inner.live_count() + } + + /// Subscribe to the harness-wide event stream. + fn events(&self) -> PyAgentSdkEventStream { + PyAgentSdkEventStream { + inner: Arc::new(AsyncMutex::new(self.inner.events())), + stop_on_finish: false, + done: Arc::new(AtomicBool::new(false)), + } + } + + /// Async: run a headless query to completion, resolving to the result + /// dict. `request` is a prompt string or a config dict with `prompt`. + fn run<'py>(&self, py: Python<'py>, request: &Bound<'py, PyAny>) -> PyResult> { + let req = query_request_from_py(py, request)?; + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let result = inner.run(req).await.map_err(crate::errors::map)?; + let value = serde_json::to_value(result).map_err(crate::errors::map)?; + Python::with_gil(|py| json_to_py(py, &value)) + }) + } + + /// Async: open a stateful interactive session. + #[pyo3(signature = (spec=None))] + fn session<'py>( + &self, + py: Python<'py>, + spec: Option<&Bound<'py, PyAny>>, + ) -> PyResult> { + let spec_val = py_to_value_or(py, spec, serde_json::json!({}))?; + let session_spec: SessionSpec = + serde_json::from_value(spec_val).map_err(crate::errors::map)?; + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let session = inner.start_session(session_spec).await.map_err(crate::errors::map)?; + Python::with_gil(|py| { + Py::new( + py, + PyAgentSdkSession { + session, + harness: inner, + }, + ) + }) + }) + } + + /// List active interactive session ids. + fn sessions(&self) -> Vec { + self.inner + .sessions() + .list() + .iter() + .map(|s| s.id.to_string()) + .collect() + } + + /// Stop an interactive session by id. + fn stop_session<'py>(&self, py: Python<'py>, id: String) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + inner + .stop_session(&AgentSessionId::from(id)) + .await + .map_err(crate::errors::map)?; + Ok(()) + }) + } + + fn __repr__(&self) -> String { + format!("AgentSdkHarness(backend={})", self.inner.backend_name()) + } +} + +// ----- module registration ----------------------------------------------- + +pub fn register(py: Python<'_>, parent: &Bound<'_, PyModule>) -> PyResult<()> { + let m = PyModule::new_bound(py, "agent_sdk")?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(crate::guest::invoke_tool, &m)?)?; + parent.add_submodule(&m)?; + Ok(()) +} diff --git a/crates/py-bindings/src/guest/agent_sdk.rs b/crates/py-bindings/src/guest/agent_sdk.rs new file mode 100644 index 0000000..c7d3626 --- /dev/null +++ b/crates/py-bindings/src/guest/agent_sdk.rs @@ -0,0 +1,243 @@ +//! Python-backed [`AgentSdkBackend`] + the reverse async-iterator bridge. +//! +//! `PythonAgentSdkBackend` implements the harness crate's backend trait by +//! delegating to a registered Python `ClaudeAgentSDKBackend` wrapper (see +//! `python/atomr_agents/agent_sdk.py`). The crux is [`aiter_to_stream`]: +//! Rust drives a Python async iterator (`__anext__`) one item at a time, +//! awaiting each coroutine via `pyo3_async_runtimes::tokio::into_future` +//! — the inverse of `observability::PyEventStream`. +//! +//! GIL discipline (each independently load-bearing): the GIL is held only +//! to *build* the coroutine / *normalize* the result; the `.await` always +//! happens with the GIL released. `StopAsyncIteration` is the terminator. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use futures::StreamExt; +use pyo3::exceptions::PyStopAsyncIteration; +use pyo3::prelude::*; + +use atomr_agents_agent_sdk_core::{ + AgentSdkBackend, AgentSdkError, AgentSdkMessage, AgentSdkSession, AgentSessionId, MessageStream, + QueryRequest, SessionSpec, +}; +use atomr_agents_core::{ + CallCtx, InvokeCtx, IterationBudget, MoneyBudget, TimeBudget, TokenBudget, +}; +use atomr_agents_tool::Tool; + +use crate::conv::{json_to_py, py_to_json}; + +/// Convert a registered Python async iterator into a Rust [`MessageStream`]. +fn aiter_to_stream(aiter: Arc) -> MessageStream { + futures::stream::unfold(Some(aiter), |state: Option>| async move { + let aiter = state?; + // (A) GIL: __anext__() → coroutine → Send future. + let step = Python::with_gil(|py| -> PyResult<_> { + let coro = aiter.bind(py).call_method0("__anext__")?; + pyo3_async_runtimes::tokio::into_future(coro) + }); + let fut = match step { + Ok(f) => f, + Err(e) => return Some((Err(AgentSdkError::Sdk(format!("__anext__: {e}"))), None)), + }; + // (B) await OUTSIDE the GIL. + match fut.await { + Ok(obj) => { + // (C) GIL: normalize → JSON → AgentSdkMessage. + let parsed = Python::with_gil(|py| py_to_json(py, obj.bind(py))); + match parsed { + Ok(value) => { + let msg = serde_json::from_value::(value) + .unwrap_or(AgentSdkMessage::Unknown); + Some((Ok(msg), Some(aiter))) + } + Err(e) => Some((Err(AgentSdkError::Sdk(format!("normalize: {e}"))), None)), + } + } + Err(e) => { + let is_stop = Python::with_gil(|py| e.is_instance_of::(py)); + if is_stop { + None + } else { + Some((Err(AgentSdkError::Sdk(format!("stream: {e}"))), None)) + } + } + } + }) + .boxed() +} + +/// Await a Python coroutine returned by a method on `target`. The method is +/// called with `(token)` or `(token, arg)`. +async fn call_void( + target: &Arc, + method: &'static str, + token: &str, + arg: Option, +) -> Result<(), AgentSdkError> { + let target = target.clone(); + let token = token.to_string(); + let fut = Python::with_gil(|py| -> PyResult<_> { + let bound = target.bind(py); + let coro = match arg { + Some(a) => bound.call_method1(method, (token, a))?, + None => bound.call_method1(method, (token,))?, + }; + pyo3_async_runtimes::tokio::into_future(coro) + }) + .map_err(|e| AgentSdkError::Sdk(format!("{method}: {e}")))?; + fut.await + .map_err(|e| AgentSdkError::Sdk(format!("{method} await: {e}")))?; + Ok(()) +} + +/// The backend: holds the registered Python wrapper instance. +pub(crate) struct PythonAgentSdkBackend { + target: Arc, +} + +#[async_trait] +impl AgentSdkBackend for PythonAgentSdkBackend { + fn name(&self) -> &str { + "python" + } + + async fn available(&self) -> bool { + true + } + + async fn query(&self, req: QueryRequest) -> Result { + let cfg = serde_json::to_value(&req.config)?; + let prompt = req.prompt; + let target = self.target.clone(); + let aiter = Python::with_gil(|py| -> PyResult> { + let cfg_py = json_to_py(py, &cfg)?; + let agen = target.bind(py).call_method1("run", (cfg_py, prompt))?; + let aiter = agen.call_method0("__aiter__")?; + Ok(Arc::new(aiter.unbind())) + }) + .map_err(|e| AgentSdkError::Sdk(format!("run: {e}")))?; + Ok(aiter_to_stream(aiter)) + } + + async fn create_session( + &self, + spec: SessionSpec, + ) -> Result, AgentSdkError> { + let cfg = serde_json::to_value(&spec.config)?; + let target = self.target.clone(); + let fut = Python::with_gil(|py| -> PyResult<_> { + let cfg_py = json_to_py(py, &cfg)?; + let coro = target.bind(py).call_method1("open_session", (cfg_py,))?; + pyo3_async_runtimes::tokio::into_future(coro) + }) + .map_err(|e| AgentSdkError::Sdk(format!("open_session: {e}")))?; + let token_obj = fut + .await + .map_err(|e| AgentSdkError::Sdk(format!("open_session await: {e}")))?; + let token: String = Python::with_gil(|py| token_obj.bind(py).extract()) + .map_err(|e| AgentSdkError::Sdk(format!("session token: {e}")))?; + let id = AgentSessionId::from(token.clone()); + Ok(Box::new(PythonAgentSession { + target: self.target.clone(), + token, + id, + })) + } +} + +/// A live Python-backed session. The stateful `ClaudeSDKClient` stays +/// Python-side, keyed by an opaque `token`; Rust holds only the token. +pub(crate) struct PythonAgentSession { + target: Arc, + token: String, + id: AgentSessionId, +} + +#[async_trait] +impl AgentSdkSession for PythonAgentSession { + fn session_id(&self) -> &AgentSessionId { + &self.id + } + + async fn send(&self, prompt: String) -> Result<(), AgentSdkError> { + call_void(&self.target, "session_send", &self.token, Some(prompt)).await + } + + async fn receive(&self) -> Result { + let target = self.target.clone(); + let token = self.token.clone(); + let aiter = Python::with_gil(|py| -> PyResult> { + let agen = target.bind(py).call_method1("session_stream", (token,))?; + let aiter = agen.call_method0("__aiter__")?; + Ok(Arc::new(aiter.unbind())) + }) + .map_err(|e| AgentSdkError::Sdk(format!("session_stream: {e}")))?; + Ok(aiter_to_stream(aiter)) + } + + async fn interrupt(&self) -> Result<(), AgentSdkError> { + call_void(&self.target, "session_interrupt", &self.token, None).await + } + + async fn set_permission_mode(&self, mode: String) -> Result<(), AgentSdkError> { + call_void(&self.target, "session_set_mode", &self.token, Some(mode)).await + } + + async fn set_model(&self, model: String) -> Result<(), AgentSdkError> { + call_void(&self.target, "session_set_model", &self.token, Some(model)).await + } + + async fn close(&self) -> Result<(), AgentSdkError> { + call_void(&self.target, "session_close", &self.token, None).await + } +} + +/// Build a Python-backed backend from a registered `agent_sdk` factory key. +pub(crate) fn build_agent_sdk_backend(key: &str) -> PyResult> { + let target = super::must_lookup("agent_sdk", key)?; + Ok(Arc::new(PythonAgentSdkBackend { target })) +} + +/// Invoke a registered atomr tool (a `@tool` guest) by key and return its +/// result — the bridge that lets a Rust/Python atomr tool run as an +/// in-process SDK custom tool. Async; runs through the Rust `Tool` trait. +#[pyfunction] +#[pyo3(signature = (key, args, ctx=None))] +pub(crate) fn invoke_tool<'py>( + py: Python<'py>, + key: String, + args: &Bound<'py, PyAny>, + ctx: Option<&Bound<'py, PyAny>>, +) -> PyResult> { + let args_val = py_to_json(py, args)?; + let _ = ctx; // reserved for future per-call budget/clearance plumbing + let adapter = { + let entry = super::registry::TOOLS.get(&key).ok_or_else(|| { + pyo3::exceptions::PyKeyError::new_err(format!("no atomr tool registered with key {key:?}")) + })?; + super::tool::PyToolAdapter::new(entry.descriptor.clone(), entry.target.clone()) + }; + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let ictx = InvokeCtx { + call: CallCtx::new( + None, + TokenBudget::new(1_000_000), + TimeBudget::new(Duration::from_secs(3600)), + MoneyBudget::from_usd(1_000.0), + IterationBudget::new(1_000), + vec![], + ), + tool_call_id: "agent-sdk".to_string(), + raw_args: args_val.clone(), + }; + let result = adapter + .invoke(args_val, &ictx) + .await + .map_err(crate::errors::map)?; + Python::with_gil(|py| json_to_py(py, &result)) + }) +} diff --git a/crates/py-bindings/src/guest/mod.rs b/crates/py-bindings/src/guest/mod.rs index d154269..7767e0f 100644 --- a/crates/py-bindings/src/guest/mod.rs +++ b/crates/py-bindings/src/guest/mod.rs @@ -23,6 +23,7 @@ use pyo3::prelude::*; use crate::tool::PyToolDescriptor; +mod agent_sdk; mod conv_helpers; mod embedder; mod instruction; @@ -57,6 +58,7 @@ pub use skill_strategy::PySkillStrategyHandle; // modules (notably `crate::agent::PyAgent::from_spec`) can construct // strategies from registered guest keys without re-implementing the // adapter wiring. +pub(crate) use agent_sdk::{build_agent_sdk_backend, invoke_tool}; pub(crate) use instruction::build_guest_instruction_strategy; pub(crate) use memory_strategy::build_guest_memory_strategy; pub(crate) use persona::build_guest_persona; @@ -289,6 +291,11 @@ fn register_ann_index_factory(key: String, target: PyObject) -> PyGuestHandle { register_kind("ann_index", key, target) } +#[pyfunction] +fn register_agent_sdk_factory(key: String, target: PyObject) -> PyGuestHandle { + register_kind("agent_sdk", key, target) +} + pub fn register(py: Python<'_>, parent: &Bound<'_, PyModule>) -> PyResult<()> { let m = PyModule::new_bound(py, "guest")?; m.add_class::()?; @@ -323,6 +330,7 @@ pub fn register(py: Python<'_>, parent: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(register_persona_reconciler_factory, &m)?)?; m.add_function(wrap_pyfunction!(register_inference_client_factory, &m)?)?; m.add_function(wrap_pyfunction!(register_ann_index_factory, &m)?)?; + m.add_function(wrap_pyfunction!(register_agent_sdk_factory, &m)?)?; m.add_function(wrap_pyfunction!(list_factories, &m)?)?; m.add_function(wrap_pyfunction!(clear_factories, &m)?)?; diff --git a/crates/py-bindings/src/lib.rs b/crates/py-bindings/src/lib.rs index 2783528..1a546e0 100644 --- a/crates/py-bindings/src/lib.rs +++ b/crates/py-bindings/src/lib.rs @@ -36,6 +36,7 @@ use pyo3::prelude::*; mod agent; +mod agent_sdk; #[cfg(feature = "avatar")] mod avatar; mod cache; @@ -114,6 +115,7 @@ fn _native(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { stt::register(py, m)?; stt_harness::register(py, m)?; coding_cli::register(py, m)?; + agent_sdk::register(py, m)?; sandbox::register(py, m)?; channel::register(py, m)?; tts::register(py, m)?; diff --git a/crates/umbrella/Cargo.toml b/crates/umbrella/Cargo.toml index cf94554..68a6c29 100644 --- a/crates/umbrella/Cargo.toml +++ b/crates/umbrella/Cargo.toml @@ -72,6 +72,12 @@ atomr-agents-sandbox-core = { workspace = true, optional = true } atomr-agents-sandbox-harness = { workspace = true, optional = true } atomr-agents-sandbox-tool = { workspace = true, optional = true } +# Claude Agent SDK harness — the programmable Claude Code agent wrapped as a +# Callable. The real SDK is driven via the Python bridge (`py-bindings`); this +# pulls the contract + Rust orchestration crates. +atomr-agents-agent-sdk-core = { workspace = true, optional = true } +atomr-agents-agent-sdk-harness = { workspace = true, optional = true } + [features] default = ["agent", "tool", "skill", "memory", "persona", "instruction"] tool = ["dep:atomr-agents-tool"] @@ -93,7 +99,7 @@ testkit = ["dep:atomr-agents-testkit", "agent", "harness"] provider-anthropic = ["agent", "atomr-agents-agent/provider-anthropic"] provider-openai = ["agent", "atomr-agents-agent/provider-openai"] provider-gemini = ["agent", "atomr-agents-agent/provider-gemini"] -full = ["agent", "workflow", "harness", "org", "registry", "eval", "embed", "security", "testkit", "stt-full"] +full = ["agent", "workflow", "harness", "org", "registry", "eval", "embed", "security", "testkit", "stt-full", "agent-sdk"] # ----- Speech-to-text feature surface --------------------------------------- stt = ["dep:atomr-agents-stt-core", "dep:atomr-agents-stt-audio", "dep:atomr-agents-stt-tool", "tool"] @@ -127,3 +133,6 @@ conversation = ["stt-full", "tts-full", "tts-voice", "stt-voice"] # ----- MicroVM sandbox feature surface -------------------------------------- sandbox = ["dep:atomr-agents-sandbox-core", "dep:atomr-agents-sandbox-harness", "dep:atomr-agents-sandbox-tool", "tool"] + +# ----- Claude Agent SDK harness feature surface ----------------------------- +agent-sdk = ["dep:atomr-agents-agent-sdk-core", "dep:atomr-agents-agent-sdk-harness"] diff --git a/crates/umbrella/src/lib.rs b/crates/umbrella/src/lib.rs index 3f096f2..290ad8a 100644 --- a/crates/umbrella/src/lib.rs +++ b/crates/umbrella/src/lib.rs @@ -110,3 +110,14 @@ pub mod sandbox { pub use atomr_agents_sandbox_harness as harness; pub use atomr_agents_sandbox_tool as tool; } + +/// Claude Agent SDK harness — Anthropic's programmable Claude Code agent +/// (`claude-agent-sdk`) wrapped as a `Callable`. Pulls in the contract +/// (`atomr_agents_agent_sdk_core`, glob-re-exported) and the orchestration +/// harness (`atomr_agents_agent_sdk_harness`). The real SDK is driven via the +/// Python bridge in `py-bindings`; a `MockBackend` keeps Rust use network-free. +#[cfg(feature = "agent-sdk")] +pub mod agent_sdk { + pub use atomr_agents_agent_sdk_core::*; + pub use atomr_agents_agent_sdk_harness as harness; +} diff --git a/docs/agent-sdk-harness.md b/docs/agent-sdk-harness.md new file mode 100644 index 0000000..5678334 --- /dev/null +++ b/docs/agent-sdk-harness.md @@ -0,0 +1,198 @@ +# Claude Agent SDK harness + +The **Claude Agent SDK harness** wraps Anthropic's +[`claude-agent-sdk`](https://pypi.org/project/claude-agent-sdk/) — the +programmable form of Claude Code — as a first-class atomr-agents harness. It +brings Claude Code's full agent harness (slash commands, subagents, hooks, +MCP, permission modes, sessions, custom system prompts, and the built-in +`Read`/`Write`/`Edit`/`Bash`/`Glob`/`Grep`/`WebSearch` tools) into the atomr +framework, and bills against **your Anthropic API credits** by driving the +bundled `claude` CLI under the hood. + +> **Not Managed Agents.** This is the client-side Agent SDK, distinct from the +> server-side "Managed Agents" API (`client.beta.agents`). The SDK shells out +> to the `claude` CLI over a line-delimited-JSON stdio protocol; it does not +> call `/v1/messages` directly. + +## Where it sits + +atomr already has a *coding-cli* harness whose `claude` vendor drives the +Claude Code **CLI** as a fire-and-forget terminal process. The Agent SDK +harness is the *programmatic* sibling: a structured agent with typed options +(hooks, subagents, MCP, permission modes), a typed bidirectional message +protocol, and in-process custom tools. The two share the `.claude/` +projection idea but are deliberately separate harnesses. + +``` + atomr config (YAML) + registry (ArtifactKind::Harness) + │ + AgentSdkHarnessSpec ──┤ (mirrors ClaudeAgentOptions; round-trips to JSON) + ▼ + AgentSdkHarness ── impl Callable ──► composes into Pipeline / workflow / team + │ EventBus + broadcast + SessionRegistry + SpendLedger + │ + ├── run() (headless) ──► AgentSdkBackend::query() + ├── start_session()/Actor ──► AgentSdkBackend::create_session() + ▼ + AgentSdkBackend (trait) ── MockBackend (tests) PythonAgentSdkBackend (py-bindings) + │ pump_async_iter (Rust drives + │ the Python async iterator) + ▼ + python/atomr_agents/agent_sdk.py + ClaudeAgentSDKBackend → claude_agent_sdk + query() / ClaudeSDKClient → `claude` CLI → API credits +``` + +## Crate layout + +| Crate | Role | +|---|---| +| `atomr-agents-agent-sdk-core` | Contract: `AgentSdkConfig` (mirrors `ClaudeAgentOptions`), normalized `AgentSdkMessage` / `ResultSummary` / `AgentSdkEvent`, the `AgentSdkBackend` / `AgentSdkSession` trait seam, and `MockBackend`. | +| `atomr-agents-agent-sdk-harness` | Orchestrator: `AgentSdkHarness` (`impl Callable`), session registry, event/budget projection, `.claude/` materialization, and (feature `actor`) `AgentSdkActor`. | +| `atomr-agents-agent-sdk-harness-web` | Axum REST + SSE companion. | +| `py-bindings` (`agent_sdk` module) | `PythonAgentSdkBackend` + the reverse async-iterator bridge; `AgentSdkHarness` / `AgentSdkSession` pyclasses; `invoke_tool`. | +| `python/atomr_agents/agent_sdk.py` | `ClaudeAgentSDKBackend` wrapper, message normalization, options construction, tool composition, the `harness(...)` builder. | +| `host` (`agent_sdk` module) | On-disk loader: `/agent-sdk//` → spec + `.claude` projection. | + +## The contract + +The pluggable seam is `AgentSdkBackend`: + +```rust +pub type MessageStream = + Pin> + Send>>; + +#[async_trait] pub trait AgentSdkBackend: Send + Sync { + fn name(&self) -> &str; + async fn available(&self) -> bool; + async fn query(&self, req: QueryRequest) -> Result; + async fn create_session(&self, spec: SessionSpec) + -> Result, AgentSdkError>; +} +``` + +`MockBackend` (in `-core`) emits a scripted `system → assistant → result` +turn so the whole stack — harness, web, PyO3 — is testable without the SDK, +the `claude` CLI, or credits. `PythonAgentSdkBackend` (in `py-bindings`) +drives the real SDK. The door is open for a future pure-Rust backend that +spawns `claude` directly. + +`AgentSdkHarness` implements `Callable`, so it composes as a `Pipeline` step, +a workflow node, or a team routing target. `run()` returns the final +`ResultSummary`; `start_session()` returns a stateful `InteractiveAgentSession`. + +## Concept mapping (REUSE > EXTEND > INTRODUCE) + +| SDK concept | atomr bridge | +|---|---| +| Skills | atomr `Skill` → `.claude/skills//SKILL.md` via the projection (`setting_sources = ["project"]`). | +| Slash commands | `/agent-sdk//commands/*.md` → `.claude/commands/`; invoked by sending `/` as the prompt (the SDK resolves it). | +| Subagents | atomr `SubagentDef` → SDK `AgentDefinition` in the `agents` option. `HandoffTool` still routes *between* harnesses at the workflow layer. | +| Hooks | Python hook callbacks (optionally driven by atomr host hooks) passed through `ClaudeAgentOptions.hooks`. | +| MCP | External servers from `mcp/*.yaml` → `mcp_servers`; atomr Python `@tool` guests + Rust tools → in-process SDK MCP via `create_sdk_mcp_server` (`mcp__atomr__`). | +| Permission modes | Spec `permission_mode` (default `bypassPermissions`) + `permission_policy` → a `can_use_tool` callback. | +| Sessions | Pass-through `resume` / `continue_conversation` / `fork_session`; the SDK owns the `.jsonl`. | +| System-prompt presets | `{type:"preset",preset:"claude_code",append:...}` via `SystemPromptConfig`. | + +## Configuration + +On-disk layout (loaded by `atomr_agents_host::load_agent_sdk`): + +```text +/agent-sdk// + harness.yaml # AgentSdkHarnessSpec (optional; defaults if absent) + commands/.md # slash commands (frontmatter: description/model/allowed-tools) + skills//SKILL.md # skills (frontmatter: name/description/allowed-tools) + mcp/.yaml # external MCP servers (McpServerConfig) +``` + +`harness.yaml`: + +```yaml +id: code-reviewer +default_model: claude-opus-4-8 # applied when a request omits one +fallback_model: claude-sonnet-4-6 +default_permission_mode: bypassPermissions # DEFAULT — override per run/spec +default_setting_sources: [project] # only harness-materialized .claude/ +max_concurrent_sessions: 16 +default_max_turns: 32 +default_max_cost_usd: 1.0 +auth: { provider: anthropic } # bedrock | vertex set CLAUDE_CODE_USE_* env +``` + +The projection materializes `.claude/` into the run's `cwd` before each run, +so the agent sees deterministic, atomr-controlled config. + +## Auth & credits + +> **This harness bills your Anthropic API credits.** Every run drives the +> `claude` CLI, which calls the Anthropic API. + +- `auth.provider: anthropic` → requires `ANTHROPIC_API_KEY` (resolved from the + host `providers:` config's `api_key_env`). +- `bedrock` → `CLAUDE_CODE_USE_BEDROCK=1` + AWS credentials. +- `vertex` → `CLAUDE_CODE_USE_VERTEX=1` + GCP credentials. +- **claude.ai OAuth / subscription tokens are prohibited** by the Agent SDK — + if `CLAUDE_CODE_OAUTH_TOKEN` is present it is ignored, not forwarded. + +`ResultSummary.cost_usd` is the SDK's **client-side estimate**, not an +invoice. Cost/usage is charged to the shared `SpendLedger`; `max_cost_usd` +caps spend at turn boundaries (`max_turns` is the hard structural cap). + +## Permission modes & safety + +The default `permission_mode` is **`bypassPermissions`**: the agent +auto-approves every tool, including `Bash`, `Write`, and network access. This +is the configured default for autonomy and is overridable per request/spec +(`default` / `acceptEdits` / `plan`). The harness validates `cwd` / `add_dirs` +as real directories and defaults `setting_sources` to `["project"]` so the +agent only sees atomr-materialized config. **Run untrusted work inside the +[sandbox harness](sandbox-architecture.md).** + +## Python parity + +```python +import atomr_agents.agent_sdk as asdk + +# Expose an atomr tool to the agent as mcp__atomr__add. +async def add(args): + return {"content": [{"type": "text", "text": str(args["a"] + args["b"])}]} + +h = asdk.harness(tools=[add], spec={"default_permission_mode": "bypassPermissions"}) + +# Headless one-shot. +result = await h.run({ + "prompt": "Use the add tool on 2 and 3, then summarize.", + "cwd": "/repo", + "allowed_tools": ["mcp__atomr__add"], +}) +print(result["result"], result["cost_usd"]) + +# Interactive bidirectional session — the actor surface. +sess = await h.session({"cwd": "/repo"}) +await sess.query("/review the auth module") +async for ev in sess.events(): + if ev["kind"] == "assistant_text_delta": + print(ev["text"], end="") + elif ev["kind"] == "run_finished": + break +await sess.close() +``` + +`AgentSdkHarness.mock()` builds the same surface over `MockBackend` for tests +that must not touch the SDK or credits. + +## Status & roadmap + +- **Now:** contract + Rust orchestration + web companion + Python bridge + + host loader. Driven via the official Python SDK. +- **Next:** a pure-Rust backend that spawns the `claude` CLI directly + (bypassing Python), and surfacing the SDK `plugins` option. + +## Related + +- [coding-cli-harness](coding-cli-harness.md) — drives the Claude Code **CLI** + as a terminal vendor (the non-programmatic sibling). +- [sandbox-architecture](sandbox-architecture.md) — run untrusted agent work + in an isolated microVM. +- The Anthropic Agent SDK docs and the `claude-api` skill for SDK specifics. diff --git a/docs/index.md b/docs/index.md index 887357f..1c8222f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -159,6 +159,7 @@ python -c "from atomr_agents import Registry; print(Registry())" - [Avatar harness](avatar-harness.md) — real-time embodied agent: perception → cognition → TTS → 60 Hz sync manager → CBOR-over-UDP `LiveLinkSink` to a UE5 MetaHuman. Includes Ubuntu setup, x86/ARM architecture rules, the current (post-web-Creator) MetaHuman authoring workflow, and a full `ILiveLinkSource` receiver-plugin skeleton. - [Deep research harness](deep-research-harness.md) — multi-step, multi-source, citation-bearing research over a user query, with three pluggable v1 topologies (AI-Q, Anthropic multi-agent, LangGraph open_deep_research). - [Coding CLI harness](coding-cli-harness.md) — wraps local AI coding CLIs (Claude Code, Codex CLI, Antigravity CLI) as atomr-agents callables: headless mode parses structured events; interactive mode bridges a tmux session to xterm.js. Local or Docker isolation. +- [Claude Agent SDK harness](agent-sdk-harness.md) — wraps Anthropic's programmable Claude Code agent (`claude-agent-sdk`) as a `Callable`: slash commands, subagents, hooks, MCP, permission modes, sessions, and in-process custom tools, billed against your Anthropic API credits. The Python interactive session is exposed into the atomr actor model. - [MicroVM sandbox](sandbox-architecture.md) — secure, instant-boot compute for executing untrusted agent code (Python / Bash / JS / Rust): backend-agnostic `SandboxBackend` / `SandboxHandle` contract, the `execute_in_sandbox` tool, a quota'd harness with bin-packing scheduler + warm snapshot pool, the vsock host↔guest protocol and in-VM guest agent, and the escalating Mock → Docker → Firecracker → cluster backend tiers. - [Agent host](agent-host/index.md) — long-lived on-disk runtime (SOUL / RULES / MEMORY / USER / SKILL.md per agent) that gives an atomr-agents agent persistent identity, skills, hooks, schedules, and inbound channels. The `atomr-host` CLI does for atomr-agents what Claude Code does for the Claude model. - [Eval](eval.md) — eval suites, judge / pairwise / rubric scorers, regression gate. diff --git a/pyproject.toml b/pyproject.toml index dae8493..35d5a03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,9 @@ Changelog = "https://github.com/rustakka/atomr-agents/blob/main/CHANGELOG.md" [project.optional-dependencies] dev = ["pytest>=7", "pytest-asyncio>=0.23", "pyyaml>=6"] host = ["pyyaml>=6"] +# Claude Agent SDK harness. Pulls the SDK (which auto-bundles the `claude` +# CLI); the native extension imports it lazily, so it's an opt-in extra. +agent-sdk = ["claude-agent-sdk>=0.1"] [project.scripts] atomr-host = "atomr_agents.agent_host.cli:main" diff --git a/python/atomr_agents/__init__.py b/python/atomr_agents/__init__.py index 5071738..6695619 100644 --- a/python/atomr_agents/__init__.py +++ b/python/atomr_agents/__init__.py @@ -50,6 +50,15 @@ async def watch(): else: _import_err = None +# The agent_sdk facade is a *substantive* submodule (the claude-agent-sdk +# driver), not a thin native re-export — bind it unconditionally so it isn't +# shadowed by a native attribute, and so it stays importable when the native +# extension or the SDK is absent (it degrades gracefully on its own). +from . import agent_sdk # noqa: E402 + +AgentSdkHarness = getattr(agent_sdk, "AgentSdkHarness", None) +AgentSdkSession = getattr(agent_sdk, "AgentSdkSession", None) + if _native is not None: # ----- subpackages re-exported as attributes ------------------------ core = _native.core @@ -220,6 +229,7 @@ def _opt(_module, _name): "guest", "stt", "stt_harness", + "agent_sdk", "tts", "voice", "voice_extras", @@ -272,6 +282,9 @@ def _opt(_module, _name): "AgentSpec", "AgentBudgets", "TurnResult", + # agent-sdk + "AgentSdkHarness", + "AgentSdkSession", # workflow "StepKind", # harness diff --git a/python/atomr_agents/agent_sdk.py b/python/atomr_agents/agent_sdk.py new file mode 100644 index 0000000..90abc91 --- /dev/null +++ b/python/atomr_agents/agent_sdk.py @@ -0,0 +1,496 @@ +"""Claude Agent SDK harness — Python wrapper + facade. + +Wraps Anthropic's programmable Claude Code agent +(`claude-agent-sdk `_) so it runs +as an atomr-agents harness. The Rust harness +(:mod:`atomr_agents._native.agent_sdk`) drives this wrapper over the PyO3 +bridge; the wrapper builds ``ClaudeAgentOptions`` and normalizes SDK message +objects into plain dicts the Rust side can carry. + +Quick start:: + + import atomr_agents.agent_sdk as asdk + + # In-process atomr tool exposed to the agent as `mcp__atomr__add`. + async def add(args): + return {"content": [{"type": "text", "text": str(args["a"] + args["b"])}]} + + h = asdk.harness(tools=[add], spec={"default_permission_mode": "bypassPermissions"}) + result = await h.run({"prompt": "Use the add tool on 2 and 3", "cwd": "/repo", + "allowed_tools": ["mcp__atomr__add"]}) + print(result["result"], result["cost_usd"]) + +Interactive (the actor surface):: + + sess = await h.session({"cwd": "/repo"}) + await sess.query("/review the auth module") + async for ev in sess.events(): + print(ev["kind"]) + await sess.close() + +**Billing:** every run shells out to the bundled ``claude`` CLI and bills your +Anthropic API credits (``ANTHROPIC_API_KEY``, or ``CLAUDE_CODE_USE_BEDROCK`` / +``CLAUDE_CODE_USE_VERTEX``). claude.ai OAuth/subscription tokens are *not* +accepted by the Agent SDK. +""" + +from __future__ import annotations + +import inspect +import json +import uuid +from typing import Any, Callable, Iterable, Optional + +# ----- native bridge -------------------------------------------------------- + +try: + from . import _native as _native_pkg + + _guest = _native_pkg.guest + _agent_sdk_native = _native_pkg.agent_sdk +except (ImportError, AttributeError): # pragma: no cover - extension not built + _native_pkg = None + _guest = None + _agent_sdk_native = None + +# Facade re-exports of the native pyclasses. +AgentSdkHarness = getattr(_agent_sdk_native, "AgentSdkHarness", None) +AgentSdkSession = getattr(_agent_sdk_native, "AgentSdkSession", None) +AgentSdkEventStream = getattr(_agent_sdk_native, "AgentSdkEventStream", None) + +# ----- optional claude-agent-sdk import ------------------------------------- + +try: + import claude_agent_sdk as _sdk + from claude_agent_sdk import ( # noqa: F401 + ClaudeAgentOptions, + ClaudeSDKClient, + create_sdk_mcp_server, + ) + from claude_agent_sdk import tool as _sdk_tool + + _SDK_AVAILABLE = True + _SDK_IMPORT_ERR: Optional[Exception] = None +except Exception as _e: # noqa: BLE001 - any import-time failure is "not available" + _SDK_AVAILABLE = False + _SDK_IMPORT_ERR = _e + + +def _require_sdk() -> None: + if not _SDK_AVAILABLE: + raise RuntimeError( + "claude-agent-sdk is not installed. Install with " + "`pip install atomr-agents[agent-sdk]` (Python 3.10+) and set " + f"ANTHROPIC_API_KEY. Original import error: {_SDK_IMPORT_ERR!r}" + ) + + +__all__ = [ + "AgentSdkHarness", + "AgentSdkSession", + "AgentSdkEventStream", + "ClaudeAgentSDKBackend", + "agent_sdk_backend", + "harness", + "tools_to_sdk_server", + "sdk_available", +] + + +def sdk_available() -> bool: + """Whether `claude-agent-sdk` is importable in this environment.""" + return _SDK_AVAILABLE + + +# ----- message normalization ------------------------------------------------ +# +# Dispatch on the class *name* (not isinstance) so we don't couple to the SDK's +# exact import symbols, which shift across versions. The output dict shape must +# stay in lock step with the Rust `AgentSdkMessage` / `ContentBlock` serde. + + +def _jsonsafe(v: Any) -> Any: + try: + json.dumps(v) + return v + except (TypeError, ValueError): + return repr(v) + + +def _usage(u: Any) -> dict: + if u is None: + u = {} + get = (lambda k: u.get(k, 0)) if isinstance(u, dict) else (lambda k: getattr(u, k, 0)) + return { + "input_tokens": int(get("input_tokens") or 0), + "output_tokens": int(get("output_tokens") or 0), + "cache_creation_input_tokens": int(get("cache_creation_input_tokens") or 0), + "cache_read_input_tokens": int(get("cache_read_input_tokens") or 0), + } + + +def _block(b: Any) -> dict: + name = type(b).__name__ + if name == "TextBlock": + return {"kind": "text", "text": getattr(b, "text", "")} + if name == "ThinkingBlock": + return {"kind": "thinking", "text": getattr(b, "thinking", "") or getattr(b, "text", "")} + if name == "ToolUseBlock": + return { + "kind": "tool_use", + "id": getattr(b, "id", ""), + "name": getattr(b, "name", ""), + "input": _jsonsafe(getattr(b, "input", {})), + } + if name == "ToolResultBlock": + return { + "kind": "tool_result", + "tool_use_id": getattr(b, "tool_use_id", ""), + "content": _jsonsafe(getattr(b, "content", None)), + "is_error": bool(getattr(b, "is_error", False)), + } + # Unknown block: surface its text if any. + return {"kind": "text", "text": str(getattr(b, "text", b))} + + +def _normalize(msg: Any) -> dict: + name = type(msg).__name__ + if name == "SystemMessage": + data = getattr(msg, "data", None) or {} + sid = getattr(msg, "session_id", None) + if sid is None and isinstance(data, dict): + sid = data.get("session_id") + tools = data.get("tools", []) if isinstance(data, dict) else [] + mcps = data.get("mcp_servers", []) if isinstance(data, dict) else [] + if mcps and isinstance(mcps[0], dict): + mcps = [m.get("name", "") for m in mcps] + return { + "type": "system", + "subtype": getattr(msg, "subtype", None), + "session_id": sid, + "model": getattr(msg, "model", None), + "tools": [t if isinstance(t, str) else t.get("name", "") for t in tools], + "mcp_servers": mcps, + } + if name == "AssistantMessage": + return {"type": "assistant", "blocks": [_block(b) for b in getattr(msg, "content", [])]} + if name == "ResultMessage": + return { + "type": "result", + "subtype": getattr(msg, "subtype", ""), + "result": getattr(msg, "result", None), + "session_id": getattr(msg, "session_id", None), + "num_turns": int(getattr(msg, "num_turns", 0) or 0), + "cost_usd": getattr(msg, "total_cost_usd", None), + "usage": _usage(getattr(msg, "usage", None)), + } + return {"type": "unknown", "repr": repr(msg)} + + +# ----- options construction ------------------------------------------------- + +_SDK_OPTION_KEYS = ( + "system_prompt", + "allowed_tools", + "disallowed_tools", + "permission_mode", + "cwd", + "add_dirs", + "env", + "setting_sources", + "model", + "fallback_model", + "max_turns", + "continue_conversation", + "resume", + "fork_session", + "include_partial_messages", + "effort", + "thinking", +) + + +def _filter_kwargs(cls: Any, kw: dict) -> dict: + """Drop kwargs the installed SDK's options object doesn't accept, so the + wrapper survives version skew without a 400 at construction.""" + try: + params = inspect.signature(cls).parameters + except (TypeError, ValueError): + return kw + # VAR_KEYWORD (**kwargs) means everything is accepted. + if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()): + return kw + return {k: v for k, v in kw.items() if k in params} + + +def _external_mcp(spec: Any) -> Optional[dict]: + """Translate an atomr `McpServerConfig` dict into the SDK `mcp_servers` + value. Returns `None` for in-process markers (those are injected here).""" + if not isinstance(spec, dict): + return spec + transport = spec.get("transport") + if transport == "stdio": + return {"command": spec.get("command"), "args": spec.get("args", []), "env": spec.get("env", {})} + if transport == "sse": + return {"type": "sse", "url": spec.get("url"), "headers": spec.get("headers", {})} + if transport == "http": + return {"type": "http", "url": spec.get("url"), "headers": spec.get("headers", {})} + if transport == "in_process": + return None + return spec + + +def _agents_from_config(agents: dict) -> dict: + """Map atomr `SubagentDef` dicts to SDK `AgentDefinition` objects (or pass + the dicts straight through if the SDK accepts dicts).""" + AgentDefinition = getattr(_sdk, "AgentDefinition", None) + if AgentDefinition is None: + return agents + out = {} + for name, d in agents.items(): + out[name] = AgentDefinition( + description=d.get("description", ""), + prompt=d.get("prompt", ""), + tools=d.get("tools") or None, + model=d.get("model"), + ) + return out + + +def _policy_to_callback(policy: dict) -> Callable: + """Turn an atomr `PermissionPolicy` data descriptor into a `can_use_tool` + callback.""" + mode = (policy or {}).get("mode", "allow_all") + allow = set((policy or {}).get("tools", [])) + + def _result(allowed: bool, tool_name: str): + allow_cls = getattr(_sdk, "PermissionResultAllow", None) + deny_cls = getattr(_sdk, "PermissionResultDeny", None) + if allowed: + return allow_cls() if allow_cls else {"behavior": "allow"} + return deny_cls(message=f"denied by atomr policy: {tool_name}") if deny_cls else {"behavior": "deny"} + + async def can_use(tool_name, tool_input, context): # noqa: ANN001 + if mode == "allow_all": + return _result(True, tool_name) + if mode == "deny_all": + return _result(False, tool_name) + if mode == "allow_list": + return _result(tool_name in allow, tool_name) + return _result(True, tool_name) # "ask" → defer to SDK default + + return can_use + + +# ----- atomr tools → in-process SDK MCP server ------------------------------ + + +def _wrap_callable_tool(t: Any): + """Wrap a plain Python callable / class as an SDK `@tool`.""" + name = getattr(t, "__name__", "tool") + doc = (getattr(t, "__doc__", None) or name).strip() + schema = getattr(t, "__atomr_tool_schema__", {}) + + @_sdk_tool(name, doc, schema) + async def _adapter(args, _t=t): # noqa: ANN001 + inst = _t() if isinstance(_t, type) else _t + fn = getattr(inst, "invoke", None) or inst + res = fn(args, {}) if _takes_two(fn) else fn(args) + if inspect.iscoroutine(res): + res = await res + if isinstance(res, dict) and "content" in res: + return res + return {"content": [{"type": "text", "text": json.dumps(res)}]} + + return _adapter + + +def _wrap_key_tool(key: str): + """Wrap a registered atomr tool key as an SDK `@tool`, executed through the + Rust `Tool` machinery via the native `invoke_tool` bridge.""" + if _agent_sdk_native is None: + raise RuntimeError("native extension not built; cannot bridge atomr tool keys") + + @_sdk_tool(key, f"atomr tool {key}", {}) + async def _adapter(args, _key=key): # noqa: ANN001 + result = await _agent_sdk_native.invoke_tool(_key, args) + if isinstance(result, dict) and "content" in result: + return result + return {"content": [{"type": "text", "text": json.dumps(result)}]} + + return _adapter + + +def _takes_two(fn: Any) -> bool: + try: + params = [ + p + for p in inspect.signature(fn).parameters.values() + if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) + ] + return len(params) >= 2 + except (TypeError, ValueError): + return False + + +def tools_to_sdk_server(tools: Iterable[Any], *, server_name: str = "atomr", version: str = "0.1.0"): + """Build an in-process SDK MCP server from a mix of atomr tool keys + (``str``) and Python callables/classes. Each becomes ``mcp____``. + """ + _require_sdk() + sdk_tools = [] + for t in tools: + if isinstance(t, str): + sdk_tools.append(_wrap_key_tool(t)) + elif callable(t) or isinstance(t, type): + sdk_tools.append(_wrap_callable_tool(t)) + else: + # Assume it's already an SDK tool object. + sdk_tools.append(t) + return create_sdk_mcp_server(name=server_name, version=version, tools=sdk_tools) + + +# ----- the wrapper backend -------------------------------------------------- + + +class ClaudeAgentSDKBackend: + """Drives `claude-agent-sdk`. Registered under the ``agent_sdk`` guest kind; + the Rust `PythonAgentSdkBackend` calls these methods over the PyO3 bridge. + """ + + def __init__( + self, + *, + tools: Optional[Iterable[Any]] = None, + can_use_tool: Optional[Callable] = None, + hooks: Optional[Any] = None, + agents: Optional[Any] = None, + server_name: str = "atomr", + ) -> None: + self._tools = list(tools) if tools else [] + self._can_use_tool = can_use_tool + self._hooks = hooks + self._agents = agents + self._server_name = server_name + self._sessions: dict[str, Any] = {} + + # -- options -------------------------------------------------------------- + + def _build_options(self, config: dict): + _require_sdk() + config = config or {} + kw = {k: config[k] for k in _SDK_OPTION_KEYS if config.get(k) is not None} + + if self._can_use_tool is not None: + kw["can_use_tool"] = self._can_use_tool + elif config.get("permission_policy"): + kw["can_use_tool"] = _policy_to_callback(config["permission_policy"]) + + if self._hooks is not None: + kw["hooks"] = self._hooks + if self._agents is not None: + kw["agents"] = self._agents + elif config.get("agents"): + kw["agents"] = _agents_from_config(config["agents"]) + + mcp: dict[str, Any] = {} + for name, spec in (config.get("mcp_servers") or {}).items(): + ext = _external_mcp(spec) + if ext is not None: + mcp[name] = ext + if self._tools: + mcp[self._server_name] = tools_to_sdk_server(self._tools, server_name=self._server_name) + if mcp: + kw["mcp_servers"] = mcp + + return ClaudeAgentOptions(**_filter_kwargs(ClaudeAgentOptions, kw)) + + # -- one-shot query ------------------------------------------------------- + + async def run(self, config: dict, prompt: str): + _require_sdk() + options = self._build_options(config) + async for msg in _sdk.query(prompt=prompt, options=options): + yield _normalize(msg) + + # -- interactive session -------------------------------------------------- + + async def open_session(self, config: dict) -> str: + _require_sdk() + client = ClaudeSDKClient(options=self._build_options(config)) + await client.connect() + token = uuid.uuid4().hex + self._sessions[token] = client + return token + + async def session_send(self, token: str, prompt: str) -> None: + await self._sessions[token].query(prompt) + + async def session_stream(self, token: str): + async for msg in self._sessions[token].receive_response(): + yield _normalize(msg) + + async def session_interrupt(self, token: str) -> None: + await self._sessions[token].interrupt() + + async def session_set_mode(self, token: str, mode: str) -> None: + fn = getattr(self._sessions[token], "set_permission_mode", None) + if fn is None: + raise RuntimeError("this claude-agent-sdk version has no set_permission_mode") + await fn(mode) + + async def session_set_model(self, token: str, model: str) -> None: + fn = getattr(self._sessions[token], "set_model", None) + if fn is None: + raise RuntimeError("this claude-agent-sdk version has no set_model") + await fn(model) + + async def session_close(self, token: str) -> None: + client = self._sessions.pop(token, None) + if client is not None: + await client.disconnect() + + +# ----- registration + convenience builders ---------------------------------- + + +def agent_sdk_backend( + name: str = "default", + *, + tools: Optional[Iterable[Any]] = None, + can_use_tool: Optional[Callable] = None, + hooks: Optional[Any] = None, + agents: Optional[Any] = None, +) -> ClaudeAgentSDKBackend: + """Create and register a :class:`ClaudeAgentSDKBackend` under *name* in the + process-wide guest registry. Reference it via + ``AgentSdkHarness.from_python_backend(name)``. + """ + backend = ClaudeAgentSDKBackend( + tools=tools, can_use_tool=can_use_tool, hooks=hooks, agents=agents + ) + if _guest is not None: + _guest.register_agent_sdk_factory(name, backend) + return backend + + +def harness( + *, + name: str = "default", + spec: Optional[dict] = None, + tools: Optional[Iterable[Any]] = None, + can_use_tool: Optional[Callable] = None, + hooks: Optional[Any] = None, + agents: Optional[Any] = None, +): + """One-liner: register a backend and build the native harness over it. + + Returns an :class:`atomr_agents._native.agent_sdk.AgentSdkHarness`. + """ + if AgentSdkHarness is None: + raise RuntimeError( + "native extension not built — run `maturin develop` or " + "`pip install -e .[agent-sdk]`" + ) + agent_sdk_backend(name, tools=tools, can_use_tool=can_use_tool, hooks=hooks, agents=agents) + return AgentSdkHarness.from_python_backend(name, spec) diff --git a/python/atomr_agents/guest.py b/python/atomr_agents/guest.py index f702478..3419496 100644 --- a/python/atomr_agents/guest.py +++ b/python/atomr_agents/guest.py @@ -105,6 +105,7 @@ def _register(kind: str, key: str, target: Any) -> Any: "persona_reconciler": _guest.register_persona_reconciler_factory, "inference_client": _guest.register_inference_client_factory, "ann_index": _guest.register_ann_index_factory, + "agent_sdk": _guest.register_agent_sdk_factory, } if kind in handle_fn: handle = handle_fn[kind](key, target) diff --git a/python/atomr_agents/tests/test_agent_sdk.py b/python/atomr_agents/tests/test_agent_sdk.py new file mode 100644 index 0000000..42e4abc --- /dev/null +++ b/python/atomr_agents/tests/test_agent_sdk.py @@ -0,0 +1,160 @@ +"""Tests for the Claude Agent SDK harness Python surface. + +Three tiers: + +* **Pure-logic** — exercise the wrapper's normalization / options helpers with + no native extension and no `claude-agent-sdk`. Always run. +* **Native** — `AgentSdkHarness.mock()` round-trips through the Rust harness + over `MockBackend`. Skipped when the native extension isn't built. +* **Live** — drives the real `claude-agent-sdk` against an in-process atomr + tool. Skipped unless `ANTHROPIC_API_KEY`, the `claude` CLI, and the SDK are + all present. **Uses real Anthropic credits** — kept tiny. +""" + +from __future__ import annotations + +import os +import shutil + +import pytest + +from atomr_agents import agent_sdk as asdk + +# ----- tier 1: pure logic (no native, no SDK) ------------------------------- + + +class SystemMessage: # noqa: D401 - fake SDK message, matched by class name + def __init__(self): + self.subtype = "init" + self.session_id = "sess-1" + self.model = "claude-opus-4-8" + self.data = {"tools": ["Read", "Bash"], "mcp_servers": [{"name": "atomr"}]} + + +class TextBlock: + def __init__(self, text): + self.text = text + + +class ToolUseBlock: + def __init__(self): + self.id = "tu-1" + self.name = "add" + self.input = {"a": 2, "b": 3} + + +class AssistantMessage: + def __init__(self): + self.content = [TextBlock("hello"), ToolUseBlock()] + + +class ResultMessage: + def __init__(self): + self.subtype = "success" + self.result = "done" + self.session_id = "sess-1" + self.num_turns = 2 + self.total_cost_usd = 0.01 + self.usage = {"input_tokens": 10, "output_tokens": 5} + + +def test_normalize_system(): + n = asdk._normalize(SystemMessage()) + assert n["type"] == "system" + assert n["session_id"] == "sess-1" + assert n["tools"] == ["Read", "Bash"] + assert n["mcp_servers"] == ["atomr"] + + +def test_normalize_assistant_blocks(): + n = asdk._normalize(AssistantMessage()) + assert n["type"] == "assistant" + kinds = [b["kind"] for b in n["blocks"]] + assert kinds == ["text", "tool_use"] + assert n["blocks"][1]["name"] == "add" + assert n["blocks"][1]["input"] == {"a": 2, "b": 3} + + +def test_normalize_result(): + n = asdk._normalize(ResultMessage()) + assert n["type"] == "result" + assert n["subtype"] == "success" + assert n["num_turns"] == 2 + assert n["cost_usd"] == 0.01 + assert n["usage"]["input_tokens"] == 10 + + +def test_external_mcp_translation(): + stdio = asdk._external_mcp({"transport": "stdio", "command": "npx", "args": ["-y"]}) + assert stdio == {"command": "npx", "args": ["-y"], "env": {}} + assert asdk._external_mcp({"transport": "in_process", "name": "atomr"}) is None + sse = asdk._external_mcp({"transport": "sse", "url": "https://x/y"}) + assert sse["type"] == "sse" + + +def test_jsonsafe_falls_back_to_repr(): + assert asdk._jsonsafe({"a": 1}) == {"a": 1} + obj = object() + assert isinstance(asdk._jsonsafe(obj), str) + + +def test_usage_handles_dict_and_object(): + assert asdk._usage({"input_tokens": 3})["input_tokens"] == 3 + assert asdk._usage(None)["output_tokens"] == 0 + + +# ----- tier 2: native (mock backend) ---------------------------------------- + +_NATIVE = asdk.AgentSdkHarness is not None +native_only = pytest.mark.skipif(not _NATIVE, reason="native extension not built") + + +@native_only +async def test_mock_harness_run(): + h = asdk.AgentSdkHarness.mock() + assert h.backend_name == "mock" + result = await h.run({"prompt": "ping"}) + assert result["subtype"] == "success" + + +@native_only +async def test_mock_session_lifecycle(): + h = asdk.AgentSdkHarness.mock() + sess = await h.session({}) + assert sess.session_id + assert h.live_count() == 1 + await sess.close() + assert h.live_count() == 0 + + +# ----- tier 3: live SDK (real credits) -------------------------------------- + +_LIVE = ( + _NATIVE + and asdk.sdk_available() + and bool(os.environ.get("ANTHROPIC_API_KEY")) + and shutil.which("claude") is not None +) +live_only = pytest.mark.skipif( + not _LIVE, reason="needs ANTHROPIC_API_KEY + `claude` CLI + claude-agent-sdk" +) + + +@live_only +async def test_live_in_process_tool(tmp_path): + """Drive the real SDK against an in-process atomr tool. Tiny + budgeted.""" + + async def add(args): + return {"content": [{"type": "text", "text": str(args["a"] + args["b"])}]} + + h = asdk.harness(tools=[add], spec={"default_max_turns": 2}) + result = await h.run( + { + "prompt": "Use the add tool to compute 2 + 3 and report only the number.", + "cwd": str(tmp_path), + "allowed_tools": ["mcp__atomr__add"], + "max_cost_usd": 0.10, + } + ) + assert result["is_error"] is False + assert result.get("cost_usd") is not None