diff --git a/Cargo.lock b/Cargo.lock index 3b14cb8..5a7c822 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2121,6 +2121,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "base64", "boa_engine", "braintrust-llm-router", "braintrust-sdk-rust", @@ -2134,6 +2135,7 @@ dependencies = [ "tempfile", "tokio", "tokio-stream", + "tracing", "url", ] @@ -2159,6 +2161,17 @@ dependencies = [ "wiremock", ] +[[package]] +name = "exoclaw-scheduler-runner" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "executor", + "tokio", + "tracing", +] + [[package]] name = "exoharness" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a0a9903..6511fec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,10 @@ [workspace] -members = ["crates/cli", "crates/exoharness", "crates/executor"] +members = [ + "crates/cli", + "crates/exoharness", + "crates/executor", + "examples/exoclaw/scheduler-runner", +] resolver = "2" [workspace.package] @@ -16,7 +21,6 @@ braintrust-sdk-rust = { git = "https://github.com/braintrustdata/braintrust-sdk- bytes = "1.10.1" chrono = { version = "0.4.42", features = ["serde"] } clap = { version = "4.5.48", features = ["derive", "env"] } -flate2 = "1.1.2" futures = "0.3.31" object_store = { version = "0.12.5", features = [ "fs", @@ -28,8 +32,8 @@ object_store = { version = "0.12.5", features = [ reqwest = { version = "0.12.23", default-features = false, features = [ "json", "rustls-tls", + "stream", ] } -shell-words = "1.1.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" sqlx = { version = "0.8.6", default-features = false, features = [ @@ -45,9 +49,11 @@ tokio = { version = "1.52.1", features = [ "io-std", "io-util", "macros", + "net", "process", "rt-multi-thread", "sync", + "time", ] } tokio-stream = "0.1.17" tokio-util = { version = "0.7.18", features = ["compat"] } diff --git a/README.md b/README.md index 19da968..48bec83 100644 --- a/README.md +++ b/README.md @@ -107,12 +107,21 @@ and recursive-language-model harness experiments. For the coding-agent setup commands, see [docs/coding-agent-harnesses.md](./docs/coding-agent-harnesses.md). +## Exoclaw Long-Running Harness + +Exoclaw is a long-running claw-type agent built on exoharness. It supports +scheduled tasks, and a full adapter system including support for WhatsApp, +Signal, and IRC. See [examples/exoclaw/README.md](./examples/exoclaw/README.md) +for setup, operation, and debugging commands. + ## Repository Layout - `crates`: Rust workspace for the CLI, exoharness substrate, and executors. - `typescript`: TypeScript harness runtime, model-runtime helpers, and adapter-specific support code. - `examples/typescript`: runnable TypeScript harness examples. +- `examples/exoclaw`: long-running TypeScript harness example with scheduled + task and adapter support. - `containers`: sandbox images used by the coding-agent harness examples. - `spec`: core architecture and terminology. - `docs`: design notes for in-progress directions. diff --git a/adapter-arch.md b/adapter-arch.md new file mode 100644 index 0000000..6511e92 --- /dev/null +++ b/adapter-arch.md @@ -0,0 +1,289 @@ +# Adapter Architecture + +This document is a review map for the Exoclaw adapter changes. It focuses on the minimal architecture: what owns adapter state, what starts workers, how messages move, and which files to inspect. + +## What Adapters Are + +Adapters are long-running host-managed connections to external services. They let Exoclaw receive messages from outside the REPL and send explicit replies back out. + +The adapter subsystem is intentionally separate from normal tools: + +- Tools run during a model turn. +- Adapters run continuously in a background host process. +- Adapter events wake a conversation by creating a normal Exoclaw turn. +- Outbound adapter sends are explicit tool calls, not implicit model output. + +## Sources + +Adapter records have a `source` describing where the adapter comes from: + +Current sources: + +- `built_in`: core Exoclaw adapter. IRC is the only built-in adapter. +- `library`: reusable adapter shipped with Exoclaw. Signal and WhatsApp are library adapters backed by shipped workers. + +All adapters in this PR are worker adapters: supervised processes using JSONL over stdin/stdout. Protocol-specific code should live under `examples/exoclaw/adapters//`, not in the shared Rust runtime. + +## Data Model + +Core records live in `crates/executor/src/adapter/types.rs`. + +Important types: + +- `AdapterRecord`: durable adapter config and status. +- `AdapterConfig::Worker`: worker command, initialization JSON, capabilities, optional state dir, optional secret env vars. +- `AdapterEventRecord`: lightweight event history. +- `AdapterOutboundMessageRecord`: queued outbound messages. + +There is no module adapter path in this PR. If agent-authored adapters are added later, they should compile or resolve to the same worker shape. + +## Storage + +The adapter store is file-backed in `crates/executor/src/adapter/store.rs`. + +Default root: + +```text +.exo/adapters/ +``` + +Layout: + +```text +.exo/adapters/adapters/.json +.exo/adapters/events//.json +.exo/adapters/outbox//.json +.exo/adapters///... +``` + +Adapter records and event records stay in the store. Larger, conversation-visible payloads are written as conversation artifacts by the runtime. + +## Runtime Ownership + +The adapter runner is a host process started by the Exoclaw script: + +```text +examples/exoclaw/scripts/exoclaw-repl +``` + +It starts: + +```bash +exo --harness exoclaw adapters run --watch --limit +``` + +The CLI entry point is: + +```text +crates/cli/src/adapters.rs +``` + +Responsibilities: + +- Acquire a lock so only one adapter watch runner is active. +- Dispatch `adapters list`, `adapters run`, `adapters disable`, and `adapters delete`. +- Call the executor adapter runtime. + +The watch loop is in: + +```text +crates/executor/src/adapter/runtime.rs +``` + +Responsibilities: + +- Poll enabled adapter records. +- Start one supervisor task per enabled adapter. +- Skip adapters that are disabled or not build-ready. +- Restart workers after they exit or error. +- Convert worker events into store records, artifacts, and conversation wakeups. +- Drain the outbox and write outbound commands to workers. + +## Worker Protocol + +The shared worker protocol is implemented in Rust and mirrored in TypeScript: + +```text +crates/executor/src/adapter/worker.rs +examples/exoclaw/adapters/protocol.ts +``` + +Host to worker: + +```json +{ "type": "send_message", "target": "...", "text": "..." } +``` + +Worker to host: + +```json +{"type":"connected","subject":"...","metadata":{}} +{"type":"message","target":"...","sender":"...","text":"...","message_id":"...","metadata":{}} +{"type":"lifecycle","name":"...","metadata":{}} +{"type":"error","message":"..."} +{"type":"disconnected","reason":"..."} +``` + +Workers receive configuration via environment: + +- `EXO_ADAPTER_ID` +- `EXO_ADAPTER_TYPE` +- `EXO_ADAPTER_STATE_DIR` +- `EXO_ADAPTER_CONFIG` +- protocol-specific secret env vars, such as `EXO_IRC_PASSWORD` + +## Inbound Flow + +1. A worker receives an external message. +2. The worker writes a `message` JSONL event to stdout. +3. `run_worker_loop` parses it. +4. `runtime.rs` writes an inbound artifact into the owning conversation. +5. `runtime.rs` records a store event. +6. `runtime.rs` calls `send_conversation_wakeup`. +7. Exoclaw receives a normal user message containing: + - adapter name + - adapter id + - target + - sender + - message text + - instructions for replying with `send_adapter_message` + +The wakeup path is shared with scheduler wakeups: + +```text +crates/executor/src/conversation_wakeup.rs +``` + +## Outbound Flow + +1. The model explicitly calls `send_adapter_message`. +2. TypeScript tool definitions pass the request to the host tool runtime. +3. `runtime.rs` writes an outbound artifact into the conversation. +4. `AdapterStore` writes an outbox record. +5. The adapter runner drains the outbox once per second. +6. The host writes a `send_message` JSONL command to the worker stdin. +7. The worker sends through the external protocol. + +This avoids short-lived reconnects for every outbound message. + +## Tool Integration + +Model-facing adapter tools are defined in: + +```text +typescript/harness/adapter-tools.ts +``` + +Tools: + +- `create_adapter` +- `list_adapters` +- `disable_adapter` +- `delete_adapter` +- `send_adapter_message` + +These tools are registered by the Exoclaw harness: + +```text +examples/exoclaw/harness.ts +``` + +Host-side execution is in: + +```text +crates/executor/src/harness_tool.rs +crates/executor/src/adapter/tools.rs +``` + +The TypeScript layer currently transforms typed user-facing adapter configs into generic worker configs. For example, a Signal config becomes a worker config pointing at: + +```text +examples/exoclaw/adapters/signal/worker.ts +``` + +## Protocol Workers + +Protocol-specific code lives under: + +```text +examples/exoclaw/adapters/ +``` + +Current workers: + +- `irc/worker.ts`: IRC socket, registration, channel join, PING/PONG, PRIVMSG parsing. +- `whatsapp/worker.ts`: Baileys linked-device client, QR pairing, WhatsApp messages. +- `signal/worker.ts`: `signal-cli` linked-device flow, JSON-RPC receive/send. + +Each adapter directory also has a local README and setup prompt: + +```text +examples/exoclaw/adapters/irc/README.md +examples/exoclaw/adapters/irc/setup-prompt.md +examples/exoclaw/adapters/whatsapp/README.md +examples/exoclaw/adapters/whatsapp/setup-prompt.md +examples/exoclaw/adapters/signal/README.md +examples/exoclaw/adapters/signal/setup-prompt.md +``` + +## Lifecycle + +Adapter lifecycle is owned by the host runner, not by the REPL. + +Startup: + +- `examples/exoclaw/scripts/exoclaw-repl` starts `exo adapters run --watch` unless `--no-adapters` is set. +- The runner writes `.exo/exoclaw-adapters.pid` and logs to `.exo/exoclaw-adapters.log`. +- The runner starts worker processes for enabled, ready adapters. + +Restart: + +- Worker exit/error returns from `run_worker_loop`. +- The watch task records the error and retries after a short delay. + +Stopping: + +- `stop_adapters` in `examples/exoclaw/scripts/exoclaw-repl` kills the runner and worker processes. +- Disabling/deleting adapter records prevents future restarts. + +## Files To Inspect For PR Review + +Core model and runtime: + +- `crates/executor/src/adapter/types.rs` +- `crates/executor/src/adapter/store.rs` +- `crates/executor/src/adapter/runtime.rs` +- `crates/executor/src/adapter/worker.rs` +- `crates/executor/src/adapter/tools.rs` +- `crates/cli/src/adapters.rs` + +TypeScript tool surface: + +- `typescript/harness/adapter-tools.ts` +- `typescript/harness/index.test.ts` +- `examples/exoclaw/harness.ts` + +Protocol-specific workers: + +- `examples/exoclaw/adapters/protocol.ts` +- `examples/exoclaw/adapters/irc/worker.ts` +- `examples/exoclaw/adapters/whatsapp/worker.ts` +- `examples/exoclaw/adapters/signal/worker.ts` + +Script and docs: + +- `examples/exoclaw/scripts/exoclaw-repl` +- `examples/exoclaw/README.md` +- `examples/exoclaw/adapter-architecture.md` +- `examples/exoclaw/adapters/*/README.md` +- `examples/exoclaw/adapters/*/setup-prompt.md` + +## Minimality Notes + +The intended split is: + +- Rust owns durable records, lifecycle supervision, outbox, artifacts, and conversation wakeups. +- TypeScript harness owns model-facing tool schemas and transforms. +- Adapter directories own protocol-specific code. + +For PR cleanup, the main question to ask in each file is whether it belongs to one of those boundaries. Protocol details should not leak into the Rust runtime beyond generic worker configuration. diff --git a/crates/cli/src/adapters.rs b/crates/cli/src/adapters.rs new file mode 100644 index 0000000..ba4af7f --- /dev/null +++ b/crates/cli/src/adapters.rs @@ -0,0 +1,163 @@ +use std::fs; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::Arc; + +use anyhow::{Result, bail}; +use clap::Subcommand; +use executor::{AdapterRunOptions, AdapterStore, Harness, run_adapters_watch}; +use tabwriter::TabWriter; + +#[derive(Debug, Subcommand)] +pub enum AdapterCommands { + List { + #[arg(long)] + include_disabled: bool, + }, + Run { + #[arg(long, default_value_t = 10)] + limit: usize, + }, + Disable { + adapter_id: String, + }, + Delete { + adapter_id: String, + }, +} + +pub async fn handle_adapter_command( + root: &Path, + harness: Arc, + command: AdapterCommands, +) -> Result<()> { + let store = AdapterStore::new(root.join("adapters")); + match command { + AdapterCommands::List { include_disabled } => { + let mut writer = TabWriter::new(std::io::stdout()); + writeln!(writer, "ADAPTER\tENABLED\tSOURCE\tNAME")?; + for adapter in store + .list_adapters() + .await? + .into_iter() + .filter(|adapter| include_disabled || adapter.enabled) + { + writeln!( + writer, + "{}\t{}\t{:?}\t{}", + adapter.id, adapter.enabled, adapter.source, adapter.name + )?; + } + writer.flush()?; + } + AdapterCommands::Run { limit } => { + let _lock = AdapterRunnerLock::acquire(root)?; + run_adapters_watch(harness, store, AdapterRunOptions { limit }).await?; + } + AdapterCommands::Disable { adapter_id } => { + if store.disable_adapter(&adapter_id).await?.is_some() { + println!("disabled adapter {}", adapter_id); + } else { + bail!("adapter not found: {adapter_id}"); + } + } + AdapterCommands::Delete { adapter_id } => { + if store.delete_adapter(&adapter_id).await?.is_some() { + println!("deleted adapter {}", adapter_id); + } else { + bail!("adapter not found: {adapter_id}"); + } + } + } + Ok(()) +} + +#[derive(Debug)] +struct AdapterRunnerLock { + path: std::path::PathBuf, +} + +impl AdapterRunnerLock { + fn acquire(root: &Path) -> Result { + let path = root.join("exoclaw-adapters.lock"); + let pid = std::process::id().to_string(); + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(mut file) => { + use std::io::Write; + writeln!(file, "{pid}")?; + Ok(Self { path }) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let existing_pid = fs::read_to_string(&path).unwrap_or_default(); + if process_is_running(existing_pid.trim()) { + bail!( + "adapter runner already appears to be running with pid {}", + existing_pid.trim() + ); + } + fs::remove_file(&path)?; + Self::acquire(root) + } + Err(error) => Err(error.into()), + } + } +} + +impl Drop for AdapterRunnerLock { + fn drop(&mut self) { + if let Err(error) = fs::remove_file(&self.path) { + tracing::warn!( + path = %self.path.display(), + %error, + "failed to remove adapter runner lock" + ); + } + } +} + +fn process_is_running(pid: &str) -> bool { + !pid.is_empty() + && Command::new("kill") + .arg("-0") + .arg(pid) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + #[test] + fn adapter_runner_lock_rejects_concurrent_holder() { + let tempdir = TempDir::new().unwrap(); + let first = AdapterRunnerLock::acquire(tempdir.path()).unwrap(); + + let error = AdapterRunnerLock::acquire(tempdir.path()).unwrap_err(); + assert!( + error + .to_string() + .contains("adapter runner already appears to be running") + ); + + drop(first); + AdapterRunnerLock::acquire(tempdir.path()).unwrap(); + } + + #[test] + fn adapter_runner_lock_reclaims_stale_pid_file() { + let tempdir = TempDir::new().unwrap(); + fs::write(tempdir.path().join("exoclaw-adapters.lock"), "999999999").unwrap(); + + AdapterRunnerLock::acquire(tempdir.path()).unwrap(); + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index cb17f65..3337e7b 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,3 +1,4 @@ +mod adapters; mod env; #[cfg(test)] mod env_tests; @@ -17,6 +18,7 @@ use std::net::{SocketAddr, TcpListener}; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Result, anyhow, bail}; use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum}; @@ -24,12 +26,13 @@ use executor::{ AgentHarnessKind, BasicExoHarness, BasicExoHarnessConfig, BasicHarness, BasicToolRuntime, Binding, BraintrustProject, BraintrustRuntimeConfig, BraintrustTracingConfig, ConversationModelConfig, CreateAgentRequest, CreateConversationRequest, EventKind, EventQuery, - EventQueryDirection, ExoHarness, ExoHarnessHttpServeOptions, FileSystemMount, - FileSystemMountMode, ForkConversationRequest, HTTP_EXOHARNESS_TRACING_TARGET, Harness, - HarnessAgent, HarnessConversation, HttpExoHarness, LocalSandboxExoHarness, PutSecretRequest, - RlmHarness, SANDBOX_MAIN_MOUNT_DIR, SandboxBackendChoice, SandboxProvider, Secret, - SecretBackendChoice, SendRequest, ToolRequest, ToolRuntime, TypeScriptHarness, - TypeScriptHarnessConfig, Uuid7, load_agent_config, serve_exoharness_http_listener_with_options, + EventQueryDirection, ExoHarness, ExoHarnessHttpServeOptions, ExoclawToolRuntime, + FileSystemMount, FileSystemMountMode, ForkConversationRequest, HTTP_EXOHARNESS_TRACING_TARGET, + Harness, HarnessAgent, HarnessConversation, HttpExoHarness, LocalSandboxExoHarness, + PutSecretRequest, RlmHarness, SANDBOX_MAIN_MOUNT_DIR, SandboxBackendChoice, SandboxProvider, + SandboxScope, Secret, SecretBackendChoice, ToolRequest, ToolRuntime, TypeScriptHarness, + TypeScriptHarnessConfig, Uuid7, effective_sandbox_scope, load_agent_config, + send_conversation_wakeup, serve_exoharness_http_listener_with_options, }; use lingua::Message; use lingua::universal::{AssistantContent, AssistantContentPart, ToolContentPart, UserContent}; @@ -93,6 +96,7 @@ enum HarnessKind { Rlm, #[value(name = "typescript")] TypeScript, + Exoclaw, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -149,6 +153,7 @@ impl FromStr for HarnessSelection { "basic" => Ok(Self::Kind(HarnessKind::Basic)), "rlm" => Ok(Self::Kind(HarnessKind::Rlm)), "typescript" => Ok(Self::Kind(HarnessKind::TypeScript)), + "exoclaw" => Ok(Self::Kind(HarnessKind::Exoclaw)), "codex" => Ok(Self::TypeScriptPreset(TypeScriptHarnessPreset::Codex)), "claude-code" => Ok(Self::TypeScriptPreset(TypeScriptHarnessPreset::ClaudeCode)), "cursor" | "cursor-sdk" => Ok(Self::TypeScriptPreset(TypeScriptHarnessPreset::Cursor)), @@ -156,7 +161,7 @@ impl FromStr for HarnessSelection { Ok(Self::TypeScriptModule(PathBuf::from(value))) } _ => Err(format!( - "unknown harness `{raw}`; expected basic, rlm, typescript, codex, claude-code, cursor, or a TypeScript module path" + "unknown harness `{raw}`; expected basic, rlm, typescript, exoclaw, codex, claude-code, cursor, or a TypeScript module path" )), } } @@ -272,6 +277,21 @@ impl EnabledDisabled { } } +#[derive(Debug, Clone, Copy, ValueEnum)] +enum SandboxScopeArg { + Agent, + Conversation, +} + +impl From for SandboxScope { + fn from(value: SandboxScopeArg) -> Self { + match value { + SandboxScopeArg::Agent => SandboxScope::Agent, + SandboxScopeArg::Conversation => SandboxScope::Conversation, + } + } +} + #[derive(Debug, Subcommand)] enum Commands { /// Manage agents and their executor configuration. @@ -306,6 +326,10 @@ enum Commands { #[arg(long)] conversation: Option, }, + Adapters { + #[command(subcommand)] + command: adapters::AdapterCommands, + }, Serve { #[arg(long, default_value = "127.0.0.1:4766")] bind: SocketAddr, @@ -405,6 +429,8 @@ enum ConversationCommands { name: Option, #[arg(long)] slug: Option, + #[arg(long, value_enum)] + sandbox_scope: Option, #[command(flatten)] sandbox_runtime: ConversationSandboxRuntimeArgs, #[arg(long)] @@ -426,6 +452,8 @@ enum ConversationCommands { conversation: String, #[command(flatten)] sandbox_runtime: ConversationSandboxRuntimeUpdateArgs, + #[arg(long, value_enum)] + sandbox_scope: Option, #[arg(long)] model: Option, #[arg(long)] @@ -648,6 +676,8 @@ async fn main() -> Result<()> { ) .await?; let harness = instantiate_harness( + &cli.root, + &exo_config, exoharness, harness_kind, runtime_config.clone(), @@ -656,6 +686,9 @@ async fn main() -> Result<()> { .await?; match cli.command { + Commands::Adapters { command } => { + adapters::handle_adapter_command(&cli.root, Arc::clone(&harness), command).await?; + } Commands::Repl { model, agent, @@ -902,8 +935,11 @@ async fn main() -> Result<()> { config.typescript = None; changed = true; } else if let Some(module) = module.as_deref() { - if config.harness != AgentHarnessKind::TypeScript { - bail!("--module is only valid with TypeScript agents"); + if !matches!( + config.harness, + AgentHarnessKind::TypeScript | AgentHarnessKind::Exoclaw + ) { + bail!("--module is only valid with TypeScript or Exoclaw agents"); } let existing_tool_modules = config .typescript @@ -931,8 +967,11 @@ async fn main() -> Result<()> { changed = true; } } else if !tool_modules.is_empty() { - if config.harness != AgentHarnessKind::TypeScript { - bail!("--tool-module is only valid with TypeScript agents"); + if !matches!( + config.harness, + AgentHarnessKind::TypeScript | AgentHarnessKind::Exoclaw + ) { + bail!("--tool-module is only valid with TypeScript or Exoclaw agents"); } let Some(typescript) = config.typescript.as_mut() else { bail!("typescript agents require a module path; pass --module "); @@ -1031,8 +1070,14 @@ async fn main() -> Result<()> { if !changed { bail!("no changes provided"); } - if config.harness == AgentHarnessKind::TypeScript && config.typescript.is_none() { - bail!("typescript agents require a module path; pass --module "); + if matches!( + config.harness, + AgentHarnessKind::TypeScript | AgentHarnessKind::Exoclaw + ) && config.typescript.is_none() + { + bail!( + "TypeScript and Exoclaw agents require a module path; pass --module " + ); } agent.put_config(config).await?; println!("updated agent {}", agent.record().slug); @@ -1127,6 +1172,7 @@ async fn main() -> Result<()> { agent, name, slug, + sandbox_scope, sandbox_runtime, repl, } => { @@ -1152,6 +1198,11 @@ async fn main() -> Result<()> { shell_program: sandbox_runtime.shell_program, }) .await?; + if let Some(sandbox_scope) = sandbox_scope { + let mut config = conversation.config().await?; + config.sandbox_scope = Some(sandbox_scope.into()); + conversation.put_config(config).await?; + } println!( "created conversation {} ({})", conversation.record().slug, @@ -1210,6 +1261,7 @@ async fn main() -> Result<()> { ConversationCommands::Update { agent, conversation, + sandbox_scope, sandbox_runtime, model, max_output_tokens, @@ -1237,6 +1289,11 @@ async fn main() -> Result<()> { let mut config = conversation.config().await?; let mut changed = sandbox_runtime.apply(&mut config)?; + if let Some(sandbox_scope) = sandbox_scope { + config.sandbox_scope = Some(sandbox_scope.into()); + changed = true; + } + let updated_model_override = if clear_model_override { changed = true; Some(None) @@ -1421,6 +1478,17 @@ async fn main() -> Result<()> { "shell_program: {}", config.shell_program.as_deref().unwrap_or("none") ); + println!( + "sandbox_scope: {}", + config + .sandbox_scope + .map(sandbox_scope_name) + .unwrap_or("default") + ); + println!( + "effective_sandbox_scope: {}", + sandbox_scope_name(effective_sandbox_scope(&agent_config, &config)) + ); println!( "sandbox_image: {}", config.sandbox_image.as_deref().unwrap_or("inherit") @@ -1503,15 +1571,7 @@ async fn main() -> Result<()> { let conversation = must_get_conversation(harness.as_ref(), &agent, &conversation).await?; let previous_messages = conversation.messages().await?; - let result = conversation - .send(SendRequest { - input: vec![Message::User { - content: UserContent::String(prompt), - }], - session_id: None, - }) - .await?; - conversation.close_session(result.session_id).await?; + send_conversation_wakeup(conversation.as_ref(), prompt).await?; let messages = conversation.messages().await?; for message in &messages[previous_messages.len()..] { print_message(message); @@ -1523,9 +1583,10 @@ async fn main() -> Result<()> { } => { let agent = must_get_agent(harness.as_ref(), &agent).await?; if !agent.delete_conversation(&conversation).await? { - bail!("conversation not found: {conversation}"); + println!("conversation {} not found; nothing to delete", conversation); + } else { + println!("deleted conversation {}", conversation); } - println!("deleted conversation {}", conversation); } }, Commands::Secret { command } => match command { @@ -1621,7 +1682,6 @@ async fn determine_harness_kind( if let Some(selection) = selection { return Ok(selection.harness_kind()); } - let Some(agent_ref) = command_agent_ref(command) else { return Ok(HarnessKind::Basic); }; @@ -1658,7 +1718,10 @@ fn command_agent_ref(command: &Commands) -> Option<&str> { }, }, Commands::Repl { agent, .. } => Some(agent.as_deref().unwrap_or(DEFAULT_REPL_SLUG)), - Commands::Secret { .. } | Commands::Model { .. } | Commands::Serve { .. } => None, + Commands::Secret { .. } + | Commands::Model { .. } + | Commands::Adapters { .. } + | Commands::Serve { .. } => None, } } @@ -1770,6 +1833,8 @@ async fn instantiate_exoharness( } async fn instantiate_harness( + root: &Path, + exo_config: &BasicExoHarnessConfig, exoharness: Arc, kind: HarnessKind, runtime_config: Option, @@ -1786,6 +1851,15 @@ async fn instantiate_harness( runtime_config, env_vars, )), + HarnessKind::Exoclaw => Arc::new( + TypeScriptHarness::::exoclaw_from_root( + root, + exo_config.clone(), + runtime_config, + env_vars, + ) + .await?, + ), HarnessKind::TypeScript => Arc::new(TypeScriptHarness::from_exoharness( exoharness, runtime_config, @@ -1800,6 +1874,7 @@ fn to_agent_harness_kind(kind: HarnessKind) -> AgentHarnessKind { HarnessKind::Basic => AgentHarnessKind::Basic, HarnessKind::Rlm => AgentHarnessKind::Rlm, HarnessKind::TypeScript => AgentHarnessKind::TypeScript, + HarnessKind::Exoclaw => AgentHarnessKind::Exoclaw, } } @@ -1808,6 +1883,7 @@ fn from_agent_harness_kind(kind: AgentHarnessKind) -> HarnessKind { AgentHarnessKind::Basic => HarnessKind::Basic, AgentHarnessKind::Rlm => HarnessKind::Rlm, AgentHarnessKind::TypeScript => HarnessKind::TypeScript, + AgentHarnessKind::Exoclaw => HarnessKind::Exoclaw, } } @@ -1816,6 +1892,7 @@ fn format_harness_kind(kind: AgentHarnessKind) -> &'static str { AgentHarnessKind::Basic => "basic", AgentHarnessKind::Rlm => "rlm", AgentHarnessKind::TypeScript => "typescript", + AgentHarnessKind::Exoclaw => "exoclaw", } } @@ -1836,8 +1913,10 @@ fn build_typescript_harness_config( let harness_kind = selection .map(HarnessSelection::harness_kind) .unwrap_or(HarnessKind::Basic); - if !matches!(harness_kind, HarnessKind::TypeScript) && !tool_modules.is_empty() { - bail!("--tool-module is only valid with --harness typescript"); + if !matches!(harness_kind, HarnessKind::TypeScript | HarnessKind::Exoclaw) + && !tool_modules.is_empty() + { + bail!("--tool-module is only valid with --harness typescript or exoclaw"); } match (selection, harness_kind, module) { (Some(HarnessSelection::TypeScriptPreset(_)), _, Some(_)) @@ -1856,14 +1935,19 @@ fn build_typescript_harness_config( resolve_typescript_tool_module_paths(tool_modules)?, )?)) } - (_, HarnessKind::TypeScript, Some(module)) => Ok(Some(resolve_typescript_harness_config( - module, - resolve_typescript_tool_module_paths(tool_modules)?, - )?)), + (_, HarnessKind::TypeScript | HarnessKind::Exoclaw, Some(module)) => { + Ok(Some(resolve_typescript_harness_config( + module, + resolve_typescript_tool_module_paths(tool_modules)?, + )?)) + } (_, HarnessKind::TypeScript, None) => Err(anyhow!( "typescript agents require --module , or use --harness codex, --harness claude-code, --harness cursor, or --harness " )), - (_, _, Some(_)) => Err(anyhow!("--module is only valid with --harness typescript")), + (_, HarnessKind::Exoclaw, None) => Err(anyhow!("exoclaw agents require --module ")), + (_, _, Some(_)) => Err(anyhow!( + "--module is only valid with --harness typescript or exoclaw" + )), (_, _, None) => Ok(None), } } @@ -1890,10 +1974,15 @@ async fn ensure_agent_matches_harness_selection( ); } - if matches!(selection.harness_kind(), HarnessKind::TypeScript) && config.typescript.is_none() { + if matches!( + selection.harness_kind(), + HarnessKind::TypeScript | HarnessKind::Exoclaw + ) && config.typescript.is_none() + { bail!( - "agent {} is configured for TypeScript but has no module path", - agent.record().slug + "agent {} is configured for {} but has no module path", + agent.record().slug, + format_harness_selection(selection) ); } @@ -1951,6 +2040,7 @@ fn format_harness_selection(selection: &HarnessSelection) -> String { HarnessKind::Basic => "basic".to_string(), HarnessKind::Rlm => "rlm".to_string(), HarnessKind::TypeScript => "typescript".to_string(), + HarnessKind::Exoclaw => "exoclaw".to_string(), }, HarnessSelection::TypeScriptPreset(preset) => match preset { TypeScriptHarnessPreset::Codex => "codex".to_string(), @@ -2252,6 +2342,7 @@ async fn run_sandbox_shell_command( conversation.record().slug ); } + let agent_handle = agent.exoharness_handle(); let conversation_handle = conversation.exoharness_handle(); let runtime = BasicToolRuntime; @@ -2259,6 +2350,7 @@ async fn run_sandbox_shell_command( arguments.insert("command".to_string(), serde_json::Value::String(command)); let result = runtime .execute( + agent_handle.as_ref(), conversation_handle.as_ref(), &agent_config, &config, @@ -2272,28 +2364,43 @@ async fn run_sandbox_shell_command( } pub(crate) fn print_message(message: &Message) { + let timestamp = compact_timestamp(); match message { Message::User { content } => { - println!("user: {}", render_user_content(content)); + println!("{timestamp} user: {}", render_user_content(content)); } Message::Assistant { content, .. } => { - println!("assistant: {}", render_assistant_content(content)); + println!( + "{timestamp} assistant: {}", + render_assistant_content(content) + ); } Message::Tool { content } => { for part in content { let ToolContentPart::ToolResult(result) = part; - println!("tool {}: {}", result.tool_name, result.output); + println!("{timestamp} tool {}: {}", result.tool_name, result.output); } } Message::System { content } => { - println!("system: {}", render_user_content(content)); + println!("{timestamp} system: {}", render_user_content(content)); } Message::Developer { content } => { - println!("developer: {}", render_user_content(content)); + println!("{timestamp} developer: {}", render_user_content(content)); } } } +pub(crate) fn compact_timestamp() -> String { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs() % 86_400) + .unwrap_or(0); + let hours = seconds / 3_600; + let minutes = (seconds % 3_600) / 60; + let seconds = seconds % 60; + format!("[{hours:02}:{minutes:02}:{seconds:02}]") +} + fn render_user_content(content: &UserContent) -> String { match content { UserContent::String(text) => text.clone(), @@ -2335,6 +2442,13 @@ fn repl_command(agent_slug: &str, conversation_slug: &str) -> String { format!("exo repl --agent {agent_slug} --conversation {conversation_slug}") } +fn sandbox_scope_name(scope: SandboxScope) -> &'static str { + match scope { + SandboxScope::Agent => "agent", + SandboxScope::Conversation => "conversation", + } +} + fn slugify(input: &str) -> String { let mut slug = String::new(); let mut last_was_dash = false; diff --git a/crates/cli/src/tui.rs b/crates/cli/src/tui.rs index 6115e1c..b8eef1e 100644 --- a/crates/cli/src/tui.rs +++ b/crates/cli/src/tui.rs @@ -14,12 +14,15 @@ use lingua::universal::{UserContent, UserContentPart}; use lingua::{Message, UniversalStreamChunk}; use rustyline::error::ReadlineError; use rustyline::history::{History, MemHistory, SearchDirection, SearchResult}; -use rustyline::{Cmd, Config, Editor, KeyCode, KeyEvent, Modifiers}; +use rustyline::{Cmd, Config, Editor, ExternalPrinter, KeyCode, KeyEvent, Modifiers}; use serde_json::{Map, Value}; use tokio::runtime::Handle; +use tokio::task::JoinHandle; use tokio_stream::StreamExt; -use crate::{print_message, render_assistant_content, run_sandbox_shell_command}; +use crate::{ + compact_timestamp, print_message, render_assistant_content, run_sandbox_shell_command, +}; const DEFAULT_SHELL_PROGRAM: &str = "/bin/bash"; const REMOTE_HISTORY_BASE: usize = 1_000_000; @@ -346,6 +349,7 @@ struct ChatRepl { conversation: Arc, editor: Editor<(), ChatHistory>, session_id: Option, + watch_after: Arc>>, } impl ChatRepl { @@ -353,10 +357,8 @@ impl ChatRepl { agent: Arc, conversation: Arc, ) -> Result { - let history = ChatHistory::new( - conversation.exoharness_handle(), - conversation.record().latest_event_id, - ); + let latest_event_id = conversation.record().latest_event_id; + let history = ChatHistory::new(conversation.exoharness_handle(), latest_event_id); let mut editor = Editor::with_history(Config::default(), history)?; editor.bind_sequence(KeyEvent(KeyCode::Enter, Modifiers::ALT), Cmd::Newline); Ok(Self { @@ -364,6 +366,7 @@ impl ChatRepl { conversation, editor, session_id: None, + watch_after: Arc::new(Mutex::new(latest_event_id)), }) } @@ -377,7 +380,12 @@ impl ChatRepl { async fn run(&mut self) -> Result<()> { loop { let prompt = format!("{}> ", self.conversation.record().slug); - match self.editor.readline(&prompt) { + let event_printer = self.spawn_event_printer()?; + let readline_result = self.editor.readline(&prompt); + event_printer.abort(); + let _ = event_printer.await; + + match readline_result { Ok(line) => { let trimmed = line.trim(); if trimmed.is_empty() { @@ -414,8 +422,11 @@ impl ChatRepl { Ok(snapshot_id) => println!("snapshot {snapshot_id}"), Err(error) => println!("snapshot failed: {error:#}"), }, - other if let Some(arg) = other.strip_prefix("/snapshot ") => { - let arg = arg.trim(); + other if other.starts_with("/snapshot ") => { + let arg = other + .strip_prefix("/snapshot ") + .expect("prefix checked") + .trim(); if arg.is_empty() { println!("usage: /snapshot []"); } else if arg.contains(char::is_whitespace) { @@ -439,8 +450,11 @@ impl ChatRepl { } Err(error) => println!("listing snapshots failed: {error:#}"), }, - other if let Some(arg) = other.strip_prefix("/rewind ") => { - let arg = arg.trim(); + other if other.starts_with("/rewind ") => { + let arg = other + .strip_prefix("/rewind ") + .expect("prefix checked") + .trim(); if arg.is_empty() { println!("usage: /rewind "); } else if arg.contains(char::is_whitespace) { @@ -546,7 +560,7 @@ impl ChatRepl { continue; } if !printed_assistant { - print!("assistant: "); + print!("{} assistant: ", compact_timestamp()); stdout.flush()?; printed_assistant = true; } @@ -571,6 +585,8 @@ impl ChatRepl { } ExecutionStreamEvent::Completed(result) => { self.session_id = Some(result.session_id); + *self.watch_after.lock().expect("chat event watch poisoned") = + Some(result.latest_event_id); } } } @@ -582,13 +598,51 @@ impl ChatRepl { { let rendered = render_assistant_content(&content); if !rendered.is_empty() { - println!("assistant: {}", rendered); + println!("{} assistant: {}", compact_timestamp(), rendered); } } println!(); Ok(()) } + fn spawn_event_printer(&mut self) -> Result> { + let conversation = self.conversation.exoharness_handle(); + let watch_after = Arc::clone(&self.watch_after); + let mut printer = self.editor.create_external_printer()?; + Ok(tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(1)); + loop { + interval.tick().await; + let cursor = *watch_after.lock().expect("chat event watch poisoned"); + match conversation + .get_events(Some(EventQuery { + cursor, + direction: Some(EventQueryDirection::Asc), + limit: Some(100), + session_id: None, + turn_id: None, + types: None, + })) + .await + { + Ok(result) => { + for event in result.events { + *watch_after.lock().expect("chat event watch poisoned") = + Some(event.id); + for rendered in render_external_event(&event.data) { + let _ = printer.print(format!("{rendered}\n")); + } + } + } + Err(error) => { + let _ = printer.print(format!("event watcher error: {error}\n")); + break; + } + } + } + })) + } + async fn run_shell(&self, command: &str) -> Result<()> { let mut config = self.conversation.config().await?; if config.shell_program.is_none() { @@ -683,6 +737,35 @@ fn render_value_inline(value: &Value) -> String { } } +fn render_external_event(data: &EventData) -> Vec { + let EventData::Messages { messages, .. } = data else { + return Vec::new(); + }; + messages + .iter() + .filter_map(|message| match message { + Message::User { content } => render_external_user_content(content) + .map(|rendered| format!("{} user: {rendered}", compact_timestamp())), + Message::Assistant { content, .. } => { + let rendered = render_assistant_content(content); + (!rendered.is_empty()) + .then(|| format!("{} assistant: {rendered}", compact_timestamp())) + } + _ => None, + }) + .collect() +} + +fn render_external_user_content(content: &UserContent) -> Option { + let rendered = render_user_content_for_history(content); + let trimmed = rendered.trim(); + if trimmed.starts_with("Scheduled task `") { + Some(trimmed.to_string()) + } else { + None + } +} + fn render_user_content_for_history(content: &UserContent) -> String { match content { UserContent::String(text) => text.clone(), @@ -794,7 +877,12 @@ async fn sandbox_id_for_snapshot( #[cfg(test)] mod tests { - use super::{render_tool_call, render_tool_result, render_user_content_for_history}; + use super::{ + render_external_event, render_tool_call, render_tool_result, + render_user_content_for_history, + }; + use executor::EventData; + use lingua::Message; use lingua::universal::UserContent; use serde_json::{Map, Value}; @@ -831,6 +919,22 @@ mod tests { ); } + #[test] + fn renders_scheduled_task_wakeup_user_messages() { + let rendered = render_external_event(&EventData::Messages { + messages: vec![Message::User { + content: UserContent::String( + "Scheduled task `joke` completed.\n\nstdout preview:\nhello".to_string(), + ), + }], + response_id: None, + }); + + assert_eq!(rendered.len(), 1); + assert!(rendered[0].contains("user: Scheduled task `joke` completed.")); + assert!(rendered[0].contains("stdout preview:\nhello")); + } + #[test] fn renders_user_string_content_for_history() { let content = UserContent::String("first second".to_string()); diff --git a/crates/executor/Cargo.toml b/crates/executor/Cargo.toml index f981c17..cd6e6da 100644 --- a/crates/executor/Cargo.toml +++ b/crates/executor/Cargo.toml @@ -8,6 +8,7 @@ rust-version.workspace = true [dependencies] anyhow.workspace = true async-trait.workspace = true +base64 = "0.22.1" boa_engine = "0.20.0" braintrust-llm-router.workspace = true braintrust-sdk-rust.workspace = true @@ -23,6 +24,7 @@ serde.workspace = true serde_json.workspace = true tokio.workspace = true tokio-stream.workspace = true +tracing.workspace = true url.workspace = true [dev-dependencies] diff --git a/crates/executor/src/adapter/mod.rs b/crates/executor/src/adapter/mod.rs new file mode 100644 index 0000000..3ce57e3 --- /dev/null +++ b/crates/executor/src/adapter/mod.rs @@ -0,0 +1,12 @@ +pub(crate) mod runtime; +pub(crate) mod store; +pub(crate) mod tools; +pub(crate) mod types; +pub(crate) mod worker; + +pub use runtime::{AdapterRunOptions, run_adapters_watch}; +pub use store::AdapterStore; +pub use types::{ + AdapterAttachment, AdapterAttachmentKind, AdapterConfig, AdapterEventRecord, AdapterEventType, + AdapterRecord, AdapterSource, NewAdapter, WorkerSecretEnvVar, +}; diff --git a/crates/executor/src/adapter/runtime.rs b/crates/executor/src/adapter/runtime.rs new file mode 100644 index 0000000..567072a --- /dev/null +++ b/crates/executor/src/adapter/runtime.rs @@ -0,0 +1,468 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; +use std::time::Duration; + +use anyhow::{Result, anyhow, bail}; +use exoharness::{AgentHandle, ConversationHandle, Secret, SecretId}; +use tokio::sync::Notify; + +use super::store::AdapterStore; +use super::types::{AdapterAttachment, AdapterConfig, AdapterEventType, AdapterRecord}; +use super::worker::{WorkerCommand, WorkerEvent, run_worker_loop}; +use crate::conversation_wakeup::send_conversation_wakeup; +use crate::{Harness, HarnessAgent, HarnessConversation}; + +#[derive(Debug, Clone, Copy)] +pub struct AdapterRunOptions { + pub limit: usize, +} + +impl Default for AdapterRunOptions { + fn default() -> Self { + Self { limit: 10 } + } +} + +pub async fn run_adapters_watch( + harness: Arc, + store: AdapterStore, + options: AdapterRunOptions, +) -> Result<()> { + let mut running = HashSet::new(); + loop { + let adapters = store.enabled_adapters().await?; + for adapter in adapters.into_iter().take(options.limit) { + if !running.insert(adapter.id.clone()) { + continue; + } + let harness = Arc::clone(&harness); + let store = store.clone(); + tokio::spawn(async move { + loop { + match store.get_adapter(&adapter.id).await { + Ok(Some(current)) if current.enabled => {} + Ok(Some(_)) => { + tokio::time::sleep(Duration::from_secs(5)).await; + continue; + } + Ok(_) => break, + Err(_) => break, + } + if let Err(error) = + run_adapter_loop(Arc::clone(&harness), &store, adapter.clone()).await + { + tracing::error!( + adapter_id = %adapter.id, + %error, + "adapter runtime error; restarting in 5s" + ); + if let Err(mark_error) = + store.mark_error(&adapter.id, error.to_string()).await + { + tracing::error!( + adapter_id = %adapter.id, + error = %mark_error, + "failed to mark adapter error" + ); + } + if let Err(record_error) = store + .record_event( + adapter.id.clone(), + AdapterEventType::Error, + error.to_string(), + ) + .await + { + tracing::error!( + adapter_id = %adapter.id, + error = %record_error, + "failed to record adapter error" + ); + } + } + tokio::time::sleep(Duration::from_secs(5)).await; + } + }); + } + tokio::time::sleep(Duration::from_secs(10)).await; + } +} + +pub async fn send_adapter_message_with_handles( + _agent: &dyn AgentHandle, + _conversation: &dyn ConversationHandle, + store: &AdapterStore, + adapter: &AdapterRecord, + text: &str, + target: Option<&str>, + attachments: Vec, +) -> Result<()> { + if !adapter.enabled { + bail!("adapter is disabled: {}", adapter.id); + } + if !attachments.is_empty() + && adapter.config.adapter_type != "whatsapp" + && adapter.config.adapter_type != "signal" + && adapter.config.adapter_type != "discord" + { + bail!( + "adapter {} does not support rich attachments", + adapter.config.adapter_type + ); + } + // Note: we intentionally do not write a conversation artifact here. + // This tool is invoked from inside an active agent turn, and writing to + // the conversation outside the turn handle advances the conversation + // head, which makes the active turn stale and crashes the adapter + // worker. The outbound message is durably queued in the AdapterStore + // outbox, and the event below records it for audit. + store + .record_event( + adapter.id.clone(), + AdapterEventType::Outbound, + format!( + "queued {} adapter message{}", + adapter.config.adapter_type, + target.map(|t| format!(" to {t}")).unwrap_or_default(), + ), + ) + .await?; + store + .enqueue_outbound_message( + adapter.id.clone(), + text.to_string(), + target.map(ToOwned::to_owned), + attachments, + ) + .await?; + notify_adapter_outbound(&adapter.id); + Ok(()) +} + +async fn run_adapter_loop( + harness: Arc, + store: &AdapterStore, + adapter: AdapterRecord, +) -> Result<()> { + let agent = require_agent(harness.as_ref(), &adapter).await?; + let conversation = require_conversation(agent.as_ref(), &adapter).await?; + store.requeue_inflight_messages(&adapter.id).await?; + let config = adapter.config.clone(); + let secret_env = worker_secret_env(agent.exoharness_handle().as_ref(), &config).await?; + let outbound_notifier = register_adapter_outbound_notifier(&adapter.id); + let event_store = store.clone(); + let event_adapter = adapter.clone(); + let event_conversation = std::sync::Arc::clone(&conversation); + let event_config = config.clone(); + let outbound_store = store.clone(); + let outbound_adapter_id = adapter.id.clone(); + let stop_store = store.clone(); + let stop_adapter_id = adapter.id.clone(); + run_worker_loop( + &adapter.id, + &config, + secret_env, + Arc::clone(&outbound_notifier.notify), + move |event| { + let store = event_store.clone(); + let adapter = event_adapter.clone(); + let conversation = std::sync::Arc::clone(&event_conversation); + let config = event_config.clone(); + async move { + handle_worker_event(&store, conversation.as_ref(), &adapter, &config, event).await + } + }, + move || { + let store = outbound_store.clone(); + let adapter_id = outbound_adapter_id.clone(); + async move { + Ok(store + .claim_outbound_messages(&adapter_id) + .await? + .into_iter() + .map(|message| WorkerCommand::SendMessage { + id: message.id, + target: message.target, + text: message.text, + attachments: message.attachments, + }) + .collect()) + } + }, + move || { + let store = stop_store.clone(); + let adapter_id = stop_adapter_id.clone(); + async move { + Ok(store + .get_adapter(&adapter_id) + .await? + .is_none_or(|adapter| !adapter.enabled)) + } + }, + ) + .await +} + +struct AdapterOutboundNotifierGuard { + adapter_id: String, + notify: Arc, +} + +impl Drop for AdapterOutboundNotifierGuard { + fn drop(&mut self) { + let mut notifiers = adapter_outbound_notifiers() + .lock() + .expect("adapter outbound notifier registry poisoned"); + if let Some(current) = notifiers.get(&self.adapter_id).and_then(Weak::upgrade) + && Arc::ptr_eq(¤t, &self.notify) + { + notifiers.remove(&self.adapter_id); + } + } +} + +fn register_adapter_outbound_notifier(adapter_id: &str) -> AdapterOutboundNotifierGuard { + let notify = Arc::new(Notify::new()); + adapter_outbound_notifiers() + .lock() + .expect("adapter outbound notifier registry poisoned") + .insert(adapter_id.to_string(), Arc::downgrade(¬ify)); + AdapterOutboundNotifierGuard { + adapter_id: adapter_id.to_string(), + notify, + } +} + +fn notify_adapter_outbound(adapter_id: &str) { + let notify = adapter_outbound_notifiers() + .lock() + .expect("adapter outbound notifier registry poisoned") + .get(adapter_id) + .and_then(Weak::upgrade); + if let Some(notify) = notify { + notify.notify_one(); + } +} + +fn adapter_outbound_notifiers() -> &'static Mutex>> { + static NOTIFIERS: OnceLock>>> = OnceLock::new(); + NOTIFIERS.get_or_init(|| Mutex::new(HashMap::new())) +} + +async fn handle_worker_event( + store: &AdapterStore, + conversation: &dyn HarnessConversation, + adapter: &AdapterRecord, + config: &AdapterConfig, + event: WorkerEvent, +) -> Result<()> { + match event { + WorkerEvent::Connected { subject, metadata } => { + store.mark_connected(&adapter.id).await?; + record_worker_lifecycle( + store, + conversation, + adapter, + config, + "connected", + serde_json::json!({ "subject": subject, "metadata": metadata }), + ) + .await + } + WorkerEvent::Message { + target, + sender, + text, + message_id, + metadata, + } => { + handle_worker_message( + store, + conversation, + adapter, + config, + target, + sender, + text, + message_id, + metadata, + ) + .await + } + WorkerEvent::Lifecycle { name, metadata } => { + record_worker_lifecycle(store, conversation, adapter, config, &name, metadata).await + } + WorkerEvent::Error { message } => { + store.mark_error(&adapter.id, message.clone()).await?; + store + .record_event(adapter.id.clone(), AdapterEventType::Error, message) + .await?; + Ok(()) + } + WorkerEvent::CommandAck { command_id } => { + store + .acknowledge_outbound_message(&adapter.id, &command_id) + .await + } + WorkerEvent::CommandNack { + command_id, + message, + } => { + store.mark_error(&adapter.id, message.clone()).await?; + store + .record_event(adapter.id.clone(), AdapterEventType::Error, message) + .await?; + store + .acknowledge_outbound_message(&adapter.id, &command_id) + .await + } + WorkerEvent::Disconnected { reason } => { + record_worker_lifecycle( + store, + conversation, + adapter, + config, + "disconnected", + serde_json::json!({ "reason": reason }), + ) + .await + } + } +} + +async fn handle_worker_message( + store: &AdapterStore, + conversation: &dyn HarnessConversation, + adapter: &AdapterRecord, + config: &AdapterConfig, + target: String, + sender: Option, + text: String, + message_id: Option, + _metadata: serde_json::Value, +) -> Result<()> { + if let Some(message_id) = &message_id + && !store + .record_inbound_message_once(&adapter.id, &target, message_id) + .await? + { + return Ok(()); + } + // Note: we intentionally do not write a conversation artifact here. + // The wakeup turn below begins immediately, and any artifact writes + // through the conversation handle (rather than the active turn) advance + // the conversation head and could race with concurrent turns. The full + // inbound text is delivered to the agent via the wakeup prompt and the + // event is recorded in the AdapterStore for audit. + store + .record_event( + adapter.id.clone(), + AdapterEventType::Inbound, + format!( + "{} adapter message from {} to {}", + config.adapter_type, + sender.as_deref().unwrap_or("unknown"), + target + ), + ) + .await?; + send_conversation_wakeup( + conversation, + format!( + "{} message received at target `{}` from {} via adapter `{}`:\n\n{}\n\nThis message came from an external adapter. If you answer this message, you MUST reply externally with send_adapter_message using adapterId `{}` and target `{}`. Do not answer only in the REPL unless you are explicitly deciding that no external reply should be sent. If this asks you to schedule future work whose results should be posted back externally, include adapterId `{}` and target `{}` in the scheduled task reportPrompt.", + config.adapter_type, + target, + sender.as_deref().unwrap_or("unknown"), + adapter.name, + text, + adapter.id, + target, + adapter.id, + target, + ), + ) + .await?; + Ok(()) +} + +async fn record_worker_lifecycle( + store: &AdapterStore, + _conversation: &dyn HarnessConversation, + adapter: &AdapterRecord, + config: &AdapterConfig, + event_type: &str, + _payload: serde_json::Value, +) -> Result<()> { + // Note: lifecycle events are recorded only in the AdapterStore. Writing + // them to the conversation as artifacts can advance the head outside of + // any active turn and corrupt the agent's turn state. + store + .record_event( + adapter.id.clone(), + match event_type { + "connected" => AdapterEventType::Connected, + "error" => AdapterEventType::Error, + _ => AdapterEventType::Inbound, + }, + format!("{} worker {event_type}", config.adapter_type), + ) + .await?; + Ok(()) +} + +async fn require_agent( + harness: &dyn Harness, + adapter: &AdapterRecord, +) -> Result> { + harness + .get_agent(&adapter.agent_id) + .await? + .ok_or_else(|| anyhow!("adapter agent does not exist: {}", adapter.agent_id)) +} + +async fn require_conversation( + agent: &dyn HarnessAgent, + adapter: &AdapterRecord, +) -> Result> { + agent + .get_conversation(&adapter.conversation_id) + .await? + .ok_or_else(|| { + anyhow!( + "adapter conversation does not exist: {}", + adapter.conversation_id + ) + }) +} + +async fn worker_secret_env( + agent: &dyn AgentHandle, + config: &AdapterConfig, +) -> Result> { + let mut env = Vec::new(); + for secret_env in &config.secret_env { + let secret_uuid = resolve_secret_id(agent, &secret_env.secret_id).await?; + let Some(secret) = agent.get_secret(&secret_uuid).await? else { + bail!("adapter secret not found: {}", secret_env.secret_id); + }; + let value = match secret { + Secret::Key { value } => value, + Secret::Oauth { .. } => bail!("adapter worker secrets must be key secrets"), + }; + env.push((secret_env.env.clone(), value)); + } + Ok(env) +} + +async fn resolve_secret_id(agent: &dyn AgentHandle, reference: &str) -> Result { + if let Ok(secret_id) = reference.parse() { + return Ok(secret_id); + } + agent + .list_secrets() + .await? + .into_iter() + .find(|secret| secret.name == reference) + .map(|secret| secret.id) + .ok_or_else(|| anyhow!("adapter secret not found: {reference}")) +} diff --git a/crates/executor/src/adapter/store.rs b/crates/executor/src/adapter/store.rs new file mode 100644 index 0000000..f77f1f0 --- /dev/null +++ b/crates/executor/src/adapter/store.rs @@ -0,0 +1,567 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use exoharness::Uuid7; +use serde::Serialize; +use tokio::fs; +use tokio::io::AsyncWriteExt; + +use super::types::{ + AdapterAttachment, AdapterEventRecord, AdapterEventType, AdapterInboundMessageRecord, + AdapterOutboundMessageRecord, AdapterRecord, NewAdapter, now_ms, +}; + +#[derive(Debug, Clone)] +pub struct AdapterStore { + root: PathBuf, +} + +impl AdapterStore { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub async fn create_adapter(&self, request: NewAdapter) -> Result { + let adapter = AdapterRecord::new(request, now_ms())?; + self.put_adapter(&adapter).await?; + Ok(adapter) + } + + pub async fn list_adapters(&self) -> Result> { + let adapter_dir = self.adapters_dir(); + match fs::metadata(&adapter_dir).await { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(Vec::new()); + } + Err(error) => return Err(error.into()), + } + let mut entries = fs::read_dir(&adapter_dir) + .await + .with_context(|| format!("failed to read adapter directory {adapter_dir:?}"))?; + let mut adapters = Vec::new(); + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + let bytes = fs::read(&path) + .await + .with_context(|| format!("failed to read adapter {}", path.display()))?; + adapters.push(serde_json::from_slice::(&bytes)?); + } + adapters.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id))); + Ok(adapters) + } + + pub async fn list_adapters_for_conversation( + &self, + agent_id: &str, + conversation_id: &str, + include_disabled: bool, + ) -> Result> { + Ok(self + .list_adapters() + .await? + .into_iter() + .filter(|adapter| { + adapter.agent_id == agent_id && adapter.conversation_id == conversation_id + }) + .filter(|adapter| include_disabled || adapter.enabled) + .collect()) + } + + pub async fn enabled_adapters(&self) -> Result> { + Ok(self + .list_adapters() + .await? + .into_iter() + .filter(|adapter| adapter.enabled) + .collect()) + } + + pub async fn get_adapter(&self, adapter_id: &str) -> Result> { + let path = self.adapter_path(adapter_id); + match fs::read(&path).await { + Ok(bytes) => Ok(Some(serde_json::from_slice::(&bytes)?)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => { + Err(error).with_context(|| format!("failed to read adapter {}", path.display())) + } + } + } + + pub async fn put_adapter(&self, adapter: &AdapterRecord) -> Result<()> { + fs::create_dir_all(self.adapters_dir()).await?; + let path = self.adapter_path(&adapter.id); + write_json_file(&path, adapter) + .await + .with_context(|| format!("failed to write adapter {}", path.display())) + } + + pub async fn disable_adapter(&self, adapter_id: &str) -> Result> { + let Some(mut adapter) = self.get_adapter(adapter_id).await? else { + return Ok(None); + }; + adapter.enabled = false; + adapter.updated_at_ms = now_ms(); + self.put_adapter(&adapter).await?; + Ok(Some(adapter)) + } + + pub async fn delete_adapter(&self, adapter_id: &str) -> Result> { + let Some(adapter) = self.get_adapter(adapter_id).await? else { + return Ok(None); + }; + remove_file_if_exists(self.adapter_path(adapter_id)).await?; + remove_dir_if_exists(self.events_dir(adapter_id)).await?; + remove_dir_if_exists(self.outbox_dir(adapter_id)).await?; + remove_dir_if_exists(self.inflight_dir(adapter_id)).await?; + remove_dir_if_exists(self.inbound_seen_dir(adapter_id)).await?; + Ok(Some(adapter)) + } + + pub async fn mark_connected(&self, adapter_id: &str) -> Result> { + let Some(mut adapter) = self.get_adapter(adapter_id).await? else { + return Ok(None); + }; + adapter.last_connected_at_ms = Some(now_ms()); + adapter.last_error = None; + adapter.updated_at_ms = now_ms(); + self.put_adapter(&adapter).await?; + Ok(Some(adapter)) + } + + pub async fn mark_error( + &self, + adapter_id: &str, + error: impl Into, + ) -> Result> { + let Some(mut adapter) = self.get_adapter(adapter_id).await? else { + return Ok(None); + }; + adapter.last_error = Some(error.into()); + adapter.updated_at_ms = now_ms(); + self.put_adapter(&adapter).await?; + Ok(Some(adapter)) + } + + pub async fn put_event(&self, event: &AdapterEventRecord) -> Result<()> { + fs::create_dir_all(self.events_dir(&event.adapter_id)).await?; + let path = self.event_path(&event.adapter_id, &event.id); + write_json_file(&path, event) + .await + .with_context(|| format!("failed to write adapter event {}", path.display())) + } + + pub async fn record_event( + &self, + adapter_id: String, + event_type: AdapterEventType, + summary: String, + ) -> Result { + let event = AdapterEventRecord::new(adapter_id.clone(), event_type, summary, now_ms())?; + self.put_event(&event).await?; + if let Some(mut adapter) = self.get_adapter(&adapter_id).await? { + adapter.updated_at_ms = now_ms(); + self.put_adapter(&adapter).await?; + } + Ok(event) + } + + pub async fn enqueue_outbound_message( + &self, + adapter_id: String, + text: String, + target: Option, + attachments: Vec, + ) -> Result { + let message = + AdapterOutboundMessageRecord::new(adapter_id, text, target, attachments, now_ms())?; + fs::create_dir_all(self.outbox_dir(&message.adapter_id)).await?; + let path = self.outbox_path(&message.adapter_id, &message.id); + write_json_file(&path, &message).await.with_context(|| { + format!( + "failed to write adapter outbound message {}", + path.display() + ) + })?; + Ok(message) + } + + pub async fn claim_outbound_messages( + &self, + adapter_id: &str, + ) -> Result> { + let outbox_dir = self.outbox_dir(adapter_id); + match fs::metadata(&outbox_dir).await { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(Vec::new()); + } + Err(error) => return Err(error.into()), + } + let mut entries = fs::read_dir(&outbox_dir) + .await + .with_context(|| format!("failed to read adapter outbox directory {outbox_dir:?}"))?; + let mut messages = Vec::new(); + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + let bytes = fs::read(&path).await.with_context(|| { + format!("failed to read adapter outbound message {}", path.display()) + })?; + let message = serde_json::from_slice::(&bytes)?; + fs::create_dir_all(self.inflight_dir(adapter_id)).await?; + let inflight_path = self.inflight_path(adapter_id, &message.id); + fs::rename(&path, &inflight_path).await.with_context(|| { + format!( + "failed to claim adapter outbound message {} into {}", + path.display(), + inflight_path.display() + ) + })?; + messages.push(message); + } + messages.sort_by_key(|message| message.created_at_ms); + Ok(messages) + } + + pub async fn acknowledge_outbound_message( + &self, + adapter_id: &str, + message_id: &str, + ) -> Result<()> { + remove_file_if_exists(self.inflight_path(adapter_id, message_id)).await?; + remove_file_if_exists(self.outbox_path(adapter_id, message_id)).await?; + Ok(()) + } + + pub async fn requeue_outbound_message(&self, adapter_id: &str, message_id: &str) -> Result<()> { + let inflight_path = self.inflight_path(adapter_id, message_id); + match fs::metadata(&inflight_path).await { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + } + fs::create_dir_all(self.outbox_dir(adapter_id)).await?; + let outbox_path = self.outbox_path(adapter_id, message_id); + fs::rename(&inflight_path, &outbox_path) + .await + .with_context(|| { + format!( + "failed to requeue adapter outbound message {} into {}", + inflight_path.display(), + outbox_path.display() + ) + }) + } + + pub async fn requeue_inflight_messages(&self, adapter_id: &str) -> Result<()> { + let inflight_dir = self.inflight_dir(adapter_id); + match fs::metadata(&inflight_dir).await { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + } + let mut entries = fs::read_dir(&inflight_dir).await.with_context(|| { + format!("failed to read adapter inflight directory {inflight_dir:?}") + })?; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + let Some(message_id) = path.file_stem().and_then(|stem| stem.to_str()) else { + continue; + }; + self.requeue_outbound_message(adapter_id, message_id) + .await?; + } + Ok(()) + } + + pub async fn record_inbound_message_once( + &self, + adapter_id: &str, + target: &str, + message_id: &str, + ) -> Result { + let record = AdapterInboundMessageRecord { + adapter_id: adapter_id.to_string(), + target: target.to_string(), + message_id: message_id.to_string(), + first_seen_at_ms: now_ms(), + }; + let seen_dir = self.inbound_seen_dir(adapter_id); + fs::create_dir_all(&seen_dir).await?; + let path = seen_dir.join(format!("{}.json", stable_message_key(target, message_id))); + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .await + { + Ok(mut file) => { + let bytes = serde_json::to_vec_pretty(&record)?; + file.write_all(&bytes).await.with_context(|| { + format!("failed to write inbound seen marker {}", path.display()) + })?; + file.flush().await.with_context(|| { + format!("failed to flush inbound seen marker {}", path.display()) + })?; + Ok(true) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false), + Err(error) => Err(error).with_context(|| { + format!("failed to create inbound seen marker {}", path.display()) + }), + } + } + + fn adapters_dir(&self) -> PathBuf { + self.root.join("adapters") + } + + fn adapter_path(&self, adapter_id: &str) -> PathBuf { + self.adapters_dir().join(format!("{adapter_id}.json")) + } + + fn events_dir(&self, adapter_id: &str) -> PathBuf { + self.root.join("events").join(adapter_id) + } + + fn event_path(&self, adapter_id: &str, event_id: &str) -> PathBuf { + self.events_dir(adapter_id).join(format!("{event_id}.json")) + } + + fn outbox_dir(&self, adapter_id: &str) -> PathBuf { + self.root.join("outbox").join(adapter_id) + } + + fn outbox_path(&self, adapter_id: &str, message_id: &str) -> PathBuf { + self.outbox_dir(adapter_id) + .join(format!("{message_id}.json")) + } + + fn inflight_dir(&self, adapter_id: &str) -> PathBuf { + self.root.join("outbox-inflight").join(adapter_id) + } + + fn inflight_path(&self, adapter_id: &str, message_id: &str) -> PathBuf { + self.inflight_dir(adapter_id) + .join(format!("{message_id}.json")) + } + + fn inbound_seen_dir(&self, adapter_id: &str) -> PathBuf { + self.root.join("inbound-seen").join(adapter_id) + } +} + +async fn remove_file_if_exists(path: PathBuf) -> Result<()> { + match fs::remove_file(&path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => { + Err(error).with_context(|| format!("failed to delete file {}", path.display())) + } + } +} + +async fn write_json_file(path: &Path, value: &T) -> Result<()> { + let temp_path = path.with_extension(format!("json.{}.tmp", Uuid7::now())); + fs::write(&temp_path, serde_json::to_vec_pretty(value)?) + .await + .with_context(|| format!("failed to write temp file {}", temp_path.display()))?; + fs::rename(&temp_path, path).await.with_context(|| { + format!( + "failed to replace {} with temp file {}", + path.display(), + temp_path.display() + ) + }) +} + +async fn remove_dir_if_exists(path: PathBuf) -> Result<()> { + match fs::remove_dir_all(&path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => { + Err(error).with_context(|| format!("failed to delete directory {}", path.display())) + } + } +} + +fn stable_message_key(target: &str, message_id: &str) -> String { + let mut hash = 0xcbf29ce484222325u64; + for byte in target.bytes().chain([0]).chain(message_id.bytes()) { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{hash:016x}") +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::super::types::{ + AdapterAttachment, AdapterAttachmentKind, AdapterConfig, AdapterEventType, AdapterSource, + }; + + use super::*; + + #[tokio::test] + async fn creates_lists_disables_and_deletes_adapters() { + let tempdir = TempDir::new().unwrap(); + let store = AdapterStore::new(tempdir.path()); + let adapter = store + .create_adapter(NewAdapter { + agent_id: "agent".to_string(), + conversation_id: "conversation".to_string(), + name: "irc".to_string(), + source: AdapterSource::BuiltIn, + config: AdapterConfig { + adapter_type: "irc".to_string(), + worker_command: vec!["node".to_string(), "irc.js".to_string()], + initialization: serde_json::json!({}), + state_dir: None, + secret_env: Vec::new(), + }, + }) + .await + .unwrap(); + + assert_eq!(store.list_adapters().await.unwrap(), vec![adapter.clone()]); + store + .record_event( + adapter.id.clone(), + AdapterEventType::Connected, + "connected".to_string(), + ) + .await + .unwrap(); + assert_eq!( + store + .list_adapters_for_conversation("agent", "conversation", false) + .await + .unwrap() + .len(), + 1 + ); + store.disable_adapter(&adapter.id).await.unwrap(); + assert!( + store + .list_adapters_for_conversation("agent", "conversation", false) + .await + .unwrap() + .is_empty() + ); + assert!(store.delete_adapter(&adapter.id).await.unwrap().is_some()); + assert!(store.get_adapter(&adapter.id).await.unwrap().is_none()); + } + + #[tokio::test] + async fn preserves_outbound_targets() { + let tempdir = TempDir::new().unwrap(); + let store = AdapterStore::new(tempdir.path()); + let message = store + .enqueue_outbound_message( + "adapter".to_string(), + "hello".to_string(), + Some("123@s.whatsapp.net".to_string()), + vec![AdapterAttachment { + kind: AdapterAttachmentKind::Image, + path: Some(".exo/generated/chart.png".to_string()), + url: None, + data: None, + sandbox_path: None, + mime_type: Some("image/png".to_string()), + file_name: None, + }], + ) + .await + .unwrap(); + + assert_eq!(message.target.as_deref(), Some("123@s.whatsapp.net")); + assert_eq!(message.attachments.len(), 1); + let messages = store.claim_outbound_messages("adapter").await.unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].target.as_deref(), Some("123@s.whatsapp.net")); + assert_eq!( + messages[0].attachments[0].path.as_deref(), + Some(".exo/generated/chart.png") + ); + assert!( + store + .claim_outbound_messages("adapter") + .await + .unwrap() + .is_empty() + ); + store + .acknowledge_outbound_message("adapter", &message.id) + .await + .unwrap(); + assert!( + store + .claim_outbound_messages("adapter") + .await + .unwrap() + .is_empty() + ); + } + + #[tokio::test] + async fn requeues_claimed_outbound_messages() { + let tempdir = TempDir::new().unwrap(); + let store = AdapterStore::new(tempdir.path()); + let message = store + .enqueue_outbound_message("adapter".to_string(), "hello".to_string(), None, Vec::new()) + .await + .unwrap(); + + let claimed = store.claim_outbound_messages("adapter").await.unwrap(); + assert_eq!(claimed.len(), 1); + assert!( + store + .claim_outbound_messages("adapter") + .await + .unwrap() + .is_empty() + ); + + store + .requeue_outbound_message("adapter", &message.id) + .await + .unwrap(); + let claimed_again = store.claim_outbound_messages("adapter").await.unwrap(); + assert_eq!(claimed_again.len(), 1); + assert_eq!(claimed_again[0].id, message.id); + } + + #[tokio::test] + async fn records_inbound_message_ids_once() { + let tempdir = TempDir::new().unwrap(); + let store = AdapterStore::new(tempdir.path()); + + assert!( + store + .record_inbound_message_once("adapter", "target", "message") + .await + .unwrap() + ); + assert!( + !store + .record_inbound_message_once("adapter", "target", "message") + .await + .unwrap() + ); + } +} diff --git a/crates/executor/src/adapter/tools.rs b/crates/executor/src/adapter/tools.rs new file mode 100644 index 0000000..5b42b1e --- /dev/null +++ b/crates/executor/src/adapter/tools.rs @@ -0,0 +1,999 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, bail}; +use base64::Engine; +use exoharness::{ + AgentHandle, ConversationHandle, Result, RunInSandboxRequest, SandboxProcess, ToolRequest, + ToolResult, Uuid7, +}; +use futures::{StreamExt, io::AsyncReadExt}; +use serde::Deserialize; +use serde_json::Value; +use tokio::fs; + +use super::runtime::send_adapter_message_with_handles; +use super::store::AdapterStore; +use super::types::{ + AdapterAttachment, AdapterConfig, AdapterSource, NewAdapter, WorkerSecretEnvVar, +}; +use crate::agent_sandbox::ensure_agent_sandbox; +use crate::conversation_sandbox::ensure_conversation_sandbox; +use crate::{AgentConfig, ConversationConfig, SandboxScope, effective_sandbox_scope}; + +const MAX_ATTACHMENT_BYTES: usize = 25 * 1024 * 1024; +const MAX_ATTACHMENT_BASE64_BYTES: usize = MAX_ATTACHMENT_BYTES.div_ceil(3) * 4 + 4; +const ATTACHMENT_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60); +const MAX_ATTACHMENT_STDERR_BYTES: usize = 64 * 1024; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateAdapterArguments { + name: String, + source: AdapterSource, + config: AdapterCreationConfig, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ConversationScopedArguments { + include_disabled: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AdapterIdArguments { + adapter_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SendAdapterMessageArguments { + adapter_id: String, + text: String, + target: Option, + #[serde(default)] + attachments: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum AdapterCreationConfig { + Irc(IrcAdapterCreationConfig), + Whatsapp(WhatsappAdapterCreationConfig), + Signal(SignalAdapterCreationConfig), + Discord(DiscordAdapterCreationConfig), +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct IrcAdapterCreationConfig { + #[serde(rename = "type")] + _adapter_type: IrcAdapterType, + server: String, + port: u16, + tls: bool, + nick: String, + username: String, + realname: String, + channel: String, + password_secret_id: Option, + trigger: IrcTrigger, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct WhatsappAdapterCreationConfig { + #[serde(rename = "type")] + _adapter_type: WhatsappAdapterType, + auth_dir: Option, + link_method: Option, + phone_number: Option, + trigger: ChatTrigger, + allowed_chats: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SignalAdapterCreationConfig { + #[serde(rename = "type")] + _adapter_type: SignalAdapterType, + account: Option, + device_name: Option, + config_dir: Option, + trigger: ChatTrigger, + allowed_contacts: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DiscordAdapterCreationConfig { + #[serde(rename = "type")] + _adapter_type: DiscordAdapterType, + bot_token_secret_id: String, + default_channel_id: Option, + trigger: DiscordTrigger, + allowed_channels: Option>, + allow_bots: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum IrcAdapterType { + Irc, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum WhatsappAdapterType { + Whatsapp, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum WhatsappLinkMethod { + Qr, + PairingCode, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum SignalAdapterType { + Signal, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum DiscordAdapterType { + Discord, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum IrcTrigger { + Mention, + AllMessages, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum ChatTrigger { + AllMessages, + ContactsOnly, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum DiscordTrigger { + AllMessages, + MentionsOnly, +} + +impl AdapterCreationConfig { + fn adapter_type(&self) -> &'static str { + match self { + Self::Irc(_) => "irc", + Self::Whatsapp(_) => "whatsapp", + Self::Signal(_) => "signal", + Self::Discord(_) => "discord", + } + } + + fn into_adapter_config(self, source: AdapterSource) -> Result { + match self { + Self::Irc(config) => { + require_source(source, AdapterSource::BuiltIn, "irc")?; + Ok(AdapterConfig { + adapter_type: "irc".to_string(), + worker_command: bundled_worker_command( + "examples/exoclaw/adapters/irc/worker.ts", + ), + initialization: serde_json::json!({ + "server": config.server, + "port": config.port, + "tls": config.tls, + "nick": config.nick, + "username": config.username, + "realname": config.realname, + "channel": config.channel, + "trigger": config.trigger.as_str(), + }), + state_dir: None, + secret_env: config + .password_secret_id + .map(|secret_id| { + vec![WorkerSecretEnvVar { + env: "EXO_IRC_PASSWORD".to_string(), + secret_id, + }] + }) + .unwrap_or_default(), + }) + } + Self::Whatsapp(config) => { + require_source(source, AdapterSource::Library, "whatsapp")?; + if matches!(config.link_method, Some(WhatsappLinkMethod::PairingCode)) + && config + .phone_number + .as_deref() + .is_none_or(|phone_number| phone_number.trim().is_empty()) + { + bail!("whatsapp pairing-code linkMethod requires phoneNumber"); + } + Ok(AdapterConfig { + adapter_type: "whatsapp".to_string(), + worker_command: bundled_worker_command( + "examples/exoclaw/adapters/whatsapp/worker.ts", + ), + initialization: serde_json::json!({ + "authDir": config.auth_dir, + "linkMethod": config.link_method.map(|method| method.as_str()), + "phoneNumber": config.phone_number, + "trigger": config.trigger.as_str(), + "allowedChats": config.allowed_chats, + }), + state_dir: None, + secret_env: Vec::new(), + }) + } + Self::Signal(config) => { + require_source(source, AdapterSource::Library, "signal")?; + Ok(AdapterConfig { + adapter_type: "signal".to_string(), + worker_command: bundled_worker_command( + "examples/exoclaw/adapters/signal/worker.ts", + ), + initialization: serde_json::json!({ + "account": config.account, + "deviceName": config.device_name, + "configDir": config.config_dir, + "trigger": config.trigger.as_str(), + "allowedContacts": config.allowed_contacts, + }), + state_dir: None, + secret_env: Vec::new(), + }) + } + Self::Discord(config) => { + require_source(source, AdapterSource::Library, "discord")?; + Ok(AdapterConfig { + adapter_type: "discord".to_string(), + worker_command: bundled_worker_command( + "examples/exoclaw/adapters/discord/worker.ts", + ), + initialization: serde_json::json!({ + "tokenEnv": "EXO_DISCORD_BOT_TOKEN", + "defaultChannelId": config.default_channel_id, + "trigger": config.trigger.as_str(), + "allowedChannels": config.allowed_channels, + "allowBots": config.allow_bots, + }), + state_dir: None, + secret_env: vec![WorkerSecretEnvVar { + env: "EXO_DISCORD_BOT_TOKEN".to_string(), + secret_id: config.bot_token_secret_id, + }], + }) + } + } + } +} + +impl IrcTrigger { + fn as_str(&self) -> &'static str { + match self { + Self::Mention => "mention", + Self::AllMessages => "all_messages", + } + } +} + +impl ChatTrigger { + fn as_str(&self) -> &'static str { + match self { + Self::AllMessages => "all_messages", + Self::ContactsOnly => "contacts_only", + } + } +} + +impl WhatsappLinkMethod { + fn as_str(&self) -> &'static str { + match self { + Self::Qr => "qr", + Self::PairingCode => "pairing-code", + } + } +} + +impl DiscordTrigger { + fn as_str(&self) -> &'static str { + match self { + Self::AllMessages => "all_messages", + Self::MentionsOnly => "mentions_only", + } + } +} + +fn require_source( + actual: AdapterSource, + expected: AdapterSource, + adapter_type: &str, +) -> Result<()> { + if actual != expected { + bail!("{adapter_type} adapters must use source {expected:?}"); + } + Ok(()) +} + +fn bundled_worker_command(relative_path: &str) -> Vec { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(relative_path); + vec![ + "pnpm".to_string(), + "tsx".to_string(), + path.to_string_lossy().into_owned(), + ] +} + +pub async fn execute_create_adapter_tool( + agent: &dyn AgentHandle, + conversation: &dyn ConversationHandle, + store: &AdapterStore, + request: &ToolRequest, +) -> Result { + let args = + serde_json::from_value::(Value::Object(request.arguments.clone()))?; + let adapter_type = args.config.adapter_type(); + let config = args.config.into_adapter_config(args.source)?; + let adapter = store + .create_adapter(NewAdapter { + agent_id: agent.record().id.to_string(), + conversation_id: conversation.record().id.to_string(), + name: args.name, + source: args.source, + config, + }) + .await?; + if adapter.config.adapter_type != adapter_type { + bail!("created adapter type mismatch"); + } + Ok(serde_json::json!({ + "ok": true, + "adapter": adapter, + })) +} + +pub async fn execute_list_adapters_tool( + agent: &dyn AgentHandle, + conversation: &dyn ConversationHandle, + store: &AdapterStore, + request: &ToolRequest, +) -> Result { + let args = serde_json::from_value::(Value::Object( + request.arguments.clone(), + ))?; + let adapters = store + .list_adapters_for_conversation( + &agent.record().id.to_string(), + &conversation.record().id.to_string(), + args.include_disabled.unwrap_or(false), + ) + .await?; + Ok(serde_json::json!({ + "ok": true, + "adapters": adapters, + })) +} + +pub async fn execute_disable_adapter_tool( + conversation: &dyn ConversationHandle, + agent: &dyn AgentHandle, + store: &AdapterStore, + request: &ToolRequest, +) -> Result { + let args = + serde_json::from_value::(Value::Object(request.arguments.clone()))?; + let Some(adapter) = store.get_adapter(&args.adapter_id).await? else { + return Ok(not_found()); + }; + if adapter.agent_id != agent.record().id.to_string() + || adapter.conversation_id != conversation.record().id.to_string() + { + return Ok(not_found()); + } + store.disable_adapter(&args.adapter_id).await?; + Ok(serde_json::json!({ + "ok": true, + "adapterId": args.adapter_id, + "disabled": true, + })) +} + +pub async fn execute_delete_adapter_tool( + conversation: &dyn ConversationHandle, + agent: &dyn AgentHandle, + store: &AdapterStore, + request: &ToolRequest, +) -> Result { + let args = + serde_json::from_value::(Value::Object(request.arguments.clone()))?; + let Some(adapter) = store.get_adapter(&args.adapter_id).await? else { + return Ok(not_found()); + }; + if adapter.agent_id != agent.record().id.to_string() + || adapter.conversation_id != conversation.record().id.to_string() + { + return Ok(not_found()); + } + store.delete_adapter(&args.adapter_id).await?; + Ok(serde_json::json!({ + "ok": true, + "adapterId": args.adapter_id, + "deleted": true, + "eventsDeleted": true, + })) +} + +pub async fn execute_send_adapter_message_tool( + agent: &dyn AgentHandle, + conversation: &dyn ConversationHandle, + agent_config: &AgentConfig, + config: &ConversationConfig, + store: &AdapterStore, + request: &ToolRequest, +) -> Result { + let args = serde_json::from_value::(Value::Object( + request.arguments.clone(), + ))?; + let Some(adapter) = store.get_adapter(&args.adapter_id).await? else { + return Ok(not_found()); + }; + if adapter.agent_id != agent.record().id.to_string() + || adapter.conversation_id != conversation.record().id.to_string() + { + return Ok(not_found()); + } + let attachments = args.attachments.unwrap_or_default(); + if !attachments.is_empty() + && adapter.config.adapter_type != "whatsapp" + && adapter.config.adapter_type != "signal" + && adapter.config.adapter_type != "discord" + { + bail!( + "adapter {} does not support rich attachments", + adapter.config.adapter_type + ); + } + let attachments = resolve_sandbox_attachments( + agent, + conversation, + agent_config, + config, + store, + &adapter, + attachments, + ) + .await?; + send_adapter_message_with_handles( + agent, + conversation, + store, + &adapter, + &args.text, + args.target.as_deref(), + attachments, + ) + .await?; + Ok(serde_json::json!({ + "ok": true, + "adapterId": args.adapter_id, + "sent": true, + })) +} + +async fn resolve_sandbox_attachments( + agent: &dyn AgentHandle, + conversation: &dyn ConversationHandle, + agent_config: &AgentConfig, + config: &ConversationConfig, + store: &AdapterStore, + adapter: &super::types::AdapterRecord, + attachments: Vec, +) -> Result> { + let mut resolved = Vec::with_capacity(attachments.len()); + for mut attachment in attachments { + attachment.validate()?; + if attachment.path.is_some() { + bail!("attachment path is host-internal; use sandboxPath, url, or data"); + } + if let Some(url) = attachment.url.clone() { + let bytes = download_attachment(&url).await?; + let path = stage_attachment(store, &adapter.id, &url, &attachment, bytes).await?; + attachment.path = Some(path.to_string_lossy().into_owned()); + attachment.url = None; + resolved.push(attachment); + continue; + } + if let Some(data) = attachment.data.clone() { + let bytes = decode_attachment_data(&data)?; + if bytes.len() > MAX_ATTACHMENT_BYTES { + bail!( + "attachment data is too large: {} bytes exceeds {} bytes", + bytes.len(), + MAX_ATTACHMENT_BYTES + ); + } + let path = + stage_attachment(store, &adapter.id, "attachment", &attachment, bytes).await?; + attachment.path = Some(path.to_string_lossy().into_owned()); + attachment.data = None; + resolved.push(attachment); + continue; + } + let Some(sandbox_path) = attachment.sandbox_path.clone() else { + resolved.push(attachment); + continue; + }; + let bytes = + read_sandbox_file(agent, conversation, agent_config, config, &sandbox_path).await?; + let path = stage_attachment(store, &adapter.id, &sandbox_path, &attachment, bytes).await?; + attachment.path = Some(path.to_string_lossy().into_owned()); + attachment.sandbox_path = None; + resolved.push(attachment); + } + Ok(resolved) +} + +async fn read_sandbox_file( + agent: &dyn AgentHandle, + conversation: &dyn ConversationHandle, + agent_config: &AgentConfig, + config: &ConversationConfig, + sandbox_path: &str, +) -> Result> { + match effective_sandbox_scope(agent_config, config) { + SandboxScope::Agent => { + let sandbox = ensure_agent_sandbox(agent, conversation, agent_config, config).await?; + read_sandbox_file_bytes( + sandbox.conversation.as_ref(), + sandbox.sandbox_id, + sandbox_path, + ) + .await + } + SandboxScope::Conversation => { + let sandbox_id = + ensure_conversation_sandbox(conversation, agent_config, config).await?; + read_sandbox_file_bytes(conversation, sandbox_id, sandbox_path).await + } + } +} + +async fn download_attachment(url: &str) -> Result> { + let client = reqwest::Client::builder() + .timeout(ATTACHMENT_DOWNLOAD_TIMEOUT) + .build() + .context("failed to create adapter attachment download client")?; + let response = client + .get(url) + .send() + .await + .with_context(|| format!("failed to download adapter attachment {url}"))? + .error_for_status() + .with_context(|| format!("failed to download adapter attachment {url}"))?; + if let Some(content_length) = response.content_length() + && content_length > MAX_ATTACHMENT_BYTES as u64 + { + bail!( + "attachment is too large: {} bytes exceeds {} bytes", + content_length, + MAX_ATTACHMENT_BYTES + ); + } + let mut stream = response.bytes_stream(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.with_context(|| format!("failed to read adapter attachment {url}"))?; + if bytes.len() + chunk.len() > MAX_ATTACHMENT_BYTES { + bail!( + "attachment is too large: more than {} bytes", + MAX_ATTACHMENT_BYTES + ); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +fn decode_attachment_data(data: &str) -> Result> { + let payload = data + .strip_prefix("data:") + .and_then(|_| data.split_once(',').map(|(_, payload)| payload)) + .unwrap_or(data); + if payload.len() > MAX_ATTACHMENT_BASE64_BYTES { + bail!( + "attachment data is too large: base64 payload exceeds {} bytes", + MAX_ATTACHMENT_BASE64_BYTES + ); + } + base64::engine::general_purpose::STANDARD + .decode(payload.trim()) + .context("failed to decode attachment data") +} + +async fn read_sandbox_file_bytes( + conversation: &dyn ConversationHandle, + sandbox_id: String, + sandbox_path: &str, +) -> Result> { + let process = conversation + .run_in_sandbox(RunInSandboxRequest { + id: sandbox_id, + command: vec![ + "sh".to_string(), + "-c".to_string(), + "cat -- \"$1\"".to_string(), + "exo-read-attachment".to_string(), + sandbox_path.to_string(), + ], + env: Default::default(), + }) + .await?; + let output = read_process_limited(process, MAX_ATTACHMENT_BYTES).await?; + if output.exit_code != 0 { + bail!( + "failed to read sandbox attachment {}: {}", + sandbox_path, + output.stderr.trim() + ); + } + if output.truncated { + bail!( + "sandbox attachment is too large: exceeds {} bytes", + MAX_ATTACHMENT_BYTES + ); + } + Ok(output.stdout) +} + +struct ProcessOutput { + stdout: Vec, + stderr: String, + exit_code: i32, + truncated: bool, +} + +async fn read_process_limited( + process: Box, + max_stdout_bytes: usize, +) -> Result { + let parts = process.into_parts(); + let mut stdout = parts.stdout; + let mut stderr = parts.stderr; + drop(parts.stdin); + + let (stdout_result, stderr_result, wait_result) = tokio::join!( + read_limited(&mut stdout, max_stdout_bytes), + read_limited(&mut stderr, MAX_ATTACHMENT_STDERR_BYTES), + parts.wait, + ); + let (stdout_bytes, stdout_truncated) = stdout_result?; + let (stderr_bytes, stderr_truncated) = stderr_result?; + let exit_code = wait_result?; + + Ok(ProcessOutput { + stdout: stdout_bytes, + stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(), + exit_code, + truncated: stdout_truncated || stderr_truncated, + }) +} + +async fn read_limited(reader: &mut R, max_bytes: usize) -> Result<(Vec, bool)> +where + R: futures::io::AsyncRead + Unpin, +{ + let mut output = Vec::new(); + let mut buffer = [0u8; 8192]; + let mut truncated = false; + loop { + let read = reader.read(&mut buffer).await?; + if read == 0 { + break; + } + let remaining = max_bytes.saturating_sub(output.len()); + if read > remaining { + output.extend_from_slice(&buffer[..remaining]); + truncated = true; + continue; + } + output.extend_from_slice(&buffer[..read]); + } + Ok((output, truncated)) +} + +async fn stage_attachment( + store: &AdapterStore, + adapter_id: &str, + sandbox_path: &str, + attachment: &AdapterAttachment, + bytes: Vec, +) -> Result { + let media_dir = store.root().join("media").join(adapter_id); + fs::create_dir_all(&media_dir).await?; + let file_name = staged_file_name(sandbox_path, attachment); + let path = media_dir.join(format!("{}-{file_name}", Uuid7::now())); + fs::write(&path, bytes) + .await + .with_context(|| format!("failed to stage adapter attachment {}", path.display()))?; + Ok(path) +} + +fn staged_file_name(sandbox_path: &str, attachment: &AdapterAttachment) -> String { + let raw = attachment + .file_name + .as_deref() + .or_else(|| { + Path::new(sandbox_path) + .file_name() + .and_then(|name| name.to_str()) + }) + .unwrap_or("attachment"); + let sanitized = raw + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect::(); + if sanitized.is_empty() { + "attachment".to_string() + } else { + sanitized + } +} + +fn not_found() -> ToolResult { + serde_json::json!({ + "ok": false, + "error": "adapter not found for this conversation", + }) +} + +#[cfg(test)] +mod tests { + use exoharness::{ + BasicExoHarness, ExoHarness, NewAgentRequest, NewConversationRequest, SandboxProvider, + ToolRequest, + }; + use tempfile::TempDir; + + use super::*; + use crate::test_support::local_test_config; + use crate::{AgentConfig, AgentHarnessKind, ConversationConfig}; + + #[tokio::test] + async fn create_and_list_adapter_tools_are_conversation_scoped() { + let tempdir = TempDir::new().unwrap(); + let exoharness = BasicExoHarness::new(local_test_config(tempdir.path().join("exoharness"))) + .await + .unwrap(); + let agent = exoharness + .new_agent(NewAgentRequest { + slug: "agent".to_string(), + name: "Agent".to_string(), + }) + .await + .unwrap(); + let conversation = agent + .new_conversation(NewConversationRequest { + slug: Some("conversation".to_string()), + name: Some("Conversation".to_string()), + }) + .await + .unwrap(); + let store = AdapterStore::new(tempdir.path().join("adapters")); + let create_result = execute_create_adapter_tool( + agent.as_ref(), + conversation.as_ref(), + &store, + &tool_request( + "create_adapter", + serde_json::json!({ + "agentId": "spoofed-agent", + "conversationId": "spoofed-conversation", + "name": "irc", + "source": "built_in", + "config": { + "type": "irc", + "server": "irc.example.test", + "port": 6697, + "tls": true, + "nick": "exo", + "username": "exo", + "realname": "Exo", + "channel": "#exo", + "passwordSecretId": null, + "trigger": "mention" + } + }), + ), + ) + .await + .unwrap(); + assert_eq!(create_result["ok"], true); + let adapter_id = create_result["adapter"]["id"].as_str().unwrap(); + let adapter = store.get_adapter(adapter_id).await.unwrap().unwrap(); + assert_eq!(adapter.agent_id, agent.record().id.to_string()); + assert_eq!( + adapter.conversation_id, + conversation.record().id.to_string() + ); + assert_eq!(adapter.config.worker_command[0], "pnpm"); + assert_eq!(adapter.config.worker_command[1], "tsx"); + assert!( + adapter.config.worker_command[2].ends_with("examples/exoclaw/adapters/irc/worker.ts") + ); + + let list_result = execute_list_adapters_tool( + agent.as_ref(), + conversation.as_ref(), + &store, + &tool_request( + "list_adapters", + serde_json::json!({ + "agentId": "spoofed-agent", + "conversationId": "spoofed-conversation", + "includeDisabled": false + }), + ), + ) + .await + .unwrap(); + assert_eq!(list_result["adapters"].as_array().unwrap().len(), 1); + } + + #[tokio::test] + async fn create_adapter_rejects_raw_worker_config() { + let tempdir = TempDir::new().unwrap(); + let exoharness = BasicExoHarness::new(local_test_config(tempdir.path().join("exoharness"))) + .await + .unwrap(); + let agent = exoharness + .new_agent(NewAgentRequest { + slug: "agent".to_string(), + name: "Agent".to_string(), + }) + .await + .unwrap(); + let conversation = agent + .new_conversation(NewConversationRequest { + slug: Some("conversation".to_string()), + name: Some("Conversation".to_string()), + }) + .await + .unwrap(); + let store = AdapterStore::new(tempdir.path().join("adapters")); + + let error = execute_create_adapter_tool( + agent.as_ref(), + conversation.as_ref(), + &store, + &tool_request( + "create_adapter", + serde_json::json!({ + "name": "whatsapp", + "source": "library", + "config": { + "type": "whatsapp", + "authDir": null, + "trigger": "all_messages", + "allowedChats": null, + "workerCommand": ["sh", "-c", "cat /etc/passwd"] + } + }), + ), + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("data did not match")); + assert!(store.list_adapters().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn send_adapter_message_rejects_host_path_attachments() { + let tempdir = TempDir::new().unwrap(); + let exoharness = BasicExoHarness::new(local_test_config(tempdir.path().join("exoharness"))) + .await + .unwrap(); + let agent = exoharness + .new_agent(NewAgentRequest { + slug: "agent".to_string(), + name: "Agent".to_string(), + }) + .await + .unwrap(); + let conversation = agent + .new_conversation(NewConversationRequest { + slug: Some("conversation".to_string()), + name: Some("Conversation".to_string()), + }) + .await + .unwrap(); + let store = AdapterStore::new(tempdir.path().join("adapters")); + let adapter = store + .create_adapter(NewAdapter { + agent_id: agent.record().id.to_string(), + conversation_id: conversation.record().id.to_string(), + name: "whatsapp".to_string(), + source: AdapterSource::Library, + config: AdapterConfig { + adapter_type: "whatsapp".to_string(), + worker_command: vec!["pnpm".to_string(), "tsx".to_string()], + initialization: serde_json::json!({}), + state_dir: None, + secret_env: Vec::new(), + }, + }) + .await + .unwrap(); + + let error = execute_send_adapter_message_tool( + agent.as_ref(), + conversation.as_ref(), + &test_agent_config(), + &ConversationConfig::default(), + &store, + &tool_request( + "send_adapter_message", + serde_json::json!({ + "adapterId": adapter.id, + "text": "hello", + "target": "chat", + "attachments": [{ + "kind": "image", + "path": "/etc/passwd", + "url": null, + "data": null, + "sandboxPath": null, + "mimeType": null, + "fileName": null + }] + }), + ), + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("host-internal")); + } + + fn tool_request(function_name: &str, arguments: serde_json::Value) -> ToolRequest { + ToolRequest { + function_name: function_name.to_string(), + arguments: arguments.as_object().unwrap().clone(), + } + } + + fn test_agent_config() -> AgentConfig { + AgentConfig { + instructions: Vec::new(), + harness: AgentHarnessKind::Exoclaw, + typescript: None, + enable_agent_tool_creation: true, + sandbox_image: None, + sandbox_provider: SandboxProvider::Docker, + enable_networking: false, + model: "test-model".to_string(), + max_output_tokens: None, + max_tool_round_trips: None, + braintrust: None, + } + } +} diff --git a/crates/executor/src/adapter/types.rs b/crates/executor/src/adapter/types.rs new file mode 100644 index 0000000..f31af21 --- /dev/null +++ b/crates/executor/src/adapter/types.rs @@ -0,0 +1,272 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Result, bail}; +use exoharness::Uuid7; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AdapterSource { + BuiltIn, + Library, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdapterConfig { + pub adapter_type: String, + pub worker_command: Vec, + #[serde(default)] + pub initialization: Value, + #[serde(default)] + pub state_dir: Option, + #[serde(default)] + pub secret_env: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorkerSecretEnvVar { + pub env: String, + pub secret_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdapterAttachment { + pub kind: AdapterAttachmentKind, + #[serde(default)] + pub path: Option, + #[serde(default)] + pub url: Option, + #[serde(default)] + pub data: Option, + #[serde(default)] + pub sandbox_path: Option, + #[serde(default)] + pub mime_type: Option, + #[serde(default)] + pub file_name: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AdapterAttachmentKind { + Image, + Video, + Audio, + Document, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AdapterRecord { + pub id: String, + pub agent_id: String, + pub conversation_id: String, + pub name: String, + pub source: AdapterSource, + pub enabled: bool, + pub created_at_ms: u64, + pub updated_at_ms: u64, + pub config: AdapterConfig, + pub last_connected_at_ms: Option, + pub last_error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct NewAdapter { + pub agent_id: String, + pub conversation_id: String, + pub name: String, + pub source: AdapterSource, + pub config: AdapterConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AdapterEventRecord { + pub id: String, + pub adapter_id: String, + pub event_type: AdapterEventType, + pub created_at_ms: u64, + pub summary: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AdapterOutboundMessageRecord { + pub id: String, + pub adapter_id: String, + pub created_at_ms: u64, + pub text: String, + #[serde(default)] + pub target: Option, + #[serde(default)] + pub attachments: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AdapterEventType { + Connected, + Inbound, + Outbound, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AdapterInboundMessageRecord { + pub adapter_id: String, + pub target: String, + pub message_id: String, + pub first_seen_at_ms: u64, +} + +impl AdapterRecord { + pub fn new(request: NewAdapter, now_ms: u64) -> Result { + validate_adapter_name(&request.name)?; + request.config.validate()?; + Ok(Self { + id: Uuid7::now().to_string(), + agent_id: non_empty("agentId", request.agent_id)?, + conversation_id: non_empty("conversationId", request.conversation_id)?, + name: request.name, + source: request.source, + enabled: true, + created_at_ms: now_ms, + updated_at_ms: now_ms, + config: request.config, + last_connected_at_ms: None, + last_error: None, + }) + } +} + +impl AdapterConfig { + pub fn validate(&self) -> Result<()> { + non_empty_ref("adapterType", &self.adapter_type)?; + if self.worker_command.is_empty() { + bail!("worker adapter workerCommand must not be empty"); + } + for arg in &self.worker_command { + non_empty_ref("workerCommand item", arg)?; + } + if let Some(state_dir) = &self.state_dir { + non_empty_ref("stateDir", state_dir)?; + } + for secret in &self.secret_env { + non_empty_ref("secretEnv env", &secret.env)?; + non_empty_ref("secretEnv secretId", &secret.secret_id)?; + } + Ok(()) + } +} + +impl AdapterEventRecord { + pub fn new( + adapter_id: String, + event_type: AdapterEventType, + summary: String, + now_ms: u64, + ) -> Result { + Ok(Self { + id: Uuid7::now().to_string(), + adapter_id: non_empty("adapterId", adapter_id)?, + event_type, + created_at_ms: now_ms, + summary, + }) + } +} + +impl AdapterOutboundMessageRecord { + pub fn new( + adapter_id: String, + text: String, + target: Option, + attachments: Vec, + now_ms: u64, + ) -> Result { + if let Some(target) = &target { + non_empty_ref("target", target)?; + } + for attachment in &attachments { + attachment.validate()?; + } + Ok(Self { + id: Uuid7::now().to_string(), + adapter_id: non_empty("adapterId", adapter_id)?, + created_at_ms: now_ms, + text: non_empty("text", text)?, + target, + attachments, + }) + } +} + +impl AdapterAttachment { + pub fn validate(&self) -> Result<()> { + let source_count = usize::from(self.path.is_some()) + + usize::from(self.url.is_some()) + + usize::from(self.data.is_some()) + + usize::from(self.sandbox_path.is_some()); + if source_count != 1 { + bail!("attachment must specify exactly one of path, url, data, or sandboxPath"); + } + if let Some(path) = &self.path { + non_empty_ref("attachment path", path)?; + } + if let Some(url) = &self.url { + non_empty_ref("attachment url", url)?; + if !url.starts_with("https://") { + bail!("attachment url must use https"); + } + } + if let Some(data) = &self.data { + non_empty_ref("attachment data", data)?; + } + if let Some(sandbox_path) = &self.sandbox_path { + non_empty_ref("attachment sandboxPath", sandbox_path)?; + } + if let Some(mime_type) = &self.mime_type { + non_empty_ref("attachment mimeType", mime_type)?; + } + if let Some(file_name) = &self.file_name { + non_empty_ref("attachment fileName", file_name)?; + } + Ok(()) + } +} + +pub fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_millis() as u64 +} + +fn validate_adapter_name(name: &str) -> Result<()> { + if name.is_empty() { + bail!("adapter name must not be empty"); + } + if !name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + { + bail!("adapter name may only contain letters, numbers, '-' and '_'"); + } + Ok(()) +} + +fn non_empty(field: &str, value: String) -> Result { + if value.trim().is_empty() { + bail!("{field} must not be empty"); + } + Ok(value) +} + +fn non_empty_ref(field: &str, value: &str) -> Result<()> { + if value.trim().is_empty() { + bail!("{field} must not be empty"); + } + Ok(()) +} diff --git a/crates/executor/src/adapter/worker.rs b/crates/executor/src/adapter/worker.rs new file mode 100644 index 0000000..888ef11 --- /dev/null +++ b/crates/executor/src/adapter/worker.rs @@ -0,0 +1,397 @@ +use std::path::Path; + +use std::sync::Arc; + +use anyhow::{Context, Result, anyhow, bail}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{ChildStdin, Command}; +use tokio::sync::{Notify, mpsc}; + +use super::types::{AdapterAttachment, AdapterConfig}; + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum WorkerCommand { + SendMessage { + id: String, + #[serde(default)] + target: Option, + text: String, + #[serde(default)] + attachments: Vec, + }, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum WorkerEvent { + Connected { + #[serde(default)] + subject: Option, + #[serde(default)] + metadata: Value, + }, + Message { + target: String, + #[serde(default)] + sender: Option, + text: String, + #[serde(default)] + message_id: Option, + #[serde(default)] + metadata: Value, + }, + Lifecycle { + name: String, + #[serde(default)] + metadata: Value, + }, + Error { + message: String, + }, + CommandAck { + command_id: String, + }, + CommandNack { + command_id: String, + message: String, + }, + Disconnected { + #[serde(default)] + reason: Option, + }, +} + +pub async fn run_worker_loop( + adapter_id: &str, + config: &AdapterConfig, + secret_env: Vec<(String, String)>, + outbound_notify: Arc, + on_event: F, + take_outbound_messages: G, + mut should_stop: S, +) -> Result<()> +where + F: FnMut(WorkerEvent) -> Fut + Send + 'static, + Fut: std::future::Future> + Send + 'static, + G: FnMut() -> OutFut + Send + 'static, + OutFut: std::future::Future>> + Send + 'static, + S: FnMut() -> StopFut, + StopFut: std::future::Future>, +{ + tracing::info!( + adapter_type = %config.adapter_type, + adapter_id = %adapter_id, + worker_command = ?config.worker_command, + "starting adapter worker" + ); + let mut command = worker_command(adapter_id, config, secret_env); + let mut child = command + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .context("failed to spawn adapter worker")?; + let stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("adapter worker stdin was not piped"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("adapter worker stdout was not piped"))?; + let mut lines = BufReader::new(stdout).lines(); + let mut stop_interval = tokio::time::interval(std::time::Duration::from_secs(1)); + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let mut event_task = tokio::spawn(process_worker_events(event_rx, on_event)); + let mut command_task = tokio::spawn(process_worker_commands( + stdin, + outbound_notify, + take_outbound_messages, + )); + + loop { + tokio::select! { + status = child.wait() => { + let status = status?; + command_task.abort(); + event_task.abort(); + bail!("adapter worker exited with status {status}"); + } + line = lines.next_line() => { + let Some(line) = line? else { + command_task.abort(); + event_task.abort(); + bail!("adapter worker closed stdout"); + }; + let event = match serde_json::from_str::(&line) { + Ok(event) => event, + Err(error) => { + command_task.abort(); + event_task.abort(); + return Err(error) + .with_context(|| format!("failed to parse adapter worker event: {line}")); + } + }; + tracing::debug!( + adapter_type = %config.adapter_type, + event = ?event, + "adapter worker event" + ); + if event_tx.send(event).is_err() { + command_task.abort(); + event_task.abort(); + bail!("adapter event handler stopped"); + } + } + _ = stop_interval.tick() => { + if should_stop().await? { + command_task.abort(); + event_task.abort(); + return Ok(()); + } + } + result = &mut event_task => { + command_task.abort(); + match result { + Ok(Ok(())) => bail!("adapter event handler stopped"), + Ok(Err(error)) => return Err(error), + Err(error) => return Err(anyhow!("adapter event handler task failed: {error}")), + } + } + result = &mut command_task => { + event_task.abort(); + match result { + Ok(Ok(())) => bail!("adapter command sender stopped"), + Ok(Err(error)) => return Err(error), + Err(error) => return Err(anyhow!("adapter command sender task failed: {error}")), + } + } + } + } +} + +async fn process_worker_events( + mut event_rx: mpsc::UnboundedReceiver, + mut on_event: F, +) -> Result<()> +where + F: FnMut(WorkerEvent) -> Fut, + Fut: std::future::Future>, +{ + while let Some(event) = event_rx.recv().await { + on_event(event).await?; + } + Ok(()) +} + +async fn process_worker_commands( + mut stdin: ChildStdin, + outbound_notify: Arc, + mut take_outbound_messages: G, +) -> Result<()> +where + G: FnMut() -> OutFut, + OutFut: std::future::Future>>, +{ + loop { + send_pending_commands(&mut stdin, &outbound_notify, &mut take_outbound_messages).await?; + } +} + +fn worker_command( + adapter_id: &str, + config: &AdapterConfig, + secret_env: Vec<(String, String)>, +) -> Command { + let args = &config.worker_command; + let mut command = Command::new(&args[0]); + command.args(&args[1..]); + command.env("EXO_ADAPTER_ID", adapter_id); + command.env("EXO_ADAPTER_TYPE", &config.adapter_type); + command.env("EXO_ADAPTER_STATE_DIR", state_dir(adapter_id, config)); + command.env( + "EXO_ADAPTER_CONFIG", + serde_json::to_string(&config.initialization).expect("adapter initialization is JSON"), + ); + for (name, value) in secret_env { + command.env(name, value); + } + command +} + +fn state_dir(adapter_id: &str, config: &AdapterConfig) -> String { + config.state_dir.clone().unwrap_or_else(|| { + Path::new(".exo") + .join("adapters") + .join(&config.adapter_type) + .join(adapter_id) + .to_string_lossy() + .to_string() + }) +} + +async fn send_pending_commands( + stdin: &mut ChildStdin, + outbound_notify: &Notify, + take_outbound_messages: &mut G, +) -> Result<()> +where + G: FnMut() -> OutFut, + OutFut: std::future::Future>>, +{ + tokio::select! { + () = outbound_notify.notified() => {} + () = tokio::time::sleep(std::time::Duration::from_secs(1)) => {} + } + for command in take_outbound_messages().await? { + tracing::debug!(command = ?command, "sending adapter worker command"); + stdin + .write_all(serde_json::to_string(&command)?.as_bytes()) + .await?; + stdin.write_all(b"\n").await?; + stdin.flush().await?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use serde_json::json; + use tempfile::TempDir; + use tokio::sync::{Notify, oneshot}; + + use super::*; + + #[tokio::test] + async fn stops_worker_when_cancellation_trips() { + let config = AdapterConfig { + adapter_type: "test".to_string(), + worker_command: vec![ + "sh".to_string(), + "-c".to_string(), + "while true; do sleep 1; done".to_string(), + ], + initialization: json!({}), + state_dir: None, + secret_env: Vec::new(), + }; + let outbound_notify = Arc::new(Notify::new()); + + run_worker_loop( + "adapter", + &config, + Vec::new(), + outbound_notify, + |_event| async { Ok(()) }, + || async { Ok(Vec::new()) }, + || async { Ok(true) }, + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn dispatches_outbound_commands_while_event_handler_is_busy() { + let tempdir = TempDir::new().unwrap(); + let output_path = tempdir.path().join("command.json"); + let config = AdapterConfig { + adapter_type: "test".to_string(), + worker_command: vec![ + "sh".to_string(), + "-c".to_string(), + "printf '%s\n' '{\"type\":\"message\",\"target\":\"target\",\"text\":\"hello\"}'; IFS= read -r line; printf '%s\n' \"$line\" > \"$OUTPUT_PATH\"; sleep 60".to_string(), + ], + initialization: json!({}), + state_dir: None, + secret_env: Vec::new(), + }; + let outbound = Arc::new(Mutex::new(Vec::::new())); + let outbound_for_worker = Arc::clone(&outbound); + let outbound_notify = Arc::new(Notify::new()); + let outbound_notify_for_worker = Arc::clone(&outbound_notify); + let (event_started_tx, event_started_rx) = oneshot::channel(); + let (release_event_tx, release_event_rx) = oneshot::channel(); + let mut event_started_tx = Some(event_started_tx); + let mut release_event_rx = Some(release_event_rx); + + let worker = tokio::spawn(async move { + run_worker_loop( + "adapter", + &config, + vec![( + "OUTPUT_PATH".to_string(), + output_path.to_string_lossy().into_owned(), + )], + outbound_notify_for_worker, + move |event| { + let event_started_tx = event_started_tx.take(); + let release_event_rx = release_event_rx.take(); + async move { + if matches!(event, WorkerEvent::Message { .. }) { + if let Some(event_started_tx) = event_started_tx + && event_started_tx.send(()).is_err() + { + panic!("test receiver dropped before event started"); + } + if let Some(release_event_rx) = release_event_rx { + release_event_rx.await.unwrap(); + } + } + Ok(()) + } + }, + move || { + let outbound = Arc::clone(&outbound_for_worker); + async move { + let mut outbound = outbound.lock().unwrap(); + Ok(outbound.drain(..).collect()) + } + }, + || async { Ok(false) }, + ) + .await + }); + + event_started_rx.await.unwrap(); + outbound.lock().unwrap().push(WorkerCommand::SendMessage { + id: "command".to_string(), + target: Some("target".to_string()), + text: "pong".to_string(), + attachments: Vec::new(), + }); + outbound_notify.notify_one(); + + let written = wait_for_file(tempdir.path().join("command.json")).await; + assert!(written.contains("\"type\":\"send_message\"")); + assert!(written.contains("\"text\":\"pong\"")); + + release_event_tx.send(()).unwrap(); + worker.abort(); + match worker.await { + Err(error) if error.is_cancelled() => {} + other => panic!("worker task should have been cancelled, got {other:?}"), + } + } + + async fn wait_for_file(path: impl AsRef) -> String { + let path = path.as_ref().to_path_buf(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Ok(contents) = tokio::fs::read_to_string(&path).await { + return contents; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("worker did not receive outbound command") + } +} diff --git a/crates/executor/src/agent_sandbox.rs b/crates/executor/src/agent_sandbox.rs new file mode 100644 index 0000000..57f2e3c --- /dev/null +++ b/crates/executor/src/agent_sandbox.rs @@ -0,0 +1,143 @@ +use exoharness::{ + AgentHandle, Artifact, ArtifactVersion, ConversationHandle, CreateSandboxRequest, + ReadArtifactRequest, Result, SandboxProvider, Uuid7, WriteArtifactRequest, +}; +use serde::{Deserialize, Serialize}; + +use crate::conversation_sandbox::{ + ConversationSandboxSpec, conversation_sandbox_spec, conversation_sandboxes, +}; +use crate::{AgentConfig, ConversationConfig}; + +const AGENT_SANDBOX_ARTIFACT_PATH: &str = "config/exoclaw-agent-sandbox.json"; + +#[derive(Clone)] +pub(crate) struct AgentSandboxHandle { + pub(crate) conversation: std::sync::Arc, + pub(crate) sandbox_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AgentSandboxRecord { + conversation_id: String, + sandbox_id: String, + provider: SandboxProvider, + image: String, + default_workdir: String, + file_system_mounts: Vec, + enable_networking: bool, + idle_seconds: u64, +} + +impl AgentSandboxRecord { + fn matches_spec(&self, spec: &ConversationSandboxSpec) -> bool { + self.provider == spec.provider + && self.image == spec.image + && self.default_workdir == spec.default_workdir + && self.file_system_mounts == spec.file_system_mounts + && self.enable_networking == spec.enable_networking + && self.idle_seconds == spec.idle_seconds + } +} + +pub(crate) async fn ensure_agent_sandbox( + agent: &dyn AgentHandle, + current_conversation: &dyn ConversationHandle, + agent_config: &AgentConfig, + conversation_config: &ConversationConfig, +) -> Result { + let spec = conversation_sandbox_spec(agent_config, conversation_config); + if let Some(record) = load_agent_sandbox_record(agent).await? + && record.matches_spec(&spec) + && let Ok(conversation_id) = record.conversation_id.parse::() + && let Some(owner) = agent.get_conversation(&conversation_id).await? + { + for sandbox in conversation_sandboxes(owner.as_ref()).await? { + if sandbox.id == record.sandbox_id && sandbox.matches_spec(&spec) { + return Ok(AgentSandboxHandle { + conversation: owner, + sandbox_id: record.sandbox_id, + }); + } + } + } + + let sandbox_id = current_conversation + .create_sandbox(CreateSandboxRequest { + provider: spec.provider, + image: spec.image.clone(), + default_workdir: Some(spec.default_workdir.clone()), + file_system_mounts: Some(spec.file_system_mounts.clone()), + enable_networking: Some(spec.enable_networking), + idle_seconds: Some(spec.idle_seconds), + }) + .await?; + store_agent_sandbox_record( + agent, + &AgentSandboxRecord { + conversation_id: current_conversation.record().id.to_string(), + sandbox_id: sandbox_id.clone(), + provider: spec.provider, + image: spec.image, + default_workdir: spec.default_workdir, + file_system_mounts: spec.file_system_mounts, + enable_networking: spec.enable_networking, + idle_seconds: spec.idle_seconds, + }, + ) + .await?; + + let Some(owner) = agent + .get_conversation(¤t_conversation.record().id) + .await? + else { + anyhow::bail!( + "agent sandbox owner conversation disappeared: {}", + current_conversation.record().id + ); + }; + Ok(AgentSandboxHandle { + conversation: owner, + sandbox_id, + }) +} + +async fn load_agent_sandbox_record(agent: &dyn AgentHandle) -> Result> { + let Some(artifact) = latest_agent_artifact(agent, AGENT_SANDBOX_ARTIFACT_PATH).await? else { + return Ok(None); + }; + Ok(Some(serde_json::from_slice(&artifact.contents)?)) +} + +async fn store_agent_sandbox_record( + agent: &dyn AgentHandle, + record: &AgentSandboxRecord, +) -> Result<()> { + agent + .write_artifact(WriteArtifactRequest { + path: AGENT_SANDBOX_ARTIFACT_PATH.to_string(), + contents: serde_json::to_vec_pretty(record)?, + }) + .await?; + Ok(()) +} + +async fn latest_agent_artifact(agent: &dyn AgentHandle, path: &str) -> Result> { + let Some(version) = latest_artifact_version(agent.list_artifacts().await?, path) else { + return Ok(None); + }; + agent + .read_artifact(ReadArtifactRequest { + artifact_id: version.artifact_id, + version: Some(version.version), + }) + .await +} + +fn latest_artifact_version(artifacts: Vec, path: &str) -> Option { + artifacts + .into_iter() + .filter(|artifact| artifact.path == path) + .max_by_key(|artifact| artifact.version) +} diff --git a/crates/executor/src/basic.rs b/crates/executor/src/basic.rs index 0431a75..29993a9 100644 --- a/crates/executor/src/basic.rs +++ b/crates/executor/src/basic.rs @@ -107,6 +107,7 @@ where async fn run_turn_loop( &self, + agent: &dyn AgentHandle, conversation: &dyn ConversationHandle, turn: &dyn TurnHandle, agent_config: &AgentConfig, @@ -142,6 +143,7 @@ where let tool_results = self .execute_tool_round( + agent, conversation, agent_config, conversation_config, @@ -244,6 +246,7 @@ where async fn execute_tool_round( &self, + agent: &dyn AgentHandle, conversation: &dyn ConversationHandle, agent_config: &AgentConfig, conversation_config: &ConversationConfig, @@ -277,6 +280,7 @@ where let (result, tool_succeeded) = match self .tools .execute( + agent, conversation, agent_config, conversation_config, @@ -330,12 +334,13 @@ where async fn prepare_conversation( &self, + agent: &dyn AgentHandle, conversation: &dyn ConversationHandle, agent_config: &AgentConfig, conversation_config: &ConversationConfig, ) -> Result<()> { self.tools - .prepare_conversation(conversation, agent_config, conversation_config) + .prepare_conversation(agent, conversation, agent_config, conversation_config) .await } @@ -345,7 +350,7 @@ where async fn execute_turn( &self, - _agent: &dyn AgentHandle, + agent: &dyn AgentHandle, conversation: &dyn ConversationHandle, turn: Arc, agent_config: &AgentConfig, @@ -355,6 +360,7 @@ where turn_trace: Option<&dyn TurnExecutionTrace>, ) -> Result<()> { self.run_turn_loop( + agent, conversation, turn.as_ref(), agent_config, diff --git a/crates/executor/src/basic_tests.rs b/crates/executor/src/basic_tests.rs index f78ba31..60e8cc9 100644 --- a/crates/executor/src/basic_tests.rs +++ b/crates/executor/src/basic_tests.rs @@ -61,6 +61,7 @@ async fn send_appends_user_and_assistant_messages() { executor .prepare_conversation( + agent.as_ref(), conversation.as_ref(), &default_agent_config(), &ConversationConfig::default(), @@ -371,6 +372,7 @@ async fn send_stream_emits_chunks_and_persists_final_response() { executor .prepare_conversation( + agent.as_ref(), conversation.as_ref(), &default_agent_config(), &ConversationConfig::default(), @@ -555,6 +557,7 @@ struct FailingToolRuntime { impl ToolRuntime for FailingToolRuntime { async fn execute( &self, + _agent: &dyn AgentHandle, _conversation: &dyn ConversationHandle, _agent_config: &AgentConfig, _config: &ConversationConfig, @@ -568,6 +571,7 @@ impl ToolRuntime for FailingToolRuntime { impl ToolRuntime for FakeToolRuntime { async fn execute( &self, + _agent: &dyn AgentHandle, _conversation: &dyn ConversationHandle, _agent_config: &AgentConfig, _config: &ConversationConfig, diff --git a/crates/executor/src/conversation_sandbox.rs b/crates/executor/src/conversation_sandbox.rs new file mode 100644 index 0000000..70d8ba2 --- /dev/null +++ b/crates/executor/src/conversation_sandbox.rs @@ -0,0 +1,168 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use crate::{AgentConfig, ConversationConfig}; +use exoharness::{ + ConversationHandle, CreateSandboxRequest, DEFAULT_SANDBOX_IMAGE, EventData, EventKind, + EventQuery, EventQueryDirection, FileSystemMount, FileSystemMountMode, Result, SandboxProvider, +}; +use tokio::sync::Mutex as AsyncMutex; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ConversationSandboxInfo { + pub(crate) id: String, + pub(crate) provider: SandboxProvider, + pub(crate) image: String, + pub(crate) default_workdir: String, + pub(crate) file_system_mounts: Vec, + pub(crate) enable_networking: bool, + pub(crate) idle_seconds: u64, +} + +impl ConversationSandboxInfo { + pub(crate) fn matches_spec(&self, spec: &ConversationSandboxSpec) -> bool { + self.provider == spec.provider + && self.image == spec.image + && self.default_workdir == spec.default_workdir + && self.file_system_mounts == spec.file_system_mounts + && self.enable_networking == spec.enable_networking + && self.idle_seconds == spec.idle_seconds + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ConversationSandboxSpec { + pub(crate) provider: SandboxProvider, + pub(crate) image: String, + pub(crate) default_workdir: String, + pub(crate) file_system_mounts: Vec, + pub(crate) enable_networking: bool, + pub(crate) idle_seconds: u64, +} + +pub(crate) async fn ensure_conversation_sandbox( + conversation: &dyn ConversationHandle, + agent_config: &AgentConfig, + config: &ConversationConfig, +) -> Result { + let sandbox_lock = conversation_sandbox_lock(&conversation.record().id.to_string()); + let _guard = sandbox_lock.lock().await; + let spec = conversation_sandbox_spec(agent_config, config); + + for sandbox in conversation_sandboxes(conversation).await? { + if sandbox.matches_spec(&spec) { + return Ok(sandbox.id); + } + } + + create_conversation_sandbox(conversation, agent_config, config).await +} + +pub(crate) async fn create_conversation_sandbox( + conversation: &dyn ConversationHandle, + agent_config: &AgentConfig, + config: &ConversationConfig, +) -> Result { + let spec = conversation_sandbox_spec(agent_config, config); + conversation + .create_sandbox(CreateSandboxRequest { + provider: spec.provider, + image: spec.image, + default_workdir: Some(spec.default_workdir), + file_system_mounts: Some(spec.file_system_mounts), + enable_networking: Some(spec.enable_networking), + idle_seconds: Some(spec.idle_seconds), + }) + .await +} + +pub(crate) async fn conversation_sandboxes( + conversation: &dyn ConversationHandle, +) -> Result> { + let events = conversation + .get_events(Some(EventQuery { + cursor: None, + direction: Some(EventQueryDirection::Asc), + limit: None, + session_id: None, + turn_id: None, + types: Some(vec![EventKind::SANDBOX_CREATED]), + })) + .await? + .events; + + let mut sandboxes = Vec::new(); + for event in events { + if let EventData::SandboxCreated { + sandbox_id, + provider, + image, + default_workdir, + file_system_mounts, + enable_networking, + idle_seconds, + } = event.data + { + sandboxes.push(ConversationSandboxInfo { + id: sandbox_id, + provider, + image, + default_workdir, + file_system_mounts, + enable_networking, + idle_seconds, + }); + } + } + + Ok(sandboxes) +} + +pub(crate) fn conversation_sandbox_spec( + agent_config: &AgentConfig, + config: &ConversationConfig, +) -> ConversationSandboxSpec { + ConversationSandboxSpec { + provider: config.effective_sandbox_provider(agent_config), + image: config + .effective_sandbox_image(agent_config) + .map(str::to_string) + .unwrap_or_else(|| DEFAULT_SANDBOX_IMAGE.to_string()), + default_workdir: config + .mounts + .first() + .map(|mount| mount.mount_path.clone()) + .unwrap_or_else(|| "/".to_string()), + file_system_mounts: normalize_mounts(&config.mounts), + enable_networking: agent_config.enable_networking, + idle_seconds: 300, + } +} + +fn normalize_mounts(mounts: &[FileSystemMount]) -> Vec { + mounts + .iter() + .map(|mount| FileSystemMount { + host_path: mount.host_path.clone(), + mount_path: mount.mount_path.clone(), + mode: match mount.mode { + FileSystemMountMode::ReadOnly => FileSystemMountMode::ReadOnly, + FileSystemMountMode::ReadWrite => FileSystemMountMode::ReadWrite, + }, + internal: Some(mount.internal.unwrap_or(false)), + }) + .collect() +} + +fn conversation_sandbox_lock(conversation_id: &str) -> Arc> { + static LOCKS: OnceLock>>>> = OnceLock::new(); + let locks = LOCKS.get_or_init(|| Mutex::new(HashMap::new())); + let mut locks = locks + .lock() + .expect("conversation sandbox lock registry poisoned"); + Arc::clone( + locks + .entry(conversation_id.to_string()) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))), + ) +} diff --git a/crates/executor/src/conversation_wakeup.rs b/crates/executor/src/conversation_wakeup.rs new file mode 100644 index 0000000..21f7673 --- /dev/null +++ b/crates/executor/src/conversation_wakeup.rs @@ -0,0 +1,136 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, SystemTime}; + +use anyhow::Context; +use exoharness::Result; +use lingua::Message; +use lingua::universal::UserContent; +use tokio::sync::Mutex as AsyncMutex; + +use crate::{HarnessConversation, SendRequest, SendResult}; + +pub async fn send_conversation_wakeup( + conversation: &dyn HarnessConversation, + prompt: String, +) -> Result { + let _file_guard = WakeupFileLock::acquire(&conversation.record().id.to_string()).await?; + let result = conversation + .send(SendRequest { + input: vec![Message::User { + content: UserContent::String(prompt), + }], + session_id: None, + }) + .await?; + conversation.close_session(result.session_id).await?; + Ok(result) +} + +struct WakeupFileLock { + path: PathBuf, +} + +impl WakeupFileLock { + async fn acquire(conversation_id: &str) -> Result { + let dir = std::env::temp_dir().join("exo-wakeup-locks"); + tokio::fs::create_dir_all(&dir).await?; + let path = dir.join(format!("{conversation_id}.lock")); + loop { + match tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .await + { + Ok(_) => return Ok(Self { path }), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + remove_stale_lock(&path).await?; + tokio::time::sleep(Duration::from_millis(100)).await; + } + Err(error) => { + return Err(error).with_context(|| { + format!("failed to acquire wakeup lock {}", path.display()) + }); + } + } + } + } +} + +impl Drop for WakeupFileLock { + fn drop(&mut self) { + if let Err(error) = std::fs::remove_file(&self.path) { + tracing::error!( + path = %self.path.display(), + %error, + "failed to remove wakeup lock" + ); + } + } +} + +async fn remove_stale_lock(path: &PathBuf) -> Result<()> { + const STALE_AFTER: Duration = Duration::from_secs(30 * 60); + let Ok(metadata) = tokio::fs::metadata(path).await else { + return Ok(()); + }; + let Ok(modified) = metadata.modified() else { + return Ok(()); + }; + if SystemTime::now() + .duration_since(modified) + .is_ok_and(|age| age > STALE_AFTER) + { + match tokio::fs::remove_file(path).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +pub(crate) fn conversation_send_lock(conversation_id: &str) -> Arc> { + static LOCKS: OnceLock>>>> = OnceLock::new(); + let locks = LOCKS.get_or_init(|| Mutex::new(HashMap::new())); + let mut locks = locks + .lock() + .expect("conversation wakeup lock registry poisoned"); + Arc::clone( + locks + .entry(conversation_id.to_string()) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))), + ) +} + +#[cfg(test)] +mod tests { + use std::pin::pin; + + use exoharness::Uuid7; + + use super::*; + + #[tokio::test] + async fn wakeup_file_lock_serializes_conversation_ids() { + let conversation_id = format!("test-{}", Uuid7::now()); + let first = WakeupFileLock::acquire(&conversation_id).await.unwrap(); + let mut second = pin!(WakeupFileLock::acquire(&conversation_id)); + + tokio::select! { + _ = &mut second => { + panic!("second lock acquired while first lock was held"); + } + _ = tokio::time::sleep(Duration::from_millis(20)) => {} + } + + drop(first); + let second = tokio::time::timeout(Duration::from_secs(1), second) + .await + .unwrap() + .unwrap(); + drop(second); + } +} diff --git a/crates/executor/src/executor_types.rs b/crates/executor/src/executor_types.rs index 7de45ab..68292fc 100644 --- a/crates/executor/src/executor_types.rs +++ b/crates/executor/src/executor_types.rs @@ -5,12 +5,13 @@ use std::time::Duration; use async_trait::async_trait; use exoharness::{ - ConversationHandle, EventId, FileSystemMount, ResponseId, Result, SandboxProvider, SessionId, - ToolArguments, ToolCallId, ToolRequest, ToolResult, TurnId, + AgentHandle, ConversationHandle, EventId, FileSystemMount, ResponseId, Result, SandboxProvider, + SessionId, ToolArguments, ToolCallId, ToolRequest, ToolResult, TurnId, }; use lingua::{Message, UniversalStreamChunk, UniversalUsage}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use tokio::sync::OwnedMutexGuard; use tokio_stream::{Stream, wrappers::UnboundedReceiverStream}; use crate::braintrust::BraintrustTracingConfig; @@ -43,6 +44,7 @@ pub enum AgentHarnessKind { Rlm, #[serde(rename = "typescript")] TypeScript, + Exoclaw, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -65,6 +67,15 @@ pub struct ConversationConfig { pub shell_program: Option, #[serde(default)] pub mounts: Vec, + #[serde(default)] + pub sandbox_scope: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SandboxScope { + Agent, + Conversation, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -93,10 +104,23 @@ impl Default for ConversationConfig { sandbox_provider: None, shell_program: Some("/bin/bash".to_string()), mounts: Vec::new(), + sandbox_scope: None, } } } +pub fn effective_sandbox_scope( + agent_config: &AgentConfig, + conversation_config: &ConversationConfig, +) -> SandboxScope { + conversation_config + .sandbox_scope + .unwrap_or(match agent_config.harness { + AgentHarnessKind::Exoclaw => SandboxScope::Agent, + _ => SandboxScope::Conversation, + }) +} + impl ConversationConfig { pub fn effective_sandbox_image<'a>(&'a self, agent_config: &'a AgentConfig) -> Option<&'a str> { self.sandbox_image @@ -126,6 +150,7 @@ pub trait ModelResponseStream: Send { pub trait ToolRuntime: Send + Sync { async fn prepare_conversation( &self, + _agent: &dyn AgentHandle, _conversation: &dyn ConversationHandle, _agent_config: &AgentConfig, _config: &ConversationConfig, @@ -135,6 +160,7 @@ pub trait ToolRuntime: Send + Sync { async fn execute( &self, + agent: &dyn AgentHandle, conversation: &dyn ConversationHandle, agent_config: &AgentConfig, config: &ConversationConfig, @@ -188,11 +214,20 @@ pub struct SendResult { pub struct ExecutionStreamHandle { event_stream: UnboundedReceiverStream>, + _send_guard: Option>, } impl ExecutionStreamHandle { pub fn new(event_stream: UnboundedReceiverStream>) -> Self { - Self { event_stream } + Self { + event_stream, + _send_guard: None, + } + } + + pub(crate) fn with_send_guard(mut self, send_guard: OwnedMutexGuard<()>) -> Self { + self._send_guard = Some(send_guard); + self } } diff --git a/crates/executor/src/harness_basic_tests.rs b/crates/executor/src/harness_basic_tests.rs index 4ae4ab0..f2db1ed 100644 --- a/crates/executor/src/harness_basic_tests.rs +++ b/crates/executor/src/harness_basic_tests.rs @@ -503,7 +503,7 @@ async fn harness_exposes_raw_exoharness_handles() { } #[tokio::test(flavor = "current_thread")] -async fn updating_mounts_recreates_shell_sandbox() { +async fn updating_mounts_recreates_conversation_sandbox() { let tempdir = TempDir::new().expect("tempdir should exist"); let mount_dir = tempdir.path().join("mount"); std::fs::create_dir_all(&mount_dir).expect("mount dir should exist"); diff --git a/crates/executor/src/harness_executor.rs b/crates/executor/src/harness_executor.rs index c136512..29aa905 100644 --- a/crates/executor/src/harness_executor.rs +++ b/crates/executor/src/harness_executor.rs @@ -34,6 +34,7 @@ pub(crate) trait HarnessExecutor: Send + Sync + Clone + 'static { async fn prepare_conversation( &self, + _agent: &dyn AgentHandle, _conversation: &dyn ConversationHandle, _agent_config: &AgentConfig, _conversation_config: &ConversationConfig, @@ -160,7 +161,12 @@ where )?; apply_conversation_model_override(&mut agent_config, model_override); self.executor - .prepare_conversation(conversation.as_ref(), &agent_config, &conversation_config) + .prepare_conversation( + agent.as_ref(), + conversation.as_ref(), + &agent_config, + &conversation_config, + ) .await?; let prepared = self.executor.prepare_request(&request)?; let turn = conversation @@ -214,7 +220,12 @@ where )?; apply_conversation_model_override(&mut agent_config, model_override); self.executor - .prepare_conversation(conversation.as_ref(), &agent_config, &conversation_config) + .prepare_conversation( + agent.as_ref(), + conversation.as_ref(), + &agent_config, + &conversation_config, + ) .await?; let prepared = self.executor.prepare_request(&request)?; let turn = conversation diff --git a/crates/executor/src/harness_facade.rs b/crates/executor/src/harness_facade.rs index bbd872b..3c18aab 100644 --- a/crates/executor/src/harness_facade.rs +++ b/crates/executor/src/harness_facade.rs @@ -8,6 +8,7 @@ use exoharness::{ }; use lingua::Message; +use crate::conversation_wakeup::conversation_send_lock; use crate::harness_helpers::{ get_conversation_model_override, materialize_conversation_messages, put_conversation_model_override, resolve_agent_handle, resolve_conversation_handle, @@ -242,6 +243,7 @@ where .shell_program .or(default_conversation_config.shell_program), mounts: default_conversation_config.mounts, + sandbox_scope: default_conversation_config.sandbox_scope, }; self.runtime .put_conversation_config(conversation.as_ref(), conversation_config) @@ -318,6 +320,8 @@ where } async fn send(&self, request: SendRequest) -> Result { + let send_lock = conversation_send_lock(&self.conversation.record().id.to_string()); + let _guard = send_lock.lock().await; self.runtime .send( Arc::clone(&self.agent), @@ -328,12 +332,16 @@ where } async fn send_stream(&self, request: SendRequest) -> Result { - self.runtime + let send_lock = conversation_send_lock(&self.conversation.record().id.to_string()); + let send_guard = send_lock.lock_owned().await; + let stream = self + .runtime .send_stream( Arc::clone(&self.agent), Arc::clone(&self.conversation), request, ) - .await + .await?; + Ok(stream.with_send_guard(send_guard)) } } diff --git a/crates/executor/src/harness_tool.rs b/crates/executor/src/harness_tool.rs index 6d9ccd1..a4b723c 100644 --- a/crates/executor/src/harness_tool.rs +++ b/crates/executor/src/harness_tool.rs @@ -1,9 +1,23 @@ +use std::path::PathBuf; + +use crate::adapter::AdapterStore; +use crate::adapter::tools::{ + execute_create_adapter_tool, execute_delete_adapter_tool, execute_disable_adapter_tool, + execute_list_adapters_tool, execute_send_adapter_message_tool, +}; +use crate::agent_sandbox::ensure_agent_sandbox; +use crate::conversation_sandbox::ensure_conversation_sandbox; +use crate::scheduler_store::SchedulerStore; +use crate::scheduler_types::{ + DEFAULT_MAX_OUTPUT_BYTES, NewScheduledTask, ScheduledTaskSandboxMode, +}; use crate::{AgentConfig, ConversationConfig, ToolRuntime}; +use crate::{SandboxScope, effective_sandbox_scope}; use async_trait::async_trait; use exoharness::{ - ConversationHandle, CreateSandboxRequest, DEFAULT_SANDBOX_IMAGE, EventData, EventKind, - EventQuery, EventQueryDirection, FileSystemMount, FileSystemMountMode, Result, - RunInSandboxRequest, SandboxProvider, ToolRequest, ToolResult, + AgentHandle, ConversationHandle, CreateSandboxRequest, DEFAULT_SANDBOX_IMAGE, EventData, + EventKind, EventQuery, EventQueryDirection, FileSystemMount, FileSystemMountMode, Result, + RunInSandboxRequest, SandboxProcess, SandboxProvider, ToolRequest, ToolResult, }; use futures::io::AsyncReadExt; use serde::{Deserialize, Serialize}; @@ -12,10 +26,29 @@ use serde_json::Value; #[derive(Debug, Clone, Default)] pub struct BasicToolRuntime; +#[derive(Debug, Clone)] +pub struct ExoclawToolRuntime { + scheduler_store: SchedulerStore, + adapter_store: AdapterStore, +} + +impl ExoclawToolRuntime { + pub fn with_roots( + scheduler_root: impl Into, + adapter_root: impl Into, + ) -> Self { + Self { + scheduler_store: SchedulerStore::new(scheduler_root), + adapter_store: AdapterStore::new(adapter_root), + } + } +} + #[async_trait] impl ToolRuntime for BasicToolRuntime { async fn prepare_conversation( &self, + _agent: &dyn AgentHandle, _conversation: &dyn ConversationHandle, _agent_config: &AgentConfig, _config: &ConversationConfig, @@ -25,6 +58,7 @@ impl ToolRuntime for BasicToolRuntime { async fn execute( &self, + _agent: &dyn AgentHandle, conversation: &dyn ConversationHandle, agent_config: &AgentConfig, config: &ConversationConfig, @@ -39,6 +73,100 @@ impl ToolRuntime for BasicToolRuntime { } } +#[async_trait] +impl ToolRuntime for ExoclawToolRuntime { + async fn prepare_conversation( + &self, + agent: &dyn AgentHandle, + conversation: &dyn ConversationHandle, + agent_config: &AgentConfig, + config: &ConversationConfig, + ) -> Result<()> { + match effective_sandbox_scope(agent_config, config) { + SandboxScope::Agent => { + ensure_agent_sandbox(agent, conversation, agent_config, config).await?; + } + SandboxScope::Conversation => { + ensure_conversation_sandbox(conversation, agent_config, config).await?; + } + } + Ok(()) + } + + async fn execute( + &self, + agent: &dyn AgentHandle, + conversation: &dyn ConversationHandle, + agent_config: &AgentConfig, + config: &ConversationConfig, + request: &ToolRequest, + ) -> Result { + match request.function_name.as_str() { + "shell" => { + execute_exoclaw_shell_tool(agent, conversation, agent_config, config, request).await + } + "schedule_sandbox_task" => { + execute_schedule_task_tool(agent, conversation, &self.scheduler_store, request) + .await + } + "list_scheduled_tasks" => { + execute_list_scheduled_tasks_tool( + agent, + conversation, + &self.scheduler_store, + request, + ) + .await + } + "cancel_scheduled_task" => { + execute_cancel_scheduled_task_tool( + agent, + conversation, + &self.scheduler_store, + request, + ) + .await + } + "delete_scheduled_task" => { + execute_delete_scheduled_task_tool( + agent, + conversation, + &self.scheduler_store, + request, + ) + .await + } + "create_adapter" => { + execute_create_adapter_tool(agent, conversation, &self.adapter_store, request).await + } + "list_adapters" => { + execute_list_adapters_tool(agent, conversation, &self.adapter_store, request).await + } + "disable_adapter" => { + execute_disable_adapter_tool(conversation, agent, &self.adapter_store, request) + .await + } + "delete_adapter" => { + execute_delete_adapter_tool(conversation, agent, &self.adapter_store, request).await + } + "send_adapter_message" => { + execute_send_adapter_message_tool( + agent, + conversation, + agent_config, + config, + &self.adapter_store, + request, + ) + .await + } + other => Err(anyhow::anyhow!( + "tool execution is not configured for {other}" + )), + } + } +} + #[derive(Debug, Deserialize)] struct ShellToolArguments { command: String, @@ -51,6 +179,152 @@ struct ShellToolResult { exit_code: i32, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScheduleTaskArguments { + name: String, + schedule: String, + sandbox_mode: Option, + setup_command: Option>, + command: Vec, + report_prompt: String, + max_output_bytes: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ConversationScopedArguments { + include_disabled: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScheduledTaskIdArguments { + task_id: String, +} + +async fn execute_schedule_task_tool( + agent: &dyn AgentHandle, + conversation: &dyn ConversationHandle, + store: &SchedulerStore, + request: &ToolRequest, +) -> Result { + let args = + serde_json::from_value::(Value::Object(request.arguments.clone()))?; + let task = store + .create_task(NewScheduledTask { + agent_id: agent.record().id.to_string(), + conversation_id: conversation.record().id.to_string(), + name: args.name, + schedule: args.schedule, + sandbox_mode: args.sandbox_mode, + setup_command: args.setup_command, + command: args.command, + report_prompt: args.report_prompt, + max_output_bytes: Some(args.max_output_bytes.unwrap_or(DEFAULT_MAX_OUTPUT_BYTES)), + }) + .await?; + Ok(serde_json::json!({ + "ok": true, + "taskId": task.id, + "name": task.name, + "schedule": task.schedule, + "nextRunAtMs": task.next_run_at_ms, + })) +} + +async fn execute_list_scheduled_tasks_tool( + agent: &dyn AgentHandle, + conversation: &dyn ConversationHandle, + store: &SchedulerStore, + request: &ToolRequest, +) -> Result { + let args = serde_json::from_value::(Value::Object( + request.arguments.clone(), + ))?; + let tasks = store + .list_tasks_for_conversation( + &agent.record().id.to_string(), + &conversation.record().id.to_string(), + args.include_disabled.unwrap_or(false), + ) + .await?; + Ok(serde_json::json!({ + "ok": true, + "tasks": tasks, + })) +} + +async fn execute_cancel_scheduled_task_tool( + agent: &dyn AgentHandle, + conversation: &dyn ConversationHandle, + store: &SchedulerStore, + request: &ToolRequest, +) -> Result { + let args = serde_json::from_value::(Value::Object( + request.arguments.clone(), + ))?; + let Some(task) = store.get_task(&args.task_id).await? else { + return Ok(serde_json::json!({ + "ok": false, + "error": "scheduled task not found for this conversation", + })); + }; + if task.agent_id != agent.record().id.to_string() + || task.conversation_id != conversation.record().id.to_string() + { + return Ok(serde_json::json!({ + "ok": false, + "error": "scheduled task not found for this conversation", + })); + } + let task_sandbox_id = task.task_sandbox_id.clone(); + store.disable_task(&args.task_id).await?; + if let Some(sandbox_id) = task_sandbox_id { + conversation.stop_sandbox(sandbox_id).await?; + } + Ok(serde_json::json!({ + "ok": true, + "taskId": args.task_id, + "cancelled": true, + })) +} + +async fn execute_delete_scheduled_task_tool( + agent: &dyn AgentHandle, + conversation: &dyn ConversationHandle, + store: &SchedulerStore, + request: &ToolRequest, +) -> Result { + let args = serde_json::from_value::(Value::Object( + request.arguments.clone(), + ))?; + let Some(task) = store.get_task(&args.task_id).await? else { + return Ok(serde_json::json!({ + "ok": false, + "error": "scheduled task not found for this conversation", + })); + }; + if task.agent_id != agent.record().id.to_string() + || task.conversation_id != conversation.record().id.to_string() + { + return Ok(serde_json::json!({ + "ok": false, + "error": "scheduled task not found for this conversation", + })); + } + if let Some(sandbox_id) = task.task_sandbox_id.clone() { + conversation.stop_sandbox(sandbox_id).await?; + } + store.delete_task(&args.task_id).await?; + Ok(serde_json::json!({ + "ok": true, + "taskId": args.task_id, + "deleted": true, + "runsDeleted": true, + })) +} + async fn execute_shell_tool( conversation: &dyn ConversationHandle, agent_config: &AgentConfig, @@ -71,6 +345,10 @@ async fn execute_shell_tool( env: Default::default(), }) .await?; + read_shell_process(process).await +} + +async fn read_shell_process(process: Box) -> Result { let parts = process.into_parts(); let mut stdout = parts.stdout; let mut stderr = parts.stderr; @@ -94,6 +372,35 @@ async fn execute_shell_tool( })?) } +async fn execute_exoclaw_shell_tool( + agent: &dyn AgentHandle, + conversation: &dyn ConversationHandle, + agent_config: &AgentConfig, + config: &ConversationConfig, + request: &ToolRequest, +) -> Result { + if effective_sandbox_scope(agent_config, config) == SandboxScope::Conversation { + return execute_shell_tool(conversation, agent_config, config, request).await; + } + + let args = + serde_json::from_value::(Value::Object(request.arguments.clone()))?; + let program = config + .shell_program + .clone() + .ok_or_else(|| anyhow::anyhow!("shell tool is not enabled for this conversation"))?; + let agent_sandbox = ensure_agent_sandbox(agent, conversation, agent_config, config).await?; + let process = agent_sandbox + .conversation + .run_in_sandbox(RunInSandboxRequest { + id: agent_sandbox.sandbox_id, + command: vec![program, "-lc".to_string(), args.command], + env: Default::default(), + }) + .await?; + read_shell_process(process).await +} + pub(crate) async fn ensure_shell_sandbox( conversation: &dyn ConversationHandle, agent_config: &AgentConfig, @@ -222,3 +529,87 @@ async fn latest_shell_sandbox( )), } } + +#[cfg(test)] +mod tests { + use exoharness::{BasicExoHarness, ExoHarness, NewAgentRequest, NewConversationRequest}; + use tempfile::TempDir; + + use super::*; + use crate::test_support::local_test_config; + + #[tokio::test] + async fn schedule_task_tool_uses_current_conversation_scope() { + let tempdir = TempDir::new().unwrap(); + let exoharness = BasicExoHarness::new(local_test_config(tempdir.path().join("exoharness"))) + .await + .unwrap(); + let agent = exoharness + .new_agent(NewAgentRequest { + slug: "agent".to_string(), + name: "Agent".to_string(), + }) + .await + .unwrap(); + let conversation = agent + .new_conversation(NewConversationRequest { + slug: Some("conversation".to_string()), + name: Some("Conversation".to_string()), + }) + .await + .unwrap(); + let store = SchedulerStore::new(tempdir.path().join("scheduled-tasks")); + + let schedule_result = execute_schedule_task_tool( + agent.as_ref(), + conversation.as_ref(), + &store, + &tool_request( + "schedule_sandbox_task", + serde_json::json!({ + "agentId": "spoofed-agent", + "conversationId": "spoofed-conversation", + "name": "check", + "schedule": "@every 1m", + "sandboxMode": null, + "setupCommand": null, + "command": ["true"], + "reportPrompt": "Report.", + "maxOutputBytes": 1024 + }), + ), + ) + .await + .unwrap(); + assert_eq!(schedule_result["ok"], true); + + let task_id = schedule_result["taskId"].as_str().unwrap(); + let task = store.get_task(task_id).await.unwrap().unwrap(); + assert_eq!(task.agent_id, agent.record().id.to_string()); + assert_eq!(task.conversation_id, conversation.record().id.to_string()); + + let list_result = execute_list_scheduled_tasks_tool( + agent.as_ref(), + conversation.as_ref(), + &store, + &tool_request( + "list_scheduled_tasks", + serde_json::json!({ + "agentId": "spoofed-agent", + "conversationId": "spoofed-conversation", + "includeDisabled": false + }), + ), + ) + .await + .unwrap(); + assert_eq!(list_result["tasks"].as_array().unwrap().len(), 1); + } + + fn tool_request(function_name: &str, arguments: serde_json::Value) -> ToolRequest { + ToolRequest { + function_name: function_name.to_string(), + arguments: arguments.as_object().unwrap().clone(), + } + } +} diff --git a/crates/executor/src/lib.rs b/crates/executor/src/lib.rs index f4b600f..858dc37 100644 --- a/crates/executor/src/lib.rs +++ b/crates/executor/src/lib.rs @@ -1,9 +1,13 @@ +mod adapter; +mod agent_sandbox; mod basic; #[cfg(test)] mod basic_tests; mod braintrust; #[cfg(test)] mod braintrust_tests; +mod conversation_sandbox; +mod conversation_wakeup; mod execution_tracing; mod executor_types; mod harness_basic; @@ -21,17 +25,27 @@ mod local_sandbox; mod rlm; #[cfg(test)] mod rlm_tests; +mod scheduler_runtime; +mod scheduler_store; +mod scheduler_types; mod shared; #[cfg(test)] mod test_support; mod typescript; +pub use adapter::AdapterStore; +pub use adapter::{ + AdapterAttachment, AdapterAttachmentKind, AdapterConfig, AdapterEventRecord, AdapterEventType, + AdapterRecord, AdapterSource, NewAdapter, WorkerSecretEnvVar, +}; +pub use adapter::{AdapterRunOptions, run_adapters_watch}; pub use braintrust::{BraintrustProject, BraintrustRuntimeConfig, BraintrustTracingConfig}; +pub use conversation_wakeup::send_conversation_wakeup; pub use executor_types::{ AgentConfig, AgentHarnessKind, ConversationConfig, ConversationModelConfig, ExecutionStreamEvent, ExecutionStreamHandle, ModelClient, ModelRequest, ModelResponse, - ModelResponseStream, PendingToolCall, SendRequest, SendResult, ToolDefinition, ToolRuntime, - TypeScriptHarnessConfig, + ModelResponseStream, PendingToolCall, SandboxScope, SendRequest, SendResult, ToolDefinition, + ToolRuntime, TypeScriptHarnessConfig, effective_sandbox_scope, }; pub use exoharness::{ AgentHandle, BasicExoHarness, BasicExoHarnessConfig, Binding, BindingRecord, @@ -45,12 +59,17 @@ pub use exoharness::{ }; pub use harness_basic::BasicHarness; pub use harness_config::load_agent_config; -pub use harness_tool::BasicToolRuntime; +pub use harness_tool::{BasicToolRuntime, ExoclawToolRuntime}; pub use harness_types::{ CreateAgentRequest, CreateConversationRequest, Harness, HarnessAgent, HarnessConversation, }; pub use local_sandbox::LocalSandboxExoHarness; pub use rlm::RlmHarness; +pub use scheduler_runtime::{SchedulerRunOptions, run_due_tasks, run_task}; +pub use scheduler_store::SchedulerStore; +pub use scheduler_types::{ + DEFAULT_MAX_OUTPUT_BYTES, NewScheduledTask, ScheduledTaskRecord, ScheduledTaskRunRecord, now_ms, +}; pub use typescript::TypeScriptHarness; pub(crate) use basic::BasicExecutor; diff --git a/crates/executor/src/scheduler_runtime.rs b/crates/executor/src/scheduler_runtime.rs new file mode 100644 index 0000000..5ad004c --- /dev/null +++ b/crates/executor/src/scheduler_runtime.rs @@ -0,0 +1,503 @@ +use std::sync::Arc; + +use anyhow::{Result, anyhow}; +use exoharness::{RunInSandboxRequest, SandboxProcess, WriteArtifactRequest}; +use futures::io::{AsyncRead, AsyncReadExt}; +use serde::Serialize; + +use crate::agent_sandbox::{AgentSandboxHandle, ensure_agent_sandbox}; +use crate::conversation_sandbox::{create_conversation_sandbox, ensure_conversation_sandbox}; +use crate::conversation_wakeup::send_conversation_wakeup; +use crate::scheduler_store::SchedulerStore; +use crate::scheduler_types::{ + DEFAULT_COMMAND_TIMEOUT_MS, DEFAULT_TASK_LEASE_MS, ScheduledTaskRecord, ScheduledTaskRunRecord, + ScheduledTaskSandboxMode, now_ms, +}; +use crate::{Harness, Uuid7}; + +#[derive(Debug, Clone, Copy)] +pub struct SchedulerRunOptions { + pub limit: usize, +} + +impl Default for SchedulerRunOptions { + fn default() -> Self { + Self { limit: 10 } + } +} + +#[derive(Debug, Serialize)] +struct ScheduledTaskArtifact { + task_id: String, + task_name: String, + run_id: String, + sandbox_id: Option, + setup_command: Option>, + command: Vec, + exit_code: Option, + setup_stdout: Option, + setup_stderr: Option, + stdout: String, + stderr: String, + truncated: bool, + error: Option, +} + +pub async fn run_due_tasks( + harness: Arc, + store: &SchedulerStore, + options: SchedulerRunOptions, +) -> Result> { + let due = store + .claim_due_tasks(now_ms(), options.limit, DEFAULT_TASK_LEASE_MS) + .await?; + futures::future::try_join_all( + due.into_iter() + .map(|task| run_task(Arc::clone(&harness), store, task)), + ) + .await +} + +pub async fn run_task( + harness: Arc, + store: &SchedulerStore, + mut task: ScheduledTaskRecord, +) -> Result { + let started_at_ms = now_ms(); + let run_id = Uuid7::now().to_string(); + let run_result = run_task_inner(Arc::clone(&harness), &mut task, &run_id).await; + let finished_at_ms = now_ms(); + + let (mut run, result_artifact_id) = match run_result { + Ok(output) => { + let stdout_bytes = output.stdout.len() as u64; + let stderr_bytes = output.stderr.len() as u64; + ( + ScheduledTaskRunRecord { + id: run_id, + task_id: task.id.clone(), + started_at_ms, + finished_at_ms, + exit_code: output.exit_code, + stdout_bytes, + stderr_bytes, + truncated: output.truncated, + result_artifact_id: output.result_artifact_id.clone(), + error: output.error.clone(), + }, + output.result_artifact_id, + ) + } + Err(error) => ( + ScheduledTaskRunRecord { + id: run_id, + task_id: task.id.clone(), + started_at_ms, + finished_at_ms, + exit_code: None, + stdout_bytes: 0, + stderr_bytes: 0, + truncated: false, + result_artifact_id: None, + error: Some(error.to_string()), + }, + None, + ), + }; + + task.mark_completed(&run, result_artifact_id, finished_at_ms)?; + if run + .error + .as_deref() + .is_some_and(is_missing_task_owner_error) + { + task.enabled = false; + task.updated_at_ms = finished_at_ms; + } + run.task_id = task.id.clone(); + store.put_run(&run).await?; + store.put_task(&task).await?; + Ok(run) +} + +fn is_missing_task_owner_error(error: &str) -> bool { + error.starts_with("scheduled task agent does not exist:") + || error.starts_with("scheduled task conversation does not exist:") +} + +struct TaskOutput { + exit_code: Option, + stdout: Vec, + stderr: Vec, + truncated: bool, + result_artifact_id: Option, + error: Option, +} + +async fn run_task_inner( + harness: Arc, + task: &mut ScheduledTaskRecord, + run_id: &str, +) -> Result { + let agent = harness + .get_agent(&task.agent_id) + .await? + .ok_or_else(|| anyhow!("scheduled task agent does not exist: {}", task.agent_id))?; + let conversation = agent + .get_conversation(&task.conversation_id) + .await? + .ok_or_else(|| { + anyhow!( + "scheduled task conversation does not exist: {}", + task.conversation_id + ) + })?; + let agent_config = agent.config().await?; + let conversation_config = conversation.config().await?; + let conversation_handle = conversation.exoharness_handle(); + let sandbox = resolve_task_sandbox( + task, + agent.exoharness_handle().as_ref(), + std::sync::Arc::clone(&conversation_handle), + &agent_config, + &conversation_config, + ) + .await?; + let command_result: Result = async { + let process = sandbox + .conversation + .run_in_sandbox(RunInSandboxRequest { + id: sandbox.sandbox_id.clone(), + command: task + .setup_command + .clone() + .unwrap_or_else(|| task.command.clone()), + env: Default::default(), + }) + .await?; + let setup_output = + read_process_output(process, task.max_output_bytes, DEFAULT_COMMAND_TIMEOUT_MS).await?; + if task.setup_command.is_none() { + return Ok(CommandOutput { + sandbox_id: sandbox.sandbox_id.clone(), + setup: None, + main: setup_output, + error: None, + }); + } + if setup_output.exit_code != Some(0) { + return Ok(CommandOutput { + sandbox_id: sandbox.sandbox_id.clone(), + setup: Some(setup_output), + main: ProcessOutput::empty(), + error: Some("setup command exited non-zero".to_string()), + }); + } + let process = sandbox + .conversation + .run_in_sandbox(RunInSandboxRequest { + id: sandbox.sandbox_id.clone(), + command: task.command.clone(), + env: Default::default(), + }) + .await?; + let main_output = + read_process_output(process, task.max_output_bytes, DEFAULT_COMMAND_TIMEOUT_MS).await?; + Ok(CommandOutput { + sandbox_id: sandbox.sandbox_id.clone(), + setup: Some(setup_output), + main: main_output, + error: None, + }) + } + .await; + + let (exit_code, stdout, stderr, truncated, error, setup, sandbox_id) = match command_result { + Ok(output) => { + let truncated = + output.main.truncated || output.setup.as_ref().is_some_and(|setup| setup.truncated); + ( + output.main.exit_code, + output.main.stdout, + output.main.stderr, + truncated, + output.error, + output.setup, + Some(output.sandbox_id), + ) + } + Err(error) => ( + None, + Vec::new(), + Vec::new(), + false, + Some(error.to_string()), + None, + None, + ), + }; + + let artifact = ScheduledTaskArtifact { + task_id: task.id.clone(), + task_name: task.name.clone(), + run_id: run_id.to_string(), + sandbox_id, + setup_command: task.setup_command.clone(), + command: task.command.clone(), + exit_code, + setup_stdout: setup + .as_ref() + .map(|output| String::from_utf8_lossy(&output.stdout).into_owned()), + setup_stderr: setup + .as_ref() + .map(|output| String::from_utf8_lossy(&output.stderr).into_owned()), + stdout: String::from_utf8_lossy(&stdout).into_owned(), + stderr: String::from_utf8_lossy(&stderr).into_owned(), + truncated, + error: error.clone(), + }; + let artifact_version = conversation_handle + .write_artifact(WriteArtifactRequest { + path: format!("scheduled-tasks/{}/{run_id}.json", task.name), + contents: serde_json::to_vec_pretty(&artifact)?, + }) + .await?; + let artifact_id = artifact_version.artifact_id.to_string(); + let prompt = if let Some(error) = &error { + error_wakeup_prompt(task, run_id, error, &artifact_version) + } else { + wakeup_prompt( + task, + run_id, + exit_code.expect("completed scheduled command has exit code"), + truncated, + &artifact_version, + &stdout, + &stderr, + ) + }; + send_conversation_wakeup(conversation.as_ref(), prompt).await?; + + Ok(TaskOutput { + exit_code, + stdout, + stderr, + truncated, + result_artifact_id: Some(artifact_id), + error, + }) +} + +async fn resolve_task_sandbox( + task: &mut ScheduledTaskRecord, + agent: &dyn exoharness::AgentHandle, + conversation: std::sync::Arc, + agent_config: &crate::AgentConfig, + conversation_config: &crate::ConversationConfig, +) -> Result { + match task.sandbox_mode { + ScheduledTaskSandboxMode::Agent => { + ensure_agent_sandbox( + agent, + conversation.as_ref(), + agent_config, + conversation_config, + ) + .await + } + ScheduledTaskSandboxMode::Conversation => Ok(AgentSandboxHandle { + sandbox_id: ensure_conversation_sandbox( + conversation.as_ref(), + agent_config, + conversation_config, + ) + .await?, + conversation, + }), + ScheduledTaskSandboxMode::TaskFresh => { + if let Some(sandbox_id) = &task.task_sandbox_id { + return Ok(AgentSandboxHandle { + conversation, + sandbox_id: sandbox_id.clone(), + }); + } + let sandbox_id = create_conversation_sandbox( + conversation.as_ref(), + agent_config, + conversation_config, + ) + .await?; + task.task_sandbox_id = Some(sandbox_id.clone()); + Ok(AgentSandboxHandle { + conversation, + sandbox_id, + }) + } + } +} + +async fn read_process_output( + process: Box, + max_stream_bytes: u64, + timeout_ms: u64, +) -> Result { + let parts = process.into_parts(); + drop(parts.stdin); + + let read_output = async { + let (stdout_result, stderr_result, exit_result) = tokio::join!( + read_limited(parts.stdout, max_stream_bytes), + read_limited(parts.stderr, max_stream_bytes), + parts.wait, + ); + let (stdout, stdout_truncated) = stdout_result?; + let (stderr, stderr_truncated) = stderr_result?; + let exit_code = exit_result?; + Result::<_>::Ok(( + stdout, + stdout_truncated, + stderr, + stderr_truncated, + exit_code, + )) + }; + let (stdout, stdout_truncated, stderr, stderr_truncated, exit_code) = + tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), read_output) + .await + .map_err(|_| anyhow!("scheduled task command timed out after {timeout_ms}ms"))??; + let truncated = stdout_truncated || stderr_truncated; + Ok(ProcessOutput { + exit_code: Some(exit_code), + stdout, + stderr, + truncated, + }) +} + +struct CommandOutput { + sandbox_id: String, + setup: Option, + main: ProcessOutput, + error: Option, +} + +#[derive(Debug)] +struct ProcessOutput { + exit_code: Option, + stdout: Vec, + stderr: Vec, + truncated: bool, +} + +impl ProcessOutput { + fn empty() -> Self { + Self { + exit_code: None, + stdout: Vec::new(), + stderr: Vec::new(), + truncated: false, + } + } +} + +async fn read_limited(mut reader: R, max_bytes: u64) -> Result<(Vec, bool)> +where + R: AsyncRead + Unpin, +{ + let mut output = Vec::new(); + let mut truncated = false; + let mut buffer = [0u8; 8192]; + loop { + let read = reader.read(&mut buffer).await?; + if read == 0 { + break; + } + let remaining = max_bytes.saturating_sub(output.len() as u64) as usize; + if read > remaining { + output.extend_from_slice(&buffer[..remaining]); + truncated = true; + continue; + } + output.extend_from_slice(&buffer[..read]); + } + Ok((output, truncated)) +} + +fn wakeup_prompt( + task: &ScheduledTaskRecord, + run_id: &str, + exit_code: i32, + truncated: bool, + artifact: &exoharness::ArtifactVersion, + stdout: &[u8], + stderr: &[u8], +) -> String { + format!( + "Scheduled task `{}` completed.\n\nRun id: `{}`\nExit code: {}\nResult artifact: `{}` version {} at `{}`\nOutput truncated: {}\n\nReport instructions:\n{}\n\nstdout preview:\n{}\n\nstderr preview:\n{}", + task.name, + run_id, + exit_code, + artifact.artifact_id, + artifact.version, + artifact.path, + truncated, + task.report_prompt, + preview(stdout), + preview(stderr), + ) +} + +fn error_wakeup_prompt( + task: &ScheduledTaskRecord, + run_id: &str, + error: &str, + artifact: &exoharness::ArtifactVersion, +) -> String { + format!( + "Scheduled task `{}` failed.\n\nRun id: `{}`\nResult artifact: `{}` version {} at `{}`\n\nReport instructions:\n{}\n\nError:\n{}", + task.name, + run_id, + artifact.artifact_id, + artifact.version, + artifact.path, + task.report_prompt, + error, + ) +} + +fn preview(bytes: &[u8]) -> String { + const PREVIEW_BYTES: usize = 4_000; + let end = bytes.len().min(PREVIEW_BYTES); + String::from_utf8_lossy(&bytes[..end]).into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::{future::FutureExt, io::Cursor}; + + struct HangingSandboxProcess; + + impl SandboxProcess for HangingSandboxProcess { + fn into_parts(self: Box) -> exoharness::SandboxProcessParts { + exoharness::SandboxProcessParts { + stdout: Box::pin(Cursor::new(Vec::new())), + stderr: Box::pin(Cursor::new(Vec::new())), + stdin: Box::pin(Cursor::new(Vec::new())), + wait: async { + futures::future::pending::<()>().await; + Ok(0) + } + .boxed(), + } + } + } + + #[tokio::test] + async fn read_process_output_times_out() { + let error = read_process_output(Box::new(HangingSandboxProcess), 1024, 1) + .await + .unwrap_err(); + assert!(error.to_string().contains("timed out")); + } +} diff --git a/crates/executor/src/scheduler_store.rs b/crates/executor/src/scheduler_store.rs new file mode 100644 index 0000000..191a854 --- /dev/null +++ b/crates/executor/src/scheduler_store.rs @@ -0,0 +1,276 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use tokio::fs; + +use crate::scheduler_types::{ + NewScheduledTask, ScheduledTaskRecord, ScheduledTaskRunRecord, now_ms, +}; + +#[derive(Debug, Clone)] +pub struct SchedulerStore { + root: PathBuf, +} + +impl SchedulerStore { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub async fn create_task(&self, request: NewScheduledTask) -> Result { + let task = ScheduledTaskRecord::new(request, now_ms())?; + self.put_task(&task).await?; + Ok(task) + } + + pub async fn list_tasks(&self) -> Result> { + let task_dir = self.tasks_dir(); + match fs::metadata(&task_dir).await { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(Vec::new()); + } + Err(error) => return Err(error.into()), + } + let mut entries = fs::read_dir(&task_dir) + .await + .with_context(|| format!("failed to read scheduled task directory {task_dir:?}"))?; + let mut tasks = Vec::new(); + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + let bytes = fs::read(&path) + .await + .with_context(|| format!("failed to read scheduled task {}", path.display()))?; + tasks.push(serde_json::from_slice::(&bytes)?); + } + tasks.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id))); + Ok(tasks) + } + + pub async fn list_tasks_for_conversation( + &self, + agent_id: &str, + conversation_id: &str, + include_disabled: bool, + ) -> Result> { + Ok(self + .list_tasks() + .await? + .into_iter() + .filter(|task| task.agent_id == agent_id && task.conversation_id == conversation_id) + .filter(|task| include_disabled || task.enabled) + .collect()) + } + + pub async fn due_tasks(&self, now_ms: u64) -> Result> { + Ok(self + .list_tasks() + .await? + .into_iter() + .filter(|task| task.is_due(now_ms)) + .collect()) + } + + pub async fn claim_due_tasks( + &self, + now_ms: u64, + limit: usize, + lease_ms: u64, + ) -> Result> { + let mut due = self.due_tasks(now_ms).await?; + due.sort_by_key(|task| task.next_run_at_ms); + due.truncate(limit); + let mut claimed = Vec::new(); + for mut task in due { + task.claim(now_ms, lease_ms); + self.put_task(&task).await?; + claimed.push(task); + } + Ok(claimed) + } + + pub async fn get_task(&self, task_id: &str) -> Result> { + let path = self.task_path(task_id); + match fs::read(&path).await { + Ok(bytes) => Ok(Some(serde_json::from_slice::(&bytes)?)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error) + .with_context(|| format!("failed to read scheduled task {}", path.display())), + } + } + + pub async fn put_task(&self, task: &ScheduledTaskRecord) -> Result<()> { + fs::create_dir_all(self.tasks_dir()).await?; + let path = self.task_path(&task.id); + fs::write(&path, serde_json::to_vec_pretty(task)?) + .await + .with_context(|| format!("failed to write scheduled task {}", path.display())) + } + + pub async fn disable_task(&self, task_id: &str) -> Result> { + let Some(mut task) = self.get_task(task_id).await? else { + return Ok(None); + }; + task.enabled = false; + task.updated_at_ms = now_ms(); + self.put_task(&task).await?; + Ok(Some(task)) + } + + pub async fn delete_task(&self, task_id: &str) -> Result> { + let Some(task) = self.get_task(task_id).await? else { + return Ok(None); + }; + remove_file_if_exists(self.task_path(task_id)).await?; + remove_dir_if_exists(self.runs_dir(task_id)).await?; + Ok(Some(task)) + } + + pub async fn put_run(&self, run: &ScheduledTaskRunRecord) -> Result<()> { + fs::create_dir_all(self.runs_dir(&run.task_id)).await?; + let path = self.run_path(&run.task_id, &run.id); + fs::write(&path, serde_json::to_vec_pretty(run)?) + .await + .with_context(|| format!("failed to write scheduled task run {}", path.display())) + } + + fn tasks_dir(&self) -> PathBuf { + self.root.join("tasks") + } + + fn task_path(&self, task_id: &str) -> PathBuf { + self.tasks_dir().join(format!("{task_id}.json")) + } + + fn runs_dir(&self, task_id: &str) -> PathBuf { + self.root.join("runs").join(task_id) + } + + fn run_path(&self, task_id: &str, run_id: &str) -> PathBuf { + self.runs_dir(task_id).join(format!("{run_id}.json")) + } +} + +async fn remove_file_if_exists(path: PathBuf) -> Result<()> { + match fs::remove_file(&path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => { + Err(error).with_context(|| format!("failed to delete file {}", path.display())) + } + } +} + +async fn remove_dir_if_exists(path: PathBuf) -> Result<()> { + match fs::remove_dir_all(&path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => { + Err(error).with_context(|| format!("failed to delete directory {}", path.display())) + } + } +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + #[tokio::test] + async fn creates_and_lists_tasks() { + let tempdir = TempDir::new().unwrap(); + let store = SchedulerStore::new(tempdir.path()); + let task = store + .create_task(NewScheduledTask { + agent_id: "agent".to_string(), + conversation_id: "conversation".to_string(), + name: "check".to_string(), + schedule: "@every 1m".to_string(), + sandbox_mode: None, + setup_command: None, + command: vec!["true".to_string()], + report_prompt: "Report.".to_string(), + max_output_bytes: None, + }) + .await + .unwrap(); + + assert_eq!(store.list_tasks().await.unwrap(), vec![task]); + } + + #[tokio::test] + async fn disables_and_deletes_tasks() { + let tempdir = TempDir::new().unwrap(); + let store = SchedulerStore::new(tempdir.path()); + let task = store + .create_task(NewScheduledTask { + agent_id: "agent".to_string(), + conversation_id: "conversation".to_string(), + name: "check".to_string(), + schedule: "@every 1m".to_string(), + sandbox_mode: None, + setup_command: None, + command: vec!["true".to_string()], + report_prompt: "Report.".to_string(), + max_output_bytes: None, + }) + .await + .unwrap(); + + store.disable_task(&task.id).await.unwrap(); + assert!( + store + .list_tasks_for_conversation("agent", "conversation", false) + .await + .unwrap() + .is_empty() + ); + assert_eq!( + store + .list_tasks_for_conversation("agent", "conversation", true) + .await + .unwrap() + .len(), + 1 + ); + + let deleted = store.delete_task(&task.id).await.unwrap().unwrap(); + assert_eq!(deleted.id, task.id); + assert!(store.get_task(&task.id).await.unwrap().is_none()); + } + + #[tokio::test] + async fn claim_due_tasks_leases_until_expiry() { + let tempdir = TempDir::new().unwrap(); + let store = SchedulerStore::new(tempdir.path()); + let mut task = store + .create_task(NewScheduledTask { + agent_id: "agent".to_string(), + conversation_id: "conversation".to_string(), + name: "check".to_string(), + schedule: "@every 1m".to_string(), + sandbox_mode: None, + setup_command: None, + command: vec!["true".to_string()], + report_prompt: "Report.".to_string(), + max_output_bytes: None, + }) + .await + .unwrap(); + task.next_run_at_ms = 1; + store.put_task(&task).await.unwrap(); + + let claimed = store.claim_due_tasks(2, 10, 100).await.unwrap(); + assert_eq!(claimed.len(), 1); + assert!(store.claim_due_tasks(3, 10, 100).await.unwrap().is_empty()); + assert_eq!(store.claim_due_tasks(103, 10, 100).await.unwrap().len(), 1); + } +} diff --git a/crates/executor/src/scheduler_types.rs b/crates/executor/src/scheduler_types.rs new file mode 100644 index 0000000..f230689 --- /dev/null +++ b/crates/executor/src/scheduler_types.rs @@ -0,0 +1,346 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Result, anyhow, bail}; +use exoharness::Uuid7; +use serde::{Deserialize, Serialize}; + +pub const DEFAULT_MAX_OUTPUT_BYTES: u64 = 200_000; +pub const MAX_OUTPUT_BYTES: u64 = 2_000_000; +pub const DEFAULT_TASK_LEASE_MS: u64 = 10 * 60 * 1_000; +pub const DEFAULT_COMMAND_TIMEOUT_MS: u64 = 10 * 60 * 1_000; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScheduledTaskRecord { + pub id: String, + pub agent_id: String, + pub conversation_id: String, + pub name: String, + pub schedule: String, + #[serde(default)] + pub sandbox_mode: ScheduledTaskSandboxMode, + #[serde(default)] + pub task_sandbox_id: Option, + #[serde(default)] + pub setup_command: Option>, + pub command: Vec, + pub report_prompt: String, + pub max_output_bytes: u64, + pub enabled: bool, + pub created_at_ms: u64, + pub updated_at_ms: u64, + pub next_run_at_ms: u64, + pub last_run_at_ms: Option, + pub latest_run_id: Option, + pub latest_result_artifact_id: Option, + pub lease: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct NewScheduledTask { + pub agent_id: String, + pub conversation_id: String, + pub name: String, + pub schedule: String, + pub sandbox_mode: Option, + pub setup_command: Option>, + pub command: Vec, + pub report_prompt: String, + pub max_output_bytes: Option, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ScheduledTaskSandboxMode { + #[default] + Agent, + Conversation, + TaskFresh, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScheduledTaskRunRecord { + pub id: String, + pub task_id: String, + pub started_at_ms: u64, + pub finished_at_ms: u64, + pub exit_code: Option, + pub stdout_bytes: u64, + pub stderr_bytes: u64, + pub truncated: bool, + pub result_artifact_id: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScheduledTaskLease { + pub id: String, + pub leased_at_ms: u64, + pub expires_at_ms: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ParsedSchedule { + interval_ms: u64, +} + +impl ScheduledTaskRecord { + pub fn new(request: NewScheduledTask, now_ms: u64) -> Result { + validate_task_name(&request.name)?; + validate_command(&request.command)?; + if let Some(setup_command) = &request.setup_command { + validate_command(setup_command)?; + } + let schedule = parse_schedule(&request.schedule)?; + let max_output_bytes = request.max_output_bytes.unwrap_or(DEFAULT_MAX_OUTPUT_BYTES); + if max_output_bytes == 0 { + bail!("scheduled task maxOutputBytes must be greater than zero"); + } + if max_output_bytes > MAX_OUTPUT_BYTES { + bail!( + "scheduled task maxOutputBytes must be less than or equal to {}", + MAX_OUTPUT_BYTES + ); + } + Ok(Self { + id: Uuid7::now().to_string(), + agent_id: non_empty("agentId", request.agent_id)?, + conversation_id: non_empty("conversationId", request.conversation_id)?, + name: request.name, + schedule: request.schedule, + sandbox_mode: request.sandbox_mode.unwrap_or_default(), + task_sandbox_id: None, + setup_command: request.setup_command, + command: request.command, + report_prompt: non_empty("reportPrompt", request.report_prompt)?, + max_output_bytes, + enabled: true, + created_at_ms: now_ms, + updated_at_ms: now_ms, + next_run_at_ms: schedule.next_after_ms(now_ms), + last_run_at_ms: None, + latest_run_id: None, + latest_result_artifact_id: None, + lease: None, + }) + } + + pub fn is_due(&self, now_ms: u64) -> bool { + self.enabled + && self.next_run_at_ms <= now_ms + && self + .lease + .as_ref() + .is_none_or(|lease| lease.expires_at_ms <= now_ms) + } + + pub fn claim(&mut self, now_ms: u64, lease_ms: u64) { + self.lease = Some(ScheduledTaskLease { + id: Uuid7::now().to_string(), + leased_at_ms: now_ms, + expires_at_ms: now_ms.saturating_add(lease_ms), + }); + self.updated_at_ms = now_ms; + } + + pub fn mark_completed( + &mut self, + run: &ScheduledTaskRunRecord, + result_artifact_id: Option, + now_ms: u64, + ) -> Result<()> { + let schedule = parse_schedule(&self.schedule)?; + self.updated_at_ms = now_ms; + self.last_run_at_ms = Some(run.finished_at_ms); + self.latest_run_id = Some(run.id.clone()); + self.latest_result_artifact_id = result_artifact_id; + self.next_run_at_ms = schedule.next_after_ms(now_ms); + self.lease = None; + Ok(()) + } +} + +impl ParsedSchedule { + pub fn next_after_ms(&self, now_ms: u64) -> u64 { + now_ms.saturating_add(self.interval_ms) + } +} + +pub fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_millis() as u64 +} + +pub fn parse_schedule(raw: &str) -> Result { + let schedule = raw.trim(); + if let Some(interval) = schedule.strip_prefix("@every ") { + return parse_interval(interval.trim()); + } + + let parts = schedule.split_whitespace().collect::>(); + if parts.len() == 5 + && parts[0].starts_with("*/") + && parts[1] == "*" + && parts[2] == "*" + && parts[3] == "*" + && parts[4] == "*" + { + let minutes = parts[0] + .trim_start_matches("*/") + .parse::() + .map_err(|_| anyhow!("cron minute interval must be a positive integer"))?; + if minutes == 0 { + bail!("cron minute interval must be greater than zero"); + } + return Ok(ParsedSchedule { + interval_ms: minutes.saturating_mul(60_000), + }); + } + + bail!("schedule must be '@every ' or '*/N * * * *'"); +} + +fn parse_interval(raw: &str) -> Result { + if raw.len() < 2 { + bail!("interval must include a value and unit"); + } + let (value, unit) = raw.split_at(raw.len() - 1); + let value = value + .parse::() + .map_err(|_| anyhow!("interval value must be a positive integer"))?; + if value == 0 { + bail!("interval value must be greater than zero"); + } + let multiplier = match unit { + "s" => 1_000, + "m" => 60_000, + "h" => 3_600_000, + "d" => 86_400_000, + _ => bail!("interval unit must be one of s, m, h, or d"), + }; + Ok(ParsedSchedule { + interval_ms: value.saturating_mul(multiplier), + }) +} + +fn validate_task_name(name: &str) -> Result<()> { + if name.is_empty() { + bail!("scheduled task name must not be empty"); + } + if !name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + { + bail!("scheduled task name may only contain letters, numbers, '-' and '_'"); + } + Ok(()) +} + +fn validate_command(command: &[String]) -> Result<()> { + if command.is_empty() { + bail!("scheduled task command must not be empty"); + } + if command.iter().any(|part| part.is_empty()) { + bail!("scheduled task command entries must not be empty"); + } + Ok(()) +} + +fn non_empty(field: &str, value: String) -> Result { + if value.trim().is_empty() { + bail!("{field} must not be empty"); + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_every_interval() { + assert_eq!( + parse_schedule("@every 5m").unwrap().next_after_ms(1), + 300_001 + ); + } + + #[test] + fn parses_simple_cron_interval() { + assert_eq!( + parse_schedule("*/30 * * * *").unwrap().next_after_ms(1), + 1_800_001 + ); + } + + #[test] + fn rejects_invalid_schedule() { + assert!(parse_schedule("* * * * *").is_err()); + } + + #[test] + fn scheduled_task_defaults_to_agent_sandbox() { + let task = ScheduledTaskRecord::new( + NewScheduledTask { + agent_id: "agent".to_string(), + conversation_id: "conversation".to_string(), + name: "check".to_string(), + schedule: "@every 1m".to_string(), + sandbox_mode: None, + setup_command: None, + command: vec!["true".to_string()], + report_prompt: "Report.".to_string(), + max_output_bytes: None, + }, + 1, + ) + .unwrap(); + + assert_eq!(task.sandbox_mode, ScheduledTaskSandboxMode::Agent); + assert_eq!(task.task_sandbox_id, None); + } + + #[test] + fn scheduled_task_accepts_fresh_task_sandbox_mode() { + let task = ScheduledTaskRecord::new( + NewScheduledTask { + agent_id: "agent".to_string(), + conversation_id: "conversation".to_string(), + name: "check".to_string(), + schedule: "@every 1m".to_string(), + sandbox_mode: Some(ScheduledTaskSandboxMode::TaskFresh), + setup_command: None, + command: vec!["true".to_string()], + report_prompt: "Report.".to_string(), + max_output_bytes: None, + }, + 1, + ) + .unwrap(); + + assert_eq!(task.sandbox_mode, ScheduledTaskSandboxMode::TaskFresh); + assert_eq!(task.task_sandbox_id, None); + } + + #[test] + fn scheduled_task_rejects_excessive_output_cap() { + assert!( + ScheduledTaskRecord::new( + NewScheduledTask { + agent_id: "agent".to_string(), + conversation_id: "conversation".to_string(), + name: "check".to_string(), + schedule: "@every 1m".to_string(), + sandbox_mode: None, + setup_command: None, + command: vec!["true".to_string()], + report_prompt: "Report.".to_string(), + max_output_bytes: Some(MAX_OUTPUT_BYTES + 1), + }, + 1, + ) + .is_err() + ); + } +} diff --git a/crates/executor/src/typescript.rs b/crates/executor/src/typescript.rs index 75f1fb5..0c34035 100644 --- a/crates/executor/src/typescript.rs +++ b/crates/executor/src/typescript.rs @@ -30,7 +30,7 @@ use tokio::task::JoinHandle; use crate::execution_tracing::TurnExecutionTrace; use crate::harness_executor::{ExecutorHarnessRuntime, ExecutorStreamMode, HarnessExecutor}; use crate::harness_facade::{SharedHarness, SharedHarnessBacked}; -use crate::harness_tool::{BasicToolRuntime, ensure_shell_sandbox}; +use crate::harness_tool::{BasicToolRuntime, ExoclawToolRuntime, ensure_shell_sandbox}; use crate::shared::try_send_stream_event; use crate::{ AgentConfig, BraintrustRuntimeConfig, ConversationConfig, ExecutionStreamEvent, SendRequest, @@ -83,12 +83,13 @@ where async fn prepare_conversation( &self, + agent: &dyn AgentHandle, conversation: &dyn ConversationHandle, agent_config: &AgentConfig, conversation_config: &ConversationConfig, ) -> Result<()> { self.tools - .prepare_conversation(conversation, agent_config, conversation_config) + .prepare_conversation(agent, conversation, agent_config, conversation_config) .await } @@ -174,7 +175,7 @@ where async fn execute_runtime_request( &self, - _agent: &dyn AgentHandle, + agent: &dyn AgentHandle, conversation: &dyn ConversationHandle, agent_config: &AgentConfig, conversation_config: &ConversationConfig, @@ -184,7 +185,13 @@ where RuntimeRequest::ExecuteTool { request } => Ok(RuntimeResponsePayload::ToolResult { result: self .tools - .execute(conversation, agent_config, conversation_config, &request) + .execute( + agent, + conversation, + agent_config, + conversation_config, + &request, + ) .await?, }), RuntimeRequest::StartSandboxProcess { .. } @@ -396,6 +403,7 @@ impl TypeScriptRunnerProcess { send_host_message(&self.host_tx, response)?; } GuestToHostMessage::ExoRequest { id, request } => { + let request_kind = request.kind(); let response = match exoharness_server.handle_request(request).await { Ok(response) => HostToGuestMessage::ExoResponse { id, @@ -409,7 +417,9 @@ impl TypeScriptRunnerProcess { response: None, error: Some(format_error_chain( &error, - format_args!("typescript exoharness request failed"), + format_args!( + "typescript exoharness request `{request_kind}` failed" + ), )), }, }; @@ -828,6 +838,31 @@ impl TypeScriptHarness { } } +impl TypeScriptHarness { + pub async fn exoclaw_from_root( + root: impl AsRef, + exo_config: BasicExoHarnessConfig, + runtime_config: Option, + env: HashMap, + ) -> Result { + let workspace_root = std::env::current_dir() + .context("failed to resolve current directory for Exoclaw harness")?; + let root = root.as_ref(); + let exoharness: Arc = Arc::new(BasicExoHarness::new(exo_config).await?); + let tools = Arc::new(ExoclawToolRuntime::with_roots( + root.join("scheduled-tasks"), + root.join("adapters"), + )); + let runtime = ExecutorHarnessRuntime::new( + TypeScriptExecutor::new(Arc::clone(&exoharness), workspace_root, env, tools), + runtime_config, + ); + Ok(Self { + inner: SharedHarness::new(exoharness, runtime), + }) + } +} + impl SharedHarnessBacked for TypeScriptHarness where T: ToolRuntime + 'static, diff --git a/crates/exoharness/src/basic_tests.rs b/crates/exoharness/src/basic_tests.rs index 0d204fd..33bdf12 100644 --- a/crates/exoharness/src/basic_tests.rs +++ b/crates/exoharness/src/basic_tests.rs @@ -550,6 +550,86 @@ async fn basic_backend_runs_commands_in_created_sandbox() { assert_eq!(wait_result.expect("process should exit"), 0); } +#[tokio::test(flavor = "current_thread")] +async fn basic_backend_reattaches_running_sandbox_in_new_harness_process() { + let tempdir = TempDir::new().expect("tempdir"); + let harness = BasicExoHarness::new(local_test_config(tempdir.path())) + .await + .expect("harness should initialize"); + let agent = harness + .new_agent(NewAgentRequest { + slug: "agent".to_string(), + name: "Agent".to_string(), + }) + .await + .expect("agent"); + let conversation = agent + .new_conversation(NewConversationRequest::default()) + .await + .expect("conversation"); + let agent_id = agent.record().id; + let conversation_id = conversation.record().id; + + let sandbox_id = conversation + .create_sandbox(CreateSandboxRequest { + provider: SandboxProvider::LocalProcess, + image: "basic-local-process".to_string(), + default_workdir: Some(tempdir.path().display().to_string()), + file_system_mounts: None, + enable_networking: Some(true), + idle_seconds: Some(60), + }) + .await + .expect("sandbox should be created"); + drop(conversation); + drop(agent); + drop(harness); + + let reloaded_harness = BasicExoHarness::new(local_test_config(tempdir.path())) + .await + .expect("harness should reload"); + let reloaded_agent = reloaded_harness + .get_agent(&agent_id) + .await + .expect("get agent") + .expect("agent exists"); + let reloaded_conversation = reloaded_agent + .get_conversation(&conversation_id) + .await + .expect("get conversation") + .expect("conversation exists"); + + let process = reloaded_conversation + .run_in_sandbox(RunInSandboxRequest { + id: sandbox_id, + command: vec![ + "/bin/sh".to_string(), + "-lc".to_string(), + "printf reattached".to_string(), + ], + env: Default::default(), + }) + .await + .expect("sandbox command should run after reload"); + let parts = process.into_parts(); + let mut stdout = parts.stdout; + let mut stderr = parts.stderr; + drop(parts.stdin); + let mut stdout_bytes = Vec::new(); + let mut stderr_bytes = Vec::new(); + let (stdout_result, stderr_result, wait_result) = tokio::join!( + stdout.read_to_end(&mut stdout_bytes), + stderr.read_to_end(&mut stderr_bytes), + parts.wait, + ); + + stdout_result.expect("stdout should read"); + stderr_result.expect("stderr should read"); + assert_eq!(String::from_utf8_lossy(&stdout_bytes), "reattached"); + assert_eq!(String::from_utf8_lossy(&stderr_bytes), ""); + assert_eq!(wait_result.expect("process should exit"), 0); +} + #[tokio::test(flavor = "current_thread")] async fn basic_backend_exposes_process_events_and_input() { let tempdir = TempDir::new().expect("tempdir"); diff --git a/crates/exoharness/src/sandbox.rs b/crates/exoharness/src/sandbox.rs index 006b1dd..f9812d3 100644 --- a/crates/exoharness/src/sandbox.rs +++ b/crates/exoharness/src/sandbox.rs @@ -174,7 +174,7 @@ const DEFAULT_ENABLED_NETWORK_NAME: &str = "exo-default"; const WARM_SANDBOX_KEEPALIVE_ARGV: &[&str] = &["sleep", "infinity"]; const WARM_SANDBOX_HEALTHCHECK_TIMEOUT: Duration = Duration::from_secs(3); const WARM_SANDBOX_CLEANUP_TIMEOUT: Duration = Duration::from_secs(5); -const ORPHANED_WARM_SANDBOX_MIN_AGE: Duration = Duration::from_secs(60 * 60); +const ORPHANED_WARM_SANDBOX_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60); const WARM_SANDBOX_KEY_LABEL: &str = "exo.sandbox.key"; const WARM_SANDBOX_SPEC_HASH_LABEL: &str = "exo.sandbox.spec-hash"; const WARM_SANDBOX_OWNER_PID_LABEL: &str = "exo.sandbox.owner-pid"; @@ -185,6 +185,7 @@ struct WarmSandboxEntry { name: String, request: SandboxRequest, last_used_at: Instant, + owned: bool, } #[derive(Debug, Deserialize)] @@ -244,7 +245,8 @@ impl CliContainerSandboxBackend { .arg("start") .kill_on_drop(true) .output() - .await?; + .await + .with_context(|| missing_container_cli_message(self.cli, &self.container_bin))?; if !output.status.success() { return Err(anyhow!( "failed to start container system: {}", @@ -331,7 +333,7 @@ impl CliContainerSandboxBackend { .iter() .filter_map(|(key, entry)| { let ttl = entry.request.lifecycle.idle_ttl?; - (entry.last_used_at + ttl <= now).then(|| key.clone()) + (entry.owned && entry.last_used_at + ttl <= now).then(|| key.clone()) }) .collect::>(); @@ -342,7 +344,9 @@ impl CliContainerSandboxBackend { }; for entry in expired { - cleanup_named_container(&self.container_bin, self.cli, &entry.name).await?; + if entry.owned { + cleanup_named_container(&self.container_bin, self.cli, &entry.name).await?; + } } Ok(()) @@ -351,16 +355,9 @@ impl CliContainerSandboxBackend { impl Drop for CliContainerSandboxBackend { fn drop(&mut self) { - let Ok(mut warm_sandboxes) = self.warm_sandboxes.try_lock() else { - return; - }; - let names = warm_sandboxes - .drain() - .map(|(_, entry)| entry.name) - .collect::>(); - for name in names { - cleanup_named_container_blocking(&self.container_bin, self.cli, &name); - } + // Warm sandboxes intentionally outlive a single CLI/REPL process so a + // restarted Exoclaw agent can reattach to the same environment. Stale + // containers are cleaned by the orphan reaper on later backend startup. } } @@ -395,11 +392,19 @@ impl ManagedSandboxBackend for CliContainerSandboxBackend { None => None, } }; - if let Some(entry) = replaced { + if let Some(entry) = replaced + && entry.owned + { schedule_cleanup_named_container(self.container_bin.clone(), self.cli, entry.name); } - let name = create_unique_warm_sandbox(&self.container_bin, &request).await?; + let (name, owned) = match find_running_warm_sandbox(&self.container_bin, &request).await? { + Some(name) => (name, false), + None => ( + create_unique_warm_sandbox(&self.container_bin, &request).await?, + true, + ), + }; { let mut warm_sandboxes = self.warm_sandboxes.lock().await; @@ -409,6 +414,7 @@ impl ManagedSandboxBackend for CliContainerSandboxBackend { name: name.clone(), request: request.clone(), last_used_at: Instant::now(), + owned, }, ); } @@ -465,6 +471,7 @@ impl ManagedSandboxBackend for CliContainerSandboxBackend { name: name.clone(), request: request.clone(), last_used_at: Instant::now(), + owned: true, }, ); } @@ -568,7 +575,9 @@ impl ManagedSandboxHandle for WarmSandboxHandle { warm_sandboxes.remove(&self.request.key) }; - if let Some(entry) = removed { + if let Some(entry) = removed + && entry.owned + { cleanup_named_container(&self.container_bin, self.cli, &entry.name).await } else { Ok(()) @@ -781,6 +790,40 @@ async fn create_unique_warm_sandbox( )) } +async fn find_running_warm_sandbox( + container_bin: &Path, + request: &SandboxRequest, +) -> Result> { + let output = run_container_admin_command( + container_bin, + WARM_SANDBOX_CLEANUP_TIMEOUT, + ["list", "--format", "json"], + ) + .await?; + if !output.status.success() { + return Err(anyhow!( + "failed to list warm sandboxes: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let spec_hash = sandbox_spec_hash(&request.spec); + let containers: Vec = serde_json::from_slice(&output.stdout)?; + Ok(containers.into_iter().find_map(|container| { + if container.status.as_deref() != Some("running") { + return None; + } + let labels = &container.configuration.labels; + let key_matches = labels + .get(WARM_SANDBOX_KEY_LABEL) + .is_some_and(|value| value == &request.key.to_string()); + let spec_matches = labels + .get(WARM_SANDBOX_SPEC_HASH_LABEL) + .is_some_and(|value| value == &spec_hash); + (key_matches && spec_matches).then_some(container.configuration.id) + })) +} + async fn ensure_warm_sandbox_ready( container_bin: &Path, cli: ContainerCliFlavor, @@ -796,35 +839,51 @@ async fn ensure_warm_sandbox_ready( }; let mut warm_sandboxes = warm_sandboxes.lock().await; - let current_name = match warm_sandboxes.get_mut(&request.key) { + let (current_name, current_owned) = match warm_sandboxes.get_mut(&request.key) { Some(entry) if entry.request.spec == request.spec => { entry.last_used_at = Instant::now(); - entry.name.clone() + (entry.name.clone(), entry.owned) } Some(_) => { let stale = warm_sandboxes .remove(&request.key) .expect("entry disappeared while locked"); - schedule_cleanup_named_container(container_bin.to_path_buf(), cli, stale.name); - let name = create_unique_warm_sandbox(container_bin, request).await?; + if stale.owned { + schedule_cleanup_named_container(container_bin.to_path_buf(), cli, stale.name); + } + let (name, owned) = match find_running_warm_sandbox(container_bin, request).await? { + Some(name) => (name, false), + None => ( + create_unique_warm_sandbox(container_bin, request).await?, + true, + ), + }; warm_sandboxes.insert( request.key.clone(), WarmSandboxEntry { name: name.clone(), request: request.clone(), last_used_at: Instant::now(), + owned, }, ); return Ok(name); } None => { - let name = create_unique_warm_sandbox(container_bin, request).await?; + let (name, owned) = match find_running_warm_sandbox(container_bin, request).await? { + Some(name) => (name, false), + None => ( + create_unique_warm_sandbox(container_bin, request).await?, + true, + ), + }; warm_sandboxes.insert( request.key.clone(), WarmSandboxEntry { name: name.clone(), request: request.clone(), last_used_at: Instant::now(), + owned, }, ); return Ok(name); @@ -839,17 +898,26 @@ async fn ensure_warm_sandbox_ready( return Ok(current_name); } - let replacement_name = create_unique_warm_sandbox(container_bin, request).await?; + let (replacement_name, owned) = match find_running_warm_sandbox(container_bin, request).await? { + Some(name) => (name, false), + None => ( + create_unique_warm_sandbox(container_bin, request).await?, + true, + ), + }; warm_sandboxes.insert( request.key.clone(), WarmSandboxEntry { name: replacement_name.clone(), request: request.clone(), last_used_at: Instant::now(), + owned, }, ); drop(warm_sandboxes); - schedule_cleanup_named_container(container_bin.to_path_buf(), cli, current_name); + if current_owned { + schedule_cleanup_named_container(container_bin.to_path_buf(), cli, current_name); + } Ok(replacement_name) } @@ -1223,18 +1291,6 @@ fn owner_pid_is_alive(pid: &str) -> bool { .is_ok_and(|status| status.success()) } -fn cleanup_named_container_blocking(container_bin: &Path, cli: ContainerCliFlavor, name: &str) { - match cli { - ContainerCliFlavor::AppleContainer => { - run_container_admin_command_blocking(container_bin, ["stop", name]); - run_container_admin_command_blocking(container_bin, ["delete", name]); - } - ContainerCliFlavor::Docker => { - run_container_admin_command_blocking(container_bin, ["rm", "-f", name]); - } - } -} - fn schedule_cleanup_named_container(container_bin: PathBuf, cli: ContainerCliFlavor, name: String) { tokio::spawn(async move { if let Err(error) = cleanup_named_container(&container_bin, cli, &name).await { @@ -1243,6 +1299,19 @@ fn schedule_cleanup_named_container(container_bin: PathBuf, cli: ContainerCliFla }); } +fn missing_container_cli_message(cli: ContainerCliFlavor, container_bin: &Path) -> String { + match cli { + ContainerCliFlavor::AppleContainer => format!( + "apple-container sandbox backend requires the `{}` CLI; install Apple container CLI or use `--sandbox-backend local-process`", + container_bin.display() + ), + ContainerCliFlavor::Docker => format!( + "docker sandbox backend requires the `{}` CLI; install Docker or use `--sandbox-backend local-process`", + container_bin.display() + ), + } +} + async fn run_container_admin_command( container_bin: &Path, timeout: Duration, @@ -1260,35 +1329,6 @@ async fn run_container_admin_command( } } -fn run_container_admin_command_blocking(container_bin: &Path, args: [&str; N]) { - let Ok(mut child) = std::process::Command::new(container_bin) - .args(args) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - else { - return; - }; - - let deadline = Instant::now() + WARM_SANDBOX_CLEANUP_TIMEOUT; - loop { - match child.try_wait() { - Ok(Some(_)) => return, - Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)), - Ok(None) => { - if let Err(error) = child.kill() { - tracing::warn!(%error, "failed to kill timed out container admin command"); - } - if let Err(error) = child.wait() { - tracing::warn!(%error, "failed to wait for timed out container admin command"); - } - return; - } - Err(_) => return, - } - } -} - fn is_missing_container_error(stderr: &str) -> bool { let lower = stderr.to_ascii_lowercase(); lower.contains("not found") || lower.contains("no such") @@ -1385,9 +1425,10 @@ async fn docker_snapshot_container( if let Ok(output) = &rm_output && !output.status.success() { - eprintln!( - "warning: failed to remove ephemeral snapshot image {snap_tag}: {}", - render_command_error(&output.stderr) + tracing::warn!( + image_tag = %snap_tag, + stderr = %render_command_error(&output.stderr), + "failed to remove ephemeral snapshot image" ); } @@ -1450,3 +1491,29 @@ async fn docker_load_image(container_bin: &Path, payload: &Bytes) -> Result(&self, key: impl AsRef) -> Result { + let key = key.as_ref(); let bytes = self.get_bytes(key).await?; - serde_json::from_slice(&bytes).map_err(Into::into) + serde_json::from_slice(&bytes) + .with_context(|| format!("failed to decode JSON {}", key.display())) } pub(crate) async fn get_json_if_exists( &self, key: impl AsRef, ) -> Result> { + let key = key.as_ref(); let Some(bytes) = self.get_bytes_if_exists(key).await? else { return Ok(None); }; - serde_json::from_slice(&bytes).map(Some).map_err(Into::into) + serde_json::from_slice(&bytes) + .map(Some) + .with_context(|| format!("failed to decode JSON {}", key.display())) } pub(crate) async fn get_bytes(&self, key: impl AsRef) -> Result> { diff --git a/crates/exoharness/src/types.rs b/crates/exoharness/src/types.rs index e20d6c4..963ad11 100644 --- a/crates/exoharness/src/types.rs +++ b/crates/exoharness/src/types.rs @@ -449,9 +449,10 @@ pub struct CreateSandboxRequest { pub idle_seconds: Option, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum SandboxProvider { + #[default] Daytona, AppleContainer, Docker, diff --git a/email-tool-arch.md b/email-tool-arch.md new file mode 100644 index 0000000..06a0cbf --- /dev/null +++ b/email-tool-arch.md @@ -0,0 +1,568 @@ +# Exoclaw Email Adapter Architecture + +This document proposes email as an Exoclaw adapter backed by Resend. Email is +bidirectional and event-driven: inbound messages arrive while the agent is idle, +and outbound messages should be queued and delivered through a durable external +integration. That matches the existing adapter model better than a pure +synchronous tool. + +The main design is: + +```text +Resend inbound webhook + -> email adapter worker + -> AdapterStore inbound event + -> Exoclaw conversation wakeup + -> agent decides what to do + -> send_adapter_message + -> AdapterStore outbound queue + -> email adapter worker sends via Resend +``` + +Optional library tools can still exist later for inbox inspection, but the +primary integration should be `examples/exoclaw/adapters/email/`. + +## Goals + +- Add first-party email support using the existing Exoclaw adapter subsystem. +- Support inbound email, outbound email, replies, and attachments. +- Use Resend for outbound delivery and inbound routing/webhooks. +- Keep email-specific code under `examples/exoclaw/`. +- Keep secrets and deploy-specific defaults out of Git. +- Reuse `create_adapter`, `list_adapters`, `disable_adapter`, `delete_adapter`, + and `send_adapter_message` where possible. +- Keep wakeups compact and make the agent explicitly decide whether to reply. + +## Non-Goals + +- Do not add email as a core Exoharness concept. +- Do not add a generic CLI email command. +- Do not make email a TypeScript library tool as the primary abstraction. +- Do not implement a full inbox, contact manager, or CRM in the first version. +- Do not require the `resend` npm package unless it materially simplifies the + implementation. + +## Proposed Location + +```text +examples/exoclaw/adapters/email/ + README.md + setup-prompt.md + worker.ts + resend.ts + webhook-server.ts + email-store.ts +``` + +Shared adapter protocol changes, if any, should stay in: + +```text +examples/exoclaw/adapters/protocol.ts +crates/executor/src/adapter/ +typescript/harness/adapter-tools.ts +``` + +The first pass should try to fit inside the current adapter protocol: + +- inbound email maps to `WorkerEvent::Message`. +- outbound email maps to `WorkerCommand::SendMessage`. +- email attachments reuse `AdapterAttachment`. + +Only extend the Rust/TypeScript adapter protocol if email needs fields that +cannot safely fit into the existing `metadata` JSON. + +## Why Adapter, Not Tool + +A tool is invoked during an active agent turn. It is good for actions the agent +initiates, such as "send this email now." + +Receiving email is different: + +- It happens while the agent is idle. +- It needs a long-running webhook listener or externally reachable endpoint. +- It must deduplicate provider retries. +- It should wake the conversation when a message arrives. +- It needs durable integration state independent of the current turn. + +Those are adapter responsibilities. Email should therefore be an adapter first, +with any tools layered on later as convenience helpers. + +## Adapter Configuration + +Add an email adapter config shape that the existing `create_adapter` tool can +store in `AdapterConfig.settings`. + +Suggested settings: + +```json +{ + "adapterType": "email", + "settings": { + "provider": "resend", + "from": "Exoclaw ", + "replyTo": "agent@example.com", + "inbound": { + "bind": "127.0.0.1:8765", + "publicWebhookUrl": "https://example.ngrok.app/email", + "webhookPath": "/email", + "secretEnv": "RESEND_WEBHOOK_SECRET" + }, + "routing": { + "mode": "singleConversation", + "addresses": ["agent@example.com"] + }, + "allow": { + "recipientDomains": ["example.com"], + "recipients": [], + "senders": [] + }, + "maxAttachmentBytes": 10485760 + }, + "secrets": { + "RESEND_API_KEY": "", + "RESEND_WEBHOOK_SECRET": "" + } +} +``` + +Initial implementation can be simpler: + +- one adapter instance +- one configured conversation +- one or more inbound addresses routed to that conversation +- one Resend API key +- one webhook secret + +Address-based routing can come later if needed. + +## Secrets And Local Config + +Required secrets: + +- `RESEND_API_KEY`: API key for sending email and calling Resend APIs. +- `RESEND_WEBHOOK_SECRET`: shared secret for webhook verification. + +Required non-secret config: + +- `from`: default sender address. +- `inbound.bind`: local bind address for the webhook server. +- `inbound.webhookPath`: HTTP path to receive webhooks. + +Optional config: + +- `replyTo`: default reply-to address. +- `publicWebhookUrl`: public URL to register in Resend. +- `allow.recipientDomains`: outbound recipient domain allowlist. +- `allow.recipients`: exact outbound recipient allowlist. +- `allow.senders`: inbound sender allowlist. +- `maxAttachmentBytes`: max total outbound or inbound attachment bytes. + +Secrets should be stored through the existing adapter secret mechanism. Local +setup prompts and `.env` can help users configure the initial adapter, but +committed files should not contain real addresses, API keys, or personalized +settings. + +## Inbound Flow + +The email worker starts a small HTTP server and receives Resend webhooks. + +Flow: + +1. Resend receives an email for a configured domain/address. +2. Resend posts an inbound event to the adapter webhook endpoint. +3. The worker verifies the webhook secret/signature. +4. The worker normalizes the provider payload. +5. The worker deduplicates by provider message id. +6. The worker stores the full email payload and any staged attachments. +7. The worker emits a `message` worker event to the adapter runtime. +8. The adapter runtime records the inbound event and wakes the conversation. + +The wakeup prompt should be compact: + +```text +Email received. + +Adapter id: +Email id: +From: +To: +Subject: +Preview: + + +Attachments: + +Use send_adapter_message with this adapter id and target only if +you intentionally want to reply. Read stored email details before acting on long +or sensitive content. +``` + +The existing `WorkerEvent::Message` fields can map as: + +- `target`: reply target, probably the local email id or sender address with + metadata for threading. +- `sender`: normalized `from`. +- `text`: compact preview and key email metadata. +- `message_id`: provider message id or local email id. +- `metadata`: full structured metadata, including local email id, headers, + subject, recipients, and attachment references. + +## Outbound Flow + +The agent sends email through the existing `send_adapter_message` tool. + +For a new email: + +```json +{ + "adapterId": "", + "target": "person@example.com", + "text": "Hello from Exoclaw.", + "attachments": [] +} +``` + +For a reply, the target should identify the inbound email or thread: + +```json +{ + "adapterId": "", + "target": "email:", + "text": "Thanks, I will take a look.", + "attachments": [] +} +``` + +The email worker drains outbound messages from `AdapterStore` and maps them to +Resend's send API. It should support: + +- new outbound email by recipient address +- reply by local email id +- reply by explicit metadata, if needed later +- text body +- optional HTML body via metadata, if the protocol is extended +- attachments using existing `AdapterAttachment` + +If the existing outbound command only has `target`, `text`, and `attachments`, +the first version can treat `text` as the plain text body and use metadata-free +defaults for subject: + +- new outbound subject: require the agent to include `subject` in metadata once + protocol support exists, or use a conservative default and document the + limitation. +- reply subject: derive from stored inbound email (`Re: `). + +This suggests one small adapter protocol extension may be worthwhile: allow +`send_adapter_message` to accept optional `metadata` for adapter-specific fields +such as email subject, cc, bcc, reply-to, and html. If we add that, keep it a +generic JSON field rather than email-specific Rust types. + +## Suggested `send_adapter_message` Metadata + +If we extend outbound messages, add optional metadata: + +```json +{ + "subject": "Status update", + "cc": ["cc@example.com"], + "bcc": [], + "replyTo": "agent@example.com", + "html": "

Hello

", + "inReplyToEmailId": "" +} +``` + +Rust changes: + +- add `metadata: serde_json::Value` or `Option` to + `AdapterOutboundMessageRecord`. +- add `metadata` to `WorkerCommand::SendMessage`. +- expose `metadata` in `typescript/harness/adapter-tools.ts`. +- keep validation adapter-specific in the worker where possible. + +This keeps the core adapter protocol generic and lets future adapters use +structured outbound metadata too. + +## Email Store + +Use an email-specific store under the adapter root rather than the conversation +event log. + +Suggested layout: + +```text +.exo/adapters/email// + received/ + .json + sent/ + .json + blobs/ + / + body.txt + body.html + attachments/ +``` + +Received record fields: + +- `id` +- `adapterId` +- `provider` +- `providerMessageId` +- `from` +- `to` +- `cc` +- `subject` +- `textPreview` +- `textPath` +- `htmlPath` +- `headers` +- `attachments` +- `receivedAt` +- `readAt` +- `wakeupEventId` or wakeup metadata if useful + +Sent record fields: + +- `id` +- `adapterId` +- `provider` +- `providerMessageId` +- `target` +- `to` +- `cc` +- `bcc` +- `subject` +- `replyToEmailId` +- `sentAt` +- `attachments` + +This mirrors existing adapter and scheduler design: subsystem state lives in a +subsystem store, while the conversation receives compact prompts and can inspect +details through tools or metadata. + +## Attachments + +Outbound attachments should reuse `AdapterAttachment`: + +- `sandboxPath`: preferred for files generated by the agent. +- `path`: host-visible files staged by the runtime. +- `url`: remote files staged by the runtime if supported. +- `data`: small inline payloads only. + +The worker should convert attachments into Resend's expected attachment shape, +usually filename plus base64 content. + +Inbound attachments should be staged by the email worker into the email store. +The wakeup prompt should include attachment count and names, not full content. +Later, optional helper tools can expose attachments as artifacts or staged paths. + +## Optional Helper Tools + +Even with an adapter-first design, a small library tool module may still be +useful for inspecting stored email: + +```text +examples/exoclaw/tools/library/email/ + index.ts + store.ts +``` + +Potential helper tools: + +- `list_received_emails` +- `read_received_email` +- `list_sent_emails` + +These tools would read the email adapter store and return compact results or +artifact references. They should not own receiving or sending. Sending should go +through `send_adapter_message` so email behaves like IRC, WhatsApp, and Signal. + +This can be phase two. The first adapter version can include enough detail in +the wakeup prompt and metadata to be useful without extra tools. + +## Resend Integration + +Outbound endpoint: + +```text +POST https://api.resend.com/emails +``` + +Headers: + +```text +Authorization: Bearer $RESEND_API_KEY +Content-Type: application/json +``` + +Outbound payload fields to map: + +- `from` +- `to` +- `cc` +- `bcc` +- `reply_to` +- `subject` +- `text` +- `html` +- `attachments` +- provider-specific reply/threading headers if supported + +Inbound should use Resend inbound routing/webhook support. The worker should +isolate provider-specific parsing in `resend.ts` so a future provider can be +added without changing the adapter protocol. + +Normalized inbound fields: + +- provider +- provider message id +- from +- to +- cc +- subject +- text body +- HTML body +- headers needed for replies/threading +- attachments metadata and staged content +- received timestamp + +## Safety Model + +Email is external and often sensitive. The adapter should be conservative: + +- Verify inbound webhooks. +- Deduplicate webhook retries before waking the agent. +- Keep wakeup prompts compact. +- Do not dump long email bodies or attachments directly into prompts. +- Do not include API keys or full provider payloads in logs. +- Support outbound recipient allowlists. +- Support inbound sender allowlists. +- Treat inbound email as notification, not permission to reply. +- Require explicit `send_adapter_message` calls for all replies. +- Record sent email metadata for audit. + +The Exoclaw prompt should tell the agent: + +- email messages can wake the conversation +- it should not auto-reply unless the user or standing instructions make that + appropriate +- it should use `send_adapter_message` for intentional email replies +- it should preserve recipient privacy and avoid including sensitive content in + unrelated channels + +## Setup Flow + +The setup prompt should mirror other adapters: + +```text +Create an email adapter using Resend. + +Ask the user for: +- Resend API key secret +- from address +- inbound webhook secret +- local bind address or public webhook URL +- inbound address/domain +- optional allowlists +``` + +Manual setup should be possible through the existing adapter tools once the +adapter type exists: + +```text +create_adapter({ + "adapterType": "email", + "name": "email", + "trigger": { "type": "all" }, + "settings": { ... }, + "secrets": { ... } +}) +``` + +The Exoclaw startup script can later add `email` to `--adapters` alongside +`irc`, `whatsapp`, and `signal`. + +## File-Level Plan + +Phase 1: adapter skeleton. + +- Add `examples/exoclaw/adapters/email/README.md`. +- Add `examples/exoclaw/adapters/email/setup-prompt.md`. +- Add `examples/exoclaw/adapters/email/worker.ts`. +- Add `examples/exoclaw/adapters/email/resend.ts`. +- Add `examples/exoclaw/adapters/email/email-store.ts`. +- Update docs to mention `email` as a supported adapter. + +Phase 2: inbound receive. + +- Implement local webhook server in the worker. +- Verify webhook secret/signature. +- Normalize Resend inbound events. +- Store received email records and blobs. +- Deduplicate provider message ids. +- Emit adapter `message` events with compact previews and metadata. + +Phase 3: outbound send. + +- Implement Resend send API client. +- Drain outbound messages from the adapter runtime. +- Support plain text new emails. +- Support replies using stored inbound email ids. +- Record sent email metadata. + +Phase 4: metadata and richer outbound email. + +- Decide whether to extend `send_adapter_message` with generic outbound + `metadata`. +- Support subject, cc, bcc, reply-to, html, and reply/threading fields. +- Keep the Rust protocol generic and validate email-specific metadata in the + email worker. + +Phase 5: attachments. + +- Map outbound `AdapterAttachment` values to Resend attachments. +- Stage inbound attachments in the email store. +- Add size limits and clear errors. + +Phase 6: optional helper tools. + +- Add `examples/exoclaw/tools/library/email/index.ts` only if needed. +- Implement `list_received_emails` and `read_received_email`. +- Keep sending through `send_adapter_message`. + +Phase 7: verification and docs. + +- Unit test Resend payload mapping. +- Unit test inbound webhook normalization. +- Unit test email store dedupe. +- Add worker-level smoke tests where practical. +- Update `examples/exoclaw/adapter-architecture.md`. +- Update `examples/exoclaw/README.md`. +- Update `examples/exoclaw/prompts/me.md`. + +## Open Questions + +- Should v1 route all inbound email to one configured conversation, or support + address-based routing immediately? +- Does Resend provide enough inbound webhook signature data for strong + verification, or should we require an unguessable webhook path plus shared + secret? +- Should outbound email require generic `metadata` support before v1, or can v1 + start with replies and plain text bodies? +- Should received emails be exposed only through wakeup metadata, or should v1 + include helper tools to read stored messages? +- Should the email adapter use the existing `AdapterStore` only, or add a + sibling email-specific store under the adapter root? + +## Recommendation + +Implement email as `examples/exoclaw/adapters/email/`. Treat receiving as the +primary reason for choosing the adapter abstraction: a Resend webhook worker +stores inbound email, deduplicates events, and wakes the configured Exoclaw +conversation. Treat sending as the adapter's outbound path through +`send_adapter_message`, not as a standalone `send_email` tool. + +Add optional helper tools only after the adapter works, and only for richer inbox +inspection. Keep email out of core Exoharness; the core runtime should see +normal adapter events, queued outbound messages, and wakeup turns. diff --git a/examples/exoclaw/README.md b/examples/exoclaw/README.md new file mode 100644 index 0000000..70b3bc6 --- /dev/null +++ b/examples/exoclaw/README.md @@ -0,0 +1,192 @@ +# Exoclaw Harness + +Exoclaw is a persistent agent built on exoclaw designed to be helpful wherever +there is a task to do from a computer. It supports task scheuling, a full +sandbox where it can install its own tools and integrations, and right now +supports WhatsApp, Signal, and IRC out of the box. + +Exoclaw includes a helper script to start up all the subservices (task +scheduling and adapters). To get started, simply run: + +```bash + examples/exoclaw/scripts/exoclaw-repl fresh \ + --pull-sandbox \ + --agent exoclaw \ + --agent-name "exoclaw" \ + --conversation dev \ + --setup-all \ + --setup-profile +``` + +Or for a minimal start (just REPL, pull sandbox): +`examples/exoclaw/scripts/exoclaw-repl --pull-sandbox` + +## Setting up the identity + +`examples/exoclaw/prompts/me.md` is the committed, generic Exoclaw identity +prompt. It's best to keep this high level, and not specific to any given +instance or the local deployment environment. + +Use `.exo/exoclaw-profile.md` for local instructions instead. The harness loads +it as an additional developer prompt when it exists, and `.exo` is ignored by +git. To create it interactively: + +```bash +examples/exoclaw/scripts/exoclaw-repl setup-profile +``` + +The script asks for the user's name and any extra local instructions. To use a +different local prompt path, set `EXOCLAW_LOCAL_PROMPT_FILE` or pass +`--local-prompt-file `. + +## Tools + +Exoclaw includes the normal minimal tools: + +- `shell` +- `install_agent_tool` when agent tool creation is enabled +- configured library tools + +It also adds scheduler tools: + +- `schedule_sandbox_task` +- `list_scheduled_tasks` +- `cancel_scheduled_task` +- `delete_scheduled_task` + +`cancel_scheduled_task` disables a task and preserves its record/history. +`delete_scheduled_task` removes the task record and stored run history. + +And adapter tools: + +- `create_adapter` +- `list_adapters` +- `disable_adapter` +- `delete_adapter` +- `send_adapter_message` + +`disable_adapter` stops future adapter wake-ups while preserving the adapter +record and event history. `delete_adapter` removes the adapter record and stored +events. + +## Adapters + +Adapters are host-owned long-running runtimes for external applications. They +are intentionally separate from scheduled sandbox commands: adapters own sockets, +reconnect behavior, inbound message parsing, event history, and conversation +wake-ups. Agents configure adapters with tools, and the local adapter runner +started by `examples/exoclaw/scripts/exoclaw-repl` keeps them connected. + +Exoclaw ships with three adapters: IRC, WhatsApp, and Signal. The easiest way to +use them is to have the script send all three setup prompts before opening the +REPL: + +```bash +examples/exoclaw/scripts/exoclaw-repl --setup-all +``` + +For a fresh control agent with a local profile prompt and all adapters: + +```bash +PATH="/opt/homebrew/opt/openjdk/bin:$PATH" \ + examples/exoclaw/scripts/exoclaw-repl fresh \ + --agent spooky \ + --agent-name Spooky \ + --conversation dev \ + --setup-profile \ + --setup-all +``` + +This will: + +- Prompt you for any needed adapter configuration (such as nicknames, channel/server info for IRC, or pairing for WhatsApp/Signal). +- Write adapter configuration to `.exo/adapters/`. +- Start the background adapter runner so the agent can receive and react to external messages in real time. + +The adapter runner starts by default. Use `--no-adapters` to skip it, or +`--adapters` to force it on when an environment override disabled it. + +You can list configured adapters with: + +```bash +target/debug/exo --harness exoclaw adapters list +``` + +See the sections below for more details on individual adapter configuration. + +### IRC + +The first built-in adapter is IRC. It runs as a host-supervised Node.js worker +that connects to a configured server/channel, responds to `PING`, parses +`PRIVMSG`, and wakes the conversation when the trigger policy matches. The +recommended trigger is `mention`, which only wakes the conversation when a +channel message mentions the adapter nick. `all_messages` is available for +quieter channels. + +### WhatsApp + +Exoclaw also includes an experimental WhatsApp adapter using Baileys. The Rust +adapter runtime owns supervision, durable events, conversation wakeups, and +outbox draining; workers own protocol-specific sockets and parsing. When first +run, the WhatsApp worker emits a QR pairing event into adapter history and logs; +after pairing, Baileys auth state is stored under +`.exo/adapters/whatsapp//auth` by default. + +### Signal + +The Signal adapter uses `signal-cli` as a linked device. If its `account` config +is null, the worker starts `signal-cli link`, logs a QR code for the phone's +linked-device flow, discovers the linked account with `signal-cli listAccounts`, +then runs `signal-cli -a jsonRpc`. Outbound DM targets should be +Signal usernames with the `u:` prefix, such as `u:example.01`, unless an inbound +wakeup provides a more precise target. + +## Sandbox Modes + +Exoclaw conversations default to `sandboxScope: "agent"`. The `shell` tool uses +the sticky agent sandbox, so packages installed through the Exoclaw REPL, such as +`curl` or `python3`, are available to scheduled task runs and future +conversations for the same agent while that warm sandbox is still alive. Normal +REPL exits leave the warm sandbox running so the next Exoclaw process can +reattach to it. + +Because exoclaw defaults to agent scope, you don't need to specify anything from +the cli. The following command will create a REPL with the agent and a +persistent sandbox that will be durable across conversations + +```bash +examples/exoclaw/scripts/exoclaw-repl --pull-sandbox +``` + +If you want a conversation to have its own sandbox, use `sandboxScope: "conversation"`: + +```bash +examples/exoclaw/scripts/exoclaw-repl --conversation isolated-dev --sandbox-scope conversation +exo --harness exoclaw conversation update exoclaw-agent isolated-dev --sandbox-scope conversation +``` + +Scheduled tasks also default to `sandboxMode: "agent"`. A task can explicitly use +`sandboxMode: "conversation"` to run in the current conversation's sandbox, or +`sandboxMode: "task_fresh"` to create a separate task-owned sandbox. + +Important limitation: the current sandbox filesystem is not durable across warm +container death. Exoclaw stores a durable pointer to the agent's sandbox, but +package installs made interactively live in the running warm container. If the +host restarts or the container backend cleans up the warm container, a later +scheduled task may recreate the sandbox from the base image and lose packages +installed with commands like `apt-get install python3`. Stale warm containers are +eligible for orphan cleanup after roughly 24 hours. + +For reliable scheduled tasks, prefer one of these: + +- Use a sandbox image that already contains required dependencies. +- Include a `setupCommand` that installs required packages before the task runs. +- Keep task code/data on mounted storage instead of relying on mutated container + filesystem state. + +The task-owned sandbox starts from the configured image and mounts. It is reused +across the task's runs and stopped when the task is cancelled. + +The current scope model is Exoclaw-specific policy on top of conversation-owned +exoharness sandbox records. The default mental model is agent-scoped, while +conversation and task scopes remain available for isolation. diff --git a/examples/exoclaw/adapter-architecture.md b/examples/exoclaw/adapter-architecture.md new file mode 100644 index 0000000..1fb8b6c --- /dev/null +++ b/examples/exoclaw/adapter-architecture.md @@ -0,0 +1,365 @@ +# Exoclaw Adapter Architecture + +Exoclaw adapters are host-owned long-running integrations between an Exoclaw +conversation and an external application. The first adapter is IRC, but the +same worker shape now supports IRC, WhatsApp, and Signal, and is intended to +support Slack, Discord, IRC networks, and custom local services. + +Adapters are deliberately not scheduled sandbox tasks. A scheduled task is a +periodic command: it starts, runs, writes output, wakes the conversation, and +exits. An adapter owns a live external connection: it keeps sockets open, +responds to protocol keepalives, reconnects after errors, parses inbound +messages, records event history, and wakes a conversation when something should +be handled by the agent. + +## Components + +The implementation is split across a few small executor and CLI modules: + +- `crates/executor/src/adapter/types.rs` defines durable adapter records, + source enums, generic worker config, event records, and outbound message records. +- `crates/executor/src/adapter/store.rs` is the file-backed store under + `.exo/adapters`. It stores adapter records, per-adapter event history, and + the adapter outbox. +- `crates/executor/src/adapter/runtime.rs` supervises enabled worker adapters, + writes event artifacts, sends conversation wakeups, and queues outbound + messages. +- `crates/executor/src/adapter/worker.rs` implements the generic JSONL worker + bridge used by host-supervised sidecar adapters. +- `examples/exoclaw/adapters/irc/worker.ts` implements IRC connection behavior: + TLS/plain TCP, `PASS`/`NICK`/`USER`, `JOIN`, `PING`/`PONG`, `PRIVMSG` + parsing, trigger matching, and draining outbound messages over the persistent + IRC connection. +- `examples/exoclaw/adapters/whatsapp/worker.ts` is the Baileys worker for the + library WhatsApp adapter. +- `examples/exoclaw/adapters/signal/worker.ts` is the `signal-cli` worker for + the library Signal adapter. +- `crates/executor/src/adapter/tools.rs` implements the host-backed tool calls + used by Exoclaw. +- `typescript/harness/adapter-tools.ts` exposes the model-facing Exoclaw tools. +- `crates/cli/src/adapters.rs` provides `exo --harness exoclaw adapters ...`. +- `examples/exoclaw/scripts/exoclaw-repl` starts the adapter runner next to the scheduler. + +At a high level: + +```mermaid +flowchart LR + externalApp["IRC / WhatsApp / Signal"] <--> worker["Adapter Worker"] + worker <--> runtime["Adapter Runtime"] + runtime --> store["Adapter Store"] + tools["Adapter Tools"] --> store + runtime --> wakeup["Conversation Wakeup"] + wakeup --> convo["Exoclaw Conversation"] + convo --> tools + scheduler["Task Scheduler"] --> convo + tools --> outbox["Adapter Outbox"] + outbox --> runtime +``` + +## Durable Records + +Adapter state lives under `.exo/adapters`: + +- `.exo/adapters/adapters/.json` contains the `AdapterRecord`. +- `.exo/adapters/events//*.json` contains lifecycle, inbound, + outbound, and error event records. +- `.exo/adapters/outbox//*.json` contains queued outbound messages + waiting for the long-running adapter connection to send them. + +The key record is `AdapterRecord`: + +- `id`: stable adapter id used by tools and scheduler report prompts. +- `agent_id` and `conversation_id`: the owning Exoclaw agent/conversation. +- `name`: human-friendly adapter name. +- `source`: `built_in` or `library`. +- `enabled`: disabled adapters preserve history but stop receiving. +- `config`: worker config, including adapter type, command, initialization JSON, + optional state dir, and optional secret environment bindings. +- `last_connected_at_ms`, `last_error`: runtime status fields. + +## Tool Surface + +Exoclaw exposes these adapter tools: + +- `create_adapter`: create and enable an adapter. +- `list_adapters`: list adapters for the current conversation. +- `disable_adapter`: stop receiving while preserving history. +- `delete_adapter`: remove adapter state and event history. +- `send_adapter_message`: request an explicit outbound send. + +All adapter tools are conversation-scoped. Rust derives the current `agentId` +and `conversationId` from the active handles, then verifies that the requested +adapter belongs to that conversation. + +## IRC Adapter Example + +An IRC adapter can be created from the REPL with arguments like: + +```json +{ + "name": "libera-test", + "source": "built_in", + "config": { + "type": "irc", + "server": "irc.libera.chat", + "port": 6697, + "tls": true, + "nick": "exo12345", + "username": "exo12345", + "realname": "Exoclaw Test Bot", + "channel": "##exo12345", + "passwordSecretId": null, + "trigger": "mention" + } +} +``` + +The IRC worker connects to the server, sends optional `PASS`, then `NICK` and +`USER`, waits for IRC welcome numeric `001`, joins the configured channel, and +keeps the socket open. It responds to `PING` with `PONG` so the server does not +disconnect it. + +For each channel `PRIVMSG`, the worker applies the trigger policy: + +- `mention`: wake the conversation only if the message mentions the bot nick. +- `all_messages`: wake the conversation for every channel message. + +The `mention` default is important. Busy channels can generate many messages, +and waking the model for every line would be noisy and expensive. + +When a message matches, the worker emits a JSONL `message` event. The Rust +runtime writes an inbound artifact containing the target, sender, text, metadata, +adapter id, and timestamps. It also records an inbound event, then wakes the +owning conversation with a user message that includes the adapter id: + +```text +irc message received at target `##exo12345` from spooky via adapter `libera-test`: + +hello @exo12345 + +Use send_adapter_message with adapterId `...` if you should reply to IRC. +``` + +The agent decides whether to respond. There is no automatic model-output-to-IRC +bridge. + +## Worker Protocol + +IRC, WhatsApp, and Signal are implemented as Node.js workers. Rust launches each +worker with: + +- `EXO_ADAPTER_ID` +- `EXO_ADAPTER_TYPE` +- `EXO_ADAPTER_STATE_DIR` +- `EXO_ADAPTER_CONFIG`, a JSON object containing adapter-specific initialization +- any secret-derived environment variables declared by the worker config + +Worker communication is newline-delimited JSON: + +- Worker to Rust: `connected`, `message`, `lifecycle`, `error`, and + `disconnected`. +- Rust to worker: `send_message` with `target` and `text`. + +This keeps protocol code in Exoclaw while preserving one host-owned supervision, +event, wakeup, and outbox implementation in Rust. + +## WhatsApp Worker Example + +The WhatsApp adapter uses the same durable adapter store and runtime lifecycle as +IRC, with protocol-specific work in a Node.js worker: + +```mermaid +flowchart LR + runtime["Rust Adapter Runtime"] --> worker["Baileys Worker"] + worker <--> wa["WhatsApp Web"] + runtime --> store["Adapter Store"] + tools["send_adapter_message"] --> outbox["Adapter Outbox"] + outbox --> runtime +``` + +The worker is launched with: + +```bash +pnpm tsx examples/exoclaw/adapters/whatsapp/worker.ts +``` + +If `authDir` is not configured, auth state is stored under the worker state dir's +`auth` subdirectory. On first connection the worker emits a `lifecycle` event +named `qr`; scan that QR with WhatsApp to pair the account. After pairing, the +worker emits `connected` and then `message` events for text messages. + +For inbound WhatsApp messages, the runtime writes an artifact with the chat id, +sender, message id, and text, then wakes the conversation. The wakeup instructs +the agent to call `send_adapter_message` with both the adapter id and WhatsApp +`target` chat id when a reply is appropriate. + +The MVP is intentionally narrow: text messages only, one worker per WhatsApp +account/session, QR pairing through logs/artifacts, and no media handling or +message edits yet. + +## Signal Worker Example + +The Signal adapter follows the same worker bridge but delegates protocol work to +`signal-cli`: + +```mermaid +flowchart LR + runtime["Rust Adapter Runtime"] --> worker["Signal Worker"] + worker <--> signalCli["signal-cli jsonRpc"] + signalCli <--> signal["Signal"] + runtime --> store["Adapter Store"] + tools["send_adapter_message"] --> outbox["Adapter Outbox"] + outbox --> runtime +``` + +The worker is launched with: + +```bash +pnpm tsx examples/exoclaw/adapters/signal/worker.ts +``` + +If `account` is not configured, the worker runs `signal-cli link`, prints a +linked-device QR code to the adapter log, discovers the linked account with +`signal-cli listAccounts`, then starts `signal-cli -a jsonRpc`. Signal +CLI state is stored under the worker state dir's `signal-cli` subdirectory unless +`configDir` is set. + +For inbound Signal messages, the runtime writes an artifact with the sender, +target, text, message id, and metadata, then wakes the conversation. Outbound +Signal messages require a `target`: use a Signal username with the `u:` prefix, +a phone number, a UUID, a group id, or the target from an inbound wakeup. + +## Outbound Messages + +Adapter sends must be explicit and auditable. When the agent calls +`send_adapter_message`, the tool does two things: + +1. Writes an outbound event for history. +2. Writes an `AdapterOutboundMessageRecord` into the adapter outbox. + +The long-running adapter runtime drains that outbox once per second and sends +queued messages to the worker as `send_message` commands. The IRC worker sends +them as `PRIVMSG` over the already-connected IRC socket. The WhatsApp worker +sends them through Baileys. The Signal worker sends them through `signal-cli` +JSON-RPC. WhatsApp and Signal messages require an outbox `target`; IRC messages +do not because the destination channel is fixed in adapter config. + +WhatsApp and Signal also support outbound rich attachments on `send_adapter_message`. +Attachments specify exactly one HTTPS `url`, base64 `data` payload, or +`sandboxPath` and a `kind` of `image`, `video`, `audio`, or `document`. Use +`sandboxPath` for files created inside the agent sandbox; the host tool copies +the file into `.exo/adapters/media` before queueing the outbound send. URL and +inline-data attachments are also size-checked and staged by the host before +they reach adapter workers. Image, video, and document WhatsApp attachments use +the message text as the first caption-capable media caption. Documents also +require `mimeType` and `fileName`. + +The outbox also decouples conversation turns from socket ownership. The model +turn can finish after queueing a message; the adapter runtime owns the external +connection and sends when it is ready. + +## Adapter Runner Lifecycle + +The adapter runner is started by: + +```bash +./target/debug/exo --harness exoclaw adapters run --limit 50 +``` + +`examples/exoclaw/scripts/exoclaw-repl` starts this automatically unless `--no-adapters` is +provided. It also records a pid file at `.exo/exoclaw-adapters.pid`. + +The runtime periodically lists enabled adapters and starts one supervision task +per adapter. Each supervision task: + +1. Loads the latest adapter record. +2. Skips disabled or not-yet-built adapters. +3. Connects and runs the adapter loop. +4. Records connection events and errors. +5. Reconnects after failures with a short delay. + +Disabling or deleting the adapter causes the loop to stop on its next store +check or reconnect cycle. + +## Cooperation With The Scheduler + +The scheduler and adapter runtime cooperate through the conversation, not by +calling each other directly. + +When a user says in IRC: + +```text +exo12345: every minute, fetch BBC headlines and post them here +``` + +the flow is: + +1. The IRC adapter receives the message and wakes the Exoclaw conversation. +2. The agent decides to create a scheduled task with `schedule_sandbox_task`. +3. The task stores normal scheduler state under `.exo/scheduled-tasks`. +4. The agent includes external routing in the task `reportPrompt`, including the + `adapterId` and, for WhatsApp or Signal, the `target`. +5. The scheduler runner executes the task when it is due. +6. The scheduler writes a task result artifact and wakes the conversation. +7. The scheduler wakeup includes the task `reportPrompt`, stdout/stderr preview, + artifact id, and run metadata. +8. The agent follows the `reportPrompt` and calls `send_adapter_message`. +9. `send_adapter_message` queues an outbox record. +10. The adapter loop drains the outbox and posts the result to the external app. + +The important bridge is the task `reportPrompt`. Scheduled tasks are generic; +they do not know that they were created from IRC unless the agent records that +intent. For IRC-originated scheduled work, the report prompt should say something +like: + +```text +Summarize the headline output and send it back using send_adapter_message with +adapterId 019e... . For WhatsApp, include target 123@s.whatsapp.net. For Signal, +include target u:example.01 or the inbound target. Keep the message under 400 +chars. +``` + +This keeps side effects explicit. The scheduler wakes the agent with the result, +and the agent performs the external send through the adapter tool. + +## Background Credentials + +Scheduler wakeups are model turns, so the scheduler process needs access to the +same model credentials as the REPL. On macOS, secrets may be encrypted with a +master key stored in Keychain. Background processes can fail with secure storage +errors until Keychain access is approved. + +For local testing, running the scheduler once in a normal terminal and choosing +"Always Allow" in the macOS prompt lets future background runs decrypt the model +secret and post scheduled task results back to IRC. + +## Source Model + +Adapters mirror the tool source model: + +- `built_in`: core adapter implementations shipped as host-native Exoclaw + behavior. IRC is currently the only built-in adapter. +- `library`: reusable adapters shipped with Exoclaw or loaded from manifest + metadata. WhatsApp and Signal are library adapters backed by shipped workers. +- `agent`: adapter modules written or installed by the agent at runtime. + +The current runtime runs built-in IRC plus library WhatsApp and Signal adapters +as host-supervised workers. Module-backed library and agent adapters are persisted +and build-validated so the registry boundary is in place; a future module runner can +implement the same host-owned lifecycle and outbox semantics without changing the +model-facing tools. + +## Operational Notes + +- Use short IRC nicks. Networks often reject long nicks during registration. +- Prefer `trigger: "mention"` for public or busy channels. +- `send_adapter_message` queues outbound messages; the adapter runner must be + active for them to reach the external app. +- WhatsApp `send_adapter_message` calls must include the inbound chat id as + `target`. +- Signal `send_adapter_message` calls must include a Signal username, phone + number, UUID, group id, or inbound target. +- If scheduled results do not appear in IRC, check scheduler run records first. + The adapter may be healthy while the scheduler wakeup is failing. +- `disable_adapter` is the safe stop operation because it preserves history. + `delete_adapter` removes the adapter and event/outbox state. diff --git a/examples/exoclaw/adapters/discord/README.md b/examples/exoclaw/adapters/discord/README.md new file mode 100644 index 0000000..7791100 --- /dev/null +++ b/examples/exoclaw/adapters/discord/README.md @@ -0,0 +1,123 @@ +# Discord Adapter + +The Discord adapter is an experimental Exoclaw library adapter implemented as a TypeScript worker using `discord.js`. It logs in as a Discord bot, emits inbound Discord messages as adapter wakeups, and sends explicit outbound messages through `send_adapter_message`. + +## Setup + +### 1. Create a Discord Bot + +1. Open the Discord Developer Portal: . +2. Click **New Application**, give it a name, and open the new application. +3. Open **Bot** in the left sidebar. +4. Click **Reset Token** or **View Token**, then copy the bot token. Keep this token private. +5. In **Privileged Gateway Intents**, enable **Message Content Intent**. This is required for Exo to read message text. + +### 2. Invite the Bot to a Server + +1. Open **OAuth2** > **URL Generator** in the Developer Portal. +2. Under **Scopes**, select `bot`. +3. Under **Bot Permissions**, select at least: + - `View Channels` + - `Send Messages` + - `Read Message History` + - `Attach Files` if you want Exo to send attachments +4. Copy the generated URL, open it in a browser, and add the bot to your Discord server. +5. In Discord, make sure the bot can see the target channel. Copy the channel id for testing: + - Enable Discord developer mode in **User Settings** > **Advanced** > **Developer Mode**. + - Right-click the target channel and choose **Copy Channel ID**. + +### 3. Store the Bot Token in Exo + +Export the token locally and store it as an Exo secret: + +```bash +export DISCORD_BOT_TOKEN="..." +exo secret set discord-bot-token --env DISCORD_BOT_TOKEN +``` + +The setup prompt below expects the secret name to be `discord-bot-token`. + +### 4. Create the Exoclaw Adapter + +Run the Exoclaw setup prompt: + +```bash +examples/exoclaw/scripts/exoclaw-repl --setup discord +``` + +If you are setting up a fresh local Exoclaw agent, use the same flags you normally use for your agent/conversation, for example: + +```bash +examples/exoclaw/scripts/exoclaw-repl \ + --agent exospooky \ + --conversation dev \ + --setup discord +``` + +The setup prompt at `setup-prompt.md` asks Exoclaw to create a library adapter similar to: + +```json +{ + "name": "discord-dev", + "source": "library", + "config": { + "type": "discord", + "botTokenSecretId": "discord-bot-token", + "defaultChannelId": null, + "trigger": "all_messages", + "allowedChannels": null, + "allowBots": false + } +} +``` + +Use `defaultChannelId` when you want outbound messages to go to one channel by default. Otherwise, pass the copied Discord channel id as `target` when calling `send_adapter_message`. + +Set `allowBots: true` if you want the adapter to wake on messages from other bot accounts (useful for bot-to-bot integrations). The adapter never wakes on its own messages regardless of this flag. + +### 5. Test It + +Ask Exoclaw to send a Discord message with the adapter id returned by setup: + +```text +Send "hello from exo" to Discord using adapter and target . +``` + +To test inbound wakeups with the default `all_messages` trigger, send any normal message in a channel the bot can read. If you configure `mentions_only`, mention the bot instead: + +```text +@YourBot hello exo +``` + +## Configuration + +- `botTokenSecretId` is the Exoclaw secret name or id containing the Discord bot token. +- `defaultChannelId` is used when `send_adapter_message` is called with `target: null`. +- `trigger` is either `mentions_only` or `all_messages`. Direct messages always trigger. +- `allowedChannels` optionally restricts inbound wakeups to specific Discord channel ids. + +Outbound messages support text plus the shared adapter attachment forms: `sandboxPath`, HTTPS `url`, or base64/data URL `data`. + +## Rich Attachments + +Discord supports outbound image, video, audio, and document attachments through `send_adapter_message`. Prefer `sandboxPath` for files created by shell commands in the Exoclaw sandbox: + +```json +{ + "adapterId": "", + "target": "", + "text": "Here is the generated file.", + "attachments": [ + { + "kind": "document", + "url": null, + "data": null, + "sandboxPath": "/tmp/report.txt", + "mimeType": "text/plain", + "fileName": "report.txt" + } + ] +} +``` + +For files created in the sandbox, use `sandboxPath`. For remote media, use an HTTPS `url`. For small inline payloads, use base64 `data` or a data URL. diff --git a/examples/exoclaw/adapters/discord/setup-prompt.md b/examples/exoclaw/adapters/discord/setup-prompt.md new file mode 100644 index 0000000..30ce5a1 --- /dev/null +++ b/examples/exoclaw/adapters/discord/setup-prompt.md @@ -0,0 +1,16 @@ +Set up a Discord adapter for testing. + +Create a library Discord adapter if one does not already exist for this conversation, then make sure it is ready for the background adapter runner. Use these settings: + +- name: `discord-dev` +- source: `library` +- type: `discord` +- botTokenSecretId: `discord-bot-token` +- defaultChannelId: `null` +- trigger: `all_messages` +- allowedChannels: `null` +- allowBots: `false` + +Do not block on secret inspection. The harness may not expose a secret-listing tool, so assume secret `discord-bot-token` exists and create the adapter. If the adapter later reports a missing Discord token, tell the user to create the secret with `exo secret set discord-bot-token --env DISCORD_BOT_TOKEN` after exporting a Discord bot token locally. + +After creating or confirming the adapter, briefly explain that the Discord bot must be invited to the target server with message read/send permissions and the Message Content Intent enabled in the Discord Developer Portal. Tell me the adapter id and what Discord channel id to use as `target` for a test `send_adapter_message` call. diff --git a/examples/exoclaw/adapters/discord/worker.ts b/examples/exoclaw/adapters/discord/worker.ts new file mode 100644 index 0000000..b03d5e3 --- /dev/null +++ b/examples/exoclaw/adapters/discord/worker.ts @@ -0,0 +1,274 @@ +import fs from "node:fs/promises"; +import readline from "node:readline/promises"; + +import { + ChannelType, + Client, + GatewayIntentBits, + Partials, + type Message, + type MessageCreateOptions, +} from "discord.js"; + +import { + type AdapterAttachment, + adapterConfig, + optionalStringField, + parseWorkerCommand, + writeWorkerEvent, +} from "../protocol"; + +const SEND_TIMEOUT_MS = 60_000; + +const config = adapterConfig(); +const tokenEnv = + optionalStringField(config, "tokenEnv") ?? "EXO_DISCORD_BOT_TOKEN"; +const token = process.env[tokenEnv]; +if (!token) { + throw new Error(`Discord bot token missing from ${tokenEnv}`); +} +const trigger = optionalStringField(config, "trigger") ?? "mentions_only"; +const defaultChannelId = optionalStringField(config, "defaultChannelId"); +const allowedChannels = stringArrayOrNull(config.allowedChannels); +const allowBots = config.allowBots === true; +if (trigger !== "all_messages" && trigger !== "mentions_only") { + throw new Error("Discord trigger must be all_messages or mentions_only"); +} + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.MessageContent, + ], + partials: [Partials.Channel], +}); + +client.on("error", (error) => { + writeWorkerEvent({ type: "error", message: error.message }); +}); + +client.once("ready", () => { + writeWorkerEvent({ + type: "connected", + subject: client.user?.id ?? null, + metadata: { + username: client.user?.tag ?? null, + }, + }); +}); + +client.on("shardDisconnect", (event) => { + writeWorkerEvent({ + type: "disconnected", + reason: event.reason || String(event.code), + }); +}); + +client.on("messageCreate", (message) => { + if (message.author.id === client.user?.id) { + return; + } + if (message.author.bot && !allowBots) { + return; + } + if (!shouldTrigger(message)) { + return; + } + writeWorkerEvent({ + type: "message", + target: message.channelId, + sender: message.author.id, + text: message.content, + message_id: message.id, + metadata: { + authorUsername: message.author.tag, + channelId: message.channelId, + guildId: message.guildId, + channelType: message.channel.type, + }, + }); +}); + +await client.login(token); + +const input = readline.createInterface({ + input: process.stdin, + crlfDelay: Number.POSITIVE_INFINITY, +}); + +for await (const line of input) { + if (line.trim().length === 0) { + continue; + } + let commandId: string | null = null; + try { + const command = parseWorkerCommand(JSON.parse(line)); + commandId = command.id; + const target = command.target ?? defaultChannelId; + if (!target) { + throw new Error( + "Discord send_message requires a target channel id or configured defaultChannelId", + ); + } + writeWorkerEvent({ + type: "lifecycle", + name: "send_starting", + metadata: { + target, + attachmentCount: command.attachments.length, + }, + }); + await sendDiscordMessage(target, { + content: command.text, + files: await discordAttachmentFiles(command.attachments), + }); + writeWorkerEvent({ + type: "lifecycle", + name: "send_result", + metadata: { + target, + attachmentCount: command.attachments.length, + }, + }); + writeWorkerEvent({ type: "command_ack", command_id: command.id }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + writeWorkerEvent({ + type: "error", + message, + }); + if (commandId !== null) { + writeWorkerEvent({ + type: "command_nack", + command_id: commandId, + message, + }); + } + } +} + +async function sendDiscordMessage( + target: string, + options: MessageCreateOptions, +): Promise { + const channel = await client.channels.fetch(target); + if (!isSendableChannel(channel)) { + throw new Error(`Discord target ${target} cannot send messages`); + } + let timeout: NodeJS.Timeout | null = null; + try { + await Promise.race([ + channel.send(options), + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject( + new Error( + `Discord send_message timed out after ${SEND_TIMEOUT_MS}ms`, + ), + ); + }, SEND_TIMEOUT_MS); + }), + ]); + } finally { + if (timeout !== null) { + clearTimeout(timeout); + } + } +} + +type SendableChannel = { + send(options: MessageCreateOptions): Promise; +}; + +function isSendableChannel(channel: unknown): channel is SendableChannel { + if (!channel || typeof channel !== "object" || !("send" in channel)) { + return false; + } + return typeof (channel as { send?: unknown }).send === "function"; +} + +async function discordAttachmentFiles( + attachments: AdapterAttachment[], +): Promise> { + return Promise.all( + attachments.map(async (attachment) => ({ + attachment: await attachmentBytes(attachment), + name: attachment.fileName ?? fileNameForAttachment(attachment), + description: attachment.mimeType ?? undefined, + })), + ); +} + +async function attachmentBytes(attachment: AdapterAttachment): Promise { + if (attachment.path) { + return fs.readFile(attachment.path); + } + if (attachment.url) { + const response = await fetch(attachment.url); + if (!response.ok) { + throw new Error( + `Discord attachment URL fetch failed with ${response.status}`, + ); + } + return Buffer.from(await response.arrayBuffer()); + } + if (attachment.data) { + return Buffer.from(base64Payload(attachment.data), "base64"); + } + throw new Error("Discord attachment requires path, url, or data"); +} + +function shouldTrigger(message: Message): boolean { + if ( + allowedChannels !== null && + !allowedChannels.includes(message.channelId) + ) { + return false; + } + if (message.channel.type === ChannelType.DM) { + return true; + } + const botId = client.user?.id; + if (trigger === "all_messages") { + return true; + } + return botId !== undefined && message.mentions.users.has(botId); +} + +function fileNameForAttachment(attachment: AdapterAttachment): string { + switch (attachment.kind) { + case "image": + return "image"; + case "video": + return "video"; + case "audio": + return "audio"; + case "document": + return "document"; + } +} + +function base64Payload(data: string): string { + const dataUrlSeparator = data.indexOf(","); + if (data.startsWith("data:") && dataUrlSeparator !== -1) { + return data.slice(dataUrlSeparator + 1); + } + return data; +} + +function stringArrayOrNull(value: unknown): string[] | null { + if (value === undefined || value === null) { + return null; + } + if ( + Array.isArray(value) && + value.every((item): item is string => typeof item === "string") + ) { + return value; + } + throw new Error( + "Discord allowedChannels must be null or an array of strings", + ); +} diff --git a/examples/exoclaw/adapters/irc/README.md b/examples/exoclaw/adapters/irc/README.md new file mode 100644 index 0000000..cba5f60 --- /dev/null +++ b/examples/exoclaw/adapters/irc/README.md @@ -0,0 +1,56 @@ +# IRC Adapter + +The IRC adapter is a built-in Exoclaw adapter implemented as a TypeScript worker. The host adapter runner supervises the worker, passes configuration through `EXO_ADAPTER_CONFIG`, receives JSONL events on stdout, and sends outbound messages by writing JSONL commands to stdin. + +## How It Works + +On startup, the worker opens a TCP or TLS socket to the configured IRC server, optionally sends `PASS`, then registers with `NICK` and `USER`. After the server sends welcome numeric `001`, the worker joins the configured channel. + +The worker handles `PING` with `PONG`, parses `PRIVMSG` lines, and emits Exoclaw message events when the configured trigger policy matches. Outbound `send_adapter_message` calls are converted into `PRIVMSG :` on the existing IRC connection. + +## Setup + +Use the Exoclaw setup flow: + +```bash +examples/exoclaw/scripts/exoclaw-repl fresh --pull-sandbox --setup irc +``` + +The setup prompt at `setup-prompt.md` asks Exoclaw to create a built-in adapter similar to: + +```json +{ + "name": "libera-exo-test", + "source": "built_in", + "config": { + "type": "irc", + "server": "irc.libera.chat", + "port": 6697, + "tls": true, + "nick": "exospooky", + "username": "exospooky", + "realname": "Exoclaw Test Bot", + "channel": "#exoclaw", + "passwordSecretId": null, + "trigger": "mention" + } +} +``` + +Edit the nick and channel before using it on a public network. The default test channel and nick are only examples. + +## Configuration + +- `server`, `port`, and `tls` select the IRC endpoint. +- `nick`, `username`, and `realname` are used during IRC registration. +- `channel` must start with `#`. +- `passwordSecretId` can be used by the host-side config transform to inject `EXO_IRC_PASSWORD`; leave it `null` for unauthenticated networks. +- `trigger` is either `mention` or `all_messages`. + +## Quirks And Gotchas + +- IRC nicknames are global per network. If the adapter reports `Nickname is already in use`, pick a different nick or stop the old runner. +- With `trigger: "mention"`, the bot only wakes Exoclaw when a channel message mentions the bot nick. +- IRC has limited formatting and no rich document support. Treat it as a reliable control channel, not a rich UI. +- The worker exits on socket close so the host runner can restart it. +- Outbound messages always go to the configured channel; the `target` from `send_adapter_message` is not required by this worker. diff --git a/examples/exoclaw/adapters/irc/irc.test.ts b/examples/exoclaw/adapters/irc/irc.test.ts new file mode 100644 index 0000000..902924e --- /dev/null +++ b/examples/exoclaw/adapters/irc/irc.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { isIrcErrorNumeric, parseIrcLine, shouldTrigger } from "./irc"; + +describe("IRC worker helpers", () => { + it("parses PING and PRIVMSG lines", () => { + expect(parseIrcLine("PING :abc")).toEqual({ + type: "ping", + token: "abc", + }); + expect(parseIrcLine(":alice!u@h PRIVMSG #exo :hello exo-bot")).toEqual({ + type: "privmsg", + message: { + nick: "alice", + target: "#exo", + text: "hello exo-bot", + raw: ":alice!u@h PRIVMSG #exo :hello exo-bot", + }, + }); + }); + + it("applies mention trigger policy", () => { + expect(shouldTrigger("mention", "exo-bot", "alice", "hello exo-bot")).toBe( + true, + ); + expect(shouldTrigger("mention", "exo-bot", "alice", "hello")).toBe(false); + expect(shouldTrigger("mention", "exo-bot", "exo-bot", "exo-bot")).toBe( + false, + ); + expect(shouldTrigger("all_messages", "exo-bot", "alice", "hello")).toBe( + true, + ); + }); + + it("detects IRC error numerics", () => { + expect( + isIrcErrorNumeric( + ":cadmium.libera.chat 432 * exoclaw-test-12345 :Erroneous Nickname", + ), + ).toBe(true); + expect(isIrcErrorNumeric(":cadmium.libera.chat 001 exoclaw :Welcome")).toBe( + false, + ); + }); +}); diff --git a/examples/exoclaw/adapters/irc/irc.ts b/examples/exoclaw/adapters/irc/irc.ts new file mode 100644 index 0000000..9e17008 --- /dev/null +++ b/examples/exoclaw/adapters/irc/irc.ts @@ -0,0 +1,66 @@ +export type IrcTriggerPolicy = "mention" | "all_messages"; + +export type IrcPrivateMessage = { + nick: string; + target: string; + text: string; + raw: string; +}; + +export type IrcLine = + | { type: "ping"; token: string } + | { type: "privmsg"; message: IrcPrivateMessage } + | { type: "other"; raw: string }; + +export function parseIrcLine(raw: string): IrcLine { + const pingToken = raw.match(/^PING :(.*)$/)?.[1]; + if (pingToken !== undefined) { + return { type: "ping", token: pingToken }; + } + if (!raw.startsWith(":")) { + return { type: "other", raw }; + } + const [prefixPart, ...restParts] = raw.slice(1).split(" "); + const rest = restParts.join(" "); + if (!prefixPart || !rest.startsWith("PRIVMSG ")) { + return { type: "other", raw }; + } + const textIndex = rest.indexOf(" :"); + if (textIndex < 0) { + return { type: "other", raw }; + } + const target = rest.slice("PRIVMSG ".length, textIndex); + const text = rest.slice(textIndex + 2); + return { + type: "privmsg", + message: { + nick: prefixPart.split("!")[0] ?? prefixPart, + target, + text, + raw, + }, + }; +} + +export function shouldTrigger( + policy: IrcTriggerPolicy, + nick: string, + sender: string, + text: string, +): boolean { + if (sender.toLowerCase() === nick.toLowerCase()) { + return false; + } + if (policy === "all_messages") { + return true; + } + return text.toLowerCase().includes(nick.toLowerCase()); +} + +export function isIrcErrorNumeric(raw: string): boolean { + if (!raw.startsWith(":")) { + return false; + } + const code = raw.slice(1).split(" ")[1]; + return Boolean(code?.match(/^[45]\d\d$/)); +} diff --git a/examples/exoclaw/adapters/irc/setup-prompt.md b/examples/exoclaw/adapters/irc/setup-prompt.md new file mode 100644 index 0000000..c7bcfa7 --- /dev/null +++ b/examples/exoclaw/adapters/irc/setup-prompt.md @@ -0,0 +1,16 @@ +Set up an IRC adapter for testing. + +Create a built-in IRC adapter if one does not already exist for this conversation, then make sure it is ready for the background adapter runner. Use these settings: + +- name: `libera-exo-test` +- server: `irc.libera.chat` +- port: `6697` +- tls: `true` +- nick: `exospooky` +- username: `exospooky` +- realname: `Exoclaw Test Bot` +- channel: `#exoclaw` +- passwordSecretId: `null` +- trigger: `mention` + +After creating or confirming the adapter, briefly tell me the adapter id, nick, channel, and exactly what message I should send from IRC to test it. diff --git a/examples/exoclaw/adapters/irc/worker.ts b/examples/exoclaw/adapters/irc/worker.ts new file mode 100644 index 0000000..ab66ffa --- /dev/null +++ b/examples/exoclaw/adapters/irc/worker.ts @@ -0,0 +1,178 @@ +import net from "node:net"; +import readline from "node:readline"; +import tls from "node:tls"; +import process from "node:process"; +import inputReadline from "node:readline/promises"; + +import { + adapterConfig, + booleanField, + numberField, + optionalStringField, + parseWorkerCommand, + stringField, + writeWorkerEvent, +} from "../protocol"; +import { + isIrcErrorNumeric, + parseIrcLine, + shouldTrigger as shouldTriggerForPolicy, + type IrcTriggerPolicy, +} from "./irc"; + +const config = adapterConfig(); +const server = stringField(config, "server"); +const port = numberField(config, "port"); +const useTls = booleanField(config, "tls"); +const nick = stringField(config, "nick"); +const username = stringField(config, "username"); +const realname = stringField(config, "realname"); +const channel = stringField(config, "channel"); +const password = + process.env.EXO_IRC_PASSWORD ?? optionalStringField(config, "password"); +const trigger = stringField(config, "trigger") as IrcTriggerPolicy; + +if (port <= 0 || !Number.isInteger(port)) { + throw new Error("IRC port must be a positive integer"); +} +if (!channel.startsWith("#")) { + throw new Error("IRC channel must start with '#'"); +} +if (trigger !== "mention" && trigger !== "all_messages") { + throw new Error("IRC trigger must be mention or all_messages"); +} + +const socket = useTls + ? tls.connect({ host: server, port, servername: server }) + : net.connect({ host: server, port }); + +socket.setEncoding("utf8"); +socket.on("error", (error) => { + writeWorkerEvent({ type: "error", message: error.message }); +}); +socket.on("close", () => { + writeWorkerEvent({ type: "disconnected", reason: "socket closed" }); + process.exit(1); +}); + +await onceConnected(socket); +if (password) { + writeIrcCommand(`PASS ${password}`); +} +writeIrcCommand(`NICK ${nick}`); +writeIrcCommand(`USER ${username} 0 * :${realname}`); + +const lines = readline.createInterface({ + input: socket, + crlfDelay: Number.POSITIVE_INFINITY, +}); + +let registered = false; +let joined = false; + +lines.on("line", (raw) => { + const line = parseIrcLine(raw); + if (line.type === "ping") { + writeIrcCommand(`PONG :${line.token}`); + return; + } + if (isIrcErrorNumeric(raw)) { + writeWorkerEvent({ type: "error", message: raw }); + return; + } + if (!registered && raw.includes(` 001 ${nick} `)) { + registered = true; + writeIrcCommand(`JOIN ${channel}`); + return; + } + if (!joined && isJoinConfirmation(raw)) { + joined = true; + process.stderr.write( + `[irc-adapter] connected ${server}/${channel} as ${nick}\n`, + ); + writeWorkerEvent({ + type: "connected", + subject: `${server}/${channel}`, + metadata: { server, channel, nick }, + }); + return; + } + if (line.type === "privmsg" && line.message.target === channel) { + if ( + !shouldTriggerForPolicy( + trigger, + nick, + line.message.nick, + line.message.text, + ) + ) { + return; + } + process.stderr.write( + `[irc-adapter] received message from ${line.message.nick} in ${channel}\n`, + ); + writeWorkerEvent({ + type: "message", + target: channel, + sender: line.message.nick, + text: line.message.text, + message_id: null, + metadata: { raw: line.message.raw, server, channel }, + }); + } +}); + +const input = inputReadline.createInterface({ + input: process.stdin, + crlfDelay: Number.POSITIVE_INFINITY, +}); + +for await (const line of input) { + if (line.trim().length === 0) { + continue; + } + let commandId: string | null = null; + try { + const command = parseWorkerCommand(JSON.parse(line)); + commandId = command.id; + process.stderr.write(`[irc-adapter] sending message to ${channel}\n`); + writeIrcCommand(`PRIVMSG ${channel} :${command.text}`); + writeWorkerEvent({ type: "command_ack", command_id: command.id }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + writeWorkerEvent({ + type: "error", + message, + }); + if (commandId !== null) { + writeWorkerEvent({ + type: "command_nack", + command_id: commandId, + message, + }); + } + } +} + +function onceConnected(socket: net.Socket): Promise { + if (socket.readyState === "open") { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("secureConnect", resolve); + socket.once("error", reject); + }); +} + +function writeIrcCommand(command: string): void { + socket.write(`${command}\r\n`); +} + +function isJoinConfirmation(raw: string): boolean { + return ( + raw.includes(` JOIN ${channel}`) || + raw.includes(` JOIN :${channel}`) || + raw.includes(` 366 ${nick} ${channel} `) + ); +} diff --git a/examples/exoclaw/adapters/protocol.ts b/examples/exoclaw/adapters/protocol.ts new file mode 100644 index 0000000..d43001b --- /dev/null +++ b/examples/exoclaw/adapters/protocol.ts @@ -0,0 +1,212 @@ +export type WorkerOutboundCommand = { + type: "send_message"; + id: string; + target?: string | null; + text: string; + attachments: AdapterAttachment[]; +}; + +export type AdapterAttachment = { + kind: "image" | "video" | "audio" | "document"; + path?: string | null; + url?: string | null; + data?: string | null; + mimeType?: string | null; + fileName?: string | null; +}; + +export type WorkerInboundEvent = + | { + type: "connected"; + subject?: string | null; + metadata?: JsonObject; + } + | { + type: "message"; + target: string; + sender?: string | null; + text: string; + message_id?: string | null; + metadata?: JsonObject; + } + | { + type: "lifecycle"; + name: string; + metadata?: JsonObject; + } + | { + type: "error"; + message: string; + } + | { + type: "command_ack"; + command_id: string; + } + | { + type: "command_nack"; + command_id: string; + message: string; + } + | { + type: "disconnected"; + reason?: string | null; + }; + +type JsonObject = Record; +let stdoutErrorHandlerInstalled = false; + +export function adapterConfig(): JsonObject { + const raw = process.env.EXO_ADAPTER_CONFIG; + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed)) { + throw new Error("EXO_ADAPTER_CONFIG must contain a JSON object"); + } + return parsed; +} + +export function parseWorkerCommand(value: unknown): WorkerOutboundCommand { + if (!isRecord(value) || value.type !== "send_message") { + throw new Error("worker command must be a send_message object"); + } + if (typeof value.id !== "string" || value.id.length === 0) { + throw new Error("send_message id must be a non-empty string"); + } + if ( + value.target !== undefined && + value.target !== null && + (typeof value.target !== "string" || value.target.length === 0) + ) { + throw new Error("send_message target must be null or a non-empty string"); + } + if (typeof value.text !== "string" || value.text.length === 0) { + throw new Error("send_message text must be a non-empty string"); + } + return { + type: "send_message", + id: value.id, + target: value.target ?? null, + text: value.text, + attachments: parseAttachments(value.attachments), + }; +} + +function parseAttachments(value: unknown): AdapterAttachment[] { + if (value === undefined || value === null) { + return []; + } + if (!Array.isArray(value)) { + throw new Error("send_message attachments must be null or an array"); + } + return value.map((item) => { + if (!isRecord(item)) { + throw new Error("send_message attachment must be an object"); + } + if ( + item.kind !== "image" && + item.kind !== "video" && + item.kind !== "audio" && + item.kind !== "document" + ) { + throw new Error( + "send_message attachment kind must be image, video, audio, or document", + ); + } + const path = nullableStringValue(item.path, "attachment path"); + const url = nullableStringValue(item.url, "attachment url"); + const data = nullableStringValue(item.data, "attachment data"); + const sourceCount = [path, url, data].filter( + (source) => source !== null, + ).length; + if (sourceCount !== 1) { + throw new Error( + "send_message attachment must specify exactly one of path, url, or data", + ); + } + if (url !== null && !url.startsWith("https://")) { + throw new Error("send_message attachment url must use https"); + } + return { + kind: item.kind, + path, + url, + data, + mimeType: nullableStringValue(item.mimeType, "attachment mimeType"), + fileName: nullableStringValue(item.fileName, "attachment fileName"), + }; + }); +} + +function nullableStringValue(value: unknown, name: string): string | null { + if (value === undefined || value === null) { + return null; + } + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${name} must be null or a non-empty string`); + } + return value; +} + +export function stringField(config: JsonObject, name: string): string { + const value = config[name]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`adapter config ${name} must be a non-empty string`); + } + return value; +} + +export function optionalStringField( + config: JsonObject, + name: string, +): string | null { + const value = config[name]; + if (value === undefined || value === null) { + return null; + } + if (typeof value !== "string" || value.length === 0) { + throw new Error( + `adapter config ${name} must be null or a non-empty string`, + ); + } + return value; +} + +export function booleanField(config: JsonObject, name: string): boolean { + const value = config[name]; + if (typeof value !== "boolean") { + throw new Error(`adapter config ${name} must be a boolean`); + } + return value; +} + +export function numberField(config: JsonObject, name: string): number { + const value = config[name]; + if (typeof value !== "number") { + throw new Error(`adapter config ${name} must be a number`); + } + return value; +} + +export function writeWorkerEvent(event: WorkerInboundEvent): void { + ensureStdoutErrorHandler(); + process.stdout.write(`${JSON.stringify(event)}\n`); +} + +export function isRecord(value: unknown): value is JsonObject { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function ensureStdoutErrorHandler(): void { + if (stdoutErrorHandlerInstalled) { + return; + } + stdoutErrorHandlerInstalled = true; + process.stdout.on("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EPIPE") { + process.exit(0); + } + throw error; + }); +} diff --git a/examples/exoclaw/adapters/signal/README.md b/examples/exoclaw/adapters/signal/README.md new file mode 100644 index 0000000..56be952 --- /dev/null +++ b/examples/exoclaw/adapters/signal/README.md @@ -0,0 +1,103 @@ +# Signal Adapter + +The Signal adapter is an experimental Exoclaw library adapter implemented as a TypeScript worker around `signal-cli`. It assumes you already have a real Signal account on a phone, have set a Signal username, and want Exoclaw to run as a linked device. + +## How It Works + +The host adapter runner starts `worker.ts` and passes adapter configuration through `EXO_ADAPTER_CONFIG`. The worker stores `signal-cli` state on disk, discovers or links a local account, starts `signal-cli jsonRpc --receive-mode=on-connection` for inbound messages, emits JSONL events on stdout, and receives outbound send commands on stdin. + +If `account` is `null`, the worker first runs `signal-cli listAccounts`. If an account already exists in the configured state directory, it reuses it. Otherwise it runs `signal-cli link -n `, prints a QR code, waits for linking to complete, discovers the account, and then starts JSON-RPC receive mode. + +Incoming Signal messages become Exoclaw adapter message events. Outbound `send_adapter_message` calls send through the same `signal-cli jsonRpc` process with either a recipient or a group id, depending on the target format. + +## Setup + +Install the JVM `signal-cli` distribution and Java, then make sure the `signal-cli` script and Java are available to the adapter worker. Run setup with: + +```bash +examples/exoclaw/scripts/exoclaw-repl fresh --pull-sandbox --setup signal +``` + +The script watches `.exo/exoclaw-adapters.log`, prints the linked-device QR code if it appears, and pauses while you scan it from Signal: Settings > Linked devices > Link new device. + +The setup prompt at `setup-prompt.md` asks Exoclaw to create a library adapter similar to: + +```json +{ + "name": "signal-dev", + "source": "library", + "config": { + "type": "signal", + "account": null, + "deviceName": "Exoclaw", + "configDir": null, + "trigger": "all_messages", + "allowedContacts": null + } +} +``` + +## Configuration + +- `account` is the local Signal account identifier for `signal-cli -a`. Use `null` for first-time setup or automatic account discovery. +- `deviceName` is shown in Signal's linked-device list during pairing. +- `configDir` controls where `signal-cli` stores linked-device state. If omitted, the worker uses `.exo/adapters/signal//signal-cli` or the host-provided adapter state directory. +- `trigger` is `all_messages` or `contacts_only`. +- `allowedContacts` can restrict wakeups to specific sender identifiers. + +## Installing On Mac + +Install Java with Homebrew: + +```bash +brew install openjdk +``` + +The native/Homebrew `signal-cli` binary may receive messages but fail outbound sends with `NETWORK_FAILURE` and an error mentioning `IdentityKeyDeserializer has no default (no arg) constructor`. Use the JVM `signal-cli` distribution for Exoclaw instead. + +One tested local setup uses a wrapper or symlink named `signal-cli` on `PATH`, backed by the JVM distribution. + +Java must be visible on `PATH` for the JVM script. On Homebrew macOS, prefix Exoclaw commands with: + +```bash +PATH="/opt/homebrew/opt/openjdk/bin:$PATH" \ +examples/exoclaw/scripts/exoclaw-repl fresh --pull-sandbox --setup signal +``` + +You can also add `/opt/homebrew/opt/openjdk/bin` to your shell profile. + +## Targets + +Signal outbound sends require a target. Use the `target` from the inbound wakeup when replying. For direct messages, supported target forms include: + +- Signal usernames with the `u:` prefix, such as `u:example.01`. +- Phone numbers, such as `+16505551212`. +- Signal UUIDs from inbound events. +- `ACI:` or `PNI:` identifiers. + +Long opaque non-recipient strings are treated as group ids. + +## Rich Outbound Content + +The Signal worker supports outbound attachments through `signal-cli` JSON-RPC. Use the shared `attachments` field on `send_adapter_message`. + +For sandbox-generated files, prefer `sandboxPath`; the host tool stages the file and passes a local path to `signal-cli`: + +```json +{ + "kind": "image", + "url": null, + "data": null, + "sandboxPath": "/tmp/exoclaw_media/image.png", + "mimeType": "image/png", + "fileName": "image.png" +} +``` + +Signal attachments can also use HTTPS `url` or small inline `data` payloads. The host tool validates and stages all attachment sources into `.exo/adapters/media` before passing them to `signal-cli`. + +## Quirks And Gotchas + +- If linking succeeds but later setup keeps asking for QR codes, check that the same `configDir` is being reused. +- Signal group support depends on targets surfaced by incoming group messages. Prefer replying to the inbound target instead of inventing group ids. +- QR codes, account ids, phone numbers, and message routing metadata may appear in `.exo/exoclaw-adapters.log`; treat that log as sensitive local state. diff --git a/examples/exoclaw/adapters/signal/setup-prompt.md b/examples/exoclaw/adapters/signal/setup-prompt.md new file mode 100644 index 0000000..9659026 --- /dev/null +++ b/examples/exoclaw/adapters/signal/setup-prompt.md @@ -0,0 +1,20 @@ +Set up a Signal adapter for testing. + +Create a library Signal adapter if one does not already exist for this conversation, then make sure it is ready for the background adapter runner. Use these settings: + +- name: `signal-dev` +- source: `library` +- type: `signal` +- account: `null` +- deviceName: `Exoclaw` +- configDir: `null` +- trigger: `all_messages` +- allowedContacts: `null` + +Assume the user already has a Signal account on a phone with a real phone number and has set a Signal username. The Signal adapter uses `signal-cli` locally and will start linked-device setup when `account` is `null`. If `signal-cli` is missing, tell the user to install it first, for example with `brew install signal-cli` on macOS. + +If outbound sends fail with `NETWORK_FAILURE` and an error like `IdentityKeyDeserializer has no default (no arg) constructor`, the installed `signal-cli` is likely a GraalVM/native build with incomplete reflection metadata. Tell the user to put the JVM signal-cli distribution first on `PATH` before starting the adapter runner. + +After creating or confirming the adapter, explain that the setup script will try to print a Signal linked-device QR code from `.exo/exoclaw-adapters.log` and pause before entering the REPL. The user should scan it from Signal: Settings > Linked devices > Link new device. If no QR appears immediately, tell the user to watch `.exo/exoclaw-adapters.log`; the adapter may already be linked, `signal-cli` may be missing, or the link flow may still be starting. + +Briefly tell me the adapter id, where the adapter state is stored, and that outbound targets should be Signal usernames such as `u:example.01` unless an inbound wakeup provides a more precise target. diff --git a/examples/exoclaw/adapters/signal/worker.ts b/examples/exoclaw/adapters/signal/worker.ts new file mode 100644 index 0000000..627ac1c --- /dev/null +++ b/examples/exoclaw/adapters/signal/worker.ts @@ -0,0 +1,625 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import fs from "node:fs/promises"; +import readline from "node:readline/promises"; + +import qrcodeTerminal from "qrcode-terminal"; + +import { + type AdapterAttachment, + adapterConfig, + optionalStringField, + parseWorkerCommand, + writeWorkerEvent, +} from "../protocol"; + +const config = adapterConfig(); +const signalCliCommand = stringArrayOrDefault( + config.signalCliCommand, + ["signal-cli"], + "signalCliCommand", +); +const configDir = + optionalStringField(config, "configDir") ?? + (process.env.EXO_ADAPTER_STATE_DIR === undefined + ? `.exo/adapters/signal/${process.env.EXO_ADAPTER_ID ?? "default"}/signal-cli` + : `${process.env.EXO_ADAPTER_STATE_DIR}/signal-cli`); +const configuredAccount = optionalStringField(config, "account"); +const deviceName = optionalStringField(config, "deviceName") ?? "Exoclaw"; +const trigger = optionalStringField(config, "trigger") ?? "all_messages"; +const allowedContacts = stringArrayOrNull(config.allowedContacts); +if (trigger !== "all_messages" && trigger !== "contacts_only") { + throw new Error("Signal trigger must be all_messages or contacts_only"); +} + +await fs.mkdir(configDir, { recursive: true }); +const account = configuredAccount ?? (await discoverOrLinkAccount()); +const signal = spawnSignalCli([ + "-a", + account, + "jsonRpc", + "--receive-mode=on-start", +]); +installChildCleanup(signal); + +writeWorkerEvent({ + type: "connected", + subject: account, + metadata: { account }, +}); + +const pending = new Map< + string, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + } +>(); +let nextRequestId = 1; + +signal.stderr.on("data", (chunk) => { + process.stderr.write(`[signal-adapter] ${chunk.toString()}`); +}); + +signal.on("exit", (code, signalName) => { + writeWorkerEvent({ + type: "disconnected", + reason: signalName ?? (code === null ? null : String(code)), + }); + if (code !== 0) { + process.exit(code ?? 1); + } +}); + +const signalOutput = readline.createInterface({ + input: signal.stdout, + crlfDelay: Number.POSITIVE_INFINITY, +}); + +void (async () => { + for await (const line of signalOutput) { + if (line.trim().length === 0) { + continue; + } + try { + handleJsonRpcMessage(JSON.parse(line) as unknown); + } catch (error) { + writeWorkerEvent({ + type: "error", + message: error instanceof Error ? error.message : String(error), + }); + } + } +})(); + +const input = readline.createInterface({ + input: process.stdin, + crlfDelay: Number.POSITIVE_INFINITY, +}); + +for await (const line of input) { + if (line.trim().length === 0) { + continue; + } + let commandId: string | null = null; + try { + const command = parseWorkerCommand(JSON.parse(line)); + commandId = command.id; + if (!command.target) { + throw new Error( + "Signal send_message requires a target username, uuid, phone number, or group id", + ); + } + await sendSignalMessage(command.target, command.text, command.attachments); + writeWorkerEvent({ type: "command_ack", command_id: command.id }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + writeWorkerEvent({ + type: "error", + message, + }); + if (commandId !== null) { + writeWorkerEvent({ + type: "command_nack", + command_id: commandId, + message, + }); + } + } +} + +function handleJsonRpcMessage(message: unknown): void { + if (!isRecord(message)) { + throw new Error("signal-cli JSON-RPC message must be an object"); + } + if (typeof message.id === "string" || typeof message.id === "number") { + const id = String(message.id); + const callback = pending.get(id); + if (callback !== undefined) { + pending.delete(id); + if (isRecord(message.error)) { + callback.reject(new Error(JSON.stringify(message.error))); + } else { + callback.resolve(message.result); + } + } + return; + } + if (message.method !== "receive" || !isRecord(message.params)) { + return; + } + const inbound = inboundMessage(message.params); + if (inbound === null || !shouldTrigger(inbound.sender ?? inbound.target)) { + return; + } + writeWorkerEvent(inbound); +} + +function inboundMessage(params: Record) { + const envelope = params.envelope; + if (!isRecord(envelope)) { + return null; + } + const dataMessage = envelope.dataMessage; + if (!isRecord(dataMessage)) { + return null; + } + const message = dataMessage.message; + if (typeof message !== "string" || message.length === 0) { + return null; + } + const groupInfo = dataMessage.groupInfo; + const groupId = isRecord(groupInfo) ? stringOrNull(groupInfo.groupId) : null; + const source = + stringOrNull(envelope.source) ?? + stringOrNull(envelope.sourceNumber) ?? + stringOrNull(envelope.sourceUuid) ?? + stringOrNull(envelope.sourceName); + const timestamp = + numberOrStringOrNull(dataMessage.timestamp) ?? + numberOrStringOrNull(envelope.timestamp); + return { + type: "message" as const, + target: groupId ?? source ?? account, + sender: source, + text: message, + message_id: timestamp, + metadata: { + sourceName: stringOrNull(envelope.sourceName), + sourceUuid: stringOrNull(envelope.sourceUuid), + groupId, + }, + }; +} + +async function sendSignalMessage( + target: string, + text: string, + attachments: AdapterAttachment[], +): Promise { + const attachmentParams = signalAttachments(attachments); + const params = looksLikeGroupId(target) + ? { groupId: target, message: text, ...attachmentParams } + : { + recipient: [normalizeRecipient(target)], + message: text, + ...attachmentParams, + }; + writeWorkerEvent({ + type: "lifecycle", + name: "send_starting", + metadata: { target, params }, + }); + const result = await jsonRpcRequest("send", params, 30_000); + writeWorkerEvent({ + type: "lifecycle", + name: "send_result", + metadata: { target, params, result }, + }); +} + +function signalAttachments(attachments: AdapterAttachment[]): { + attachments?: string[]; +} { + if (attachments.length === 0) { + return {}; + } + return { + attachments: attachments.map(signalAttachmentSource), + }; +} + +function signalAttachmentSource(attachment: AdapterAttachment): string { + if (attachment.path) { + return attachment.path; + } + if (attachment.data) { + return signalDataUri(attachment); + } + if (attachment.url) { + throw new Error( + "Signal attachment URL should have been staged by the host tool before reaching the worker", + ); + } + throw new Error("Signal attachment requires staged path or data"); +} + +function signalDataUri(attachment: AdapterAttachment): string { + if (attachment.data?.startsWith("data:")) { + return attachment.data; + } + const mimeType = attachment.mimeType ?? "application/octet-stream"; + const fileName = attachment.fileName + ? `;filename=${encodeURIComponent(attachment.fileName)}` + : ""; + return `data:${mimeType}${fileName};base64,${attachment.data}`; +} + +function jsonRpcRequest( + method: string, + params: Record, + timeoutMs = 30_000, +): Promise { + const id = String(nextRequestId++); + const request = { jsonrpc: "2.0", method, params, id }; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(id); + killSignalCliProcess(signal); + reject( + new Error( + `signal-cli JSON-RPC ${method} timed out after ${timeoutMs}ms`, + ), + ); + }, timeoutMs); + pending.set(id, { + resolve: (value) => { + clearTimeout(timeout); + resolve(value); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + }, + }); + signal.stdin.write(`${JSON.stringify(request)}\n`, (error) => { + if (error) { + clearTimeout(timeout); + pending.delete(id); + reject(error); + } + }); + }); +} + +async function discoverOrLinkAccount(): Promise { + const localAccounts = await localAccountsFromConfig(); + if (localAccounts.length > 0) { + const existing = localAccounts[0]; + writeWorkerEvent({ + type: "lifecycle", + name: "account_discovered", + metadata: { account: existing, source: "config" }, + }); + return existing; + } + const existingAccounts = await listAccountsOrEmpty( + "account_discovery_failed", + ); + if (existingAccounts.length > 0) { + const existing = existingAccounts[0]; + writeWorkerEvent({ + type: "lifecycle", + name: "account_discovered", + metadata: { account: existing }, + }); + return existing; + } + return linkAndDiscoverAccount(); +} + +async function linkAndDiscoverAccount(): Promise { + writeWorkerEvent({ + type: "lifecycle", + name: "link_starting", + metadata: { deviceName }, + }); + const link = spawnSignalCli(["link", "-n", deviceName]); + const linkOutput = readline.createInterface({ + input: link.stdout, + crlfDelay: Number.POSITIVE_INFINITY, + }); + let linkUri: string | null = null; + const outputTask = (async () => { + for await (const line of linkOutput) { + process.stderr.write(`[signal-adapter] ${line}\n`); + const uri = line.match(/(?:sgnl|tsdevice):\/\/\S+/)?.[0] ?? null; + if (uri !== null && linkUri === null) { + linkUri = uri; + qrcodeTerminal.generate(uri, { small: true }, (qr) => { + process.stderr.write( + `\n[signal-adapter] Scan this QR with Signal:\n${qr}\n`, + ); + }); + writeWorkerEvent({ + type: "lifecycle", + name: "link_qr", + metadata: { uri }, + }); + } + } + })(); + link.stderr.on("data", (chunk) => { + process.stderr.write(`[signal-adapter] ${chunk.toString()}`); + }); + const exitCode = await waitForExit(link); + await outputTask; + if (exitCode !== 0) { + throw new Error(`signal-cli link failed with exit code ${exitCode}`); + } + const accounts = await localAccountsFromConfig(); + if (accounts.length === 0) { + const signalCliAccounts = await listAccountsOrEmpty( + "post_link_account_discovery_failed", + ); + if (signalCliAccounts.length > 0) { + const discovered = signalCliAccounts[0]; + writeWorkerEvent({ + type: "lifecycle", + name: "linked", + metadata: { account: discovered, source: "signal-cli" }, + }); + return discovered; + } + throw new Error( + "signal-cli link completed, but no local accounts were found", + ); + } + const discovered = accounts[0]; + writeWorkerEvent({ + type: "lifecycle", + name: "linked", + metadata: { account: discovered, source: "config" }, + }); + return discovered; +} + +async function listAccountsOrEmpty(errorEvent: string): Promise { + try { + return await listAccounts(); + } catch (error) { + writeWorkerEvent({ + type: "lifecycle", + name: errorEvent, + metadata: { + message: error instanceof Error ? error.message : String(error), + }, + }); + return []; + } +} + +async function localAccountsFromConfig(): Promise { + const accountsPath = `${configDir}/data/accounts.json`; + let contents: string; + try { + contents = await fs.readFile(accountsPath, "utf8"); + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT")) { + return []; + } + throw error; + } + + const parsed = JSON.parse(contents) as unknown; + if (!isRecord(parsed) || !Array.isArray(parsed.accounts)) { + return []; + } + return parsed.accounts + .map((account) => { + if (!isRecord(account)) { + return null; + } + return stringOrNull(account.number) ?? stringOrNull(account.uuid); + }) + .filter((account) => account !== null); +} + +async function listAccounts(): Promise { + const output = await runSignalCli(["listAccounts"]); + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .map(parseSignalAccountLine) + .filter((account) => account !== null); +} + +function parseSignalAccountLine(line: string): string | null { + const number = line.match(/(?:Number|Account):\s*(\+\d+)/)?.[1]; + if (number !== undefined) { + return number; + } + if ( + line.startsWith("+") || + line.startsWith("ACI:") || + line.startsWith("PNI:") || + /^[0-9a-fA-F-]{32,36}$/.test(line) + ) { + return line; + } + return null; +} + +function spawnSignalCli(args: string[]): ChildProcessWithoutNullStreams { + return spawn( + signalCliCommand[0], + [...signalCliCommand.slice(1), "--config", configDir, ...args], + { + detached: process.platform !== "win32", + stdio: ["pipe", "pipe", "pipe"], + }, + ); +} + +function installChildCleanup(child: ChildProcessWithoutNullStreams): void { + const stopChild = () => { + killSignalCliProcess(child); + }; + process.once("exit", stopChild); + process.once("SIGINT", () => { + stopChild(); + process.exit(130); + }); + process.once("SIGTERM", () => { + stopChild(); + process.exit(143); + }); +} + +function killSignalCliProcess(child: ChildProcessWithoutNullStreams): void { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + if (process.platform !== "win32" && child.pid !== undefined) { + try { + process.kill(-child.pid, "SIGTERM"); + return; + } catch { + // Fall back to killing the direct child below. + } + } + child.kill(); +} + +async function runSignalCli(args: string[]): Promise { + const child = spawnSignalCli(args); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + const code = await waitForExitWithTimeout(child, args, 30_000); + if (code !== 0) { + throw new Error(`signal-cli ${args.join(" ")} failed: ${stderr.trim()}`); + } + return stdout; +} + +function waitForExit( + child: ChildProcessWithoutNullStreams, +): Promise { + return new Promise((resolve, reject) => { + child.on("error", reject); + child.on("exit", (code) => resolve(code)); + }); +} + +async function waitForExitWithTimeout( + child: ChildProcessWithoutNullStreams, + args: string[], + timeoutMs: number, +): Promise { + let timeout: NodeJS.Timeout | null = null; + try { + return await Promise.race([ + waitForExit(child), + new Promise((_, reject) => { + timeout = setTimeout(() => { + killSignalCliProcess(child); + reject( + new Error( + `signal-cli ${args.join(" ")} timed out after ${timeoutMs}ms`, + ), + ); + }, timeoutMs); + }), + ]); + } finally { + if (timeout !== null) { + clearTimeout(timeout); + } + } +} + +function normalizeRecipient(target: string): string { + if ( + target.startsWith("u:") || + target.startsWith("+") || + target.startsWith("ACI:") || + target.startsWith("PNI:") || + /^[0-9a-fA-F-]{32,36}$/.test(target) + ) { + return target; + } + return `u:${target}`; +} + +function looksLikeRecipient(target: string): boolean { + return ( + target.startsWith("u:") || + target.startsWith("+") || + target.startsWith("ACI:") || + target.startsWith("PNI:") || + /^[0-9a-fA-F-]{32,36}$/.test(target) + ); +} + +function shouldTrigger(sender: string): boolean { + if (allowedContacts !== null) { + return allowedContacts.includes(sender); + } + return trigger === "all_messages" || sender.length > 0; +} + +function looksLikeGroupId(target: string): boolean { + return !looksLikeRecipient(target) && /^[A-Za-z0-9+/=_-]{20,}$/.test(target); +} + +function stringArrayOrDefault( + value: unknown, + fallback: string[], + name: string, +): string[] { + if (value === null || value === undefined) { + return fallback; + } + if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + return value.length === 0 ? fallback : value; + } + throw new Error(`Signal ${name} must be null or an array of strings`); +} + +function stringArrayOrNull(value: unknown): string[] | null { + if (value === null || value === undefined) { + return null; + } + if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + return value; + } + throw new Error("Signal allowedContacts must be null or an array of strings"); +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function numberOrStringOrNull(value: unknown): string | null { + if (typeof value === "number" || typeof value === "string") { + return String(value); + } + return null; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isNodeErrorWithCode(error: unknown, code: string): boolean { + return ( + error instanceof Error && + "code" in error && + (error as { code?: unknown }).code === code + ); +} diff --git a/examples/exoclaw/adapters/whatsapp/README.md b/examples/exoclaw/adapters/whatsapp/README.md new file mode 100644 index 0000000..c4dd8d3 --- /dev/null +++ b/examples/exoclaw/adapters/whatsapp/README.md @@ -0,0 +1,106 @@ +# WhatsApp Adapter + +The WhatsApp adapter is an experimental Exoclaw library adapter implemented as a TypeScript worker using Baileys. It runs as a linked-device client: WhatsApp remains owned by the phone account, and Exoclaw connects as an additional device after QR or pairing-code linking. + +## How It Works + +The host adapter runner starts `worker.ts` and passes adapter configuration through `EXO_ADAPTER_CONFIG`. The worker stores Baileys auth state on disk, opens a WhatsApp socket, emits JSONL events on stdout, and receives outbound send commands on stdin. + +When WhatsApp asks for pairing, the worker logs an ASCII QR code and emits a lifecycle event named `qr`, or requests and emits a `pairing_code` when configured for pairing-code linking. After pairing, incoming text messages become Exoclaw adapter message events. Outbound `send_adapter_message` calls send text, or text plus rich attachments, to the target WhatsApp chat id. + +## Setup + +Use the Exoclaw setup flow: + +```bash +examples/exoclaw/scripts/exoclaw-repl fresh --pull-sandbox --setup whatsapp +``` + +The script watches `.exo/exoclaw-adapters.log`, prints the QR code if it appears, and pauses while you scan it. Scan from WhatsApp using the linked-device flow. + +The setup prompt at `setup-prompt.md` asks Exoclaw to create a library adapter similar to: + +```json +{ + "name": "whatsapp-dev", + "source": "library", + "config": { + "type": "whatsapp", + "authDir": null, + "linkMethod": "qr", + "phoneNumber": null, + "trigger": "all_messages", + "allowedChats": null + } +} +``` + +## Configuration + +- `authDir` controls where Baileys stores linked-device credentials. If omitted, the worker uses `.exo/adapters/whatsapp//auth` or the host-provided adapter state directory. +- `linkMethod` is `qr` or `pairing-code`. Use `pairing-code` with `phoneNumber` when QR linking is unreliable. +- `trigger` is `all_messages` or `contacts_only`. +- `allowedChats` can restrict wakeups to specific WhatsApp chat ids. + +## Rich Outbound Content + +The WhatsApp worker supports outbound image, video, audio, and document attachments. Use the `attachments` field on `send_adapter_message`; each attachment must specify exactly one of HTTPS `url`, base64 `data`, or `sandboxPath`. + +Example image send: + +```json +{ + "adapterId": "adapter-id", + "target": "120363426815150953@g.us", + "text": "Here is the chart.", + "attachments": [ + { + "kind": "image", + "url": "https://example.com/chart.png", + "data": null, + "sandboxPath": null, + "mimeType": "image/png", + "fileName": null + } + ] +} +``` + +Documents require `mimeType` and `fileName`: + +```json +{ + "kind": "document", + "url": null, + "data": "base64-pdf-bytes", + "sandboxPath": null, + "mimeType": "application/pdf", + "fileName": "report.pdf" +} +``` + +If the image was created inside the agent sandbox, the adapter worker cannot read that sandbox path directly. Pass the file as `sandboxPath`; the host tool will stage it into `.exo/adapters/media` and send that staged host path to the worker: + +```json +{ + "kind": "image", + "url": null, + "data": null, + "sandboxPath": "/tmp/exoclaw_media/funny-cat.jpg", + "mimeType": "image/png", + "fileName": null +} +``` + +Use `data` only for small inline payloads. Large inline base64 is rejected by the host tool. + +For image, video, and document attachments, `text` is sent as the caption on the first caption-capable attachment. Audio attachments are sent as media, followed by a separate text message when needed. + +## Quirks And Gotchas + +- Baileys is an unofficial WhatsApp Web client library. It works for testing, but it is inherently more brittle than a supported API. +- If another WhatsApp Web session replaces this device, the worker can disconnect with a conflict/replaced message. Re-pair if needed. +- If the session is logged out, delete the adapter auth directory and pair again. +- WhatsApp sends require the target chat id from the inbound wakeup. Do not guess a phone number as the target. +- Inbound media is not downloaded yet. The worker currently exposes inbound text plus captions on image/video messages. +- QR codes and chat ids may appear in `.exo/exoclaw-adapters.log`; treat that log as sensitive local state. diff --git a/examples/exoclaw/adapters/whatsapp/qrcode-terminal.d.ts b/examples/exoclaw/adapters/whatsapp/qrcode-terminal.d.ts new file mode 100644 index 0000000..66b5720 --- /dev/null +++ b/examples/exoclaw/adapters/whatsapp/qrcode-terminal.d.ts @@ -0,0 +1,15 @@ +declare module "qrcode-terminal" { + const qrcodeTerminal: { + generate( + input: string, + options: { small?: boolean }, + callback: (qr: string) => void, + ): void; + }; + export default qrcodeTerminal; + export function generate( + input: string, + options: { small?: boolean }, + callback: (qr: string) => void, + ): void; +} diff --git a/examples/exoclaw/adapters/whatsapp/setup-prompt.md b/examples/exoclaw/adapters/whatsapp/setup-prompt.md new file mode 100644 index 0000000..d4c3410 --- /dev/null +++ b/examples/exoclaw/adapters/whatsapp/setup-prompt.md @@ -0,0 +1,14 @@ +Set up a WhatsApp adapter for testing. + +Create a library WhatsApp adapter if one does not already exist for this conversation, then make sure it is ready for the background adapter runner. Use these settings: + +- name: `whatsapp-dev` +- source: `library` +- type: `whatsapp` +- authDir: `null` +- linkMethod: `qr` unless the user asks for a pairing code +- phoneNumber: `null` unless linkMethod is `pairing-code` +- trigger: `all_messages` +- allowedChats: `null` + +After creating or confirming the adapter, explain that the setup script will try to print the QR code from `.exo/exoclaw-adapters.log`. If it does not appear immediately, tell the user to watch that log and scan the QR code with WhatsApp's linked-device flow. Briefly tell me the adapter id, where the auth state will be stored, and what message I should send from WhatsApp to test it. diff --git a/examples/exoclaw/adapters/whatsapp/worker.ts b/examples/exoclaw/adapters/whatsapp/worker.ts new file mode 100644 index 0000000..4f1197a --- /dev/null +++ b/examples/exoclaw/adapters/whatsapp/worker.ts @@ -0,0 +1,433 @@ +import fs from "node:fs/promises"; +import readline from "node:readline/promises"; + +import makeWASocket, { + DisconnectReason, + fetchLatestBaileysVersion, + useMultiFileAuthState, + type WAMessage, +} from "@whiskeysockets/baileys"; +import type { ILogger } from "@whiskeysockets/baileys/lib/Utils/logger.js"; +import qrcodeTerminal from "qrcode-terminal"; + +import { + type AdapterAttachment, + adapterConfig, + optionalStringField, + parseWorkerCommand, + writeWorkerEvent, +} from "../protocol"; + +const config = adapterConfig(); +const trigger = optionalStringField(config, "trigger") ?? "all_messages"; +const allowedChats = stringArrayOrNull(config.allowedChats); +if (trigger !== "all_messages" && trigger !== "contacts_only") { + throw new Error("WhatsApp trigger must be all_messages or contacts_only"); +} +const linkMethod = optionalStringField(config, "linkMethod") ?? "qr"; +if (linkMethod !== "qr" && linkMethod !== "pairing-code") { + throw new Error("WhatsApp linkMethod must be qr or pairing-code"); +} +const pairingPhoneNumber = optionalStringField(config, "phoneNumber"); +const authDir = + optionalStringField(config, "authDir") ?? + (process.env.EXO_ADAPTER_STATE_DIR === undefined + ? `.exo/adapters/whatsapp/${process.env.EXO_ADAPTER_ID ?? "default"}/auth` + : `${process.env.EXO_ADAPTER_STATE_DIR}/auth`); + +const logger: ILogger = { + level: "silent", + child() { + return logger; + }, + trace() {}, + debug() {}, + info() {}, + warn(value) { + process.stderr.write(`[whatsapp-adapter] ${formatLogValue(value)}\n`); + }, + error(value) { + process.stderr.write(`[whatsapp-adapter] ${formatLogValue(value)}\n`); + }, +}; + +await fs.mkdir(authDir, { recursive: true }); + +const { state, saveCreds } = await useMultiFileAuthState(authDir); +const { version } = await fetchLatestBaileysVersion(); +const SEND_TIMEOUT_MS = 60_000; +const socket = makeWASocket({ + auth: state, + logger, + printQRInTerminal: false, + syncFullHistory: false, + version, +}); + +socket.ev.on("creds.update", saveCreds); + +socket.ev.on("connection.update", (update) => { + if (update.qr && linkMethod === "qr") { + qrcodeTerminal.generate(update.qr, { small: true }, (qr) => { + process.stderr.write( + `\n[whatsapp-adapter] Scan this QR with WhatsApp:\n${qr}\n`, + ); + }); + writeWorkerEvent({ + type: "lifecycle", + name: "qr", + metadata: { qr: update.qr }, + }); + } + if (update.connection === "open") { + writeWorkerEvent({ + type: "connected", + subject: socket.user?.id ?? null, + }); + } + if (update.connection === "close") { + const statusCode = statusCodeFromError(update.lastDisconnect?.error); + writeWorkerEvent({ + type: "disconnected", + reason: statusCode === null ? null : String(statusCode), + }); + if (statusCode === DisconnectReason.loggedOut) { + writeWorkerEvent({ + type: "error", + message: `WhatsApp session logged out; delete ${authDir} and pair again`, + }); + process.exit(1); + } + process.exit(1); + } +}); + +if (linkMethod === "pairing-code" && !state.creds.registered) { + if (!pairingPhoneNumber) { + throw new Error("WhatsApp pairing-code linkMethod requires phoneNumber"); + } + const code = await socket.requestPairingCode(pairingPhoneNumber); + process.stderr.write( + `\n[whatsapp-adapter] Enter this WhatsApp pairing code for ${pairingPhoneNumber}: ${code}\n`, + ); + writeWorkerEvent({ + type: "lifecycle", + name: "pairing_code", + metadata: { phoneNumber: pairingPhoneNumber, code }, + }); +} + +socket.ev.on("messages.upsert", (event) => { + if (event.type !== "notify") { + return; + } + for (const message of event.messages) { + const inbound = inboundMessage(message); + if (inbound === null) { + continue; + } + if (!shouldTrigger(inbound.target)) { + continue; + } + writeWorkerEvent(inbound); + } +}); + +const input = readline.createInterface({ + input: process.stdin, + crlfDelay: Number.POSITIVE_INFINITY, +}); + +for await (const line of input) { + if (line.trim().length === 0) { + continue; + } + let commandId: string | null = null; + try { + const command = parseWorkerCommand(JSON.parse(line)); + commandId = command.id; + if (!command.target) { + throw new Error("WhatsApp send_message requires a target chat id"); + } + writeWorkerEvent({ + type: "lifecycle", + name: "send_starting", + metadata: { + target: command.target, + attachmentCount: command.attachments.length, + }, + }); + if (command.attachments.length === 0) { + await sendWhatsAppMessage(command.target, { text: command.text }); + } else { + let captionUsed = false; + const textBeforeMedia = command.attachments.every( + (attachment) => !attachmentSupportsCaption(attachment), + ); + if (textBeforeMedia) { + await sendWhatsAppMessage(command.target, { text: command.text }); + captionUsed = true; + } + for (const attachment of command.attachments) { + const caption: string | null = + !captionUsed && attachmentSupportsCaption(attachment) + ? command.text + : null; + captionUsed ||= caption !== null; + await sendAttachment(command.target, attachment, caption); + } + if (!captionUsed) { + await sendWhatsAppMessage(command.target, { text: command.text }); + } + } + writeWorkerEvent({ + type: "lifecycle", + name: "send_result", + metadata: { + target: command.target, + attachmentCount: command.attachments.length, + }, + }); + writeWorkerEvent({ type: "command_ack", command_id: command.id }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + writeWorkerEvent({ + type: "error", + message, + }); + if (commandId !== null) { + writeWorkerEvent({ + type: "command_nack", + command_id: commandId, + message, + }); + } + } +} + +function attachmentSupportsCaption(attachment: AdapterAttachment): boolean { + return ( + attachment.kind === "image" || + attachment.kind === "video" || + attachment.kind === "document" + ); +} + +async function sendAttachment( + target: string, + attachment: AdapterAttachment, + caption: string | null, +): Promise { + await sendWhatsAppMessage( + target, + await whatsappAttachmentContent(attachment, caption), + ); +} + +type WhatsAppMessageContent = Parameters[1]; + +async function whatsappAttachmentContent( + attachment: AdapterAttachment, + caption: string | null, +): Promise { + const media = await mediaSource(attachment); + switch (attachment.kind) { + case "image": + return { + image: media, + caption: caption ?? undefined, + } as WhatsAppMessageContent; + case "video": + return { + video: media, + caption: caption ?? undefined, + } as WhatsAppMessageContent; + case "audio": + if (!isOpusAudio(attachment)) { + return audioDocumentContent(attachment, media); + } + return { + audio: media, + mimetype: whatsappAudioMimeType(attachment), + ptt: isOpusAudio(attachment), + } as WhatsAppMessageContent; + case "document": + if (!attachment.mimeType) { + throw new Error("WhatsApp document attachment requires mimeType"); + } + if (!attachment.fileName) { + throw new Error("WhatsApp document attachment requires fileName"); + } + return { + document: media, + mimetype: attachment.mimeType, + fileName: attachment.fileName, + caption: caption ?? undefined, + } as WhatsAppMessageContent; + } +} + +async function sendWhatsAppMessage( + target: string, + content: WhatsAppMessageContent, +): Promise { + let timeout: NodeJS.Timeout | null = null; + try { + await Promise.race([ + socket.sendMessage(target, content), + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject( + new Error( + `WhatsApp send_message timed out after ${SEND_TIMEOUT_MS}ms`, + ), + ); + }, SEND_TIMEOUT_MS); + }), + ]); + } finally { + if (timeout !== null) { + clearTimeout(timeout); + } + } +} + +function audioDocumentContent( + attachment: AdapterAttachment, + media: { url: string } | Buffer, +): WhatsAppMessageContent { + return { + document: media, + mimetype: attachment.mimeType ?? "application/octet-stream", + fileName: attachment.fileName ?? "audio", + } as WhatsAppMessageContent; +} + +function whatsappAudioMimeType( + attachment: AdapterAttachment, +): string | undefined { + if (isOpusAudio(attachment)) { + return "audio/ogg; codecs=opus"; + } + return attachment.mimeType ?? undefined; +} + +function isOpusAudio(attachment: AdapterAttachment): boolean { + const mimeType = attachment.mimeType?.toLowerCase() ?? ""; + const fileName = attachment.fileName?.toLowerCase() ?? ""; + const source = (attachment.path ?? attachment.url ?? "").toLowerCase(); + return ( + mimeType.includes("opus") || + fileName.endsWith(".opus") || + source.endsWith(".opus") + ); +} + +async function mediaSource( + attachment: AdapterAttachment, +): Promise<{ url: string } | Buffer> { + if (attachment.path) { + return fs.readFile(attachment.path); + } + if (attachment.url) { + return { url: attachment.url }; + } + if (attachment.data) { + return Buffer.from(base64Payload(attachment.data), "base64"); + } + throw new Error("WhatsApp attachment requires path, url, or data"); +} + +function base64Payload(data: string): string { + const dataUrlSeparator = data.indexOf(","); + if (data.startsWith("data:") && dataUrlSeparator !== -1) { + return data.slice(dataUrlSeparator + 1); + } + return data; +} + +function inboundMessage(message: WAMessage) { + if (message.key.fromMe) { + return null; + } + const chatId = message.key.remoteJid; + if (!chatId) { + return null; + } + const text = messageText(message); + if (text === null || text.length === 0) { + return null; + } + return { + type: "message" as const, + target: chatId, + sender: message.key.participant ?? message.key.remoteJid ?? null, + text, + message_id: message.key.id ?? null, + }; +} + +function messageText(message: WAMessage): string | null { + const content = message.message; + if (!content) { + return null; + } + if (content.conversation) { + return content.conversation; + } + if (content.extendedTextMessage?.text) { + return content.extendedTextMessage.text; + } + if (content.imageMessage?.caption) { + return content.imageMessage.caption; + } + if (content.videoMessage?.caption) { + return content.videoMessage.caption; + } + return null; +} + +function shouldTrigger(chatId: string): boolean { + if (allowedChats !== null && !allowedChats.includes(chatId)) { + return false; + } + if (trigger === "contacts_only") { + return !chatId.endsWith("@g.us"); + } + return true; +} + +function stringArrayOrNull(value: unknown): string[] | null { + if (value === null || value === undefined) { + return null; + } + if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + return value; + } + throw new Error("WhatsApp allowedChats must be null or an array of strings"); +} + +function statusCodeFromError(error: unknown): number | null { + if (!isRecord(error)) { + return null; + } + const output = error.output; + if (!isRecord(output) || typeof output.statusCode !== "number") { + return null; + } + return output.statusCode; +} + +function formatLogValue(value: unknown): string { + if (value instanceof Error) { + return value.stack ?? value.message; + } + if (typeof value === "string") { + return value; + } + return JSON.stringify(value); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/examples/exoclaw/harness.ts b/examples/exoclaw/harness.ts new file mode 100644 index 0000000..86af3b8 --- /dev/null +++ b/examples/exoclaw/harness.ts @@ -0,0 +1,88 @@ +import { existsSync, readFileSync } from "node:fs"; + +import { + defineHarness, + registerBuiltInTools, + registerAgentToolsFromDirectoryIfExists, + registerLibraryToolModulePath, + registerAdapterTools, + type BuiltInToolName, + type HarnessToolRegistry, + type Message, + type TurnContext, +} from "@exo/harness"; + +import { registerSchedulerTools } from "./scheduler-tools"; +import { + basicHarnessInstructions, + defaultBuiltInToolNames, + runResponsesHarnessTurn, +} from "../typescript/turn-loop"; + +const EXOCLAW_IDENTITY_PROMPT = readFileSync( + new URL("./prompts/me.md", import.meta.url), + "utf8", +).trim(); +const DEFAULT_LOCAL_PROMPT_PATH = ".exo/exoclaw-profile.md"; + +export default defineHarness({ + async runTurn(context) { + await runResponsesHarnessTurn(context, { + instructions: exoclawInstructions, + registerTools: registerExoclawTools, + }); + }, +}); + +async function registerExoclawTools( + tools: HarnessToolRegistry, + context: TurnContext, +): Promise { + registerBuiltInTools(tools, context, builtInToolNames(context)); + registerSchedulerTools(tools); + registerAdapterTools(tools); + for (const modulePath of context.agentConfig.typescript?.toolModulePaths ?? + []) { + await registerLibraryToolModulePath(tools, context, modulePath); + } + if (context.agentConfig.enableAgentToolCreation) { + await registerAgentToolsFromDirectoryIfExists(tools, context); + } +} + +function builtInToolNames(context: TurnContext): BuiltInToolName[] { + return defaultBuiltInToolNames(context); +} + +function exoclawInstructions(context: TurnContext): Message[] { + const instructions: Message[] = [ + ...basicHarnessInstructions(context), + { + role: "developer", + content: EXOCLAW_IDENTITY_PROMPT, + }, + { + role: "developer", + content: + 'This is the Exoclaw long-running agent harness. You can schedule recurring sandbox work with schedule_sandbox_task, inspect active tasks with list_scheduled_tasks, cancel tasks with cancel_scheduled_task, and permanently delete tasks with delete_scheduled_task. You can also create long-running external adapters with create_adapter, inspect them with list_adapters, disable/delete them, and send explicit outbound replies with send_adapter_message. Use cancel_scheduled_task or disable_adapter when history should be preserved; use delete_scheduled_task or delete_adapter when the user asks to remove something entirely. Conversations default to sandboxScope: "agent", so shell commands use this agent\'s shared sandbox unless the conversation was configured with sandboxScope: "conversation". Scheduled tasks default to sandboxMode: "agent". Use sandboxMode: "conversation" when the task should run in this conversation\'s sandbox, and sandboxMode: "task_fresh" when the task should have a separate fresh sandbox that is reused across that task\'s runs. IRC, WhatsApp, Signal, and Discord adapters wake this conversation when their trigger policy matches; do not auto-send model text to external services. Call send_adapter_message only for intentional external replies, using the target value from the inbound wakeup when one is provided. For Discord, the target is a channel id unless the adapter has a defaultChannelId. If an adapter message asks you to schedule future work and the future result should appear externally, include the adapterId and target in the scheduled task reportPrompt so the scheduler wakeup can call send_adapter_message.', + }, + ]; + const localPrompt = readLocalPrompt(); + if (localPrompt !== null) { + instructions.push({ + role: "developer", + content: localPrompt, + }); + } + return instructions; +} + +function readLocalPrompt(): string | null { + const path = + process.env.EXOCLAW_LOCAL_PROMPT_FILE ?? DEFAULT_LOCAL_PROMPT_PATH; + if (!existsSync(path)) { + return null; + } + const contents = readFileSync(path, "utf8").trim(); + return contents.length === 0 ? null : contents; +} diff --git a/examples/exoclaw/prompts/me.md b/examples/exoclaw/prompts/me.md new file mode 100644 index 0000000..bd1a7e5 --- /dev/null +++ b/examples/exoclaw/prompts/me.md @@ -0,0 +1,17 @@ +You are Exoclaw, a long-running local control agent. + +Your purpose is to help the user operate a local machine over time: configure +adapters, receive messages from external channels, run sandbox commands, +schedule recurring work, and report results clearly. Be a thoughtful research +and operations assistant, but keep external side effects explicit and +inspectable. + +Keep these operating rules: + +- Treat external adapters as explicit side-effect boundaries. For adapter-originated wakeups, the external channel is the primary reply destination. If you respond, use `send_adapter_message` with the adapter id and target from the wakeup; do not only answer in the REPL unless no external reply should be sent. +- For WhatsApp, Signal, and Discord rich attachments, use HTTPS `url` for remote media, `sandboxPath` for files created inside the sandbox, and base64 `data` only for small inline payloads. Do not pass host file paths. +- When scheduling work that should report back to an external channel, include the adapter id and target in the task `reportPrompt` so future wakeups know where to send results. +- Prefer durable, inspectable setup: tell the user what adapter id, channel, chat, or group was configured and how to test it. +- Do not hide setup uncertainty. If an adapter needs a QR scan, invite, pairing step, secret, or manual action, say exactly what is needed. +- Use the shared agent sandbox for setup unless the user asks for isolated conversation or task sandboxes. +- Keep answers concise and operational. The user is testing Exoclaw, so focus on what is configured, what is running, and what to try next. diff --git a/examples/exoclaw/scheduler-runner/Cargo.toml b/examples/exoclaw/scheduler-runner/Cargo.toml new file mode 100644 index 0000000..b2386fa --- /dev/null +++ b/examples/exoclaw/scheduler-runner/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "exoclaw-scheduler-runner" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +anyhow.workspace = true +clap = { workspace = true, features = ["derive", "env"] } +executor = { path = "../../../crates/executor" } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } +tracing.workspace = true diff --git a/examples/exoclaw/scheduler-runner/src/main.rs b/examples/exoclaw/scheduler-runner/src/main.rs new file mode 100644 index 0000000..1b8c368 --- /dev/null +++ b/examples/exoclaw/scheduler-runner/src/main.rs @@ -0,0 +1,261 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Result, anyhow}; +use clap::{Parser, Subcommand}; +use executor::{ + BasicExoHarnessConfig, BraintrustRuntimeConfig, ExoclawToolRuntime, Harness, + SandboxBackendChoice, SchedulerRunOptions, SchedulerStore, SecretBackendChoice, + TypeScriptHarness, run_due_tasks, +}; + +#[derive(Debug, Parser)] +#[command(name = "exoclaw-scheduler-runner")] +#[command(about = "Example-local scheduler runner for Exoclaw")] +struct Cli { + #[arg(long, global = true, default_value = ".exo")] + root: PathBuf, + #[arg(long, global = true)] + env_file_if_exists: Option, + #[arg(long, global = true)] + env_file: Option, + #[arg(long, global = true, env = "BRAINTRUST_API_KEY", hide = true)] + braintrust_api_key: Option, + #[arg(long, global = true, env = "BRAINTRUST_APP_URL", hide = true)] + braintrust_app_url: Option, + #[arg(long, global = true, env = "BRAINTRUST_API_URL", hide = true)] + braintrust_api_url: Option, + #[command(subcommand)] + command: Commands, +} + +#[derive(Debug, Subcommand)] +enum Commands { + Run { + #[arg(long)] + watch: bool, + #[arg(long, default_value_t = 60)] + interval_seconds: u64, + #[arg(long, default_value_t = 10)] + limit: usize, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + let env = load_env(cli.env_file_if_exists.as_deref(), cli.env_file.as_deref())?; + let runtime_config = braintrust_runtime_config( + &env, + cli.braintrust_api_key, + cli.braintrust_app_url, + cli.braintrust_api_url, + ); + let harness = exoclaw_harness(&cli.root, runtime_config, env).await?; + + match cli.command { + Commands::Run { + watch, + interval_seconds, + limit, + } => { + let _lock = SchedulerRunnerLock::acquire(&cli.root)?; + let store = SchedulerStore::new(cli.root.join("scheduled-tasks")); + loop { + let runs = + run_due_tasks(Arc::clone(&harness), &store, SchedulerRunOptions { limit }) + .await?; + for run in runs { + println!( + "{}\t{}\texit={}\terror={}", + run.task_id, + run.id, + run.exit_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "none".to_string()), + run.error.unwrap_or_else(|| "none".to_string()) + ); + } + if !watch { + break; + } + tokio::time::sleep(Duration::from_secs(interval_seconds)).await; + } + } + } + + harness.flush_tracing().await?; + Ok(()) +} + +struct SchedulerRunnerLock { + path: PathBuf, +} + +impl SchedulerRunnerLock { + fn acquire(root: &Path) -> Result { + fs::create_dir_all(root)?; + let path = root.join("exoclaw-scheduler.lock"); + let pid = std::process::id().to_string(); + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(mut file) => { + use std::io::Write; + writeln!(file, "{pid}")?; + Ok(Self { path }) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let existing_pid = fs::read_to_string(&path).unwrap_or_default(); + if process_is_running(existing_pid.trim()) { + return Err(anyhow!( + "scheduler runner already appears to be running with pid {}", + existing_pid.trim() + )); + } + fs::remove_file(&path)?; + Self::acquire(root) + } + Err(error) => Err(error.into()), + } + } +} + +impl Drop for SchedulerRunnerLock { + fn drop(&mut self) { + if let Err(error) = fs::remove_file(&self.path) { + tracing::error!( + path = %self.path.display(), + %error, + "failed to remove scheduler lock" + ); + } + } +} + +fn process_is_running(pid: &str) -> bool { + !pid.is_empty() + && Command::new("kill") + .arg("-0") + .arg(pid) + .status() + .is_ok_and(|status| status.success()) +} + +async fn exoclaw_harness( + root: &Path, + runtime_config: Option, + env: HashMap, +) -> Result> { + let exo_config = BasicExoHarnessConfig { + root: root.join("exoharness"), + secret_backend: default_secret_backend(), + sandbox_backend: default_sandbox_backend(), + }; + Ok(Arc::new( + TypeScriptHarness::::exoclaw_from_root( + root, + exo_config, + runtime_config, + env, + ) + .await?, + )) +} + +#[cfg(target_os = "macos")] +fn default_secret_backend() -> SecretBackendChoice { + SecretBackendChoice::AppleKeychain +} + +#[cfg(not(target_os = "macos"))] +fn default_secret_backend() -> SecretBackendChoice { + SecretBackendChoice::File { path: None } +} + +#[cfg(target_os = "macos")] +fn default_sandbox_backend() -> SandboxBackendChoice { + SandboxBackendChoice::AppleContainer +} + +#[cfg(not(target_os = "macos"))] +fn default_sandbox_backend() -> SandboxBackendChoice { + SandboxBackendChoice::Docker +} + +fn load_env( + env_file_if_exists: Option<&Path>, + env_file: Option<&Path>, +) -> Result> { + let mut vars = HashMap::new(); + if let Some(path) = env_file_if_exists + && path.exists() + { + vars.extend(parse_env_file(path)?); + } + if let Some(path) = env_file { + vars.extend(parse_env_file(path)?); + } + Ok(vars) +} + +fn parse_env_file(path: &Path) -> Result> { + let contents = fs::read_to_string(path)?; + let mut vars = HashMap::new(); + for (index, raw_line) in contents.lines().enumerate() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let line = line.strip_prefix("export ").unwrap_or(line); + let Some((key, value)) = line.split_once('=') else { + return Err(anyhow!( + "invalid env file line {} in {}", + index + 1, + path.display() + )); + }; + let key = key.trim(); + if key.is_empty() { + return Err(anyhow!( + "invalid empty env key on line {} in {}", + index + 1, + path.display() + )); + } + vars.insert(key.to_string(), strip_quotes(value.trim()).to_string()); + } + Ok(vars) +} + +fn strip_quotes(value: &str) -> &str { + if value.len() >= 2 { + let bytes = value.as_bytes(); + let first = bytes[0]; + let last = bytes[value.len() - 1]; + if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') { + return &value[1..value.len() - 1]; + } + } + value +} + +fn braintrust_runtime_config( + env: &HashMap, + api_key: Option, + app_url: Option, + api_url: Option, +) -> Option { + let api_key = api_key.or_else(|| env.get("BRAINTRUST_API_KEY").cloned())?; + Some(BraintrustRuntimeConfig { + api_key, + app_url: app_url.or_else(|| env.get("BRAINTRUST_APP_URL").cloned()), + api_url: api_url.or_else(|| env.get("BRAINTRUST_API_URL").cloned()), + }) +} diff --git a/examples/exoclaw/scheduler-tools.ts b/examples/exoclaw/scheduler-tools.ts new file mode 100644 index 0000000..4614783 --- /dev/null +++ b/examples/exoclaw/scheduler-tools.ts @@ -0,0 +1,206 @@ +import type { + HarnessToolRegistry, + ToolDefinition, + ToolInstance, +} from "@exo/harness"; + +export type SchedulerToolName = + | "schedule_sandbox_task" + | "list_scheduled_tasks" + | "cancel_scheduled_task" + | "delete_scheduled_task"; + +export function createSchedulerToolInstances(): ToolInstance[] { + return [ + createScheduleSandboxTaskTool(), + createListScheduledTasksTool(), + createCancelScheduledTaskTool(), + createDeleteScheduledTaskTool(), + ]; +} + +export function registerSchedulerTools( + registry: HarnessToolRegistry, + names: SchedulerToolName[] = [ + "schedule_sandbox_task", + "list_scheduled_tasks", + "cancel_scheduled_task", + "delete_scheduled_task", + ], +): void { + const requested = new Set(names); + for (const tool of createSchedulerToolInstances()) { + if (requested.has(tool.definition.name as SchedulerToolName)) { + registry.register(tool); + } + } +} + +function createScheduleSandboxTaskTool(): ToolInstance { + return { + source: "built_in", + definition: { + name: "schedule_sandbox_task", + description: + "Schedule a recurring command to run in this Exoclaw agent's shared sandbox by default. A host scheduler owns timing and will wake this conversation with compact results when runs complete. Use setupCommand for task-specific setup that should run before each scheduled run.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + name: { + type: "string", + description: + "Stable task name using letters, numbers, dashes, or underscores.", + }, + schedule: { + type: "string", + description: + "Schedule as '@every 10m', '@every 1h', or a simple cron interval like '*/30 * * * *'.", + }, + command: { + type: "array", + items: { type: "string" }, + minItems: 1, + description: + "Command argv to run in the sandbox, for example ['bash', '-lc', 'curl -fsSL https://example.com/health'].", + }, + sandboxMode: { + anyOf: [ + { type: "string", enum: ["agent", "conversation", "task_fresh"] }, + { type: "null" }, + ], + description: + "Sandbox selection mode. Use 'agent' or null to run in the shared persistent agent sandbox. Use 'conversation' for a sandbox scoped to this conversation. Use 'task_fresh' to create a separate fresh sandbox for this task and reuse it across that task's runs.", + }, + setupCommand: { + anyOf: [ + { + type: "array", + items: { type: "string" }, + minItems: 1, + }, + { type: "null" }, + ], + description: + "Optional argv to run immediately before each scheduled run, for example ['bash', '-lc', 'apt-get update && apt-get install -y curl']. Use this for dependencies that should be prepared before each run.", + }, + reportPrompt: { + type: "string", + description: + "Instructions for how to report each completed run back to the user.", + }, + maxOutputBytes: { + type: ["number", "null"], + description: + "Maximum bytes to retain from each output stream before truncating, or null for the default.", + }, + }, + required: [ + "name", + "schedule", + "command", + "sandboxMode", + "setupCommand", + "reportPrompt", + "maxOutputBytes", + ], + }, + }, + handler: { + execute(args, execution) { + return execution.context.executeTool({ + functionName: "schedule_sandbox_task", + arguments: args, + }); + }, + }, + }; +} + +function createListScheduledTasksTool(): ToolInstance { + return { + source: "built_in", + definition: { + name: "list_scheduled_tasks", + description: + "List scheduled sandbox tasks for this conversation. Disabled tasks are hidden unless includeDisabled is true.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + includeDisabled: { + type: ["boolean", "null"], + description: + "Whether to include disabled/cancelled tasks. Use false or null for the default active-task view.", + }, + }, + required: ["includeDisabled"], + }, + }, + handler: { + execute(args, execution) { + return execution.context.executeTool({ + functionName: "list_scheduled_tasks", + arguments: args, + }); + }, + }, + }; +} + +function createDeleteScheduledTaskTool(): ToolInstance { + return { + source: "built_in", + definition: { + name: "delete_scheduled_task", + description: + "Permanently delete a scheduled sandbox task for this conversation, including its stored run history. Use cancel_scheduled_task instead when history should be preserved.", + parameters: taskIdParameters( + "Scheduled task id returned by schedule_sandbox_task or list_scheduled_tasks.", + ), + }, + handler: { + execute(args, execution) { + return execution.context.executeTool({ + functionName: "delete_scheduled_task", + arguments: args, + }); + }, + }, + }; +} + +function createCancelScheduledTaskTool(): ToolInstance { + return { + source: "built_in", + definition: { + name: "cancel_scheduled_task", + description: "Disable a scheduled sandbox task for this conversation.", + parameters: taskIdParameters( + "Scheduled task id returned by schedule_sandbox_task or list_scheduled_tasks.", + ), + }, + handler: { + execute(args, execution) { + return execution.context.executeTool({ + functionName: "cancel_scheduled_task", + arguments: args, + }); + }, + }, + }; +} + +function taskIdParameters(description: string): ToolDefinition["parameters"] { + return { + type: "object", + additionalProperties: false, + properties: { + taskId: { + type: "string", + description, + }, + }, + required: ["taskId"], + }; +} diff --git a/examples/exoclaw/scripts/exoclaw-repl b/examples/exoclaw/scripts/exoclaw-repl new file mode 100755 index 0000000..f0749e0 --- /dev/null +++ b/examples/exoclaw/scripts/exoclaw-repl @@ -0,0 +1,1005 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" + +EXO_BIN="${EXO_BIN:-$ROOT_DIR/target/debug/exo}" +SCHEDULER_BIN="${EXOCLAW_SCHEDULER_BIN:-$ROOT_DIR/target/debug/exoclaw-scheduler-runner}" +ENV_FILE="${EXO_ENV_FILE:-$ROOT_DIR/.env}" +MODEL="${EXO_MODEL:-gpt-5.4}" +AGENT="${EXO_AGENT:-exoclaw-agent}" +AGENT_NAME="${EXO_AGENT_NAME:-Exoclaw Agent}" +CONVERSATION="${EXO_CONVERSATION:-dev}" +CONVERSATION_NAME="${EXO_CONVERSATION_NAME:-Dev}" +MODULE="${EXO_MODULE:-examples/exoclaw/harness.ts}" +HARNESS="exoclaw" +LOCAL_PROMPT_FILE="${EXOCLAW_LOCAL_PROMPT_FILE:-$ROOT_DIR/.exo/exoclaw-profile.md}" +SANDBOX_IMAGE="${EXO_SANDBOX_IMAGE:-ubuntu:24.04}" +NETWORKING="${EXO_NETWORKING:-enabled}" +SHELL_PROGRAM="${EXO_SHELL_PROGRAM:-/bin/bash}" +SANDBOX_SCOPE="${EXO_SANDBOX_SCOPE:-}" +SCHEDULER_INTERVAL_SECONDS="${EXO_SCHEDULER_INTERVAL_SECONDS:-10}" +COMMAND="repl" +USE_SANDBOX=true +PULL_SANDBOX=false +START_SCHEDULER="${EXO_START_SCHEDULER:-true}" +START_ADAPTERS="${EXO_START_ADAPTERS:-true}" +ADAPTER_LIMIT="${EXO_ADAPTER_LIMIT:-50}" +CONTROL=false +SETUP_PROFILE=false +declare -a CONTROL_PIDS=() +SETUP_ADAPTER="${EXO_SETUP_ADAPTER:-}" +declare -a SETUP_ADAPTERS=() +if [[ -n "$SETUP_ADAPTER" ]]; then + SETUP_ADAPTERS+=("$SETUP_ADAPTER") +fi +INITIAL_PROMPT_FILE="${EXO_INITIAL_PROMPT_FILE:-}" +export EXOCLAW_LOCAL_PROMPT_FILE="$LOCAL_PROMPT_FILE" + +usage() { + cat <<'EOF' +Usage: + examples/exoclaw/scripts/exoclaw-repl [options] + examples/exoclaw/scripts/exoclaw-repl list + examples/exoclaw/scripts/exoclaw-repl delall all + examples/exoclaw/scripts/exoclaw-repl fresh + examples/exoclaw/scripts/exoclaw-repl stop-all + examples/exoclaw/scripts/exoclaw-repl setup-profile + examples/exoclaw/scripts/exoclaw-repl setup-sandbox + +Default behavior creates or reuses an Exoclaw agent and conversation, starts the +local scheduler and adapter loops, then starts a REPL. It reads .env by default +if present. + +Options: + --model Model binding name (default: gpt-5.4) + --agent Agent slug (default: exoclaw-agent) + --conversation Conversation slug (default: dev) + --convo Alias for --conversation + --agent-name Agent display name (default: Exoclaw Agent) + --conversation-name Conversation display name (default: Dev) + --module Exoclaw TypeScript harness module + --sandbox-image Sandbox image (default: ubuntu:24.04) + --networking enabled or disabled (default: enabled) + --shell-program Shell in the sandbox (default: /bin/bash) + --sandbox-scope agent or conversation (default: Exoclaw agent) + --scheduler-interval Scheduler polling interval (default: 10) + --no-scheduler Do not start the local scheduled task runner + --scheduler Start the local scheduled task runner + --no-adapters Do not start the local adapter runner + --adapters Start the local adapter runner + --adapter-limit Max adapters supervised by the runner (default: 50) + --control Show live scheduler and adapter logs beside the REPL + --setup-profile Prompt once and write the ignored local profile prompt + --local-prompt-file Local profile prompt path (default: .exo/exoclaw-profile.md) + --setup Send adapters//setup-prompt.md. + May be passed more than once. + --setup-all Equivalent to --setup signal --setup whatsapp --setup irc --setup discord + For whatsapp/signal, print pairing QR and pause. + --initial-prompt-file Send this file as the first message before REPL + --pull-sandbox Pull the sandbox image before starting + --no-sandbox Do not require or configure sandbox shell support + --env-file Env file to read if present (default: .env) + --exo-bin exo binary path (default: ./target/debug/exo) + --scheduler-bin Scheduler runner path (default: ./target/debug/exoclaw-scheduler-runner) + --help Show this help + +Environment overrides: + EXO_MODEL, EXO_AGENT, EXO_CONVERSATION, EXO_AGENT_NAME, + EXO_CONVERSATION_NAME, EXO_MODULE, EXO_SANDBOX_IMAGE, + EXO_NETWORKING, EXO_SHELL_PROGRAM, EXO_SANDBOX_SCOPE, EXO_ENV_FILE, + EXOCLAW_LOCAL_PROMPT_FILE, + EXO_BIN, EXO_START_SCHEDULER, EXO_START_ADAPTERS, + EXOCLAW_SCHEDULER_BIN, EXO_SCHEDULER_INTERVAL_SECONDS, EXO_ADAPTER_LIMIT, + EXO_SETUP_ADAPTER, EXO_INITIAL_PROMPT_FILE +EOF +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +terminate_process_tree() { + local pid="$1" + kill_process_tree TERM "$pid" + sleep 1 + kill_process_tree KILL "$pid" +} + +kill_process_tree() { + local signal="$1" + local pid="$2" + local child + [[ "$pid" =~ ^[0-9]+$ ]] || return + if command -v pgrep >/dev/null 2>&1; then + while IFS= read -r child; do + [[ -n "$child" ]] || continue + kill_process_tree "$signal" "$child" + done < <(pgrep -P "$pid" 2>/dev/null || true) + fi + kill "-$signal" "$pid" >/dev/null 2>&1 || true +} + +ensure_exo_bin() { + if [[ -x "$EXO_BIN" ]]; then + return + fi + if [[ "$EXO_BIN" != "$ROOT_DIR/target/debug/exo" ]]; then + die "exo binary is not executable: $EXO_BIN" + fi + build_exo +} + +ensure_scheduler_bin() { + if [[ -x "$SCHEDULER_BIN" ]] && ! scheduler_source_newer_than "$SCHEDULER_BIN"; then + return + fi + if [[ "$SCHEDULER_BIN" != "$ROOT_DIR/target/debug/exoclaw-scheduler-runner" ]]; then + die "scheduler runner is not executable: $SCHEDULER_BIN" + fi + build_exoclaw_scheduler +} + +ensure_signal_cli() { + if ! setup_requested "signal"; then + return + fi + if ! command -v signal-cli >/dev/null 2>&1; then + die "signal-cli is required for --setup signal. Install it first, for example: brew install signal-cli" + fi + + local signal_cli_path + signal_cli_path="$(command -v signal-cli)" + if command -v file >/dev/null 2>&1 && file "$signal_cli_path" | grep -q "Mach-O"; then + echo "Warning: $signal_cli_path appears to be a native signal-cli executable." + echo "If outbound sends fail with NETWORK_FAILURE, use the JVM signal-cli distribution instead." + fi +} + +setup_requested() { + local adapter="$1" + local requested + if [[ "${#SETUP_ADAPTERS[@]}" -eq 0 ]]; then + return 1 + fi + for requested in "${SETUP_ADAPTERS[@]}"; do + if [[ "$requested" == "$adapter" ]]; then + return 0 + fi + done + return 1 +} + +add_setup_adapter() { + local adapter="$1" + [[ -n "$adapter" ]] || die "--setup requires an adapter name" + if [[ ! "$adapter" =~ ^[a-zA-Z0-9_-]+$ ]]; then + die "--setup must be an adapter name, not a path: $adapter" + fi + if setup_requested "$adapter"; then + return + fi + SETUP_ADAPTERS+=("$adapter") +} + +build_exo() { + echo "Building exo binary..." + (cd "$ROOT_DIR" && CARGO_TARGET_DIR=target cargo build -p exo --ignore-rust-version) +} + +build_exoclaw_scheduler() { + echo "Building Exoclaw scheduler runner..." + (cd "$ROOT_DIR" && CARGO_TARGET_DIR=target cargo build \ + --manifest-path examples/exoclaw/scheduler-runner/Cargo.toml \ + --ignore-rust-version) +} + +scheduler_source_newer_than() { + local target="$1" + local path + for path in \ + "$ROOT_DIR/examples/exoclaw/scheduler-runner/Cargo.toml" \ + "$ROOT_DIR/examples/exoclaw/scheduler-runner/src"/*.rs; do + if [[ -e "$path" && "$path" -nt "$target" ]]; then + return 0 + fi + done + return 1 +} + +exo() { + "$EXO_BIN" --env-file-if-exists "$ENV_FILE" "$@" +} + +scheduler_pid_file() { + echo "$ROOT_DIR/.exo/exoclaw-scheduler.pid" +} + +scheduler_log_file() { + echo "$ROOT_DIR/.exo/exoclaw-scheduler.log" +} + +adapters_pid_file() { + echo "$ROOT_DIR/.exo/exoclaw-adapters.pid" +} + +adapters_log_file() { + echo "$ROOT_DIR/.exo/exoclaw-adapters.log" +} + +scheduler_process_running() { + local pid_file pid command_line + pid_file="$(scheduler_pid_file)" + [[ -f "$pid_file" ]] || return 1 + pid="$(<"$pid_file")" + [[ "$pid" =~ ^[0-9]+$ ]] || return 1 + kill -0 "$pid" >/dev/null 2>&1 || return 1 + if [[ "$SCHEDULER_BIN" -nt "$pid_file" ]]; then + echo "Restarting scheduler because $SCHEDULER_BIN is newer..." + terminate_process_tree "$pid" + return 1 + fi + command_line="$(ps -p "$pid" -o command= 2>/dev/null || true)" + [[ "$command_line" == *"exoclaw-scheduler-runner"*"run --watch"* ]] +} + +adapters_process_running() { + local pid_file pid command_line + pid_file="$(adapters_pid_file)" + [[ -f "$pid_file" ]] || return 1 + pid="$(<"$pid_file")" + [[ "$pid" =~ ^[0-9]+$ ]] || return 1 + kill -0 "$pid" >/dev/null 2>&1 || return 1 + if [[ "$EXO_BIN" -nt "$pid_file" ]]; then + echo "Restarting adapter runner because $EXO_BIN is newer..." + terminate_process_tree "$pid" + return 1 + fi + if adapter_source_newer_than "$pid_file"; then + echo "Restarting adapter runner because adapter code changed..." + terminate_process_tree "$pid" + return 1 + fi + command_line="$(ps -p "$pid" -o command= 2>/dev/null || true)" + [[ "$command_line" == *"adapters run"* ]] +} + +adapter_source_newer_than() { + local target="$1" + local path + for path in \ + "$ROOT_DIR/examples/exoclaw/adapters/protocol.ts" \ + "$ROOT_DIR/examples/exoclaw/adapters"/*/worker.ts; do + if [[ -e "$path" && "$path" -nt "$target" ]]; then + return 0 + fi + done + return 1 +} + +ensure_scheduler() { + if [[ "$START_SCHEDULER" != true ]]; then + return + fi + ensure_scheduler_bin + if scheduler_process_running; then + return + fi + + mkdir -p "$ROOT_DIR/.exo" + local pid_file log_file + pid_file="$(scheduler_pid_file)" + log_file="$(scheduler_log_file)" + echo "Starting scheduler loop..." + nohup "$SCHEDULER_BIN" --env-file-if-exists "$ENV_FILE" \ + run --watch --interval-seconds "$SCHEDULER_INTERVAL_SECONDS" \ + >>"$log_file" 2>&1 & + echo "$!" >"$pid_file" + echo "Scheduler log: $log_file" +} + +ensure_adapters() { + if [[ "$START_ADAPTERS" != true ]]; then + return + fi + if adapters_process_running; then + return + fi + + mkdir -p "$ROOT_DIR/.exo" + local pid_file log_file + pid_file="$(adapters_pid_file)" + log_file="$(adapters_log_file)" + echo "Starting adapter runner..." + nohup "$EXO_BIN" --env-file-if-exists "$ENV_FILE" --harness "$HARNESS" \ + adapters run --limit "$ADAPTER_LIMIT" \ + >>"$log_file" 2>&1 & + echo "$!" >"$pid_file" + echo "Adapter log: $log_file" +} + +container_image_exists() { + if command -v docker >/dev/null 2>&1; then + docker image inspect "$SANDBOX_IMAGE" >/dev/null 2>&1 + return + fi + if command -v podman >/dev/null 2>&1; then + podman image exists "$SANDBOX_IMAGE" >/dev/null 2>&1 + return + fi + return 2 +} + +container_pull_image() { + if command -v docker >/dev/null 2>&1; then + docker pull "$SANDBOX_IMAGE" + return + fi + if command -v podman >/dev/null 2>&1; then + podman pull "$SANDBOX_IMAGE" + return + fi + die "docker or podman is required to pre-pull sandbox images" +} + +ensure_sandbox_image() { + local status=0 + container_image_exists || status=$? + case "$status" in + 0) + return + ;; + 1) + if [[ "$PULL_SANDBOX" == true ]]; then + echo "Pulling missing sandbox image $SANDBOX_IMAGE..." + container_pull_image + else + die "sandbox image $SANDBOX_IMAGE is not present; you have to either --pull-sandbox or use --no-sandbox" + fi + ;; + 2) + die "docker/podman not found; you have to either install one or use --no-sandbox" + ;; + esac +} + +setup_sandbox() { + ensure_exo_bin + container_pull_image +} + +agent_exists() { + exo agent show "$AGENT" >/dev/null 2>&1 +} + +conversation_exists() { + exo conversation show "$AGENT" "$CONVERSATION" >/dev/null 2>&1 +} + +ensure_agent() { + if agent_exists; then + return + fi + + echo "Creating agent $AGENT..." + local args=( + --harness "$HARNESS" + agent create "$AGENT_NAME" + --slug "$AGENT" + --module "$MODULE" + --model "$MODEL" + ) + if [[ "$USE_SANDBOX" == true ]]; then + args+=(--sandbox-image "$SANDBOX_IMAGE" --networking "$NETWORKING") + fi + exo "${args[@]}" +} + +ensure_conversation() { + if conversation_exists; then + if [[ -n "$SANDBOX_SCOPE" ]]; then + exo conversation update "$AGENT" "$CONVERSATION" \ + --sandbox-scope "$SANDBOX_SCOPE" >/dev/null + fi + return + fi + + echo "Creating conversation $CONVERSATION..." + local args=(conversation create "$AGENT" "$CONVERSATION_NAME" --slug "$CONVERSATION") + if [[ -n "$SANDBOX_SCOPE" ]]; then + args+=(--sandbox-scope "$SANDBOX_SCOPE") + fi + exo "${args[@]}" + if [[ "$USE_SANDBOX" == true ]]; then + local update_args=(conversation update "$AGENT" "$CONVERSATION" --shell-program "$SHELL_PROGRAM") + if [[ -n "$SANDBOX_SCOPE" ]]; then + update_args+=(--sandbox-scope "$SANDBOX_SCOPE") + fi + exo "${update_args[@]}" >/dev/null + fi +} + +list_agents_and_conversations() { + ensure_exo_bin + echo "Agents and conversations:" + local agents + agents="$(exo agent list | awk 'NR > 1 { print $1 }')" + if [[ -z "$agents" ]]; then + echo " none" + return + fi + + while IFS= read -r agent; do + [[ -z "$agent" ]] && continue + echo + exo agent show "$agent" | awk ' + /^slug:/ { slug=$2 } + /^name:/ { name=substr($0, 7) } + END { + if (slug != "") { + printf "%s", slug + if (name != "") { + printf " - %s", name + } + printf "\n" + } + } + ' + exo conversation list "$agent" | awk 'NR == 1 { next } { printf " %s - %s\n", $1, $3 }' + done <<<"$agents" +} + +stop_scheduler() { + local pid_file pid + pid_file="$(scheduler_pid_file)" + if [[ -f "$pid_file" ]]; then + pid="$(<"$pid_file")" + if [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" >/dev/null 2>&1; then + echo "Stopping Exoclaw scheduler..." + terminate_process_tree "$pid" + fi + fi + pkill -f "exoclaw-scheduler-runner .*run --watch" >/dev/null 2>&1 || true + rm -f "$pid_file" +} + +stop_adapters() { + local pid_file pid + pid_file="$(adapters_pid_file)" + if [[ -f "$pid_file" ]]; then + pid="$(<"$pid_file")" + if [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" >/dev/null 2>&1; then + echo "Stopping Exoclaw adapter runner..." + terminate_process_tree "$pid" + fi + fi + pkill -f "exo .*adapters run" >/dev/null 2>&1 || true + pkill -f "tsx examples/exoclaw/adapters/.*/worker.ts" >/dev/null 2>&1 || true + rm -f "$pid_file" +} + +stop_all_processes() { + stop_scheduler + stop_adapters + echo "Stopped Exoclaw scheduler and adapter runners. State in .exo was preserved." +} + +delete_adapter_state() { + stop_adapters + rm -rf "$ROOT_DIR/.exo/adapters" + rm -f \ + "$ROOT_DIR/.exo/exoclaw-adapters.pid" \ + "$ROOT_DIR/.exo/exoclaw-adapters.log" \ + "$ROOT_DIR/.exo/exoclaw-adapters.lock" +} + +delete_all_agents_and_conversations() { + ensure_exo_bin + stop_scheduler + delete_adapter_state + + local agents + agents="$(exo agent list | awk 'NR > 1 { print $1 }')" + if [[ -z "$agents" ]]; then + echo "No agents to delete." + return + fi + + while IFS= read -r agent; do + [[ -z "$agent" ]] && continue + + local conversations + conversations="$(exo conversation list "$agent" | awk 'NR > 1 { print $1 }')" + while IFS= read -r conversation; do + [[ -z "$conversation" ]] && continue + echo "Deleting conversation $agent/$conversation..." + exo conversation delete "$agent" "$conversation" >/dev/null + done <<<"$conversations" + + echo "Deleting agent $agent..." + exo agent delete "$agent" >/dev/null + done <<<"$agents" + + echo "Deleted all agents and conversations." +} + +setup_local_profile() { + mkdir -p "$(dirname "$LOCAL_PROMPT_FILE")" + + if [[ -f "$LOCAL_PROMPT_FILE" ]]; then + echo "Local Exoclaw profile already exists: $LOCAL_PROMPT_FILE" + read -r -p "Overwrite it? [y/N] " overwrite + case "$overwrite" in + y|Y|yes|YES) ;; + *) + echo "Keeping existing local profile." + return + ;; + esac + fi + + local user_name extra_instructions + echo "Creating local Exoclaw profile: $LOCAL_PROMPT_FILE" + read -r -p "Your name, or blank to skip: " user_name + read -r -p "Additional local instructions, or blank to skip: " extra_instructions + + { + echo "# Local Exoclaw Profile" + echo + echo "This file is local to this machine and should not be committed." + if [[ -n "$user_name" ]]; then + echo + echo "The user's name is $user_name." + fi + if [[ -n "$extra_instructions" ]]; then + echo + echo "$extra_instructions" + fi + } >"$LOCAL_PROMPT_FILE" + + echo "Wrote local profile prompt. The harness will load it from EXOCLAW_LOCAL_PROMPT_FILE." +} + +fresh_start() { + build_exo + delete_all_agents_and_conversations + if [[ "$USE_SANDBOX" == true ]]; then + PULL_SANDBOX=true + fi + run_repl +} + +run_repl() { + ensure_exo_bin + if [[ "$SETUP_PROFILE" == true ]]; then + setup_local_profile + fi + ensure_signal_cli + if [[ "$USE_SANDBOX" == true ]]; then + ensure_sandbox_image + fi + ensure_agent + ensure_conversation + local scheduler_log_start_line + scheduler_log_start_line="$(scheduler_log_line_count)" + ensure_scheduler + local adapter_log_start_line + adapter_log_start_line="$(adapter_log_line_count)" + send_startup_prompt + ensure_adapters + show_signal_qr_if_needed "$adapter_log_start_line" + show_whatsapp_qr_if_needed "$adapter_log_start_line" + if [[ "$CONTROL" == true ]]; then + run_control_repl "$scheduler_log_start_line" "$adapter_log_start_line" + else + exec "$EXO_BIN" --env-file-if-exists "$ENV_FILE" repl \ + --agent "$AGENT" \ + --conversation "$CONVERSATION" + fi +} + +adapter_log_line_count() { + local log_file + log_file="$(adapters_log_file)" + if [[ -f "$log_file" ]]; then + wc -l <"$log_file" | tr -d '[:space:]' + else + echo 0 + fi +} + +scheduler_log_line_count() { + local log_file + log_file="$(scheduler_log_file)" + if [[ -f "$log_file" ]]; then + wc -l <"$log_file" | tr -d '[:space:]' + else + echo 0 + fi +} + +run_control_repl() { + local scheduler_start_line="$1" + local adapter_start_line="$2" + + cleanup_control_logs() { + local pid + for pid in "${CONTROL_PIDS[@]:-}"; do + kill "$pid" >/dev/null 2>&1 || true + done + } + trap cleanup_control_logs EXIT INT TERM + + echo "Control console enabled. Streaming scheduler and adapter logs into this REPL." + if [[ "$START_SCHEDULER" == true ]]; then + start_control_log_tail "scheduler" "$(scheduler_log_file)" "$scheduler_start_line" + fi + if [[ "$START_ADAPTERS" == true ]]; then + start_control_log_tail "adapters" "$(adapters_log_file)" "$adapter_start_line" + fi + + "$EXO_BIN" --env-file-if-exists "$ENV_FILE" repl \ + --agent "$AGENT" \ + --conversation "$CONVERSATION" +} + +start_control_log_tail() { + local label="$1" + local log_file="$2" + local start_line="$3" + local tail_start=$((start_line + 1)) + + mkdir -p "$(dirname "$log_file")" + touch "$log_file" + echo "[$label] tailing $log_file" + tail -n +"$tail_start" -F "$log_file" 2>/dev/null \ + | awk -v label="$label" '{ print "[" label "] " $0; fflush(); }' & + CONTROL_PIDS+=("$!") +} + +startup_prompt_file() { + local path="$1" + if [[ ! -f "$path" ]]; then + die "startup prompt file not found: $path" + fi + if [[ ! -s "$path" ]]; then + die "startup prompt file is empty: $path" + fi + printf '%s\n' "$path" +} + +adapter_setup_prompt_file() { + local adapter="$1" + if [[ ! "$adapter" =~ ^[a-zA-Z0-9_-]+$ ]]; then + die "--setup must be an adapter name, not a path: $adapter" + fi + startup_prompt_file "$ROOT_DIR/examples/exoclaw/adapters/$adapter/setup-prompt.md" +} + +send_startup_prompt() { + if [[ "${#SETUP_ADAPTERS[@]}" -eq 0 && -z "$INITIAL_PROMPT_FILE" ]]; then + return + fi + + local adapter + for adapter in "${SETUP_ADAPTERS[@]}"; do + send_prompt_from_files "$(adapter_setup_prompt_file "$adapter")" + done + + if [[ -n "$INITIAL_PROMPT_FILE" ]]; then + send_prompt_from_files "$(startup_prompt_file "$INITIAL_PROMPT_FILE")" + fi +} + +send_prompt_from_files() { + local files=("$@") + local prompt="" + local file + for file in "${files[@]}"; do + prompt+=$'\n\n' + prompt+="$(<"$file")" + done + prompt="${prompt#$'\n\n'}" + if [[ -z "${prompt//[[:space:]]/}" ]]; then + die "startup prompt is empty" + fi + + echo "Sending startup prompt from: ${files[*]}" + exo conversation send "$AGENT" "$CONVERSATION" "$prompt" +} + +show_whatsapp_qr_if_needed() { + if ! setup_requested "whatsapp"; then + return + fi + + local start_line="${1:-0}" + local log_file + log_file="$(adapters_log_file)" + echo "Waiting for WhatsApp QR code in $log_file..." + + local attempt qr + for attempt in {1..30}; do + if [[ -f "$log_file" ]]; then + qr="$(awk -v start="$start_line" ' + NR <= start { next } + /\[whatsapp-adapter\] Scan this QR with WhatsApp:/ { + capture = 1 + block = $0 "\n" + next + } + capture { + if ($0 ~ /^\[[^]]+-adapter\]/) { + capture = 0 + next + } + block = block $0 "\n" + } + END { + if (block != "") { + printf "%s", block + } + } + ' "$log_file")" + if [[ -n "$qr" ]]; then + echo + printf '%s\n' "$qr" + echo "Scan this QR from WhatsApp: Settings > Linked devices > Link a device." + echo + read -r -p "Press Enter after WhatsApp finishes linking the device..." + return + fi + fi + sleep 1 + done + + echo "No WhatsApp QR code found yet. The adapter may already be paired, or it may still be starting." + echo "Watch the adapter log with: tail -f $log_file" + read -r -p "Press Enter to continue to the REPL..." +} + +show_signal_qr_if_needed() { + if ! setup_requested "signal"; then + return + fi + + local start_line="${1:-0}" + local log_file + log_file="$(adapters_log_file)" + echo "Waiting for Signal linked-device QR code in $log_file..." + + local attempt qr + for attempt in {1..30}; do + if [[ -f "$log_file" ]]; then + qr="$(awk -v start="$start_line" ' + NR <= start { next } + /\[signal-adapter\] Scan this QR with Signal:/ { + capture = 1 + block = $0 "\n" + next + } + capture { + if ($0 ~ /^\[[^]]+-adapter\]/) { + capture = 0 + next + } + block = block $0 "\n" + } + END { + if (block != "") { + printf "%s", block + } + } + ' "$log_file")" + if [[ -n "$qr" ]]; then + echo + printf '%s\n' "$qr" + echo "Scan this QR from Signal: Settings > Linked devices > Link new device." + echo + read -r -p "Press Enter after Signal finishes linking the device..." + return + fi + fi + sleep 1 + done + + echo "No Signal QR code found yet. The adapter may already be linked, signal-cli may be missing, or it may still be starting." + echo "Watch the adapter log with: tail -f $log_file" + read -r -p "Press Enter to continue to the REPL..." +} + +while [[ $# -gt 0 ]]; do + case "$1" in + list) + shift + [[ $# -eq 0 ]] || die "list does not accept additional arguments" + COMMAND="list" + ;; + delall|delete-all) + shift + [[ "${1:-}" == "all" ]] || die "delall requires literal argument: all" + shift + [[ $# -eq 0 ]] || die "delall all does not accept additional arguments" + COMMAND="delall" + ;; + fresh|reset) + shift + COMMAND="fresh" + ;; + stop-all|stop) + shift + [[ $# -eq 0 ]] || die "stop-all does not accept additional arguments" + COMMAND="stop-all" + ;; + setup-profile) + shift + COMMAND="setup-profile" + ;; + setup-sandbox) + shift + COMMAND="setup-sandbox" + ;; + --model) + MODEL="${2:-}" + [[ -n "$MODEL" ]] || die "--model requires a value" + shift 2 + ;; + --agent) + AGENT="${2:-}" + [[ -n "$AGENT" ]] || die "--agent requires a value" + shift 2 + ;; + --conversation|--convo) + CONVERSATION="${2:-}" + [[ -n "$CONVERSATION" ]] || die "$1 requires a value" + shift 2 + ;; + --agent-name) + AGENT_NAME="${2:-}" + [[ -n "$AGENT_NAME" ]] || die "--agent-name requires a value" + shift 2 + ;; + --conversation-name) + CONVERSATION_NAME="${2:-}" + [[ -n "$CONVERSATION_NAME" ]] || die "--conversation-name requires a value" + shift 2 + ;; + --module) + MODULE="${2:-}" + [[ -n "$MODULE" ]] || die "--module requires a value" + shift 2 + ;; + --sandbox-image) + SANDBOX_IMAGE="${2:-}" + [[ -n "$SANDBOX_IMAGE" ]] || die "--sandbox-image requires a value" + shift 2 + ;; + --networking) + NETWORKING="${2:-}" + [[ "$NETWORKING" == "enabled" || "$NETWORKING" == "disabled" ]] || die "--networking must be enabled or disabled" + shift 2 + ;; + --shell-program) + SHELL_PROGRAM="${2:-}" + [[ -n "$SHELL_PROGRAM" ]] || die "--shell-program requires a value" + shift 2 + ;; + --sandbox-scope) + SANDBOX_SCOPE="${2:-}" + [[ "$SANDBOX_SCOPE" == "agent" || "$SANDBOX_SCOPE" == "conversation" ]] || die "--sandbox-scope must be agent or conversation" + shift 2 + ;; + --scheduler-interval) + SCHEDULER_INTERVAL_SECONDS="${2:-}" + [[ "$SCHEDULER_INTERVAL_SECONDS" =~ ^[0-9]+$ && "$SCHEDULER_INTERVAL_SECONDS" -gt 0 ]] || die "--scheduler-interval requires a positive integer" + shift 2 + ;; + --no-scheduler) + START_SCHEDULER=false + shift + ;; + --scheduler) + START_SCHEDULER=true + shift + ;; + --no-adapters) + START_ADAPTERS=false + shift + ;; + --adapters) + START_ADAPTERS=true + shift + ;; + --adapter-limit) + ADAPTER_LIMIT="${2:-}" + [[ "$ADAPTER_LIMIT" =~ ^[0-9]+$ && "$ADAPTER_LIMIT" -gt 0 ]] || die "--adapter-limit requires a positive integer" + shift 2 + ;; + --control) + CONTROL=true + shift + ;; + --setup-profile) + SETUP_PROFILE=true + shift + ;; + --local-prompt-file) + LOCAL_PROMPT_FILE="${2:-}" + [[ -n "$LOCAL_PROMPT_FILE" ]] || die "--local-prompt-file requires a value" + shift 2 + ;; + --setup) + add_setup_adapter "${2:-}" + shift 2 + ;; + --setup-all) + add_setup_adapter "signal" + add_setup_adapter "whatsapp" + add_setup_adapter "irc" + add_setup_adapter "discord" + shift + ;; + --initial-prompt-file) + INITIAL_PROMPT_FILE="${2:-}" + [[ -n "$INITIAL_PROMPT_FILE" ]] || die "--initial-prompt-file requires a value" + shift 2 + ;; + --pull-sandbox) + PULL_SANDBOX=true + shift + ;; + --no-sandbox) + USE_SANDBOX=false + shift + ;; + --env-file) + ENV_FILE="${2:-}" + [[ -n "$ENV_FILE" ]] || die "--env-file requires a value" + shift 2 + ;; + --exo-bin) + EXO_BIN="${2:-}" + [[ -n "$EXO_BIN" ]] || die "--exo-bin requires a value" + shift 2 + ;; + --scheduler-bin) + SCHEDULER_BIN="${2:-}" + [[ -n "$SCHEDULER_BIN" ]] || die "--scheduler-bin requires a value" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + die "unknown argument: $1" + ;; + esac +done + +export EXOCLAW_LOCAL_PROMPT_FILE="$LOCAL_PROMPT_FILE" + +case "$COMMAND" in + repl) + run_repl + ;; + list) + list_agents_and_conversations + ;; + delall) + delete_all_agents_and_conversations + ;; + fresh) + fresh_start + ;; + stop-all) + stop_all_processes + ;; + setup-profile) + setup_local_profile + ;; + setup-sandbox) + setup_sandbox + ;; +esac diff --git a/examples/typescript/basic-harness.ts b/examples/typescript/basic-harness.ts index 3c7e148..350fd3d 100644 --- a/examples/typescript/basic-harness.ts +++ b/examples/typescript/basic-harness.ts @@ -1,164 +1,13 @@ -import { - createToolRegistry, - defineHarness, - materializePromptMessages, - registerBuiltInTools, - registerAgentToolsFromDirectoryIfExists, - registerLibraryTools, - registerLibraryToolModulePath, - turnMetadata, - type EventData, - type Message, - type TurnContext, -} from "@exo/harness"; -import { - responseToLinguaEvents, - responseToolCalls, - ResponsesRuntime, - type NativeResponsesRequest, - type ResponsesRuntimeLike, - type TraceParent, -} from "@exo/model-runtime/responses"; +import { defineHarness } from "@exo/harness"; -import { resolveLlmBinding } from "./shared"; +import { runResponsesHarnessTurn } from "./turn-loop"; const harness = defineHarness({ tools: [], async runTurn(context) { - const modelBinding = await resolveLlmBinding(context); - const runtime = ResponsesRuntime.fromModelBinding( - context.agentConfig, - modelBinding, - ); - await runtime.runTurn(context, (turnParent) => - runBasicTurnLoop(runtime, context, turnParent, modelBinding.model), - ); + await runResponsesHarnessTurn(context); }, }); export default harness; - -async function runBasicTurnLoop( - runtime: ResponsesRuntimeLike, - context: TurnContext, - turnParent: TraceParent, - model: string, -): Promise { - const { conversation } = context.exoharness.current; - const maxToolRoundTrips = context.agentConfig.maxToolRoundTrips; - let latestEventId: string | null = null; - - for (let round = 0; ; round += 1) { - if ( - maxToolRoundTrips !== null && - maxToolRoundTrips !== undefined && - round > maxToolRoundTrips - ) { - return latestEventId; - } - - const tools = await createBasicToolRegistry(context); - const messages = await materializePromptMessages( - conversation, - basicHarnessInstructions(context), - ); - const request: NativeResponsesRequest = { - model, - messages, - tools: tools.definitions(), - maxOutputTokens: context.agentConfig.maxOutputTokens, - metadata: turnMetadata(context), - }; - - const response = context.streaming - ? await runtime.completeStream( - request, - { - onFirstChunk: (ttftMs) => context.stream.firstChunk(ttftMs), - onTextDelta: (text) => context.stream.text(text), - }, - { - parent: turnParent, - roundIndex: round, - }, - ) - : await runtime.complete(request, { - parent: turnParent, - roundIndex: round, - }); - - const events = responseToLinguaEvents(response); - if (events.length > 0) { - latestEventId = await appendTurnEvents(context, events); - } - - const toolCalls = responseToolCalls(response); - if (toolCalls.length === 0) { - return latestEventId; - } - - for (const toolCall of toolCalls) { - const toolResultEvents = await runtime.traceToolCall( - turnParent, - context, - toolCall, - round, - (toolCall) => tools.executePending([toolCall]), - ); - if (toolResultEvents.length > 0) { - latestEventId = await appendTurnEvents(context, toolResultEvents); - } - } - } -} - -async function appendTurnEvents( - context: TurnContext, - data: EventData[], -): Promise { - const { conversation, turn } = context.exoharness.current; - return ( - await conversation.addEvents({ - sessionId: turn.record.sessionId, - turnId: turn.record.id, - data, - }) - ).latestEventId; -} - -function basicHarnessInstructions(context: TurnContext): Message[] { - return context.agentConfig.enableAgentToolCreation - ? [...context.agentConfig.instructions, agentToolCreationInstruction()] - : context.agentConfig.instructions; -} - -function agentToolCreationInstruction(): Message { - return { - role: "developer", - content: - "Agent-created tools are supported. When the user asks you to create a reusable tool, call install_agent_tool with a complete TypeScript moduleSource. Do not claim the tool was created unless install_agent_tool returns ok: true. The moduleSource must use type-only imports from @exo/harness/tool and default-export a Tool using { definition, initializationParameters, initialize(...) } satisfies Tool; definition.parameters must be a strict JSON schema object with additionalProperties: false; handlers must implement execute(args, execution), not invoke or call. Do not use zod, inputSchema, external npm packages, or runtime imports from @exo/harness/tool. After install_agent_tool succeeds, the new tool is available in the next model round of the same turn, so use it directly rather than falling back to shell.", - }; -} - -async function createBasicToolRegistry(context: TurnContext) { - const tools = createToolRegistry(context); - registerBuiltInTools(tools, context, builtInToolNames(context)); - await registerLibraryTools(tools, context, harness.tools ?? []); - for (const modulePath of context.agentConfig.typescript?.toolModulePaths ?? - []) { - await registerLibraryToolModulePath(tools, context, modulePath); - } - if (context.agentConfig.enableAgentToolCreation) { - await registerAgentToolsFromDirectoryIfExists(tools, context); - } - return tools; -} - -function builtInToolNames( - context: TurnContext, -): Array<"shell" | "install_agent_tool"> { - return context.agentConfig.enableAgentToolCreation - ? ["shell", "install_agent_tool"] - : ["shell"]; -} diff --git a/examples/typescript/tools/irc.manifest.json b/examples/typescript/tools/irc.manifest.json new file mode 100644 index 0000000..8a17be5 --- /dev/null +++ b/examples/typescript/tools/irc.manifest.json @@ -0,0 +1,17 @@ +{ + "tools": [ + { + "modulePath": "./irc.ts", + "initialization": { + "server": "irc.libera.chat", + "port": 6697, + "nick": "exo-demo", + "username": "exo-demo", + "realname": "Exo Demo", + "tls": true, + "dryRun": false, + "passwordSecretId": null + } + } + ] +} diff --git a/examples/typescript/tools/uppercase.manifest.json b/examples/typescript/tools/uppercase.manifest.json new file mode 100644 index 0000000..f673ec3 --- /dev/null +++ b/examples/typescript/tools/uppercase.manifest.json @@ -0,0 +1,10 @@ +{ + "tools": [ + { + "modulePath": "./uppercase.ts", + "initialization": { + "prefix": "UPPER: " + } + } + ] +} diff --git a/examples/typescript/turn-loop.ts b/examples/typescript/turn-loop.ts new file mode 100644 index 0000000..79a175a --- /dev/null +++ b/examples/typescript/turn-loop.ts @@ -0,0 +1,181 @@ +import { + createToolRegistry, + materializePromptMessages, + registerAgentToolsFromDirectoryIfExists, + registerBuiltInTools, + registerLibraryToolModulePath, + turnMetadata, + type BuiltInToolName, + type EventData, + type HarnessToolRegistry, + type Message, + type TurnContext, +} from "@exo/harness"; +import { + responseToLinguaEvents, + responseToolCalls, + runtimeFromModelBinding, + type NativeResponsesRequest, + type ResponsesRuntimeLike, + type TraceParent, +} from "@exo/model-runtime/responses"; + +import { resolveLlmBinding } from "./shared"; + +export interface ResponsesTurnLoopOptions { + instructions?: (context: TurnContext) => Message[]; + registerTools?: ( + tools: HarnessToolRegistry, + context: TurnContext, + ) => Promise | void; +} + +export async function runResponsesHarnessTurn( + context: TurnContext, + options: ResponsesTurnLoopOptions = {}, +): Promise { + const modelBinding = await resolveLlmBinding(context); + const runtime = runtimeFromModelBinding(context.agentConfig, modelBinding); + await runtime.runTurn(context, (turnParent) => + runResponsesTurnLoop( + runtime, + context, + turnParent, + modelBinding.model, + options, + ), + ); +} + +export async function createDefaultToolRegistry( + context: TurnContext, + builtInToolNames: BuiltInToolName[] = defaultBuiltInToolNames(context), +): Promise { + const tools = createToolRegistry(context); + registerBuiltInTools(tools, context, builtInToolNames); + for (const modulePath of context.agentConfig.typescript?.toolModulePaths ?? + []) { + await registerLibraryToolModulePath(tools, context, modulePath); + } + if (context.agentConfig.enableAgentToolCreation) { + await registerAgentToolsFromDirectoryIfExists(tools, context); + } + return tools; +} + +export function defaultBuiltInToolNames( + context: TurnContext, +): BuiltInToolName[] { + const names: BuiltInToolName[] = ["shell"]; + if (context.agentConfig.enableAgentToolCreation) { + names.push("install_agent_tool"); + } + return names; +} + +export function basicHarnessInstructions(context: TurnContext): Message[] { + return context.agentConfig.enableAgentToolCreation + ? [...context.agentConfig.instructions, agentToolCreationInstruction()] + : context.agentConfig.instructions; +} + +export function agentToolCreationInstruction(): Message { + return { + role: "developer", + content: + "Agent-created tools are supported. When the user asks you to create a reusable tool, call install_agent_tool with a complete TypeScript moduleSource. Do not claim the tool was created unless install_agent_tool returns ok: true. The moduleSource must use type-only imports from @exo/harness/tool and default-export a Tool using { definition, initializationParameters, initialize(...) } satisfies Tool; definition.parameters must be a strict JSON schema object with additionalProperties: false; handlers must implement execute(args, execution), not invoke or call. Do not use zod, inputSchema, external npm packages, or runtime imports from @exo/harness/tool. After install_agent_tool succeeds, the new tool is available in the next model round of the same turn, so use it directly rather than falling back to shell.", + }; +} + +async function runResponsesTurnLoop( + runtime: ResponsesRuntimeLike, + context: TurnContext, + turnParent: TraceParent, + model: string, + options: ResponsesTurnLoopOptions, +): Promise { + const { conversation } = context.exoharness.current; + const maxToolRoundTrips = context.agentConfig.maxToolRoundTrips; + let latestEventId: string | null = null; + + for (let round = 0; ; round += 1) { + if ( + maxToolRoundTrips !== null && + maxToolRoundTrips !== undefined && + round > maxToolRoundTrips + ) { + return latestEventId; + } + + const tools = options.registerTools + ? createToolRegistry(context) + : await createDefaultToolRegistry(context); + if (options.registerTools) { + await options.registerTools(tools, context); + } + const messages = await materializePromptMessages( + conversation, + options.instructions?.(context) ?? basicHarnessInstructions(context), + ); + const request: NativeResponsesRequest = { + model, + messages, + tools: tools.definitions(), + maxOutputTokens: context.agentConfig.maxOutputTokens, + metadata: turnMetadata(context), + }; + + const response = context.streaming + ? await runtime.completeStream( + request, + { + onFirstChunk: (ttftMs) => context.stream.firstChunk(ttftMs), + onTextDelta: (text) => context.stream.text(text), + }, + { + parent: turnParent, + roundIndex: round, + }, + ) + : await runtime.complete(request, { + parent: turnParent, + roundIndex: round, + }); + + const events = responseToLinguaEvents(response); + if (events.length > 0) { + latestEventId = await appendTurnEvents(context, events); + } + + const toolCalls = responseToolCalls(response); + const hasSyntheticToolResult = events.some( + (event) => event.type === "tool_result", + ); + if (toolCalls.length === 0) { + if (hasSyntheticToolResult) { + continue; + } + return latestEventId; + } + + for (const toolCall of toolCalls) { + const toolResultEvents = await runtime.traceToolCall( + turnParent, + context, + toolCall, + round, + (toolCall) => tools.executePending([toolCall]), + ); + if (toolResultEvents.length > 0) { + latestEventId = await appendTurnEvents(context, toolResultEvents); + } + } + } +} + +async function appendTurnEvents( + context: TurnContext, + data: EventData[], +): Promise { + return (await context.exoharness.current.turn.addEvents(data)).latestEventId; +} diff --git a/exoharness-arch.md b/exoharness-arch.md new file mode 100644 index 0000000..93bfded --- /dev/null +++ b/exoharness-arch.md @@ -0,0 +1,779 @@ +# Exoharness Architecture + +This document describes the full Exoharness API from the perspective of the +subcomponents that use it. It covers the core Rust object model, the JSONL +protocol used to expose that model across process boundaries, the TypeScript +harness API, and the way higher-level systems such as the executor, scheduler, +adapters, CLI, and Exoclaw compose those pieces. + +## Mental Model + +Exoharness is the durable runtime substrate for agents. It owns agents, +conversations, sessions, turns, events, artifacts, sandboxes, bindings, and +secrets. The executor layer sits on top and decides how a turn is run: a basic +LLM loop, an RLM loop, or a TypeScript harness. Product-specific systems such as +Exoclaw then add tools, prompts, schedulers, and adapters on top of that +executor layer. + +The main boundary looks like this: + +```text +CLI / Exoclaw scripts / scheduler / adapter runner + -> executor Harness facade + -> exoharness object model + -> executor turn runtime + -> TypeScript runner, basic harness, or RLM harness + -> tools, model calls, sandbox process APIs +``` + +The core API is intentionally small. Most operations are scoped to one of four +handle types: + +- `ExoHarness`: root handle for global agents, bindings, and secrets. +- `AgentHandle`: per-agent conversations, artifacts, bindings, and secrets. +- `ConversationHandle`: per-conversation event log, artifacts, sandboxes, + bindings, and secrets. +- `TurnHandle`: active-turn-only writes that must preserve turn consistency. + +The distinction between `ConversationHandle` and `TurnHandle` is important. +Conversation-level writes append to the conversation head immediately. Turn-level +writes are tied to the active turn and use the turn's expected head. If another +writer advances the conversation while a turn is open, the turn becomes stale +and further turn writes fail. Code that runs inside a turn should prefer +`TurnHandle` for messages and artifacts. + +## Key Crates And Files + +- `crates/exoharness/src/types.rs`: core Rust traits and data types. +- `crates/exoharness/src/basic.rs`: filesystem-backed implementation of the + core traits. +- `crates/exoharness/src/protocol.rs`: JSON-serializable request and response + protocol for the core API. +- `crates/exoharness/src/server.rs`: protocol server that dispatches JSON + requests into an `ExoHarness`. +- `crates/executor/src/harness_types.rs`: higher-level executor-facing harness + facade. +- `crates/executor/src/harness_executor.rs`: generic turn execution lifecycle. +- `crates/executor/src/typescript.rs`: Rust host for TypeScript harness + processes. +- `typescript/harness/index.ts`: public TypeScript API exposed to harness + authors. +- `typescript/harness/runner.ts`: TypeScript guest process that converts the + public TypeScript API into host protocol messages. +- `examples/exoclaw/harness.ts`: Exoclaw's TypeScript harness module. + +## Core Rust API + +The core Rust API lives in the `exoharness` crate. Callers generally work with +trait objects (`Arc`, `Arc`, +`Arc`, and `Arc`) rather than concrete +storage implementations. + +### Root: `ExoHarness` + +The root handle manages global state: + +- `list_agents() -> Vec` +- `get_agent(id) -> Option` +- `new_agent(NewAgentRequest) -> AgentHandle` +- `delete_agent(id) -> bool` +- `list_bindings() -> Vec` +- `put_binding(Binding) -> BindingId` +- `get_binding(id) -> Option` +- `list_secrets() -> Vec` +- `put_secret(PutSecretRequest) -> SecretId` +- `get_secret(id) -> Option` + +Global bindings and secrets act as defaults. Agent and conversation scopes can +override them by name. + +### Agent: `AgentHandle` + +An agent handle exposes conversations and agent-scoped resources: + +- `record() -> AgentRecord` +- `list_conversations() -> Vec` +- `get_conversation(id) -> Option` +- `new_conversation(NewConversationRequest) -> ConversationHandle` +- `delete_conversation(id) -> bool` +- `list_bindings()`, `put_binding()`, `get_binding()` +- `list_secrets()`, `put_secret()`, `get_secret()` +- `list_artifacts()`, `write_artifact()`, `read_artifact()` + +Agent-scoped artifacts are useful for configuration and data that should live +with the agent instead of one conversation. + +### Conversation: `ConversationHandle` + +A conversation handle owns the event log and the conversation-scoped execution +environment: + +- `record() -> ConversationRecord` +- `start_session() -> SessionId` +- `end_session(session_id)` +- `begin_turn(BeginTurnRequest) -> TurnHandle` +- `get_events(EventQuery) -> GetEventsResult` +- `watch_events(after_exclusive) -> EventStream` +- `get_event(event_id) -> Option` +- `add_events(AddEventsRequest) -> AddEventsResult` +- `fork(ForkConversationRequest) -> ConversationHandle` +- `list_artifacts()`, `write_artifact()`, `read_artifact()` +- `create_sandbox(CreateSandboxRequest) -> SandboxId` +- `snapshot_sandbox(sandbox_id) -> SnapshotId` +- `start_sandbox(StartSandboxRequest)` +- `stop_sandbox(sandbox_id)` +- `run_in_sandbox(RunInSandboxRequest) -> SandboxProcess` +- `list_bindings()`, `put_binding()`, `get_binding()` +- `list_secrets()`, `put_secret()`, `get_secret()` + +Conversation-level `add_events()` accepts an optional `expected_head`. If set, +the append fails unless the conversation head still matches. This is the +optimistic concurrency primitive used throughout the system. + +### Turn: `TurnHandle` + +A turn handle is the active-turn write surface: + +- `record() -> TurnRecord` +- `add_events(Vec) -> AddEventsResult` +- `write_artifact(WriteArtifactRequest) -> ArtifactVersion` +- `finish() -> EventId` + +The turn tracks the latest event it wrote. Each turn write checks that the +conversation head is still the turn's latest event before appending more data. +`finish()` writes `turn_ended` and marks the handle complete. Calling +`finish()` again is idempotent and returns the existing latest event id. + +## Records And IDs + +Most persisted records use UUIDv7 IDs. These IDs carry sortable timestamps, +which makes event ordering straightforward and helps produce useful stale-turn +error messages. + +Important records: + +- `AgentRecord`: `id`, `slug`, `name`. +- `ConversationRecord`: `id`, `slug`, `name`, `latest_event_id`. +- `TurnRecord`: `id`, `session_id`. +- `Event`: `id`, `conversation_id`, optional `session_id`, optional `turn_id`, + `created_at`, and tagged `data`. +- `ArtifactVersion`: `artifact_id`, `path`, `version`, `created_at`, + `size_bytes`. + +The public TypeScript API translates Rust snake_case fields into camelCase, +while the wire protocols preserve serde's snake_case tags and fields. + +## Event Log API + +The event log is the durable source of truth for conversation history and +runtime side effects. Events are append-only. + +Core event variants: + +- `conversation_forked` +- `session_started` +- `session_ended` +- `turn_started` +- `turn_ended` +- `messages` +- `tool_requested` +- `tool_result` +- `artifact_written` +- `sandbox_created` +- `sandbox_started` +- `sandbox_stopped` +- `sandbox_snapshotted` +- `custom` + +`messages` events contain Lingua messages. `tool_requested` and `tool_result` +events preserve tool execution state across turns. `artifact_written` events +reference artifact metadata; artifact bytes live in artifact storage. `custom` +events are available for subcomponents that need typed event payloads without a +new core event variant. + +`EventQuery` supports: + +- `cursor`: event id boundary. +- `direction`: `asc` or `desc`. +- `limit`: max number of events. +- `session_id`: filter to one session. +- `turn_id`: filter to one turn. +- `types`: filter by event type string. + +`watch_events()` returns existing events after the requested bound and then live +events through an in-memory subscriber list. The basic implementation is useful +inside one process; it is not a cross-process pub/sub service. + +## Sessions And Turns + +A session groups related turns. `begin_turn()` accepts an optional `session_id`. +If none is provided, Exoharness creates a new session and appends +`session_started` before `turn_started`. If input messages are included, they +are appended as a `messages` event in the same turn. + +The executor-level `send()` API starts a turn, executes the configured harness, +and finishes the turn. External systems such as adapters and scheduler wakeups +usually call `send()` with a fresh session and then close the session after the +wakeup completes. + +Stale turns happen when: + +1. A turn begins and records its expected conversation head. +2. Some other writer appends to the same conversation outside that turn. +3. The active turn tries to append a message, artifact, or `turn_ended`. + +The basic implementation reports this as: + +```text +turn is stale and cannot be resumed: conversation head advanced outside this turn +``` + +For code running during a turn, use `context.exoharness.current.turn` instead of +`context.exoharness.current.conversation` when appending messages or artifacts. +For code outside a turn, serialize wakeups per conversation if multiple external +sources can fire concurrently. + +## Artifacts + +Artifacts are versioned blobs addressed by logical path. Writing to the same +path creates a new version of the same artifact id. Writing to a new path +creates a new artifact id. + +Artifact APIs exist at agent and conversation scope. Conversation-level +`write_artifact()` appends an `artifact_written` event to the conversation. +Turn-level `write_artifact()` also appends an `artifact_written` event, but it +does so through the active turn and preserves the turn's concurrency invariant. + +TypeScript convenience methods: + +- `writeArtifact({ path, contents })` +- `writeArtifactText({ path, text })` +- `writeArtifactJson({ path, value })` +- `readArtifact({ artifactId, version })` +- `readArtifactText(...)` +- `readArtifactJson(...)` + +The TypeScript tool registry writes full tool results to artifacts by default. +Large tool results are compacted in the conversation history while full +stdout/stderr/result JSON remains available under `tool-results/...`. + +## Sandboxes + +Sandboxes are conversation-scoped in the core API. A sandbox record stores: + +- image +- default workdir +- filesystem mounts +- networking flag +- idle timeout +- running state +- latest snapshot id + +The core methods create, start, stop, snapshot, and run processes in a sandbox. +`run_in_sandbox()` returns a `SandboxProcess` with async stdout, stderr, stdin, +and wait handles. + +The executor adds policy on top: + +- `ensure_conversation_sandbox()` creates or reuses the conversation sandbox. +- `ensure_agent_sandbox()` lets Exoclaw share one persistent sandbox across an + agent by using agent-level sandbox metadata. +- The scheduler supports `agent`, `conversation`, and `task_fresh` modes. + +From TypeScript, `context.startSandboxProcess({ command, env })` starts a +sandbox process and returns a `SandboxProcess` with `ReadableStream` +stdout/stderr, `writeStdin()`, `closeStdin()`, `close()`, and `wait()`. + +## Bindings And Secrets + +Bindings and secrets exist at root, agent, and conversation scope. + +Bindings: + +- `env`: name, environment variable name, secret id. +- `mcp`: name, server URL, optional secret id. +- `llm`: name, model, optional base URL, optional secret id. + +Secrets: + +- `key`: raw API key or token. +- `oauth`: access token and optional refresh token. + +The basic implementation encrypts secrets at rest through a configured secret +backend. Current backends include Apple Keychain, file-backed master key, and a +static key option for tests. + +When listing bindings or secrets from agent or conversation scope, metadata is +merged by name. More local scopes override broader scopes. + +## Core JSONL Protocol + +The `exoharness::protocol` module exposes the core Rust API as a JSONL request +protocol. It is used by the TypeScript runner when TypeScript code calls +`context.exoharness`. + +Every client message is: + +```json +{ "kind": "request", "id": 1, "request": { "type": "list_agents" } } +``` + +Every server message is: + +```json +{ + "kind": "response", + "id": 1, + "ok": true, + "response": { "type": "agents", "agents": [] }, + "error": null +} +``` + +The protocol request variants mirror the core handles: + +- Root: `list_agents`, `get_agent`, `new_agent`, `delete_agent`, + `list_bindings`, `put_binding`, `get_binding`, `list_secrets`, `put_secret`, + `get_secret`. +- Agent: `list_conversations`, `get_conversation`, `new_conversation`, + `delete_conversation`, `agent_list_artifacts`, `agent_read_artifact`, + `agent_write_artifact`, agent bindings, agent secrets. +- Conversation: `conversation_start_session`, `conversation_end_session`, + `conversation_get_events`, `conversation_get_event`, + `conversation_add_events`, `conversation_fork`, + `conversation_list_artifacts`, `conversation_read_artifact`, + `conversation_write_artifact`, conversation bindings, conversation secrets. +- Turn: `turn_add_events`, `turn_write_artifact`, `turn_finish`. + +The protocol server stores active turns in an in-memory handle table. TypeScript +code receives a numeric `handle_id` for the active turn. Subsequent turn +requests refer to that handle id. `turn_finish` removes the handle. + +## Executor Harness Facade + +The executor crate wraps the core API with a product-facing `Harness` facade: + +- `Harness`: list/create/delete agents, resolve agents by id or slug, flush + tracing, and expose the underlying `ExoHarness`. +- `HarnessAgent`: read/write agent config, list/create/delete conversations, + and expose the underlying `AgentHandle`. +- `HarnessConversation`: read/write conversation config, read/write model + overrides, materialize messages, close sessions, send turns, stream turns, and + expose the underlying `ConversationHandle`. + +This facade adds configuration artifacts and model execution semantics to the +raw storage API. Callers that want to run the agent should use +`HarnessConversation::send()` or `send_stream()`. Callers that only need storage, +artifacts, events, or sandbox execution can use `exoharness_handle()`. + +## Turn Execution Lifecycle + +`ExecutorHarnessRuntime` implements the shared send lifecycle: + +1. Load agent config. +2. Load conversation config. +3. Load any conversation-level model override. +4. Let the selected executor prepare the conversation. +5. Prepare the request. +6. Call `conversation.begin_turn()`. +7. Execute the turn with streaming enabled or disabled. +8. Append `turn_ended` through the turn handle. + +The actual turn runner is a `HarnessExecutor` implementation. Current executor +implementations include the basic harness, RLM harness, and TypeScript harness. + +Streaming turns return an `ExecutionStreamHandle` and emit: + +- first-chunk timing +- text deltas +- tool calls +- tool results +- final completion or error + +## TypeScript Harness Host Protocol + +The TypeScript executor runs one persistent Node process per harness module +path. The host command is: + +```text +node --import tsx typescript/harness/runner.ts +``` + +Rust writes host-to-guest JSONL messages to stdin. TypeScript writes +guest-to-host JSONL messages to stdout. Stderr is captured for error reporting. +Runner processes are cached by module path and removed from the cache if a turn +fails. + +Host-to-guest messages: + +- `init`: starts a turn and includes agent, conversation, turn, configs, request, + streaming flag, and optional tracing parent. +- `shutdown`: closes the runner loop. +- `runtime_response`: response to a runtime request. +- `exo_response`: response to a core Exoharness API request. +- `runtime_event`: async sandbox process output, exit, or error. + +Guest-to-host messages: + +- `runtime_request`: asks Rust to execute a tool or manage a sandbox process. +- `exo_request`: asks Rust to perform a core Exoharness protocol request. +- `stream_event`: forwards stream output to the executor stream. +- `done`: marks the TypeScript turn complete. +- `error`: fails the turn with message and stack. + +The TypeScript runner imports the harness module once, then loops over `init` +messages. A module must export either a default harness or a named `harness` +export with `runTurn(context)`. + +## TypeScript Public API + +The public TypeScript API is exported from `@exo/harness`. + +A harness module is: + +```ts +import { defineHarness } from "@exo/harness"; + +export default defineHarness({ + async runTurn(context) { + // Run one turn. + }, +}); +``` + +`TurnContext` exposes: + +- `agentConfig`: model, instructions, TypeScript module config, tool settings, + sandbox image, networking, token and tool round-trip limits, and tracing + config. +- `conversationConfig`: networking, shell program, sandbox scope, and mounts. +- `request`: input messages and optional session id. +- `streaming`: whether stream events should be emitted. +- `braintrustParent`: optional tracing parent id. +- `exoharness`: current and global Exoharness API. +- `executeTool(request)`: ask the Rust tool runtime to execute a configured + tool. +- `startSandboxProcess(request)`: start an interactive sandbox process. +- `executePendingTools(toolCalls)`: convenience for sequential tool execution. +- `stream`: first chunk, text, tool-call, and tool-result stream emitters. + +`context.exoharness.current` provides the current: + +- `agent` +- `conversation` +- `turn` + +The `Agent`, `Conversation`, and `Turn` TypeScript objects intentionally expose +the subset of the Rust handles needed by harness code, with camelCase fields and +convenience methods for text and JSON artifacts. Bindings and secrets are +readable from TypeScript, but binding/secret mutation remains on the Rust core +protocol and executor configuration paths rather than the public TypeScript +harness API. + +Helpers in `@exo/harness` include: + +- `messagesEvent()`, `toolRequestedEvent()`, `toolResultEvent()` +- `appendMessages()`, `appendCustomEvent()`, `replyText()` +- `getMessages()`, `materializeConversationMessages()` +- `materializePromptMessages()` +- `messagesToHistoryMessages()`, `messagesToTranscript()` +- `projectAnthropicMessageToolEvents()` +- `assertRoundBudget()` +- `turnMetadata()` + +## Tool API + +Tools are represented as `ToolInstance` values: + +- `definition`: name, description, JSON-schema parameters, optional output + schema. +- `source`: `built_in`, `library`, or `agent`. +- `handler.execute(args, execution)`: implementation. + +Reusable tools can be authored with `defineTool()`. Tool modules can be loaded +from library paths or from agent-created tool directories. + +Tool definitions are validated: + +- `name` must be non-empty, at most 64 characters, and only contain letters, + numbers, underscores, and dashes. +- `parameters` must be an object JSON schema. +- `parameters.additionalProperties` must be `false`. +- Handlers must implement `execute`, not `invoke`. + +Tool execution flow: + +1. The harness registers tools in a `HarnessToolRegistry`. +2. The model requests one or more tool calls. +3. The harness appends `tool_requested` events through the active turn. +4. `executePendingTools()` or the registry executes tools. +5. Tool results are normalized, streamed if needed, compacted into artifacts, + and appended as `tool_result` events. + +Large tool results are not kept fully inline in the message history. The compact +result contains preview text, truncation metadata, and artifact references. + +## Model Loop Integration + +The TypeScript harness API does not directly prescribe a model loop. Exoclaw and +the basic TypeScript harness use shared turn-loop helpers to: + +1. Materialize prompt messages from configured instructions and conversation + events. +2. Register built-in, library, adapter, scheduler, and agent-created tools. +3. Call the model. +4. Project model text and tool calls into Exoharness events. +5. Execute tools and continue until the model finishes or the configured tool + round-trip budget is reached. + +The Rust model runtime uses Lingua's universal message and tool abstractions and +routes requests through `braintrust_llm_router`. + +## Exoclaw Integration + +`examples/exoclaw/harness.ts` is a TypeScript harness module. It composes the +generic TypeScript harness API with Exoclaw-specific instructions and tools. + +On each turn it: + +1. Calls `runResponsesHarnessTurn()`. +2. Adds generic basic harness instructions. +3. Adds `examples/exoclaw/prompts/me.md`. +4. Adds an optional local profile prompt from `.exo/exoclaw-profile.md` or + `EXOCLAW_LOCAL_PROMPT_FILE`. +5. Registers built-in tools. +6. Registers scheduler tools. +7. Registers adapter tools. +8. Registers configured library tool modules. +9. Registers agent-created tools if enabled. + +Exoclaw-specific tools are still executed by the Rust tool runtime. The +TypeScript side mostly provides model-visible schemas and forwards scoped +arguments such as current agent id and conversation id. + +## Scheduler Integration + +The scheduler is not part of the core Exoharness crate. It is an executor-level +service that uses Exoharness primitives. + +The scheduler tool API is model-facing TypeScript: + +- `schedule_sandbox_task` +- `list_scheduled_tasks` +- `cancel_scheduled_task` +- `delete_scheduled_task` + +Those tool handlers call `context.executeTool()` with current agent and +conversation ids. The Rust `ExoclawToolRuntime` persists scheduled task records +outside the core conversation event log. + +When a task is due, the scheduler runtime: + +1. Resolves the agent through the `Harness` facade. +2. Resolves the conversation through the `HarnessAgent`. +3. Loads agent and conversation config. +4. Resolves a sandbox according to task mode. +5. Runs setup and command processes through `ConversationHandle::run_in_sandbox`. +6. Writes a run artifact through `ConversationHandle::write_artifact`. +7. Wakes the conversation with a prompt describing the result. + +The wakeup uses `HarnessConversation::send()`, so the task result becomes a +normal agent turn. A per-conversation wakeup lock serializes these external +wakeups to avoid stale-turn conflicts. + +## Adapter Integration + +Adapters are also executor-level services, not core Exoharness concepts. They +bridge external networks such as IRC, WhatsApp, and Signal into conversations. + +Adapter runtime flow: + +1. The adapter runner loads enabled adapter records from `AdapterStore`. +2. It resolves the configured agent and conversation through the `Harness` + facade. +3. It starts the adapter worker process. +4. Worker inbound messages are recorded in the adapter store. +5. Matching inbound messages wake the conversation with a user prompt. +6. The agent can intentionally respond by calling `send_adapter_message`. +7. Outbound messages are queued in `AdapterStore`. +8. The worker loop drains outbound messages and sends them externally. + +Inbound and outbound adapter state is intentionally stored in `AdapterStore` +rather than as conversation artifacts during an active turn. This prevents an +external adapter write from advancing the conversation head while a turn is +using its `TurnHandle`. + +Like the scheduler, adapter wakeups use the per-conversation wakeup lock before +calling `HarnessConversation::send()`. + +## CLI Integration + +The CLI mostly talks to the executor `Harness` facade. It uses the facade to +create agents, configure agent harness kind, create conversations, send turns, +stream turns, inspect events/messages, and manage bindings/secrets. It should +not need to know whether the underlying storage is `BasicExoHarness` or another +future implementation. + +Exoclaw-specific startup logic lives under `examples/exoclaw`. The root CLI +still provides generic agent and conversation operations; Exoclaw scripts and +runner binaries compose those generic operations with Exoclaw scheduler and +adapter services. + +## Storage Implementation + +`BasicExoHarness` is the current local implementation. It stores records and +artifacts under a filesystem-backed object store rooted at the configured +harness root. + +Important properties: + +- A single async write lock serializes writes inside one process. +- Event files are sorted by UUIDv7 event id. +- Conversation records cache `latest_event_id`. +- Subscribers are in-memory and per-process. +- Secrets are encrypted before writing. +- Artifact metadata and bytes are written separately. +- Sandboxes are tracked in metadata plus an in-memory running-sandbox table. + +Because some coordination is in-memory, multiple independent processes writing +to the same harness root should be treated carefully. The API has optimistic +head checks, but the basic backend is primarily designed for a coordinated local +runtime. + +## Concurrency Rules + +Use these rules when adding new subcomponents: + +- If code is running inside a turn, append messages and artifacts through + `TurnHandle` or `context.exoharness.current.turn`. +- If code is outside a turn and wants the agent to react, call the executor + `send()` API rather than appending assistant messages directly. +- If multiple external sources can wake the same conversation, serialize those + wakeups. +- If code appends conversation events directly, set `expected_head` when it + depends on a stable head. +- Do not write conversation artifacts from adapter or scheduler plumbing while a + wakeup turn is active. +- Keep durable subsystem queues in their subsystem stores when the data is not + part of the agent-visible conversation history. + +## Extending Exoharness + +Add to the core `exoharness` API only for durable, generic agent runtime +concepts. Good candidates are things every harness implementation should support +or expose consistently, such as conversations, events, artifacts, and sandboxes. + +Prefer executor- or product-level extensions when the feature is specific to a +runtime or application: + +- Scheduler tasks live in executor/Exoclaw code because they are a particular + use of sandboxes and wakeups. +- Adapters live in executor/Exoclaw code because IRC, WhatsApp, and Signal are + external integration services. +- Tool definitions live in TypeScript and executor tool runtimes because they + are model-facing behavior, not storage primitives. + +When adding a new core operation, update all three surfaces: + +1. Rust traits and implementation. +2. `exoharness::protocol` request/response variants and server dispatch. +3. TypeScript raw protocol types plus public wrapper methods. + +When adding a TypeScript-only runtime capability, update both sides of the +TypeScript harness protocol: + +1. `crates/executor/src/typescript.rs` host message/request/response handling. +2. `typescript/harness/runner.ts` raw types and `TurnContext` implementation. +3. `typescript/harness/index.ts` public type definitions. + +## Common Call Flows + +### User Sends A CLI Message + +```text +CLI + -> HarnessConversation::send() + -> ExecutorHarnessRuntime::send() + -> ConversationHandle::begin_turn() + -> selected executor runs turn + -> TurnHandle::add_events(...) + -> TurnHandle::finish() +``` + +### TypeScript Harness Calls A Core API + +```text +harness module + -> context.exoharness.current.conversation.getEvents(...) + -> runner.ts sends { kind: "exo_request" } + -> TypeScriptRunnerProcess receives request + -> ExoHarnessServer::handle_request() + -> ConversationHandle::get_events() + -> host sends { kind: "exo_response" } + -> runner resolves TypeScript promise +``` + +### TypeScript Harness Executes A Tool + +```text +harness module + -> context.executeTool({ functionName, arguments }) + -> runner.ts sends { kind: "runtime_request", type: "execute_tool" } + -> TypeScriptRunnerProcess calls ToolRuntime::execute() + -> host sends runtime_response tool_result + -> harness appends tool_result event through TurnHandle +``` + +### TypeScript Harness Starts A Sandbox Process + +```text +harness module + -> context.startSandboxProcess({ command, env }) + -> runner.ts sends runtime_request start_sandbox_process + -> Rust ensures conversation sandbox + -> ConversationHandle::run_in_sandbox() + -> host returns process_id + -> host streams runtime_event sandbox_process_output/exit/error + -> TypeScript SandboxProcess exposes streams and wait() +``` + +### Scheduled Task Wakes A Conversation + +```text +scheduler runner + -> run_due_tasks() + -> resolve HarnessAgent and HarnessConversation + -> run command in sandbox through ConversationHandle + -> write result artifact through ConversationHandle + -> send_conversation_wakeup() + -> per-conversation lock + -> HarnessConversation::send() + -> HarnessConversation::close_session() +``` + +### Adapter Message Wakes A Conversation + +```text +adapter worker + -> WorkerEvent::Message + -> AdapterStore records inbound event + -> send_conversation_wakeup() + -> per-conversation lock + -> HarnessConversation::send() + -> HarnessConversation::close_session() +``` + +## Current Caveats + +- The basic backend's event subscribers and running sandbox table are + in-memory, so they are not a distributed coordination layer. +- Turn handles are process-local protocol handles in the TypeScript bridge. + They cannot be persisted and resumed by another runner process. +- Direct conversation writes during a turn can make that turn stale. +- Tool results are compacted for model history, so callers should follow + artifact references for full output. +- TypeScript runner protocol messages are line-delimited JSON. Anything written + to stdout by the runner process must be protocol JSON; diagnostics should go + to stderr. +- Scheduler and adapters are users of Exoharness, not core Exoharness features. + Their durable stores are separate from the core event log. diff --git a/exospooky.sh b/exospooky.sh new file mode 100755 index 0000000..7df14f3 --- /dev/null +++ b/exospooky.sh @@ -0,0 +1,6 @@ +PATH="/opt/homebrew/opt/openjdk/bin:$PATH" \ + examples/exoclaw/scripts/exoclaw-repl fresh \ + --agent exospooky \ + --agent-name exoSpooky \ + --conversation dev \ + --setup-all diff --git a/package.json b/package.json index 3b6c760..4c2d53c 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,11 @@ "@braintrust/lingua": "^0.1.0", "@braintrust/lingua-wasm": "^0.1.0", "@cursor/sdk": "^1.0.12", + "@whiskeysockets/baileys": "7.0.0-rc13", "braintrust": "3.7.0", - "openai": "6.33.0" + "discord.js": "^14.26.4", + "openai": "6.33.0", + "qrcode-terminal": "^0.12.0" }, "devDependencies": { "@types/node": "25.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1ec487..0757863 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,19 +13,28 @@ importers: version: 0.2.126(zod@4.3.6) '@braintrust/lingua': specifier: ^0.1.0 - version: 0.1.0(@anthropic-ai/sdk@0.81.0(zod@4.3.6))(openai@6.33.0(zod@4.3.6)) + version: 0.1.0(@anthropic-ai/sdk@0.81.0(zod@4.3.6))(openai@6.33.0(ws@8.21.0)(zod@4.3.6)) '@braintrust/lingua-wasm': specifier: ^0.1.0 version: 0.1.0 '@cursor/sdk': specifier: ^1.0.12 version: 1.0.12 + '@whiskeysockets/baileys': + specifier: 7.0.0-rc13 + version: 7.0.0-rc13(sharp@0.34.5) braintrust: specifier: 3.7.0 version: 3.7.0(zod@4.3.6) + discord.js: + specifier: ^14.26.4 + version: 14.26.4 openai: specifier: 6.33.0 - version: 6.33.0(zod@4.3.6) + version: 6.33.0(ws@8.21.0)(zod@4.3.6) + qrcode-terminal: + specifier: ^0.12.0 + version: 0.12.0 devDependencies: '@types/node': specifier: 25.5.0 @@ -117,6 +126,9 @@ packages: resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@braintrust/lingua-wasm@0.1.0': resolution: {integrity: sha512-yHCE77Ypm4qX4EJGsm9/AGtukh1jgQmAN7rY/ycmSoVbr/61WJAsDyn1e+JX3jwn2GAA4i4M7ZZg9OIau10ffg==} engines: {pnpm: '>=10.27.0'} @@ -131,6 +143,16 @@ packages: '@bufbuild/protobuf@1.10.0': resolution: {integrity: sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==} + '@cacheable/memory@2.0.9': + resolution: {integrity: sha512-HdMx6DoGywB30vacDbBsITbIX4pgFqj1zsrV58jZBUw3klzkNoXhj7qOqAgledhxG7YZI5rBSJg7Zp8/VG0DuA==} + + '@cacheable/node-cache@1.7.6': + resolution: {integrity: sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==} + engines: {node: '>=18'} + + '@cacheable/utils@2.4.1': + resolution: {integrity: sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -176,6 +198,34 @@ packages: resolution: {integrity: sha512-jGx0wFY1N9uIdIKr303CfM6m/dLXmRCUnU/0yNP/oiOpkBXqgqaThGbgYbcOeVrYonMZc/DZJ9EydXOEPJLcbg==} engines: {node: '>=18'} + '@discordjs/builders@1.14.1': + resolution: {integrity: sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==} + engines: {node: '>=16.11.0'} + + '@discordjs/collection@1.5.3': + resolution: {integrity: sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==} + engines: {node: '>=16.11.0'} + + '@discordjs/collection@2.1.1': + resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==} + engines: {node: '>=18'} + + '@discordjs/formatters@0.6.2': + resolution: {integrity: sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==} + engines: {node: '>=16.11.0'} + + '@discordjs/rest@2.6.1': + resolution: {integrity: sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==} + engines: {node: '>=18'} + + '@discordjs/util@1.2.0': + resolution: {integrity: sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==} + engines: {node: '>=18'} + + '@discordjs/ws@1.2.3': + resolution: {integrity: sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==} + engines: {node: '>=16.11.0'} + '@emnapi/core@1.9.1': resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} @@ -348,12 +398,155 @@ packages: '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} + '@hapi/boom@9.1.4': + resolution: {integrity: sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==} + + '@hapi/hoek@9.3.0': + resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -370,6 +563,15 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} @@ -634,6 +836,39 @@ packages: cpu: [x64] os: [win32] + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.2': + resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + '@rolldown/binding-android-arm64@1.0.0-rc.12': resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -726,6 +961,22 @@ packages: '@rolldown/pluginutils@1.0.0-rc.12': resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} + '@sapphire/async-queue@1.5.5': + resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + + '@sapphire/shapeshift@4.0.0': + resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==} + engines: {node: '>=v16'} + + '@sapphire/snowflake@3.5.3': + resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + + '@sapphire/snowflake@3.5.5': + resolution: {integrity: sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -735,6 +986,13 @@ packages: '@statsig/js-client@3.31.0': resolution: {integrity: sha512-LFa5E0LjT6sTfZv3sNGoyRLSZ1078+agdgOA+Vm1ecjG+KbSOfBLTW7hMwimrJ29slRwbYDzbtKaPJo/R37N2g==} + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tootallnate/once@1.1.2': resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} engines: {node: '>= 6'} @@ -754,6 +1012,9 @@ packages: '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260328.1': resolution: {integrity: sha512-BmJGDWC0bSQ2w5O/E+Mw9eTv9RklJ3vjshu7UdD92bUMxc4V4dkBhYj5r0qxbl4f+VFNX7fXvcDDI+9o+Kb6yw==} cpu: [arm64] @@ -831,6 +1092,26 @@ packages: '@vitest/utils@4.1.2': resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} + '@vladfrangu/async_event_emitter@2.4.7': + resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + + '@whiskeysockets/baileys@7.0.0-rc13': + resolution: {integrity: sha512-8JPc8gaaCRykkjW2jxLGQ7/RZGrc7awO7WU+QJocf58eSUI9jAdcuYLynzhAbyU4UWvJJsHImZ+5E/JaZj5ypA==} + engines: {node: '>=20.0.0'} + peerDependencies: + audio-decode: ^2.1.3 + jimp: ^1.6.1 + link-preview-js: ^3.0.0 + sharp: '*' + peerDependenciesMeta: + audio-decode: + optional: true + jimp: + optional: true + link-preview-js: + optional: true + abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} @@ -907,6 +1188,13 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + async-mutex@0.5.0: + resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -954,6 +1242,9 @@ packages: resolution: {integrity: sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==} engines: {node: '>= 10'} + cacheable@2.3.5: + resolution: {integrity: sha512-EQfaKe09tl615iNvq/TBRWTFf1AKJNXYQSsMx0Z3EI0nA+pVsVPS8wJhnRlkbdacKPh1d0qVIhwTc2zsQNFEEg==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1052,6 +1343,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + curve25519-js@0.0.4: + resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==} + dc-browser@1.0.4: resolution: {integrity: sha512-7oEtnzNlcE+hr4OvO3GR6Gndgw8BhW+wKOEwMqSleyY7N29jbAxzyW5BaJl7qBCw+6OIxfMWtY0T+6dxq8RWLw==} @@ -1095,6 +1389,13 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + discord-api-types@0.38.48: + resolution: {integrity: sha512-WFUE/2o0lBlLeCQonQ+Pu2RqHAqbytBJ2RlXR91gzk05InSS6k9ShzzLYoymrA4c2oRgRKGE7/VqQJNNdGWSxQ==} + + discord.js@14.26.4: + resolution: {integrity: sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==} + engines: {node: '>=18'} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -1159,6 +1460,9 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + eventsource-parser@1.1.2: resolution: {integrity: sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==} engines: {node: '>=14.18'} @@ -1208,6 +1512,10 @@ packages: picomatch: optional: true + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -1294,6 +1602,10 @@ packages: has-unicode@2.0.1: resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} + engines: {node: '>=20'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -1302,6 +1614,12 @@ packages: resolution: {integrity: sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==} engines: {node: '>=16.9.0'} + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + hookified@2.2.0: + resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -1397,6 +1715,12 @@ packages: json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + + libsignal@6.0.0: + resolution: {integrity: sha512-d/5V3YFtDljbFMufz4ncyUYGYhJl+vzAe+c2EFFBQ6bz1h8Q3IOMEGXYMzlibU60I+e8GagMMpji18iez3P1hA==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1467,10 +1791,26 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru-cache@11.5.0: + resolution: {integrity: sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==} + engines: {node: 20 || >=22} + lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + magic-bytes.js@1.13.0: + resolution: {integrity: sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1585,6 +1925,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + music-metadata@11.12.3: + resolution: {integrity: sha512-n6hSTZkuD59qWgHh6IP5dtDlDZQXoxk/bcA85Jywg8Z1iFrlNgl2+GTFgjZyn52W5UgQpV42V4XqrQZZAMbZTQ==} + engines: {node: '>=18'} + mustache@4.2.0: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true @@ -1638,6 +1982,10 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -1676,6 +2024,14 @@ packages: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} engines: {node: '>=10'} + p-queue@9.3.0: + resolution: {integrity: sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==} + engines: {node: '>=20'} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -1704,6 +2060,16 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@9.14.0: + resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} + hasBin: true + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -1722,6 +2088,9 @@ packages: deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + promise-inflight@1.0.1: resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} peerDependencies: @@ -1734,6 +2103,10 @@ packages: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} + protobufjs@7.6.1: + resolution: {integrity: sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -1741,10 +2114,21 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + qified@0.10.1: + resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} + engines: {node: '>=20'} + + qrcode-terminal@0.12.0: + resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==} + hasBin: true + qs@6.14.2: resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -1765,6 +2149,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -1793,6 +2181,10 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -1823,6 +2215,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1874,6 +2270,9 @@ packages: resolution: {integrity: sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1882,6 +2281,10 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sqlite3@5.1.7: resolution: {integrity: sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==} @@ -1922,6 +2325,10 @@ packages: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1942,6 +2349,9 @@ packages: resolution: {integrity: sha512-2qSN6TnomHgVLtk+htSWbaYs4Rd2MH/RU7VpHTy6MBstyNyWbM4yKd1DCYpE3fDg8dmGWojXCngNi/MHCzGuAA==} engines: {node: '>=12'} + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1965,9 +2375,16 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-mixer@6.0.4: + resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1996,6 +2413,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -2003,6 +2424,10 @@ packages: resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} + undici@6.24.1: + resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} + engines: {node: '>=18.17'} + unique-filename@1.1.1: resolution: {integrity: sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==} @@ -2114,6 +2539,9 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatsapp-rust-bridge@0.5.4: + resolution: {integrity: sha512-yYO1qSs0Fe7tGtnxOFHomocUD6IZtoAgmA4oDFyGIRZ67D3QZk3w7swA6XXFXNQngiyrg2k7tul6IrM3eUFh7A==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2131,6 +2559,9 @@ packages: resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} engines: {node: '>=18'} + win-guid@0.2.1: + resolution: {integrity: sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==} + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -2138,6 +2569,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} @@ -2210,16 +2653,36 @@ snapshots: '@babel/runtime@7.29.2': {} + '@borewit/text-codec@0.2.2': {} + '@braintrust/lingua-wasm@0.1.0': {} - '@braintrust/lingua@0.1.0(@anthropic-ai/sdk@0.81.0(zod@4.3.6))(openai@6.33.0(zod@4.3.6))': + '@braintrust/lingua@0.1.0(@anthropic-ai/sdk@0.81.0(zod@4.3.6))(openai@6.33.0(ws@8.21.0)(zod@4.3.6))': dependencies: '@anthropic-ai/sdk': 0.81.0(zod@4.3.6) '@braintrust/lingua-wasm': 0.1.0 - openai: 6.33.0(zod@4.3.6) + openai: 6.33.0(ws@8.21.0)(zod@4.3.6) '@bufbuild/protobuf@1.10.0': {} + '@cacheable/memory@2.0.9': + dependencies: + '@cacheable/utils': 2.4.1 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/node-cache@1.7.6': + dependencies: + cacheable: 2.3.5 + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.4.1': + dependencies: + hashery: 1.5.1 + keyv: 5.6.0 + '@colors/colors@1.5.0': optional: true @@ -2266,6 +2729,55 @@ snapshots: - bluebird - supports-color + '@discordjs/builders@1.14.1': + dependencies: + '@discordjs/formatters': 0.6.2 + '@discordjs/util': 1.2.0 + '@sapphire/shapeshift': 4.0.0 + discord-api-types: 0.38.48 + fast-deep-equal: 3.1.3 + ts-mixer: 6.0.4 + tslib: 2.8.1 + + '@discordjs/collection@1.5.3': {} + + '@discordjs/collection@2.1.1': {} + + '@discordjs/formatters@0.6.2': + dependencies: + discord-api-types: 0.38.48 + + '@discordjs/rest@2.6.1': + dependencies: + '@discordjs/collection': 2.1.1 + '@discordjs/util': 1.2.0 + '@sapphire/async-queue': 1.5.5 + '@sapphire/snowflake': 3.5.5 + '@vladfrangu/async_event_emitter': 2.4.7 + discord-api-types: 0.38.48 + magic-bytes.js: 1.13.0 + tslib: 2.8.1 + undici: 6.24.1 + + '@discordjs/util@1.2.0': + dependencies: + discord-api-types: 0.38.48 + + '@discordjs/ws@1.2.3': + dependencies: + '@discordjs/collection': 2.1.1 + '@discordjs/rest': 2.6.1 + '@discordjs/util': 1.2.0 + '@sapphire/async-queue': 1.5.5 + '@types/ws': 8.18.1 + '@vladfrangu/async_event_emitter': 2.4.7 + discord-api-types: 0.38.48 + tslib: 2.8.1 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@emnapi/core@1.9.1': dependencies: '@emnapi/wasi-threads': 1.2.0 @@ -2365,10 +2877,112 @@ snapshots: '@gar/promisify@1.1.3': optional: true + '@hapi/boom@9.1.4': + dependencies: + '@hapi/hoek': 9.3.0 + + '@hapi/hoek@9.3.0': {} + '@hono/node-server@1.19.14(hono@4.12.16)': dependencies: hono: 4.12.16 + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.9.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2388,6 +3002,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.1 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + '@kwsites/file-exists@1.1.1': dependencies: debug: 4.4.3 @@ -2555,6 +3177,30 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.58.0': optional: true + '@pinojs/redact@0.4.0': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + '@rolldown/binding-android-arm64@1.0.0-rc.12': optional: true @@ -2607,6 +3253,17 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.12': {} + '@sapphire/async-queue@1.5.5': {} + + '@sapphire/shapeshift@4.0.0': + dependencies: + fast-deep-equal: 3.1.3 + lodash: 4.18.1 + + '@sapphire/snowflake@3.5.3': {} + + '@sapphire/snowflake@3.5.5': {} + '@standard-schema/spec@1.1.0': {} '@statsig/client-core@3.31.0': {} @@ -2615,6 +3272,15 @@ snapshots: dependencies: '@statsig/client-core': 3.31.0 + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@tootallnate/once@1.1.2': optional: true @@ -2636,6 +3302,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.5.0 + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260328.1': optional: true @@ -2710,6 +3380,27 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@vladfrangu/async_event_emitter@2.4.7': {} + + '@whiskeysockets/baileys@7.0.0-rc13(sharp@0.34.5)': + dependencies: + '@cacheable/node-cache': 1.7.6 + '@hapi/boom': 9.1.4 + async-mutex: 0.5.0 + libsignal: 6.0.0 + lru-cache: 11.5.0 + music-metadata: 11.12.3 + p-queue: 9.3.0 + pino: 9.14.0 + protobufjs: 7.6.1 + sharp: 0.34.5 + whatsapp-rust-bridge: 0.5.4 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + abbrev@1.1.1: optional: true @@ -2783,6 +3474,12 @@ snapshots: assertion-error@2.0.1: {} + async-mutex@0.5.0: + dependencies: + tslib: 2.8.1 + + atomic-sleep@1.0.0: {} + balanced-match@1.0.2: {} base64-js@1.5.1: {} @@ -2915,6 +3612,14 @@ snapshots: - bluebird optional: true + cacheable@2.3.5: + dependencies: + '@cacheable/memory': 2.0.9 + '@cacheable/utils': 2.4.1 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.10.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -2997,6 +3702,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + curve25519-js@0.0.4: {} + dc-browser@1.0.4: {} debug@2.6.9: @@ -3022,6 +3729,27 @@ snapshots: detect-libc@2.1.2: {} + discord-api-types@0.38.48: {} + + discord.js@14.26.4: + dependencies: + '@discordjs/builders': 1.14.1 + '@discordjs/collection': 1.5.3 + '@discordjs/formatters': 0.6.2 + '@discordjs/rest': 2.6.1 + '@discordjs/util': 1.2.0 + '@discordjs/ws': 1.2.3 + '@sapphire/snowflake': 3.5.3 + discord-api-types: 0.38.48 + fast-deep-equal: 3.1.3 + lodash.snakecase: 4.1.1 + magic-bytes.js: 1.13.0 + tslib: 2.8.1 + undici: 6.24.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dotenv@16.6.1: {} dunder-proto@1.0.1: @@ -3100,6 +3828,8 @@ snapshots: etag@1.8.1: {} + eventemitter3@5.0.4: {} + eventsource-parser@1.1.2: {} eventsource-parser@3.0.8: {} @@ -3194,6 +3924,15 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + file-uri-to-path@1.0.0: {} finalhandler@1.3.2: @@ -3298,12 +4037,20 @@ snapshots: has-unicode@2.0.1: optional: true + hashery@1.5.1: + dependencies: + hookified: 1.15.1 + hasown@2.0.2: dependencies: function-bind: 1.1.2 hono@4.12.16: {} + hookified@1.15.1: {} + + hookified@2.2.0: {} + http-cache-semantics@4.2.0: optional: true @@ -3400,6 +4147,15 @@ snapshots: json-schema@0.4.0: {} + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + libsignal@6.0.0: + dependencies: + curve25519-js: 0.0.4 + protobufjs: 7.6.1 + lightningcss-android-arm64@1.32.0: optional: true @@ -3449,11 +4205,21 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lodash.snakecase@4.1.1: {} + + lodash@4.18.1: {} + + long@5.3.2: {} + + lru-cache@11.5.0: {} + lru-cache@6.0.0: dependencies: yallist: 4.0.0 optional: true + magic-bytes.js@1.13.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3570,6 +4336,21 @@ snapshots: ms@2.1.3: {} + music-metadata@11.12.3: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + content-type: 1.0.5 + debug: 4.4.3 + file-type: 21.3.4 + media-typer: 1.1.0 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + win-guid: 0.2.1 + transitivePeerDependencies: + - supports-color + mustache@4.2.0: {} nanoid@3.3.11: {} @@ -3622,6 +4403,8 @@ snapshots: obug@2.1.1: {} + on-exit-leak-free@2.1.2: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -3630,8 +4413,9 @@ snapshots: dependencies: wrappy: 1.0.2 - openai@6.33.0(zod@4.3.6): + openai@6.33.0(ws@8.21.0)(zod@4.3.6): optionalDependencies: + ws: 8.21.0 zod: 4.3.6 oxfmt@0.42.0: @@ -3685,6 +4469,13 @@ snapshots: aggregate-error: 3.1.0 optional: true + p-queue@9.3.0: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.1 + + p-timeout@7.0.1: {} + parseurl@1.3.3: {} path-is-absolute@1.0.1: @@ -3702,6 +4493,26 @@ snapshots: picomatch@4.0.4: {} + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@9.14.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 3.1.0 + pkce-challenge@5.0.1: {} pluralize@8.0.0: {} @@ -3727,6 +4538,8 @@ snapshots: tar-fs: 2.1.4 tunnel-agent: 0.6.0 + process-warning@5.0.0: {} + promise-inflight@1.0.1: optional: true @@ -3736,6 +4549,21 @@ snapshots: retry: 0.12.0 optional: true + protobufjs@7.6.1: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 25.5.0 + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -3746,10 +4574,18 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + qified@0.10.1: + dependencies: + hookified: 2.2.0 + + qrcode-terminal@0.12.0: {} + qs@6.14.2: dependencies: side-channel: 1.1.0 + quick-format-unescaped@4.0.4: {} + range-parser@1.2.1: {} raw-body@2.5.3: @@ -3779,6 +4615,8 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + real-require@0.2.0: {} + require-from-string@2.0.2: {} resolve-pkg-maps@1.0.0: {} @@ -3827,6 +4665,8 @@ snapshots: safe-buffer@5.2.1: {} + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} semver@7.7.4: {} @@ -3888,6 +4728,37 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3961,10 +4832,16 @@ snapshots: smart-buffer: 4.2.0 optional: true + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map@0.7.6: {} + split2@4.2.0: {} + sqlite3@5.1.7: dependencies: bindings: 1.5.0 @@ -4014,6 +4891,10 @@ snapshots: strip-json-comments@2.0.1: {} + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -4044,6 +4925,10 @@ snapshots: termi-link@1.1.0: {} + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + tinybench@2.9.0: {} tinyexec@1.0.4: {} @@ -4059,10 +4944,17 @@ snapshots: toidentifier@1.0.1: {} + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + ts-algebra@2.0.0: {} - tslib@2.8.1: - optional: true + ts-mixer@6.0.4: {} + + tslib@2.8.1: {} tsx@4.21.0: dependencies: @@ -4090,12 +4982,16 @@ snapshots: typescript@5.9.3: {} + uint8array-extras@1.5.0: {} + undici-types@7.18.2: {} undici@5.29.0: dependencies: '@fastify/busboy': 2.1.1 + undici@6.24.1: {} + unique-filename@1.1.1: dependencies: unique-slug: 2.0.2 @@ -4168,6 +5064,8 @@ snapshots: webpack-virtual-modules@0.6.2: {} + whatsapp-rust-bridge@0.5.4: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -4186,6 +5084,8 @@ snapshots: dependencies: string-width: 7.2.0 + win-guid@0.2.1: {} + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 @@ -4194,6 +5094,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.0: {} + yallist@4.0.0: {} zod-to-json-schema@3.25.2(zod@4.3.6): diff --git a/typescript/harness/adapter-tools.ts b/typescript/harness/adapter-tools.ts new file mode 100644 index 0000000..f704006 --- /dev/null +++ b/typescript/harness/adapter-tools.ts @@ -0,0 +1,453 @@ +import type { JsonObject, ToolDefinition } from "./index"; +import type { HarnessToolRegistry, ToolInstance } from "./tools"; + +export type AdapterToolName = + | "create_adapter" + | "list_adapters" + | "disable_adapter" + | "delete_adapter" + | "send_adapter_message"; + +export function registerAdapterTools( + registry: HarnessToolRegistry, + names: AdapterToolName[] = [ + "create_adapter", + "list_adapters", + "disable_adapter", + "delete_adapter", + "send_adapter_message", + ], +): void { + const requested = new Set(names); + for (const tool of createAdapterToolInstances()) { + if (requested.has(tool.definition.name as AdapterToolName)) { + registry.register(tool); + } + } +} + +function createAdapterToolInstances(): ToolInstance[] { + return [ + createAdapterTool(), + listAdaptersTool(), + disableAdapterTool(), + deleteAdapterTool(), + sendAdapterMessageTool(), + ]; +} + +function createAdapterTool(): ToolInstance { + return { + source: "built_in", + definition: { + name: "create_adapter", + description: + "Create and enable a long-running Exoclaw adapter for this conversation. Use source 'built_in' only with config type 'irc'. Use source 'library' with config type 'whatsapp', 'signal', or 'discord' for shipped library adapters.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + name: { + type: "string", + description: + "Stable adapter name using letters, numbers, dashes, or underscores.", + }, + source: { + type: "string", + enum: ["built_in", "library"], + description: "Adapter source.", + }, + config: adapterConfigSchema(), + }, + required: ["name", "source", "config"], + }, + }, + handler: { + execute(toolArgs, execution) { + return execution.context.executeTool({ + functionName: "create_adapter", + arguments: transformCreateAdapterArguments(toolArgs), + }); + }, + }, + }; +} + +function listAdaptersTool(): ToolInstance { + return hostTool({ + name: "list_adapters", + description: + "List adapters for this conversation. Disabled adapters are hidden unless includeDisabled is true.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + includeDisabled: { + type: ["boolean", "null"], + description: "Whether to include disabled adapters.", + }, + }, + required: ["includeDisabled"], + }, + }); +} + +function disableAdapterTool(): ToolInstance { + return hostTool({ + name: "disable_adapter", + description: + "Disable an adapter for this conversation while preserving its event history.", + parameters: adapterIdParameters( + "Adapter id returned by create_adapter or list_adapters.", + ), + }); +} + +function deleteAdapterTool(): ToolInstance { + return hostTool({ + name: "delete_adapter", + description: + "Permanently delete an adapter for this conversation, including its stored event history.", + parameters: adapterIdParameters( + "Adapter id returned by create_adapter or list_adapters.", + ), + }); +} + +function sendAdapterMessageTool(): ToolInstance { + return hostTool({ + name: "send_adapter_message", + description: + "Send an explicit outbound message through an adapter. For IRC this sends PRIVMSG to the adapter channel. For WhatsApp, provide target as the chat id from the inbound message. For Signal, provide a username such as u:example.01, a phone number, UUID, or group id. For Discord, provide a channel id unless defaultChannelId was configured. Only call this when the user or conversation context makes the external side effect appropriate.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + adapterId: { + type: "string", + description: + "Adapter id returned by create_adapter or list_adapters.", + }, + text: { + type: "string", + description: "Message text to send through the adapter.", + }, + target: { + type: ["string", "null"], + description: + "External destination for adapters that need one. Use the target from the inbound wakeup when available; WhatsApp requires a chat id, Signal requires a username/phone/UUID/group id, and Discord requires a channel id unless defaultChannelId was configured.", + }, + attachments: { + anyOf: [ + { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + kind: { + type: "string", + enum: ["image", "video", "audio", "document"], + description: + "Attachment kind. Rich attachments are currently supported by the WhatsApp, Signal, and Discord adapters.", + }, + url: { + type: ["string", "null"], + description: + "HTTPS URL for the media file. Specify exactly one of url, data, or sandboxPath.", + }, + data: { + type: ["string", "null"], + description: + "Base64 media bytes, or a data: URL. Prefer sandboxPath for files created inside the sandbox.", + }, + sandboxPath: { + type: ["string", "null"], + description: + "Path to a media file inside the active Exoclaw sandbox. Use this for files generated by shell commands in the sandbox.", + }, + mimeType: { + type: ["string", "null"], + description: + "Optional MIME type. Required for WhatsApp documents and recommended for audio.", + }, + fileName: { + type: ["string", "null"], + description: + "Optional file name. Required for WhatsApp documents.", + }, + }, + required: [ + "kind", + "url", + "data", + "sandboxPath", + "mimeType", + "fileName", + ], + }, + }, + { type: "null" }, + ], + description: + "Optional rich media attachments. Use null for text-only messages.", + }, + }, + required: ["adapterId", "text", "target", "attachments"], + }, + }); +} + +function hostTool(args: { + name: AdapterToolName; + functionName?: string; + description: string; + parameters: ToolDefinition["parameters"]; +}): ToolInstance { + return { + source: "built_in", + definition: { + name: args.name, + description: args.description, + parameters: args.parameters, + }, + handler: { + execute(toolArgs, execution) { + return execution.context.executeTool({ + functionName: args.functionName ?? args.name, + arguments: toolArgs, + }); + }, + }, + }; +} + +function adapterConfigSchema(): ToolDefinition["parameters"] { + return { + anyOf: [ + { + type: "object", + additionalProperties: false, + properties: { + type: { type: "string", enum: ["irc"] }, + server: { type: "string" }, + port: { type: "number" }, + tls: { type: "boolean" }, + nick: { type: "string" }, + username: { type: "string" }, + realname: { type: "string" }, + channel: { type: "string" }, + passwordSecretId: { type: ["string", "null"] }, + trigger: { + type: "string", + enum: ["mention", "all_messages"], + description: + "Wake policy. Use mention unless the user explicitly wants every channel message.", + }, + }, + required: [ + "type", + "server", + "port", + "tls", + "nick", + "username", + "realname", + "channel", + "passwordSecretId", + "trigger", + ], + }, + { + type: "object", + additionalProperties: false, + properties: { + type: { type: "string", enum: ["signal"] }, + account: { + type: ["string", "null"], + description: + "Optional local signal-cli account identifier. Use null to have the worker start signal-cli link and discover the linked account.", + }, + deviceName: { + type: ["string", "null"], + description: + "Optional linked-device name when account is null. Use null for Exoclaw.", + }, + configDir: { + type: ["string", "null"], + description: + "Optional signal-cli config directory. Use null for the adapter state directory.", + }, + trigger: { + type: "string", + enum: ["all_messages", "contacts_only"], + description: + "Wake policy. Use all_messages for the MVP unless allowedContacts is set.", + }, + allowedContacts: { + anyOf: [ + { type: "array", items: { type: "string" } }, + { type: "null" }, + ], + description: + "Optional list of Signal usernames, phone numbers, UUIDs, or group ids to wake on. Use null to allow all inbound messages.", + }, + }, + required: [ + "type", + "account", + "deviceName", + "configDir", + "trigger", + "allowedContacts", + ], + }, + { + type: "object", + additionalProperties: false, + properties: { + type: { type: "string", enum: ["whatsapp"] }, + authDir: { + type: ["string", "null"], + description: + "Optional directory for Baileys auth state. Use null for the default under .exo.", + }, + linkMethod: { + type: ["string", "null"], + enum: ["qr", "pairing-code", null], + description: + "Link method for first-time pairing. Use qr by default; use pairing-code when QR linking is unreliable.", + }, + phoneNumber: { + type: ["string", "null"], + description: + "Phone number for pairing-code linkMethod. Use null with qr.", + }, + trigger: { + type: "string", + enum: ["all_messages", "contacts_only"], + description: + "Wake policy. Use all_messages for the MVP unless the user wants to ignore groups.", + }, + allowedChats: { + anyOf: [ + { type: "array", items: { type: "string" } }, + { type: "null" }, + ], + description: + "Optional list of WhatsApp chat ids to wake on. Use null to allow all chats permitted by trigger.", + }, + }, + required: [ + "type", + "authDir", + "linkMethod", + "phoneNumber", + "trigger", + "allowedChats", + ], + }, + { + type: "object", + additionalProperties: false, + properties: { + type: { type: "string", enum: ["discord"] }, + botTokenSecretId: { + type: "string", + description: + "Secret name or id containing the Discord bot token. The worker receives it as EXO_DISCORD_BOT_TOKEN.", + }, + defaultChannelId: { + type: ["string", "null"], + description: + "Optional Discord channel id used when send_adapter_message target is null.", + }, + trigger: { + type: "string", + enum: ["all_messages", "mentions_only"], + description: + "Wake policy. Use all_messages for test adapters unless the user explicitly wants mention-only wakeups.", + }, + allowedChannels: { + anyOf: [ + { type: "array", items: { type: "string" } }, + { type: "null" }, + ], + description: + "Optional list of Discord channel ids to wake on. Use null to allow every channel the bot can read.", + }, + allowBots: { + type: "boolean", + description: + "When true, messages from other bot accounts wake this adapter. Defaults to false (ignore all bots). The adapter never wakes on its own messages.", + }, + }, + required: [ + "type", + "botTokenSecretId", + "defaultChannelId", + "trigger", + "allowedChannels", + "allowBots", + ], + }, + ], + } as ToolDefinition["parameters"]; +} + +function transformCreateAdapterArguments(args: JsonObject): JsonObject { + const config = objectField(args, "config"); + validateAdapterSource( + stringField(args, "source"), + stringField(config, "type"), + ); + return args; +} + +function validateAdapterSource(source: string, type: string): void { + if (type === "irc" && source !== "built_in") { + throw new Error("IRC adapters must use source 'built_in'"); + } + if ( + (type === "whatsapp" || type === "signal" || type === "discord") && + source !== "library" + ) { + throw new Error(`${type} adapters must use source 'library'`); + } +} + +function adapterIdParameters( + description: string, +): ToolDefinition["parameters"] { + return { + type: "object", + additionalProperties: false, + properties: { + adapterId: { + type: "string", + description, + }, + }, + required: ["adapterId"], + }; +} + +function stringField(args: JsonObject, name: string): string { + const value = args[name]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`adapter argument ${name} must be a non-empty string`); + } + return value; +} + +function objectField(args: JsonObject, name: string): JsonObject { + const value = args[name]; + if (!isRecord(value)) { + throw new Error(`adapter argument ${name} must be an object`); + } + return value; +} + +function isRecord(value: unknown): value is JsonObject { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/typescript/harness/built-in-tools.ts b/typescript/harness/built-in-tools.ts index 001c430..67aac7d 100644 --- a/typescript/harness/built-in-tools.ts +++ b/typescript/harness/built-in-tools.ts @@ -111,7 +111,14 @@ function createInstallAgentToolInstance(): ToolInstance { initialization: { type: "object", additionalProperties: false, - properties: {}, + properties: { + apiKeyEnv: { + type: ["string", "null"], + description: + "Optional environment variable name containing an API key for generated tools that need one.", + }, + }, + required: ["apiKeyEnv"], description: "Initialization arguments for the tool's initializationParameters schema.", }, @@ -149,7 +156,9 @@ async function installAgentTool( ); } const moduleSource = stringArgument(args, "moduleSource"); - const initialization = objectArgument(args, "initialization"); + const initialization = compactInitialization( + objectArgument(args, "initialization"), + ); const toolsDirectory = DEFAULT_AGENT_TOOL_DIRECTORY; const modulePath = path.join(toolsDirectory, `${name}.ts`); const sourcePath = path.join(toolsDirectory, `${name}.source.ts`); @@ -237,6 +246,12 @@ function objectArgument(args: JsonObject, name: string): JsonObject { return value; } +function compactInitialization(initialization: JsonObject): JsonObject { + return Object.fromEntries( + Object.entries(initialization).filter(([, value]) => value !== null), + ); +} + function isRecord(value: unknown): value is JsonObject { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } diff --git a/typescript/harness/index.ts b/typescript/harness/index.ts index 685a60a..45847f6 100644 --- a/typescript/harness/index.ts +++ b/typescript/harness/index.ts @@ -9,6 +9,7 @@ export interface JsonObject { export * from "./tools"; export * from "./built-in-tools"; export * from "./tool-modules"; +export * from "./adapter-tools"; export type MessageRole = | "system" @@ -25,7 +26,7 @@ export interface Message { export interface AgentConfig { instructions: Message[]; - harness: "basic" | "rlm" | "typescript"; + harness: "basic" | "rlm" | "typescript" | "exoclaw"; typescript?: { modulePath: string; toolModulePaths: string[]; @@ -96,6 +97,7 @@ export interface ConversationConfig { | "local_process" | null; shellProgram?: string | null; + sandboxScope?: "agent" | "conversation" | null; mounts: FileSystemMount[]; } diff --git a/typescript/harness/runner.ts b/typescript/harness/runner.ts index 732e575..6239903 100644 --- a/typescript/harness/runner.ts +++ b/typescript/harness/runner.ts @@ -43,7 +43,7 @@ import { interface RawAgentConfig { instructions: Message[]; - harness: "basic" | "rlm" | "typescript" | "type_script"; + harness: "basic" | "rlm" | "typescript" | "type_script" | "exoclaw"; typescript?: { module_path: string; tool_module_paths?: string[]; @@ -67,6 +67,7 @@ interface RawConversationConfig { | "local_process" | null; shell_program?: string | null; + sandbox_scope?: "agent" | "conversation" | null; mounts: Array<{ host_path: string; mount_path: string; @@ -538,7 +539,12 @@ class ProtocolClient { id, request, }); - return response; + try { + return await response; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`exoharness request ${request.type} failed: ${message}`); + } } async emitStream(event: RawTypeScriptStreamEvent): Promise { @@ -843,6 +849,7 @@ function toConversationConfig(raw: RawConversationConfig): ConversationConfig { sandboxImage: raw.sandbox_image ?? null, sandboxProvider: raw.sandbox_provider ?? null, shellProgram: raw.shell_program ?? null, + sandboxScope: raw.sandbox_scope ?? null, mounts: raw.mounts.map(toFileSystemMount), }; } diff --git a/typescript/model-runtime/responses.test.ts b/typescript/model-runtime/responses.test.ts new file mode 100644 index 0000000..da92789 --- /dev/null +++ b/typescript/model-runtime/responses.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import type { Response } from "openai/resources/responses/responses"; + +import { + ChatCompletionsRuntime, + modelRequiresResponsesApi, + responseToLinguaEvents, + responseToolCalls, + runtimeFromModelBinding, + ResponsesRuntime, +} from "./responses"; + +describe("model runtime dispatch", () => { + it("matches the Responses-required model families", () => { + for (const model of [ + "o1-pro", + "o3-pro", + "gpt-5-pro", + "gpt-5.3", + "gpt-5.4", + "gpt-5-codex", + "gpt-5.1-codex-mini", + ]) { + expect(modelRequiresResponsesApi(model)).toBe(true); + } + + for (const model of [ + "deepseek-chat", + "gpt-4o", + "gpt-5", + "gpt-5.1", + "gpt-5.2-chat-latest", + ]) { + expect(modelRequiresResponsesApi(model)).toBe(false); + } + }); + + it("dispatches chat-only models away from Responses", () => { + expect( + runtimeFromModelBinding(undefined, { + model: "deepseek-chat", + apiKey: "key", + }), + ).toBeInstanceOf(ChatCompletionsRuntime); + expect( + runtimeFromModelBinding(undefined, { + model: "gpt-5.4", + apiKey: "key", + }), + ).toBeInstanceOf(ResponsesRuntime); + }); +}); + +describe("response tool-call parsing", () => { + it("turns malformed function arguments into tool result errors", () => { + const response = { + id: "resp_1", + output: [ + { + type: "function_call", + call_id: "call_1", + name: "shell", + arguments: '{"command":', + }, + ], + } as unknown as Response; + + expect(responseToolCalls(response)).toEqual([]); + expect(responseToLinguaEvents(response)).toContainEqual({ + type: "tool_result", + tool_call_id: "call_1", + result: { + ok: false, + error: expect.stringContaining("Invalid JSON arguments for shell"), + }, + }); + }); +}); diff --git a/typescript/model-runtime/responses.ts b/typescript/model-runtime/responses.ts index db72704..6ac23ab 100644 --- a/typescript/model-runtime/responses.ts +++ b/typescript/model-runtime/responses.ts @@ -22,6 +22,7 @@ import type { import { messagesEvent, + toolResultEvent, toolRequestedEvent, type AgentConfig, type EventData, @@ -30,7 +31,16 @@ import { type PendingToolCall, type ToolDefinition, type TurnContext, -} from "@exo/harness"; +} from "../harness"; +import type { + ChatCompletion, + ChatCompletionChunk, + ChatCompletionCreateParamsNonStreaming, + ChatCompletionCreateParamsStreaming, + ChatCompletionMessageParam, + ChatCompletionMessageToolCall, + ChatCompletionTool, +} from "openai/resources/chat/completions"; export interface NativeBraintrustOptions { apiKey?: string; @@ -49,6 +59,7 @@ export interface ResponsesRuntimeOptions { } export interface ResponsesModelBinding { + model?: string; apiKey?: string; baseUrl?: string | null; } @@ -81,6 +92,10 @@ export interface NativeTraceOptions { } export interface ResponsesRuntimeLike { + runTurn( + context: TurnContext, + run: (turnParent: TraceParent) => Promise, + ): Promise; complete( request: NativeResponsesRequest, options?: NativeTraceOptions, @@ -305,6 +320,204 @@ export class ResponsesRuntime implements ResponsesRuntimeLike { } } +export function runtimeFromModelBinding( + agentConfig: AgentConfig | undefined, + binding: ResponsesModelBinding, +): ResponsesRuntimeLike { + return modelRequiresResponsesApi(binding.model ?? "") + ? ResponsesRuntime.fromModelBinding(agentConfig, binding) + : ChatCompletionsRuntime.fromModelBinding(agentConfig, binding); +} + +export function modelRequiresResponsesApi(model: string): boolean { + const lower = model.toLowerCase(); + const gpt5Minor = lower.match(/^gpt-5\.(\d+)/)?.[1]?.match(/^\d+$/)?.[0]; + return ( + lower.startsWith("o1-pro") || + lower.startsWith("o3-pro") || + lower.startsWith("gpt-5-pro") || + (gpt5Minor !== undefined && Number(gpt5Minor) >= 3) || + (lower.startsWith("gpt-5") && lower.includes("-codex")) + ); +} + +export class ChatCompletionsRuntime implements ResponsesRuntimeLike { + private readonly client: OpenAI; + + constructor(options: ResponsesRuntimeOptions = {}) { + ensureBraintrustLogger(options.braintrust ?? null); + this.client = new OpenAI({ + apiKey: options.apiKey, + baseURL: options.baseURL, + organization: options.organization, + project: options.project, + }); + } + + static fromModelBinding( + agentConfig: AgentConfig | undefined, + binding: ResponsesModelBinding, + ): ChatCompletionsRuntime { + return new ChatCompletionsRuntime({ + apiKey: binding.apiKey, + baseURL: binding.baseUrl ?? undefined, + organization: process.env.OPENAI_ORG_ID, + project: process.env.OPENAI_PROJECT, + braintrust: braintrustOptionsFromAgentConfig(agentConfig), + }); + } + + async runTurn( + context: TurnContext, + run: (turnParent: TraceParent) => Promise, + ): Promise { + await traceExecutorTurn(context, run); + } + + async complete( + request: NativeResponsesRequest, + options: NativeTraceOptions = {}, + ): Promise { + const { response } = await this.runLlmRequest(request, { + ...options, + streamed: false, + }); + return response; + } + + async completeStream( + request: NativeResponsesRequest, + handlers: NativeStreamHandlers = {}, + options: NativeTraceOptions = {}, + ): Promise { + const { response } = await this.runLlmRequest(request, { + ...options, + streamed: true, + handlers, + }); + return response; + } + + async traceToolCall( + turnParent: TraceParent, + context: TurnContext, + toolCall: PendingToolCall, + roundIndex: number, + execute: ToolCallExecutor = (toolCall) => + context.executePendingTools([toolCall]), + ): Promise { + return tracedUnderParent( + turnParent, + async (span) => { + try { + const events = await execute(toolCall); + span.log({ output: toolResultTraceOutput(events) }); + return events; + } catch (error) { + span.log({ error: errorMessage(error) }); + throw error; + } + }, + { + name: toolCall.request.functionName, + type: "tool", + spanAttributes: { purpose: "tool_call" }, + event: { + input: toolCall.request, + metadata: { + round_index: roundIndex, + }, + }, + }, + ); + } + + private async runLlmRequest( + request: NativeResponsesRequest, + options: NativeLlmTraceOptions, + ): Promise { + const toolNames = (request.tools ?? []).map((tool) => tool.name); + const run = async (span: Span): Promise => { + try { + const result = options.streamed + ? await this.completeStreamRaw( + buildChatStreamingBody(request), + options.handlers, + ) + : { + response: chatCompletionToResponse( + await this.completeRaw(buildChatNonStreamingBody(request)), + ), + ttftMs: null, + }; + + span.log({ + output: llmOutputTraceValue(result.response), + metadata: { + response_id: result.response.id, + }, + metrics: responseUsageMetrics(result.response, result.ttftMs), + }); + return result; + } catch (error) { + span.log({ error: errorMessage(error) }); + throw error; + } + }; + return tracedUnderParent(options.parent, run, { + name: `chat:${request.model}`, + type: "llm", + event: { + input: llmInputTraceValue(request), + metadata: { + round_index: options.roundIndex, + runtime: "chat_completions", + model: request.model, + max_output_tokens: request.maxOutputTokens ?? null, + tool_count: toolNames.length, + tools: toolNames, + streamed: options.streamed, + }, + }, + }); + } + + private async completeRaw( + body: ChatCompletionCreateParamsNonStreaming, + ): Promise { + return this.client.chat.completions.create(body); + } + + private async completeStreamRaw( + body: ChatCompletionCreateParamsStreaming, + handlers: NativeStreamHandlers = {}, + ): Promise { + const startedAt = performance.now(); + let sawFirstChunk = false; + let ttftMs: number | null = null; + const accumulator = new ChatCompletionAccumulator(); + const stream = await this.client.chat.completions.create(body); + + for await (const chunk of stream) { + if (!sawFirstChunk) { + sawFirstChunk = true; + ttftMs = Math.max(0, Math.round(performance.now() - startedAt)); + await handlers.onFirstChunk?.(ttftMs); + } + accumulator.push(chunk); + const text = chunk.choices[0]?.delta.content; + if (text) { + await handlers.onTextDelta?.(text); + } + } + + return { + response: accumulator.finalize(), + ttftMs, + }; + } +} + export async function runResponsesTurn( context: TurnContext, run: ( @@ -424,6 +637,295 @@ function buildStreamingBody( }; } +function buildChatNonStreamingBody( + request: NativeResponsesRequest, +): ChatCompletionCreateParamsNonStreaming { + const tools = toolDefinitionsToChatTools(request.tools ?? []); + return { + model: request.model, + messages: messagesToChatMessages(request.messages ?? []), + tools: tools.length === 0 ? undefined : tools, + tool_choice: tools.length === 0 ? undefined : "auto", + max_tokens: request.maxOutputTokens ?? undefined, + stream: false, + }; +} + +function buildChatStreamingBody( + request: NativeResponsesRequest, +): ChatCompletionCreateParamsStreaming { + const tools = toolDefinitionsToChatTools(request.tools ?? []); + return { + model: request.model, + messages: messagesToChatMessages(request.messages ?? []), + tools: tools.length === 0 ? undefined : tools, + tool_choice: tools.length === 0 ? undefined : "auto", + max_tokens: request.maxOutputTokens ?? undefined, + stream: true, + stream_options: { include_usage: true }, + }; +} + +function messagesToChatMessages( + messages: Message[], +): ChatCompletionMessageParam[] { + return messages.map(messageToChatMessage); +} + +function messageToChatMessage(message: Message): ChatCompletionMessageParam { + if (message.role === "system" || message.role === "developer") { + return { role: "system", content: messageContentText(message.content) }; + } + if (message.role === "user") { + return { role: "user", content: messageContentText(message.content) }; + } + if (message.role === "tool") { + const result = toolResultContent(message.content); + return { + role: "tool", + tool_call_id: result.toolCallId, + content: JSON.stringify(result.output), + }; + } + const toolCalls = assistantToolCalls(message.content); + return { + role: "assistant", + content: assistantTextContent(message.content), + tool_calls: toolCalls.length === 0 ? undefined : toolCalls, + }; +} + +function toolDefinitionsToChatTools( + tools: ToolDefinition[], +): ChatCompletionTool[] { + return tools.map((tool) => ({ + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters as JsonObject, + strict: true, + }, + })); +} + +function chatCompletionToResponse(completion: ChatCompletion): Response { + const choice = completion.choices[0]; + const output: unknown[] = []; + if (choice?.message.content) { + output.push( + responseMessageOutput(`${completion.id}_message`, choice.message.content), + ); + } + for (const toolCall of choice?.message.tool_calls ?? []) { + if (toolCall.type === "function") { + output.push(responseFunctionCallOutput(toolCall)); + } + } + return { + id: completion.id, + object: "response", + created_at: completion.created, + status: "completed", + model: completion.model, + output, + usage: chatUsageToResponseUsage(completion.usage), + } as unknown as Response; +} + +class ChatCompletionAccumulator { + private id = `chatcmpl_${Date.now()}`; + private created = Math.floor(Date.now() / 1000); + private model = ""; + private content = ""; + private usage: ChatCompletionChunk["usage"] | null = null; + private readonly toolCalls = new Map< + number, + { + id?: string; + name?: string; + arguments: string; + } + >(); + + push(chunk: ChatCompletionChunk): void { + this.id = chunk.id || this.id; + this.created = chunk.created || this.created; + this.model = chunk.model || this.model; + this.usage = chunk.usage ?? this.usage; + for (const choice of chunk.choices) { + const delta = choice.delta; + if (delta.content) { + this.content += delta.content; + } + for (const toolCall of delta.tool_calls ?? []) { + const index = toolCall.index; + const current = this.toolCalls.get(index) ?? { arguments: "" }; + current.id = toolCall.id ?? current.id; + current.name = toolCall.function?.name ?? current.name; + current.arguments += toolCall.function?.arguments ?? ""; + this.toolCalls.set(index, current); + } + } + } + + finalize(): Response { + const output: unknown[] = []; + if (this.content.length > 0) { + output.push(responseMessageOutput(`${this.id}_message`, this.content)); + } + for (const [, toolCall] of [...this.toolCalls.entries()].sort( + ([left], [right]) => left - right, + )) { + if (!toolCall.id || !toolCall.name) { + continue; + } + output.push( + responseFunctionCallOutput({ + id: toolCall.id, + type: "function", + function: { + name: toolCall.name, + arguments: toolCall.arguments, + }, + } as ChatFunctionToolCall), + ); + } + return { + id: this.id, + object: "response", + created_at: this.created, + status: "completed", + model: this.model, + output, + usage: chatUsageToResponseUsage(this.usage), + } as unknown as Response; + } +} + +function responseMessageOutput(id: string, text: string): unknown { + return { + id, + type: "message", + role: "assistant", + status: "completed", + content: [ + { + type: "output_text", + text, + annotations: [], + }, + ], + }; +} + +type ChatFunctionToolCall = Extract< + ChatCompletionMessageToolCall, + { type: "function" } +>; + +function responseFunctionCallOutput(toolCall: ChatFunctionToolCall): unknown { + return { + id: `${toolCall.id}_item`, + type: "function_call", + call_id: toolCall.id, + name: toolCall.function.name, + arguments: toolCall.function.arguments, + status: "completed", + }; +} + +function chatUsageToResponseUsage( + usage: + | ChatCompletion["usage"] + | ChatCompletionChunk["usage"] + | null + | undefined, +): unknown { + if (!usage) { + return null; + } + return { + input_tokens: usage.prompt_tokens, + output_tokens: usage.completion_tokens, + total_tokens: usage.total_tokens, + input_tokens_details: { + cached_tokens: usage.prompt_tokens_details?.cached_tokens ?? 0, + }, + output_tokens_details: { + reasoning_tokens: usage.completion_tokens_details?.reasoning_tokens ?? 0, + }, + }; +} + +function assistantToolCalls(content: unknown): ChatCompletionMessageToolCall[] { + if (!Array.isArray(content)) { + return []; + } + return content.flatMap((part): ChatCompletionMessageToolCall[] => { + if (!isRecord(part) || part.type !== "tool_call") { + return []; + } + if ( + typeof part.tool_call_id !== "string" || + typeof part.tool_name !== "string" + ) { + return []; + } + return [ + { + id: part.tool_call_id, + type: "function", + function: { + name: part.tool_name, + arguments: JSON.stringify( + isRecord(part.arguments) ? part.arguments : {}, + ), + }, + }, + ]; + }); +} + +function assistantTextContent(content: unknown): string | null { + if (Array.isArray(content)) { + const text = content + .filter((part) => isRecord(part) && part.type === "text") + .map((part) => messageContentText((part as { text?: unknown }).text)) + .join(""); + return text || null; + } + return messageContentText(content); +} + +function toolResultContent(content: unknown): { + toolCallId: string; + output: unknown; +} { + const part = Array.isArray(content) ? content.find(isRecord) : null; + if ( + !isRecord(part) || + part.type !== "tool_result" || + typeof part.tool_call_id !== "string" + ) { + throw new Error("tool message must contain a tool_result content part"); + } + return { + toolCallId: part.tool_call_id, + output: part.output, + }; +} + +function messageContentText(content: unknown): string { + if (typeof content === "string") { + return content; + } + if (content === null || content === undefined) { + return ""; + } + return JSON.stringify(content); +} + export function tracedUnderParent( parent: TraceParent | undefined, run: (span: Span) => R, @@ -452,8 +954,17 @@ export function responseToLinguaEvents(response: Response): EventData[] { if (messages.length > 0) { events.push(messagesEvent(messages)); } - for (const toolCall of responseToolCalls(response)) { - events.push(toolRequestedEvent(toolCall)); + for (const result of responseToolCallResults(response)) { + if (result.type === "tool_call") { + events.push(toolRequestedEvent(result.toolCall)); + } else { + events.push( + toolResultEvent(result.toolCallId, { + ok: false, + error: result.error, + }), + ); + } } return events; } @@ -471,15 +982,45 @@ export function responseMessages(response: Response): Message[] { } export function responseToolCalls(response: Response): PendingToolCall[] { + return responseToolCallResults(response).flatMap((result) => + result.type === "tool_call" ? [result.toolCall] : [], + ); +} + +type ResponseToolCallResult = + | { + type: "tool_call"; + toolCall: PendingToolCall; + } + | { + type: "parse_error"; + toolCallId: string; + error: string; + }; + +function responseToolCallResults(response: Response): ResponseToolCallResult[] { return response.output .filter((item) => item.type === "function_call") - .map((item) => ({ - toolCallId: item.call_id, - request: { - functionName: item.name, - arguments: parseJsonObject(item.arguments), - }, - })); + .map((item) => { + const parsed = parseJsonObject(item.arguments); + if (!parsed.ok) { + return { + type: "parse_error", + toolCallId: item.call_id, + error: `Invalid JSON arguments for ${item.name}: ${parsed.error}`, + }; + } + return { + type: "tool_call", + toolCall: { + toolCallId: item.call_id, + request: { + functionName: item.name, + arguments: parsed.value, + }, + }, + }; + }); } export function toolDefinitionsToResponsesTools( @@ -593,12 +1134,26 @@ export function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function parseJsonObject(json: string): JsonObject { - const value = JSON.parse(json) as unknown; - if (!isRecord(value)) { - throw new Error("Responses function call arguments must be a JSON object"); +type JsonObjectParseResult = + | { ok: true; value: JsonObject } + | { ok: false; error: string }; + +function parseJsonObject(json: string): JsonObjectParseResult { + try { + const value = JSON.parse(json) as unknown; + if (!isRecord(value)) { + return { + ok: false, + error: "function call arguments must be a JSON object", + }; + } + return { ok: true, value: value as JsonObject }; + } catch (error) { + return { + ok: false, + error: errorMessage(error), + }; } - return value as JsonObject; } function stringField(