diff --git a/Cargo.lock b/Cargo.lock index 2b74df92..461ac317 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3305,6 +3305,7 @@ dependencies = [ "futures-util", "infinity-provider-protocol", "insta", + "libc", "rap-client", "rap-protocol", "rhai", diff --git a/crates/infinity-agent-core/Cargo.toml b/crates/infinity-agent-core/Cargo.toml index 429c58f2..60836517 100644 --- a/crates/infinity-agent-core/Cargo.toml +++ b/crates/infinity-agent-core/Cargo.toml @@ -25,6 +25,7 @@ rhai = { workspace = true } [dev-dependencies] insta = { version = "1", features = ["json", "redactions"] } +libc = "0.2" rig-mock = { path = "../rig-mock" } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } futures-util = { workspace = true, features = ["sink"] } diff --git a/crates/infinity-agent-core/examples/agent_scale.rs b/crates/infinity-agent-core/examples/agent_scale.rs new file mode 100644 index 00000000..7db5d626 --- /dev/null +++ b/crates/infinity-agent-core/examples/agent_scale.rs @@ -0,0 +1,342 @@ +//! Memory-scaling benchmark for the local agent runtime. +//! +//! Launches `AGENTS` agents on one `LocalAgentSystem` and runs each of them +//! through `TURNS` synthetic turns against an in-process scripted model. Every +//! turn is a full runtime round trip: the user message enters through the +//! input queue, the model streams a ~1 KB completion that calls a tool, the +//! tool result comes back through the queue, and a second completion closes +//! the turn. After each wave of agents finishes its turns, the process RSS is +//! sampled — at that point every agent is idle, so the measurement reflects +//! what a resident agent actually costs: its conversation history in the +//! stores, and nothing else (no task, no stack, no connection). +//! +//! Run with: +//! +//! ```sh +//! cargo run --release -p infinity-agent-core --example agent_scale +//! ``` +//! +//! Environment variables: `AGENTS` (default 10000), `TURNS` (default 20), +//! `WAVE` (default 500). Prints a CSV of `agents,rss_bytes` to stdout. + +use std::cell::Cell; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Instant; + +use async_trait::async_trait; +use rig::OneOrMany; +use rig::completion::{CompletionError, CompletionRequest, CompletionResponse, Usage}; +use rig::message::{AssistantContent, Message, ToolResult, ToolResultContent, UserContent}; +use rig::streaming::{ + RawStreamingChoice, RawStreamingToolCall, StreamingCompletionResponse, StreamingResult, +}; + +use infinity_agent_core::message::{InputMessage, InputMessageContent}; +use infinity_agent_core::stores::{InMemoryConversationStore, InMemoryStateStore}; +use infinity_agent_core::system::local::ChannelSender; +use infinity_agent_core::system::{ + AgentEvent, AgentSystemBuilder, ReplaySnapshot, StaticModel, ThreadObserver, +}; +use infinity_agent_core::tools::{Tool, ToolContext}; +use infinity_agent_core::traits::InputSender; +use infinity_provider_protocol::{ModelEntry, SingleModelProvider}; +use rig_mock::MockStreamingResponse; + +/// ~100 characters of assistant text per streamed chunk. +const CHUNK_TEXT: &str = "Reviewed the module and updated the failing case; the assertion now covers the boundary path. "; +/// Chunks per completion (~1.2 KB of assistant text each round). +const CHUNKS_PER_COMPLETION: usize = 12; + +// ── Scripted model ── +// +// A self-driving stand-in for a model provider: every `stream()` call +// immediately streams a fixed-shape response through the runtime's real +// streaming pipeline. If the last history message is a tool result, the +// response is text that ends the turn; otherwise it is text plus a tool +// call, so each turn exercises two completion rounds and one tool dispatch. + +#[derive(Clone)] +struct ScriptedModel; + +impl rig::completion::CompletionModel for ScriptedModel { + type Response = serde_json::Value; + type StreamingResponse = MockStreamingResponse; + type Client = (); + + fn make(_client: &Self::Client, _model: impl Into) -> Self { + panic!("bug: construct ScriptedModel directly"); + } + + async fn completion( + &self, + _request: CompletionRequest, + ) -> Result, CompletionError> { + Ok(CompletionResponse { + choice: OneOrMany::one(AssistantContent::text("")), + usage: Usage::new(), + raw_response: serde_json::Value::Null, + message_id: None, + }) + } + + async fn stream( + &self, + request: CompletionRequest, + ) -> Result, CompletionError> { + let after_tool_result = matches!( + request.chat_history.last(), + Message::User { content } if content + .iter() + .any(|c| matches!(c, UserContent::ToolResult(_))) + ); + + let mut chunks: Vec, CompletionError>> = + Vec::with_capacity(CHUNKS_PER_COMPLETION + 2); + for _ in 0..CHUNKS_PER_COMPLETION { + chunks.push(Ok(RawStreamingChoice::Message(CHUNK_TEXT.to_owned()))); + } + if !after_tool_result { + chunks.push(Ok(RawStreamingChoice::ToolCall(RawStreamingToolCall::new( + uuid::Uuid::new_v4().to_string(), + "run_command".to_owned(), + serde_json::json!({"command": "cargo test --workspace"}), + )))); + } + chunks.push(Ok(RawStreamingChoice::FinalResponse( + MockStreamingResponse { + usage: Some(Usage::new()), + }, + ))); + + let pinned: StreamingResult = + Box::pin(futures_util::stream::iter(chunks)); + Ok(StreamingCompletionResponse::stream(pinned)) + } +} + +// ── Synthetic async tool ── +// +// Delivers its result back through the input queue, like every real +// asynchronous tool: the slice that dispatched the call ends, and the result +// starts a new one. + +struct RunCommand; + +#[async_trait] +impl Tool for RunCommand { + fn name(&self) -> &str { + "run_command" + } + + fn description(&self) -> &str { + "Run a shell command." + } + + fn parameters(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"] + }) + } + + async fn execute( + &self, + _args: serde_json::Value, + id: String, + call_id: Option, + context: &ToolContext, + ) -> Result<(), Box> { + let result = InputMessage { + content: InputMessageContent::User(UserContent::ToolResult(ToolResult { + id, + call_id, + content: OneOrMany::one(ToolResultContent::Text(rig::agent::Text { + text: "test result: ok. 148 passed; 0 failed; 3 ignored; finished in 21.38s" + .repeat(5), + })), + })), + group_id: context.group_id.clone(), + metadata: None, + synthetic: None, + display_as: None, + subscription: false, + }; + context + .message_sender + .send_to_input_queue(result, &uuid::Uuid::new_v4().to_string()) + .await?; + Ok(()) + } +} + +// ── Completion-counting observer ── + +struct CountObserver { + finished: Rc>, +} + +#[async_trait(?Send)] +impl ThreadObserver for CountObserver { + type SubscribeRequest = (); + + fn on_event(&self, _thread_id: &str, event: &AgentEvent) { + if matches!(event, AgentEvent::CompletionFinished { .. }) { + self.finished.set(self.finished.get() + 1); + } + } + + fn on_subscribe(&self, _thread_id: &str, _request: (), _snapshot: ReplaySnapshot) {} +} + +// ── Measurement helpers ── + +fn rss_bytes() -> u64 { + let status = std::fs::read_to_string("/proc/self/status").expect("read /proc/self/status"); + let line = status + .lines() + .find(|l| l.starts_with("VmRSS:")) + .expect("VmRSS line in /proc/self/status"); + let kb: u64 = line + .trim_start_matches("VmRSS:") + .trim() + .trim_end_matches("kB") + .trim() + .parse() + .expect("parse VmRSS value"); + kb * 1024 +} + +/// Return freed allocator caches to the OS so RSS reflects live data. +fn trim_allocator() { + #[cfg(target_os = "linux")] + unsafe { + libc::malloc_trim(0); + } +} + +fn env_usize(name: &str, default: usize) -> usize { + match std::env::var(name) { + Ok(v) => v.parse().expect("numeric environment variable"), + Err(_) => default, + } +} + +fn user_text(thread_id: &str, turn: usize) -> InputMessage { + InputMessage { + content: InputMessageContent::User(UserContent::text(format!( + "Turn {turn}: re-run the affected tests for {thread_id} and summarize any failures \ + along with the modules they belong to." + ))), + group_id: thread_id.to_owned(), + metadata: None, + synthetic: None, + display_as: None, + subscription: false, + } +} + +async fn wait_for(finished: &Rc>, expected: u64) { + let mut last = finished.get(); + let mut stalled_for = 0u32; + while finished.get() < expected { + tokio::time::sleep(std::time::Duration::from_millis(2)).await; + let now = finished.get(); + if now == last { + stalled_for += 1; + assert!( + stalled_for < 15_000, + "bug: stalled at {now}/{expected} completions" + ); + } else { + stalled_for = 0; + last = now; + } + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let agents = env_usize("AGENTS", 10_000); + let turns = env_usize("TURNS", 20); + let wave = env_usize("WAVE", 500); + + tokio::task::LocalSet::new() + .run_until(async move { + let entry = ModelEntry { + model_id: "scripted".to_owned(), + display_name: "scripted".to_owned(), + // Disables the auto-compaction threshold so history growth + // stays deterministic across the run. + context_window: 0, + max_output_tokens: None, + supports_image_input: false, + }; + let provider = Arc::new(SingleModelProvider::new(entry.clone(), ScriptedModel)); + let model = StaticModel::from_entry(provider, &entry); + + let finished = Rc::new(Cell::new(0u64)); + let observer_count = finished.clone(); + let running = AgentSystemBuilder::new_local( + InMemoryConversationStore::new(), + InMemoryStateStore::new(), + model, + ) + .tool(Box::new(RunCommand)) + .start_with_observer(move |_| CountObserver { + finished: observer_count.clone(), + }); + let sender = running.sender(); + + trim_allocator(); + println!("agents,rss_bytes"); + println!("0,{}", rss_bytes()); + + let start = Instant::now(); + let mut launched = 0usize; + let mut expected = 0u64; + while launched < agents { + let batch = wave.min(agents - launched); + let ids: Vec = (launched..launched + batch) + .map(|i| format!("agent-{i}")) + .collect(); + + // Drive the whole wave turn by turn: each turn is two + // completion rounds (tool call + follow-up after its result). + for turn in 0..turns { + for id in &ids { + sender + .send_to_input_queue( + user_text(id, turn), + &uuid::Uuid::new_v4().to_string(), + ) + .await + .expect("send user text"); + } + expected += (batch * 2) as u64; + wait_for(&finished, expected).await; + } + + launched += batch; + // Let idle drivers finish exiting before sampling. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + trim_allocator(); + println!("{launched},{}", rss_bytes()); + } + + let elapsed = start.elapsed(); + let total = rss_bytes(); + eprintln!( + "{agents} agents x {turns} turns ({} completions) in {:.1}s; \ + rss {:.1} MiB, {:.1} KiB per agent", + expected, + elapsed.as_secs_f64(), + total as f64 / (1024.0 * 1024.0), + total as f64 / 1024.0 / agents as f64, + ); + + running.shutdown().await; + }) + .await; +} diff --git a/crates/infinity-agent-core/src/system/builder.rs b/crates/infinity-agent-core/src/system/builder.rs index 24d23e53..cd1e44c3 100644 --- a/crates/infinity-agent-core/src/system/builder.rs +++ b/crates/infinity-agent-core/src/system/builder.rs @@ -14,7 +14,9 @@ use rap_client::notifier::RapNotifier; use super::config::{StaticThreadConfig, ThreadConfigSource}; use super::defer::DeferQueue; use super::local::ChannelSender; -use super::local::{LaunchRegistry, UnionConfigSource, UnionModelSource}; +use super::local::{ + LaunchRegistry, LaunchingSystem, RunningSystem, UnionConfigSource, UnionModelSource, +}; use super::model::ModelSource; use super::observer::ThreadObserver; use super::thread::{StepOutcome, Thread}; @@ -82,7 +84,7 @@ where /// API**: the platform delivers each thread's message batches and calls /// [`AgentSystem::step`] per batch. /// - [`AgentSystemBuilder::new_local`] creates an internal in-process queue -/// ([`ChannelSender`]). [`LocalAgentSystem::start`] then runs the full +/// ([`ChannelSender`]). [`start`](Self::start) then runs the full /// actor-system-style driver: a router that spawns one worker per thread, /// batches inputs, handles interruption, deferral, idling, and /// auto-compaction. @@ -341,9 +343,11 @@ where S: StateStore + 'static, H: HttpClient + 'static, { - /// Build a local system ready to run with either the built-in - /// [`ThreadBuilder`](super::local::ThreadBuilder) convenience API or a - /// custom [`ThreadObserver`]. Requires construction via + /// Build a local system without starting it. Most embeddings can call + /// [`start`](Self::start) or + /// [`start_with_observer`](Self::start_with_observer) directly; use this + /// two-phase form when the built [`LocalAgentSystem`] must be held before + /// running it. Requires construction via /// [`new_local`](AgentSystemBuilder::new_local). pub fn build_local(mut self) -> LocalAgentSystem { let registry = LaunchRegistry::default(); @@ -358,6 +362,31 @@ where registry, } } + + /// Build the local system and start it with the built-in thread-builder + /// API: create threads through [`LaunchingSystem::thread_builder`], and + /// reattach to existing threads with [`LaunchingSystem::thread_handle`]. + /// + /// Shorthand for [`build_local()`](Self::build_local) followed by + /// [`LocalAgentSystem::start`]. Requires construction via + /// [`new_local`](AgentSystemBuilder::new_local). + pub fn start(self) -> LaunchingSystem { + self.build_local().start() + } + + /// Build the local system and start it with a custom [`ThreadObserver`]. + /// + /// Shorthand for [`build_local()`](Self::build_local) followed by + /// [`LocalAgentSystem::start_with_observer`], which documents the + /// observer contract. Requires construction via + /// [`new_local`](AgentSystemBuilder::new_local). + pub fn start_with_observer(self, make_observer: F) -> RunningSystem + where + O: ThreadObserver + 'static, + F: Fn(&str) -> O + 'static, + { + self.build_local().start_with_observer(make_observer) + } } /// A configured system of agents sharing stores, tools, and a model source. diff --git a/crates/infinity-agent-core/src/system/mod.rs b/crates/infinity-agent-core/src/system/mod.rs index 08bd3ca4..e0b4428c 100644 --- a/crates/infinity-agent-core/src/system/mod.rs +++ b/crates/infinity-agent-core/src/system/mod.rs @@ -14,10 +14,9 @@ //! model, //! ) //! .tools(my_tools) -//! .build_local(); +//! .start(); //! -//! let running = system.start(); -//! let mut thread = running.thread_builder().launch().await; +//! let mut thread = system.thread_builder().launch().await; //! thread.send_user_text("hello!").await?; //! while let Some(event) = thread.recv().await { /* ... */ } //! ``` diff --git a/crates/infinity-agent-core/src/system/test_support.rs b/crates/infinity-agent-core/src/system/test_support.rs index 80d22150..2b3189e1 100644 --- a/crates/infinity-agent-core/src/system/test_support.rs +++ b/crates/infinity-agent-core/src/system/test_support.rs @@ -213,9 +213,8 @@ pub(crate) fn start_system_with( if !builtin_tools { builder = builder.without_builtin_tools(); } - let system = builder.build_local(); let (tx, rx) = mpsc::unbounded_channel(); - let running = system.start_with_observer(move |_thread_id| TestObserver { tx: tx.clone() }); + let running = builder.start_with_observer(move |_thread_id| TestObserver { tx: tx.clone() }); (running, rx, ctrl, conv) } @@ -425,7 +424,6 @@ pub(crate) fn start_launcher_system( let system = AgentSystemBuilder::new_local(conv, state, model) .tools(tools) .extra_system_prompt(extra_system_prompt) - .build_local() .start(); (system, ctrl) } diff --git a/crates/infinity-agent-core/src/system/tests.rs b/crates/infinity-agent-core/src/system/tests.rs index 81c20a22..f44b0e98 100644 --- a/crates/infinity-agent-core/src/system/tests.rs +++ b/crates/infinity-agent-core/src/system/tests.rs @@ -86,12 +86,10 @@ async fn pending_choices_are_thread_local_and_replayed() { .ensure_root_thread("t1") .await .expect("ensure replay root"); - let local_system = - AgentSystemBuilder::new_local(replay_conv, state, model_source(None).0) - .build_local(); - let running = local_system.start_with_observer(|_| TestObserver { - tx: mpsc::unbounded_channel().0, - }); + let running = AgentSystemBuilder::new_local(replay_conv, state, model_source(None).0) + .start_with_observer(|_| TestObserver { + tx: mpsc::unbounded_channel().0, + }); running.subscribe("t1", sub_tx).await; let Evt::Replay(snapshot) = next_evt(&mut sub_rx).await else { panic!("expected replay"); diff --git a/docs/docs/infinity-runtime/agent-systems/building-a-system.md b/docs/docs/infinity-runtime/agent-systems/building-a-system.md index 3e5602ba..471a74cb 100644 --- a/docs/docs/infinity-runtime/agent-systems/building-a-system.md +++ b/docs/docs/infinity-runtime/agent-systems/building-a-system.md @@ -23,7 +23,6 @@ async fn main() -> Result<(), Box> { model, ) .with_tokio_sleep_tools() - .build_local() .start(); let mut thread = system.thread_builder().launch().await; diff --git a/docs/docs/infinity-runtime/agent-systems/customizing-the-engine.md b/docs/docs/infinity-runtime/agent-systems/customizing-the-engine.md index cf61ead7..4b9284b9 100644 --- a/docs/docs/infinity-runtime/agent-systems/customizing-the-engine.md +++ b/docs/docs/infinity-runtime/agent-systems/customizing-the-engine.md @@ -29,7 +29,7 @@ let system = AgentSystemBuilder::new_local( RedisStateStore::connect(&redis_url).await?, model, ) -.build_local(); +.start(); ``` Store methods define the runtime's ordering, deduplication, and thread-tree contract. Implement both traits directly when adding a persistence provider, and test interrupted turns, duplicate inputs, child threads, compaction, and active subscriptions. The [platform traits](../low-level/overview.md#the-platform-traits) document the complete interfaces. diff --git a/docs/docs/infinity-runtime/agent-systems/dynamic-configuration.md b/docs/docs/infinity-runtime/agent-systems/dynamic-configuration.md index e7e6e9b8..af78bc60 100644 --- a/docs/docs/infinity-runtime/agent-systems/dynamic-configuration.md +++ b/docs/docs/infinity-runtime/agent-systems/dynamic-configuration.md @@ -97,7 +97,7 @@ let system = AgentSystemBuilder::new_local( conversations: conversation_store, tenants, }) -.build_local(); +.start(); ``` :::note diff --git a/docs/docs/infinity-runtime/agent-systems/mcp-servers.md b/docs/docs/infinity-runtime/agent-systems/mcp-servers.md index f8c6f748..61c0b9bd 100644 --- a/docs/docs/infinity-runtime/agent-systems/mcp-servers.md +++ b/docs/docs/infinity-runtime/agent-systems/mcp-servers.md @@ -27,7 +27,6 @@ let filesystem = McpToolSet::stdio( let system = AgentSystemBuilder::new_local(conversation_store, state_store, model) .tools(filesystem.tools()) - .build_local() .start(); ``` @@ -63,7 +62,7 @@ let github = McpToolSet::http( let system = AgentSystemBuilder::new_local(conversation_store, state_store, model) .tools(github.tools()) - .build_local(); + .start(); ``` The client retains the MCP session ID returned by the server and sends it on later requests. Store credentials in transport headers rather than prompts or tool arguments. diff --git a/docs/docs/infinity-runtime/agent-systems/overview.md b/docs/docs/infinity-runtime/agent-systems/overview.md index 605f9872..c7db9d98 100644 --- a/docs/docs/infinity-runtime/agent-systems/overview.md +++ b/docs/docs/infinity-runtime/agent-systems/overview.md @@ -23,7 +23,7 @@ The builder constructor selects who schedules slices. ```rust let system = AgentSystemBuilder::new_local(conversation_store, state_store, model) .tools(shared_tools) - .build_local(); + .start(); ``` Use local mode for a daemon, desktop application, or service that stays alive. User text interrupts an in-progress completion, active threads compact automatically, and an idle thread releases its driver until another message arrives. [Launch Local Threads](./running-locally.md) covers the `thread_builder()` and `ThreadHandle` APIs. diff --git a/docs/docs/infinity-runtime/agent-systems/rap-servers.md b/docs/docs/infinity-runtime/agent-systems/rap-servers.md index a8c85b68..24ad3d91 100644 --- a/docs/docs/infinity-runtime/agent-systems/rap-servers.md +++ b/docs/docs/infinity-runtime/agent-systems/rap-servers.md @@ -23,7 +23,6 @@ let rap = RapToolSet::connect( let system = AgentSystemBuilder::new_local(conversation_store, state_store, model) .tools(rap.tools()) .rap_notifier(rap.notifier()) - .build_local() .start(); let (mut views, callback_server_task) = bridge.serve_into(system.sender()); diff --git a/docs/docs/infinity-runtime/agent-systems/running-locally.md b/docs/docs/infinity-runtime/agent-systems/running-locally.md index a5873d24..9a960586 100644 --- a/docs/docs/infinity-runtime/agent-systems/running-locally.md +++ b/docs/docs/infinity-runtime/agent-systems/running-locally.md @@ -9,7 +9,6 @@ A **local agent system** runs for the lifetime of a Tokio process and exposes ea ```rust let system = AgentSystemBuilder::new_local(conversation_store, state_store, model) .tools(shared_tools) - .build_local() .start(); let mut reviewer = system diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index 2d4a1e91..4fbc50d4 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -90,28 +90,42 @@ const config: Config = { style: "dark", links: [ { - title: "Learn", - items: [ - { label: "What is RAP?", to: "/docs/rap/what-is-rap" }, - { label: "Architecture", to: "/docs/rap/about/architecture" }, - { label: "Specification", to: "/docs/rap/spec/overview" }, - ], - }, - { - title: "Build", + title: "Infinity Runtime", items: [ { - label: "Infinity Runtime", - to: "/docs/infinity-runtime/overview", + label: "Quickstart", + to: "/docs/infinity-runtime/agent-systems/building-a-system", + }, + { + label: "Architecture", + to: "/docs/infinity-runtime/architecture", }, { label: "Deploy on AWS Lambda", to: "/docs/infinity-runtime/deploying-on-lambda", }, + ], + }, + { + title: "Reactive Agent Protocol", + items: [ + { label: "What is RAP?", to: "/docs/rap/what-is-rap" }, { label: "Build a RAP Tool", to: "/docs/rap/using-rap/building-a-rap-tool", }, + { label: "Specification", to: "/docs/rap/spec/overview" }, + ], + }, + { + title: "Infinity Code", + items: [ + { label: "Get Started", to: "/docs/infinity-code/overview" }, + { + label: "Background Agents", + to: "/docs/infinity-code/background-agents", + }, + { label: "Slack Bot", to: "/docs/infinity-code/slack-bot" }, ], }, ], diff --git a/docs/src/components/AgentTrace.tsx b/docs/src/components/AgentTrace.tsx new file mode 100644 index 00000000..f3677e5c --- /dev/null +++ b/docs/src/components/AgentTrace.tsx @@ -0,0 +1,140 @@ +import React, { useEffect, useRef, useState } from "react"; + +/** + * AgentTrace: a transcript of one agent working asynchronously. + * + * Reads like a session log: the agent backgrounds a command, then goes idle. + * Subscription events wake it for one short turn each, and the idle rows + * state how long nothing ran in between. + * + * All lines are always rendered (revealed by opacity only), so the container + * height never changes. Idle rows pause the reveal longer, acting out the + * hibernation they describe. + */ + +type Line = + | { + kind: "msg"; + tag: "user" | "agent" | "event"; + text: string; + note?: string; + } + | { kind: "idle"; text: string }; + +/** Each line appears `delay` ms after the previous one. */ +const LINES: (Line & { delay: number })[] = [ + { + kind: "msg", + tag: "user", + text: "Run the test suite and write release notes for 0.4.", + delay: 300, + }, + { + kind: "msg", + tag: "agent", + text: 'run_command("cargo test --workspace")', + note: "returns immediately; output streams back as events", + delay: 900, + }, + { + kind: "msg", + tag: "agent", + text: 'edit_file("docs/release-notes.md")', + note: "keeps working while the tests run", + delay: 900, + }, + { + kind: "idle", + text: "18 minutes idle: no task, no polling, zero compute", + delay: 1100, + }, + { + kind: "msg", + tag: "event", + text: "cargo test: exit 1, FAILED checkout::refunds (refunds.rs:214)", + delay: 2600, + }, + { + kind: "msg", + tag: "agent", + text: 'edit_file("src/checkout/refunds.rs")', + delay: 900, + }, + { + kind: "msg", + tag: "agent", + text: 'run_command("cargo test checkout::refunds")', + note: "reruns the test it fixed", + delay: 900, + }, + { kind: "idle", text: "2 minutes idle", delay: 1100 }, + { + kind: "msg", + tag: "event", + text: "cargo test: exit 0", + delay: 2600, + }, + { + kind: "msg", + tag: "agent", + text: "Release notes drafted; all tests green after fixing the refunds rounding.", + delay: 900, + }, +]; + +export default function AgentTrace({ + active, +}: { + active: boolean; +}): React.JSX.Element { + const [shown, setShown] = useState(0); + const timersRef = useRef[]>([]); + + useEffect(() => { + timersRef.current.forEach(clearTimeout); + timersRef.current = []; + if (!active) { + setShown(0); + return; + } + setShown(0); + let at = 0; + LINES.forEach((line, i) => { + at += line.delay; + timersRef.current.push(setTimeout(() => setShown(i + 1), at)); + }); + return () => { + timersRef.current.forEach(clearTimeout); + }; + }, [active]); + + return ( +
+ {LINES.map((line, i) => { + const style = { opacity: i < shown ? 1 : 0 }; + if (line.kind === "idle") { + return ( +
+ {line.text} +
+ ); + } + return ( +
+ + {line.tag} + + + {line.text} + {line.note && ({line.note})} + +
+ ); + })} +
+ ); +} diff --git a/docs/src/components/MemoryChart.tsx b/docs/src/components/MemoryChart.tsx new file mode 100644 index 00000000..4003a9ff --- /dev/null +++ b/docs/src/components/MemoryChart.tsx @@ -0,0 +1,275 @@ +import React, { useEffect, useRef, useState } from "react"; + +/** + * MemoryChart: measured agents-per-memory curve for the local runtime. + * + * Data source: `crates/infinity-agent-core/examples/agent_scale.rs`, run with + * AGENTS=50000 TURNS=20 WAVE=2500 on an AMD EPYC 9R14 (single thread, + * in-memory stores, scripted in-process model). Each agent runs 20 synthetic + * turns; every turn is two completion rounds and one asynchronous tool round + * trip through the input queue. RSS is sampled after each wave of 2,500 + * agents finishes and goes idle. + * + * Plotted inverted (memory on x, agents on y): how many resident agents fit + * in a given amount of memory. + */ + +// (rss_bytes, agents) pairs from the benchmark run (AMD EPYC 9R14, 2026-08). +// Regenerate with the command in the component doc comment. +const DATA: [number, number][] = [ + [5353472, 0], + [580362240, 2500], + [965492736, 5000], + [1352081408, 7500], + [1736101888, 10000], + [2120110080, 12500], + [2510336000, 15000], + [2894336000, 17500], + [3278622720, 20000], + [3663183872, 22500], + [4046798848, 25000], + [4430938112, 27500], + [4826189824, 30000], + [5210464256, 32500], + [5594009600, 35000], + [5977997312, 37500], + [6360985600, 40000], + [6745284608, 42500], + [7129370624, 45000], + [7513305088, 47500], + [7896977408, 50000], +]; + +const GB = 1e9; + +// Layout +const W = 720; +const H = 400; +const MARGIN = { top: 24, right: 96, bottom: 52, left: 76 }; +const PLOT_W = W - MARGIN.left - MARGIN.right; +const PLOT_H = H - MARGIN.top - MARGIN.bottom; + +const X_MAX_GB = 8.6; +const Y_MAX = 52000; + +const C_LINE = "var(--ifm-color-primary)"; +const C_AXIS = "var(--ifm-color-emphasis-400)"; +const C_GRID = "var(--ifm-color-emphasis-200)"; +const C_LABEL = "var(--ifm-color-emphasis-600)"; +const C_TEXT = "var(--ifm-color-emphasis-800)"; + +function x(gb: number): number { + return MARGIN.left + (gb / X_MAX_GB) * PLOT_W; +} + +function y(agents: number): number { + return MARGIN.top + PLOT_H - (agents / Y_MAX) * PLOT_H; +} + +export default function MemoryChart({ + active, +}: { + active: boolean; +}): React.JSX.Element { + // Draw the line once when the chart scrolls into view. + const [drawn, setDrawn] = useState(false); + const pathRef = useRef(null); + useEffect(() => { + if (active) setDrawn(true); + }, [active]); + + const points = DATA.map(([bytes, agents]) => [x(bytes / GB), y(agents)]); + const path = points + .map( + ([px, py], i) => + `${i === 0 ? "M" : "L"} ${px.toFixed(1)} ${py.toFixed(1)}`, + ) + .join(" "); + const [endX, endY] = points[points.length - 1]; + const lastAgents = DATA[DATA.length - 1][1]; + const lastGb = DATA[DATA.length - 1][0] / GB; + + // Per-agent cost from the overall slope (excluding the empty-system base). + const perAgentKb = + (DATA[DATA.length - 1][0] - DATA[0][0]) / lastAgents / 1024; + + const yTicks = [0, 10000, 20000, 30000, 40000, 50000]; + const xTicks = [0, 2, 4, 6, 8]; + + return ( +
+ + {/* Horizontal gridlines */} + {yTicks.slice(1).map((t) => ( + + ))} + + {/* Axes */} + + + + {/* Y tick labels */} + {yTicks.map((t) => ( + + {t === 0 ? "0" : `${t / 1000}k`} + + ))} + + resident agents + + + {/* X tick labels */} + {xTicks.map((t) => ( + + {t} GB + + ))} + + process memory (RSS) + + + {/* Measured line */} + + + {/* Raspberry Pi reference */} + + + Raspberry Pi 5 (8 GB) + + + {/* Endpoint annotation */} + + {lastAgents.toLocaleString()} agents in {lastGb.toFixed(1)} GB + + + {/* Slope annotation */} + + ≈ {Math.round(perAgentKb)} KB per agent + + +
+ Measured: idle resident agents after 20 tool-calling turns each + (2,000,000 completions total), single thread, in-memory stores.{" "} + + Benchmark source + +
+
+ ); +} diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css index cae68d0d..0528612c 100644 --- a/docs/src/css/custom.css +++ b/docs/src/css/custom.css @@ -10,21 +10,30 @@ html { } } -.layer-section { +.chapter { scroll-margin-top: calc(var(--ifm-navbar-height) + 0.5rem); } /* Hero */ .hero-section { - max-width: 1600px; + max-width: 1200px; margin: 0 auto; - padding: 5rem 2rem 4rem; - border-bottom: 1px solid var(--ifm-color-emphasis-300); - text-align: center; + padding: 4.5rem 2rem 0; + display: grid; + grid-template-columns: minmax(0, 10fr) minmax(0, 11fr); + gap: 3.5rem; + align-items: center; +} + +/* Separator matching the chapter borders: spans the content width. */ +.hero-section::after { + content: ""; + grid-column: 1 / -1; + border-top: 1px solid var(--ifm-color-emphasis-200); } .hero-section h1 { - font-size: 5rem; + font-size: 4.5rem; font-weight: 700; line-height: 1.1; margin-bottom: 1rem; @@ -32,19 +41,40 @@ html { } .hero-tagline { - font-size: 1.35rem; + font-size: 1.3rem; + line-height: 1.55; + color: var(--ifm-font-color-base); + margin: 0 0 1rem; +} + +.hero-subline { + font-size: 1.02rem; line-height: 1.6; color: var(--ifm-color-emphasis-700); - margin: 0 auto 2rem; - max-width: 560px; + margin: 0 0 2rem; +} + +.hero-code { + min-width: 0; +} + +.hero-code .theme-code-block { + margin: 0; } .hero-buttons { display: flex; gap: 1rem; - justify-content: center; + justify-content: flex-start; flex-wrap: wrap; - margin-bottom: 3.5rem; +} + +@media (max-width: 996px) { + .hero-section { + grid-template-columns: 1fr; + gap: 2.5rem; + padding-top: 3rem; + } } .hero-buttons a { @@ -82,131 +112,114 @@ html { text-decoration: none; } -/* Hero layer cards */ -.hero-layers { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 1rem; - max-width: 1100px; +/* Story chapters */ +.story { + max-width: 1200px; margin: 0 auto; + padding: 0 2rem; } -@media (max-width: 996px) { - .hero-layers { - grid-template-columns: 1fr; - max-width: 480px; - } +.chapter { + display: grid; + grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); + gap: 3.5rem; + align-items: center; + padding: 4.5rem 0; } -.hero-layer-card { - position: relative; - display: flex; - flex-direction: column; - gap: 0.35rem; - padding: 1.25rem 3rem 1.25rem 1.5rem; - border: 1px solid var(--ifm-color-emphasis-200); +.chapter + .chapter { + border-top: 1px solid var(--ifm-color-emphasis-200); +} + +/* Agent trace (asynchrony section) */ +.trace { + background: #0d1117; + border: 1px solid #21262d; border-radius: 12px; - background: var(--ifm-background-surface-color); - text-align: left; - text-decoration: none; - transition: border-color 0.15s ease; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.35); + font-family: var(--ifm-font-family-monospace); + font-size: 0.82rem; + line-height: 1.55; + padding: 20px 22px; } -.hero-layer-card:hover { - border-color: var(--ifm-color-primary); - text-decoration: none; +.trace-line { + display: grid; + grid-template-columns: 3.8rem minmax(0, 1fr); + gap: 0.9rem; + padding: 3px 0; + transition: opacity 0.4s ease; } -.hero-layer-name { - font-size: 1.05rem; - font-weight: 700; - letter-spacing: -0.01em; - color: var(--ifm-font-color-base); +.trace-tag { + font-weight: 600; } -.hero-layer-desc { - font-size: 0.9rem; - line-height: 1.5; - color: var(--ifm-color-emphasis-600); +.trace-tag-user { + color: #f0f6fc; } -.hero-layer-arrow { - position: absolute; - right: 1.25rem; - top: 50%; - transform: translateY(-50%); - font-size: 1.15rem; - font-weight: 600; - color: var(--ifm-color-emphasis-400); - transition: - color 0.15s ease, - transform 0.15s ease; +.trace-tag-agent { + color: #79c0ff; } -.hero-layer-card:hover .hero-layer-arrow { - color: var(--ifm-color-primary); - transform: translateY(calc(-50% + 3px)); +.trace-tag-event { + color: #ffa657; } -/* Layer sections */ -.layer-section { - padding: 4rem 2rem; +.trace-tag-report { + color: #b392f0; } -.layer-section:nth-child(odd) { - background: var(--ifm-background-surface-color); +.trace-text { + color: #c9d1d9; + overflow-wrap: break-word; } -.layer-inner { - max-width: 1100px; - margin: 0 auto; - text-align: left; +.trace-note { + color: #8b949e; } -/* Scope element selectors to the section's own content (direct children / - .layer-paragraphs) so they never reach into embedded Infinity UI demos - rendered inside .layer-visual. */ -.layer-inner > h2 { - font-size: 2.75rem; - font-weight: 700; - letter-spacing: -0.02em; - margin-bottom: 0.25rem; +.trace-idle { + color: #8b949e; + font-style: italic; + padding: 10px 0 10px calc(3.8rem + 0.9rem); + transition: opacity 0.4s ease; } -.layer-inner .layer-subtitle { - font-size: 1.2rem; +.chapter-kicker { + font-family: var(--ifm-font-family-monospace); + font-size: 0.8rem; font-weight: 600; - line-height: 1.6; + letter-spacing: 0.14em; + text-transform: uppercase; color: var(--ifm-color-primary); - margin: 0 0 2rem; + margin: 0 0 0.75rem; } -.layer-paragraphs { - max-width: 710px; - margin: 0 auto 1.25rem; - text-align: center; +.chapter-copy h2 { + font-size: 2.1rem; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.2; + margin-bottom: 1.1rem; } -.layer-paragraphs p { - font-size: 1.05rem; - line-height: 1.6; +.chapter-copy p { + font-size: 1.02rem; + line-height: 1.65; color: var(--ifm-color-emphasis-700); margin: 0 0 1rem; - text-align: center; } -.layer-paragraphs .showcase-link { - margin-top: 0.25rem; -} - -.layer-paragraphs code { +.chapter-copy code { background: none; border: none; border-radius: 0; padding: 0; } -.showcase-link { +.chapter-link { display: inline-flex; align-items: center; font-size: 0.95rem; @@ -216,31 +229,95 @@ html { transition: opacity 0.15s; } -.showcase-link:hover { +.chapter-link:hover { opacity: 0.8; text-decoration: none; } -.layer-visual { +.chapter-visual { + min-width: 0; +} + +@media (max-width: 996px) { + .hero-section h1 { + font-size: 3.25rem; + } + + .chapter { + grid-template-columns: 1fr; + gap: 2rem; + padding: 2.5rem 0; + } +} + +/* Product sections (RAP, Infinity Code): distinct from the runtime chapters */ +.product-section { + scroll-margin-top: calc(var(--ifm-navbar-height) + 0.5rem); +} + +.product-section.product-alt { + background: var(--ifm-background-surface-color); +} + +.product-inner { + max-width: 1200px; + margin: 0 auto; + padding: 0 2rem 4.5rem; +} + +/* Separator matching the chapter borders exactly: a block inside the padded + container spans the same content width. */ +.product-inner::before { + content: ""; + display: block; + border-top: 1px solid var(--ifm-color-emphasis-200); + margin-bottom: 4.5rem; +} + +/* Scope element selectors to the section's own content (direct children / + .product-prose) so they never reach into embedded Infinity UI demos + rendered inside .product-visual. */ +.product-inner > h2 { + font-size: 2.1rem; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.2; + margin-bottom: 0.25rem; +} + +.product-inner .product-subtitle { + font-size: 1.15rem; + font-weight: 600; + line-height: 1.6; + color: var(--ifm-color-primary); + margin: 0 0 2rem; +} + +.product-visual { margin-bottom: 2.5rem; display: flex; justify-content: center; text-align: left; } -.layer-visual > * { +.product-visual > * { width: 100%; } -@media (max-width: 996px) { - .hero-section h1 { - font-size: 3.5rem; - } +.product-prose { + max-width: 760px; +} + +.product-prose p { + font-size: 1.02rem; + line-height: 1.65; + color: var(--ifm-color-emphasis-700); + margin: 0 0 1rem; } /* Closing section */ .closing-section { - max-width: 1100px; + max-width: 1200px; margin: 0 auto; padding: 5rem 2rem 6rem; text-align: center; diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index 5e0aacc6..19e3d724 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -1,7 +1,10 @@ import React, { useState, useEffect, useRef } from "react"; import Layout from "@theme/Layout"; -import ProtocolDiagram from "../components/ProtocolDiagram"; +import CodeBlock from "@theme/CodeBlock"; +import MemoryChart from "../components/MemoryChart"; import RuntimeDiagram from "../components/RuntimeDiagram"; +import AgentTrace from "../components/AgentTrace"; +import ProtocolDiagram from "../components/ProtocolDiagram"; import DesktopMini from "../components/DesktopMini"; /** Tracks whether an element is in the viewport, so diagrams animate on scroll. */ @@ -23,35 +26,88 @@ function useInView( return [ref, inView]; } -function LayerSection({ +// Trimmed from the real quickstart; see /docs/infinity-runtime/agent-systems. +const HERO_CODE = `let system = AgentSystemBuilder::new_local( + InMemoryConversationStore::new(), + InMemoryStateStore::new(), + StaticModel::new(provider, "claude-sonnet-4-5").await?, +) +.start(); + +let mut thread = system.thread_builder().launch().await; +thread.send_user_text("Write a haiku about Rust").await?; + +while let Some(event) = thread.recv().await { + println!("{event:?}"); +}`; + +function Chapter({ + id, + kicker, + title, + prose, + link, + linkLabel, + children, +}: { + id: string; + kicker: string; + title: string; + prose: React.ReactNode; + link: string; + linkLabel: string; + children: (active: boolean) => React.ReactNode; +}) { + const [ref, inView] = useInView(0.2); + return ( +
+
+

{kicker}

+

{title}

+ {prose} + + {linkLabel} + +
+
{children(inView)}
+
+ ); +} + +function ProductSection({ id, name, subtitle, - paragraphs, + prose, link, linkLabel, + alt, children, }: { id: string; name: string; subtitle: string; - paragraphs: string[]; + prose: React.ReactNode; link: string; linkLabel: string; + /** Render on the alternate (surface) background. */ + alt?: boolean; children: (active: boolean) => React.ReactNode; }) { - const [ref, inView] = useInView(); + const [ref, inView] = useInView(0.2); return ( -
-
+
+

{name}

-

{subtitle}

-
{children(inView)}
-
- {paragraphs.map((text, i) => ( -

{text}

- ))} - +

{subtitle}

+
{children(inView)}
+
@@ -64,106 +120,193 @@ export default function Home(): React.JSX.Element { return (
-

Infinity

-

- The open-source ecosystem for agents with principled concurrency. -

-
- - Get Started - - - GitHub → - +
+

Infinity

+

+ An open-source Rust framework for building agents, on a + runtime efficient enough to fit{" "} + fifty thousand of them in the memory of a Raspberry Pi. +

+

+ Infinity does for agents what async did for threads. Instead of + blocking on slow tools, Infinity agents run them concurrently, + yield while they wait, and cost nothing until the next event + arrives. +

+
-
- - {(active) => } - +
+ +

+ In Infinity, an idle agent is pure data: between turns, an + agent is just its conversation history, with no task, no + stack, and no open connection. After twenty tool-calling + turns, an agent occupies about 154 KB, so fifty thousand of + them fit in under 8 GB of RAM. +

+

+ Agents spend most of their lives waiting: builds run for + twenty minutes, webhooks fire hours later, humans reply + tomorrow. Runtimes that hold a process per agent pay for all + of that time; Infinity hibernates a waiting agent for free and + wakes it on the next message. +

+ + } + link="/docs/infinity-runtime/architecture" + linkLabel="How the runtime works →" + > + {(active) => } +
+ + +

+ Infinity gives agents primitives for asynchrony. A tool call + can stay open as a subscription and stream events, so a shell + command delivers each chunk of output as it happens;{" "} + spawn_thread forks a subagent that works in + parallel; sleep awaits a timer or the next event + without polling. +

+

+ Like actor systems, Infinity organizes agents into{" "} + agent systems: pools of concurrent agents that share + nothing and communicate through in-order messages to each + agent's mailbox. The runtime intelligently schedules the whole + pool the way an async executor schedules tasks, so thousands + of agents make progress on a few threads. +

+ + } + link="/docs/infinity-runtime/agent-systems/overview" + linkLabel="The Agent System API →" + > + {(active) => } +
+ + +

+ Because agent turns never block, Infinity is perfect for + serverless environments: on AWS Lambda, each SQS FIFO delivery + triggers one step of the runtime, which loads state, runs the + completion, dispatches tool calls, and exits. +

+

+ Infinity agents can run forever with{" "} + near-zero cost: an agent waiting on a three-day CI + pipeline costs exactly nothing until something happens. The + included CDK constructs deploy the whole stack in a few lines, + and the same agent code runs unchanged on your laptop and in + the cloud. +

+ + } + link="/docs/infinity-runtime/deploying-on-lambda" + linkLabel="Deploy on AWS Lambda →" + > + {(active) => } +
+
- +

+ With the Reactive Agent Protocol (RAP), you can serve + tools over the network without holding a connection open: the + runtime invokes a tool with one POST carrying a callback URL, + and the server delivers results or subscription events whenever + they are ready. Tool servers scale like ordinary web services, + and they can serve hibernating agents that currently have no + process at all. +

+

+ Infinity supports MCP out of the box: stdio and HTTP MCP servers + connect in-process, and existing MCP servers run unchanged + through a compatibility layer. Anyone can implement the open RAP + specification in their own runtime or tool server. +

+ + } link="/docs/rap/what-is-rap" - linkLabel="Specification →" + linkLabel="Read the RAP spec →" > {(active) => } -
+ - +

+ Infinity Code is a coding harness built on the runtime, and you + extend it with the same RAP and MCP tools as any other Infinity + agent. It boots instantly, and because sessions live in a + daemon, the interface responds with zero latency even when the + agent runs remotely. The agent runs builds and tests in the + background while their output streams in, edits in parallel + threads with stacked sandboxes, and hands you each result as a + diff to review. +

+ + } link="/docs/infinity-code/overview" - linkLabel="Get started →" + linkLabel="Get Infinity Code →" > {(active) => } -
+
-

A composable, open stack

+

Enter the stack at any layer

- Infinity is a stack you can enter at any layer. Embed the runtime - through its Rust API, build on RAP with your own runtime, or take - Infinity Code as a finished coding agent. Everything is open source - and MCP-compatible, so your existing tools keep working while you - gain async execution. + Embed the runtime through its Rust API, deploy it on Lambda with the + CDK constructs, build tool servers on RAP, or take Infinity Code as + a finished coding agent. Everything is open source and + MCP-compatible, so your existing tools keep working while your + agents stop paying to wait.