//! The asyncai agent loop (github.com/publicdomainrelay/asyncai openai_backend.rs) on the step
//! protocol: MCP servers are initialized and their tools listed, then inference (the Anthropic
//! Messages API, or OpenAI chat completions: co/core and any compatible endpoint) -> tool_use ->
//! MCP tools/call -> tool_result -> inference ... until the model stops or the step cap. One MCP client, two transports:
//! Streamable HTTP through the host's com.fedfork.exec.http.fetch (https, or any scheme the host fetches: webrtc://,
//! iroh://), or a composition hook (one JSON-RPC message in, its response out). With dynamicMcp the model connects
//! and disconnects servers mid-run (the built-in tools mcp_connect and mcp_disconnect).
use abi::{Call, Event, Handle, Run};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
abi::entries! { run => run_handler }
const HTTP: &str = "com.fedfork.exec.http.fetch";
/// The transcript lives in the host (atlibs held values): appended to, spliced into each inference
/// body and into the result, so it never rides in the state or passes back through the module.
const APPEND: &str = "com.fedfork.exec.held.append";
/// A compaction replaces the held transcript (then the kept messages are appended after it).
const PUT: &str = "com.fedfork.exec.held.put";
const TRANSCRIPT: &str = "messages";
/// OpenAI chat completions: the system message, held apart and sent before the transcript.
const SYSTEM: &str = "system";
/// provider openai's default endpoint: co/core (https://cocore.dev/docs/inference)
const OPENAI_ENDPOINT: &str = "https://cocore.dev/api/v1";
/// The in-process MCP hook: input one JSON-RPC message, output its response (null for a notification).
const MCP_HOOK: &str = "com.fedfork.exec.mcp.message";
/// An MCP request's deadline (ms), past any waitMs/timeoutMs its tool call carries (at most MCP_WAIT_MAX of it): the
/// guest agent's exec runs 60 s by default and up to 10 min, its read long-polls up to 30 s. A dead transport then
/// fails the call instead of holding the run to its wallMs.
const MCP_TIMEOUT: u64 = 120_000;
const MCP_WAIT_MAX: u64 = 600_000;
/// What initialize asks for, and the versions this client speaks (Streamable HTTP: 2025-03-26 on).
const PROTOCOL: &str = "2025-11-25";
const SUPPORTED: [&str; 3] = ["2025-11-25", "2025-06-18", "2025-03-26"];
const MODEL: &str = "claude-opus-5-5";
const ENDPOINT: &str = "https://api.anthropic.com";
const ANTHROPIC_VERSION: &str = "2026-10-21";
const MAX_TOKENS: u64 = 16_000;
/// Inference calls per run; the last one may not use tools (asyncai's MAX_TOOL_ROUNDS + 1).
const MAX_STEPS: u64 = 9;
const RETRIES: u64 = 3;
const LIST_PAGES: u64 = 50;
/// The built-in tools (dynamicMcp): connect a server mid-run, disconnect one.
const CONNECT: &str = "mcp_connect";
const DISCONNECT: &str = "mcp_disconnect";
/// Servers a run may have, the disconnected and failed ones included (each connect adds one).
const MAX_SERVERS: usize = 32;
/// Compaction: past compactAt of contextWindow (tokens) the transcript is summarized.
const CONTEXT_WINDOW: u64 = 200_000;
const COMPACT_AT: f64 = 0.8;
/// The server-side compaction beta (Anthropic): its trigger is at least this many input tokens.
const SERVER_COMPACT_BETA: &str = "compact-2026-01-12";
const SERVER_COMPACT_MIN: u64 = 50_000;
/// The summary request's instruction (after Anthropic's default compaction prompt), added to the last user message.
const COMPACT_PROMPT: &str = "You have written a partial transcript for the initial task above. Please write a summary of the transcript. \
The purpose of this summary is to provide continuity so you can continue to make progress towards solving the task in a future context, \
where the raw history above may not be accessible and will be replaced with this summary. Write down anything that would be helpful, \
including the state, next steps, learnings, the user's requests and any exact values (names, ids, numbers) you will need. \
You must wrap your summary in a block. Do not call any tools: answer with the summary only.";
/// The first message of a compacted transcript starts with this (a page can show it as the compaction).
const SUMMARY_PREFIX: &str = "This session continues an earlier conversation that was compacted to save context. Summary of the earlier conversation:";
const TOOL_BUDGET_EXHAUSTED: &str = "Tool budget exhausted: no more tool calls are possible. \
Answer now using only the results you already have, and say briefly what you could not determine.";
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Args {
prompt: Option,
system: Option,
model: Option,
endpoint: Option,
/// "anthropic" (the Messages API, the default) or "openai" (chat completions)
provider: Option,
/// openai: ask for an event stream (collected whole) instead of one JSON answer
stream: Option,
max_tokens: Option,
max_steps: Option,
/// [{ name, url } | { name, hook? } | { name, server: }], an array or its JSON (a form field).
mcp: Option,
/// mcp_connect / mcp_disconnect offered: true (url descriptors), or a list of "url", URL prefixes and hook NSIDs
dynamic_mcp: Option,
/// Fields merged over the Messages request body (thinking, output_config, fallbacks, ...).
extra: Option,
/// "client" (the default): summarize with an inference call; "server": Anthropic's compaction beta; "off" (or false)
compact: Option,
context_window: Option,
/// the fraction of contextWindow that triggers a compaction
compact_at: Option,
/// replaces COMPACT_PROMPT (and is the server's instructions)
compact_prompt: Option,
}
#[derive(Serialize, Deserialize, Clone, Default)]
struct Server {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
url: Option,
#[serde(skip_serializing_if = "Option::is_none")]
hook: Option,
/// connected by the model (mcp_connect)
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
dynamic: bool,
#[serde(skip_serializing_if = "Option::is_none")]
session: Option,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option,
/// init | initialized | list | ready | skipped | error | disconnected (mcp_disconnect, or an mcp_connect that failed)
phase: String,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option,
/// tools/list as the server answered it
#[serde(default)]
listed: Vec,
/// tool names left out (a name past 64 characters, or a clash)
#[serde(default, skip_serializing_if = "Vec::is_empty")]
dropped: Vec,
}
#[derive(Serialize, Deserialize, Default)]
struct State {
/// messages not yet appended to the held transcript (they go with the next inference or the finish)
outbox: Vec,
/// the text blocks of the last assistant message
text: Vec,
servers: Vec,
/// The Messages API tools: namespaced __
tools: Vec,
/// namespaced name -> [server index, MCP tool name]
routes: Map,
steps: u64,
retries: u64,
/// this tool_use turn's calls: (call id, tool_use id, server index, MCP tool name, slot in results; an
/// mcp_connect's call id is connect., answered when that server is ready or failed),
/// and its tool_result blocks in tool_use order (null until answered)
waiting: Vec<(String, String, usize, String, usize)>,
results: Vec,
usage: Map,
next: u64,
/// the last inference's input + output tokens: the transcript's size when the next request goes
context: u64,
compactions: u64,
/// a compaction's summary request is out: the messages it keeps (from the last assistant turn on)
#[serde(default, skip_serializing_if = "Option::is_none")]
kept: Option>,
/// the held transcript's new start, put before the outbox is next appended (a compaction)
#[serde(default, skip_serializing_if = "Option::is_none")]
base: Option>,
/// just compacted: the next request goes without checking again
fresh: bool,
/// openai: the system message is held (the first append puts it)
#[serde(default)]
started: bool,
/// the start's held.append answered the earlier turns' size
#[serde(default)]
sized: bool,
}
fn run_handler(run: Run) -> Result {
let args = run.args;
let config = Config::of(&args)?;
let mut state: State = abi::state(run.state)?;
match run.event {
Event::Start => {
state.servers = servers(&args)?;
let prompt = args.prompt.as_deref().unwrap_or_default();
if prompt.trim().is_empty() {
return Err("prompt is required".into());
}
// earlier turns, when any, are the host's held `messages` already: the prompt is appended to them
state.outbox = vec![json!({ "role": "user", "content": prompt })];
// the transcript's size so far (the earlier turns the host holds): an empty append answers it
let size = Call::new("held.start", APPEND).input(json!({ "name": TRANSCRIPT, "items": [] }));
let calls: Vec = std::iter::once(size).chain(state.servers.iter().enumerate().map(|(i, s)| initialize(i, s))).collect();
Ok(calls.into_iter().fold(Handle::new(), Handle::call).state(json!(state)))
}
Event::Result { id, result, .. } => {
let output = result.into_result();
let (kind, rest) = id.split_once('.').unwrap_or((&id, ""));
let index = || rest.split('.').next().and_then(|i| i.parse::().ok()).filter(|i| *i < state.servers.len());
match kind {
"init" => {
let i = index().ok_or("bad call id")?;
let server = &mut state.servers[i];
match output.and_then(|o| reply(server, &id, &o).map(|r| (r, o))) {
Ok((None, _)) => server.phase = "skipped".into(),
Ok((Some(r), o)) => match r["protocolVersion"].as_str().filter(|v| SUPPORTED.contains(v)) {
Some(v) => {
server.version = Some(v.into());
server.session = header(&o, "mcp-session-id").map(String::from);
server.phase = "initialized".into();
let call = mcp_call(&format!("inited.{i}"), server, json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }));
return Ok(Handle::new().call(call).state(json!(state)));
}
None => fail(server, format!("unsupported protocolVersion {}", r["protocolVersion"])),
},
Err(e) => fail(server, e),
}
Ok(ready(state, &config))
}
"inited" => {
let i = index().ok_or("bad call id")?;
let server = &mut state.servers[i];
match output.and_then(|o| notified(server, &o)) {
Ok(()) => {
server.phase = "list".into();
let call = list(i, 0, server, None);
Ok(Handle::new().call(call).state(json!(state)))
}
Err(e) => {
fail(server, e);
Ok(ready(state, &config))
}
}
}
"list" => {
let i = index().ok_or("bad call id")?;
let page: u64 = rest.split('.').nth(1).and_then(|p| p.parse().ok()).unwrap_or(0);
let server = &mut state.servers[i];
match output.and_then(|o| reply(server, &id, &o)).and_then(|r| r.ok_or_else(|| "no response to tools/list".to_string())) {
Ok(r) => {
server.listed.extend(r["tools"].as_array().cloned().unwrap_or_default());
match r["nextCursor"].as_str().filter(|c| !c.is_empty() && page + 1 < LIST_PAGES) {
Some(cursor) => {
let call = list(i, page + 1, server, Some(cursor));
return Ok(Handle::new().call(call).state(json!(state)));
}
None => server.phase = "ready".into(),
}
}
Err(e) => fail(server, e),
}
Ok(ready(state, &config))
}
"infer" => Ok(inferred(state, &config, output)),
"compact" => Ok(compacted(state, &config, output)),
// the transcript past the host's heldBytes
"held" => Ok(match output {
Err(e) => finish(state, &config, "error", Some(format!("transcript: {e}"))),
// about 4 bytes a token, until an answer's usage says
Ok(o) if rest == "start" => {
state.context = o["bytes"].as_u64().unwrap_or(0) / 4;
state.sized = true;
ready(state, &config)
}
Ok(_) => Handle::new(),
}),
"tool" => {
let Some((_, use_id, i, name, k)) = state.waiting.iter().find(|w| w.0 == id).cloned() else {
return Ok(Handle::new());
};
let content = output.and_then(|o| reply(&state.servers[i], &id, &o)).and_then(|r| r.ok_or_else(|| "no response to tools/call".to_string()));
state.results[k] = tool_result(&config, &use_id, match content {
Ok(r) => tool_text(&r),
Err(e) => (format!("tool error: {name}: {e}"), true),
});
Ok(answered(state, &config))
}
_ => Ok(Handle::new()),
}
}
Event::Timer { id } if id == "retry" => Ok(infer(state, &config)),
Event::Timer { id } if id == "recompact" => {
let call = summary_call(&state, &config);
Ok(Handle::new().call(call).state(json!(state)))
}
_ => Ok(Handle::new()),
}
}
#[derive(PartialEq, Clone, Copy)]
enum Provider {
Anthropic,
OpenAi,
}
#[derive(PartialEq)]
enum Compact {
Off,
Client,
Server,
}
struct Config {
provider: Provider,
stream: bool,
prompt: String,
compact: Compact,
/// tokens: past this the transcript is compacted
threshold: u64,
compact_prompt: Option,
model: String,
url: String,
max_tokens: u64,
max_steps: u64,
system: Option,
extra: Map,
/// what mcp_connect may connect: "url" (any URL), URL prefixes (any scheme: webrtc:/// is every webrtc URL), hook NSIDs; empty: off
dynamic: Vec,
}
impl Config {
fn of(args: &Args) -> Result {
let provider = match args.provider.as_deref().map(str::trim).unwrap_or_default() {
"" | "anthropic" => Provider::Anthropic,
"openai" => Provider::OpenAi,
other => return Err(format!("provider {other}: anthropic or openai")),
};
let default = if provider == Provider::OpenAi { OPENAI_ENDPOINT } else { ENDPOINT };
let endpoint = args.endpoint.as_deref().map(str::trim).filter(|e| !e.is_empty()).unwrap_or(default).trim_end_matches('/');
let stream = matches!(opt_json_or_text(&args.stream), Some(Value::Bool(true))) || opt_json_or_text(&args.stream) == Some(json!("true"));
if stream && provider != Provider::OpenAi {
return Err("stream is for provider openai".into());
}
let model = args.model.clone().filter(|m| !m.trim().is_empty());
if model.is_none() && provider == Provider::OpenAi {
return Err("model is required with provider openai".into());
}
let extra = match opt_json(&args.extra, "extra")? {
None => Map::new(),
Some(Value::Object(m)) => m,
Some(_) => return Err("extra must be an object".into()),
};
let compact = match opt_json_or_text(&args.compact) {
None => Compact::Client,
Some(Value::Bool(false)) => Compact::Off,
Some(Value::Bool(true)) => Compact::Client,
Some(Value::String(s)) if s == "client" => Compact::Client,
Some(Value::String(s)) if s == "server" => Compact::Server,
Some(Value::String(s)) if s == "off" || s == "false" => Compact::Off,
Some(_) => return Err("compact must be client, server or off".into()),
};
if compact == Compact::Server && provider != Provider::Anthropic {
return Err("compact server is Anthropic's compaction beta (provider anthropic)".into());
}
let window = number(&args.context_window, CONTEXT_WINDOW, 100_000_000).map_err(|e| format!("contextWindow {e}"))?;
let at = match &args.compact_at {
None | Some(Value::Null) => Some(COMPACT_AT),
Some(Value::String(s)) if s.trim().is_empty() => Some(COMPACT_AT),
Some(Value::String(s)) => s.trim().parse::().ok(),
Some(v) => v.as_f64(),
}
.filter(|a| *a > 0.0 && *a <= 1.0)
.ok_or("compactAt must be a number above 0, at most 1")?;
let dynamic = match opt_json(&args.dynamic_mcp, "dynamicMcp")? {
None | Some(Value::Bool(false)) => vec![],
Some(Value::Bool(true)) => vec!["url".into()],
Some(Value::Array(l)) if l.iter().all(|a| a.as_str().is_some_and(|a| !a.trim().is_empty())) => l.iter().filter_map(|a| a.as_str().map(|a| a.trim().to_string())).collect(),
Some(_) => return Err("dynamicMcp must be true, false or a list of \"url\", URL prefixes and hook NSIDs".into()),
};
Ok(Self {
provider,
stream,
prompt: args.prompt.clone().unwrap_or_default(),
compact,
threshold: (window as f64 * at) as u64,
compact_prompt: args.compact_prompt.clone().filter(|p| !p.trim().is_empty()),
model: model.unwrap_or_else(|| MODEL.into()),
url: if provider == Provider::OpenAi { format!("{endpoint}/chat/completions") } else { format!("{endpoint}/v1/messages") },
max_tokens: number(&args.max_tokens, MAX_TOKENS, 128_000).map_err(|e| format!("maxTokens {e}"))?,
max_steps: number(&args.max_steps, MAX_STEPS, 1000).map_err(|e| format!("maxSteps {e}"))?,
system: args.system.clone().filter(|s| !s.trim().is_empty()),
extra,
dynamic,
})
}
}
/// A string option that may also be JSON (a form field "false"): absent when empty.
fn opt_json_or_text(value: &Option) -> Option {
match value {
None | Some(Value::Null) => None,
Some(Value::String(s)) if s.trim().is_empty() => None,
Some(Value::String(s)) => Some(json!(s.trim().to_ascii_lowercase())),
Some(v) => Some(v.clone()),
}
}
/// A JSON value given as itself or as its text (form fields are strings); empty is absent.
fn opt_json(value: &Option, what: &str) -> Result