From 7f352c0bc1ca016a06fd048e003909bd9506fcf9 Mon Sep 17 00:00:00 2001 From: Jared Lunde Date: Tue, 30 Jun 2026 00:17:04 -0700 Subject: [PATCH] =?UTF-8?q?feat(agent):=20M8=20=E2=80=94=20Beyond=20platfo?= =?UTF-8?q?rm=20tools=20+=20full=20gateway=20e2e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beyond tools (crates/agent/src/tools/beyond.rs), the "Beyond twist" from PLATFORM.md — fork (branch an app's real data into an agent sandbox), sync (push the working tree to a running instance), logs (query/search instance logs). Each shells out to the `beyond` CLI through the shared CommandRunner seam; argv is unit-tested with a mock runner. default_registry() now serves all 10 tools (7 coding + 3 Beyond). Full-stack e2e (tests/gateway_e2e.rs) — the decisive proof: the real beyond-ai-agent binary drives the real beyond-ai GATEWAY binary, which verifies the bai_v1 virtual key, swaps in the pool key, routes to a (mock) provider, and relays the streamed tool round-trip back. Asserts the pool key reached the upstream and the virtual key did NOT (key-swap), and the tool result was fed back through the gateway. No NATS (gateway fails open), no real provider, plaintext upstream — but every byte flows through the gateway. Uses the deterministic dev signing key as fixed constants (no gateway crate dep). Docs: README reframed as the gateway + agent workspace; agent-core ARCHITECTURE marked complete. Scope boundary: the `beyond` CLI lives in the beyond repo; fork/sync/logs are argv-verified here, not against a live control plane. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 27 ++- crates/agent-core/ARCHITECTURE.md | 11 +- crates/agent/src/tools/beyond.rs | 279 ++++++++++++++++++++++++++++++ crates/agent/src/tools/mod.rs | 18 +- crates/agent/tests/common/mod.rs | 45 +++-- crates/agent/tests/gateway_e2e.rs | 138 +++++++++++++++ 6 files changed, 496 insertions(+), 22 deletions(-) create mode 100644 crates/agent/src/tools/beyond.rs create mode 100644 crates/agent/tests/gateway_e2e.rs diff --git a/README.md b/README.md index 620c952..5dcde91 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,14 @@ # beyond/ai -Route LLM traffic through one internal proxy. Apps use their stock OpenAI or Anthropic SDK unchanged — the gateway authenticates, swaps in the real provider key, and meters every token. +Open-source AI at Beyond — a Cargo workspace: -## Quick Start +- **`crates/gateway`** (`beyond-ai`) — the LLM egress gateway. Route LLM traffic through one internal proxy; apps use their stock OpenAI or Anthropic SDK unchanged — the gateway authenticates, swaps in the real provider key, and meters every token. +- **`crates/agent-core`** (`beyond-ai-agent-core`) — the agent harness core: a runtime/network-agnostic tool-calling loop (modeled on [pi](https://github.com/badlogic/pi-mono)), the OpenAI/Anthropic wire dialects, and the `ModelTransport`/`Tool` seams. Routes all model traffic through the gateway. +- **`crates/agent`** (`beyond-ai-agent`) — the agent CLI: `run` (one-shot coding task) and `serve` (headless control protocol over stdio, for remote control over SSH). Ships the coding tools (read/write/edit/bash/ls/grep/find) plus the Beyond platform tools (fork/sync/logs). + +The gateway is the unified model API: the agent speaks OpenAI/Anthropic wire to it with a `bai_v1` key and never holds a provider key. See each crate's `ARCHITECTURE.md`. + +## Gateway Quick Start ```sh cp crates/gateway/config.example.toml config.toml @@ -23,6 +29,23 @@ Or pass your own provider key directly (BYO — forwarded unchanged, no swap): client = OpenAI(base_url="http://ai.internal/v1", api_key="sk-your-openai-key") ``` +## Agent Quick Start + +The agent harness routes through the gateway. Point it at a running gateway with a `bai_v1` key: + +```sh +# One-shot coding task +AI_GATEWAY_URL=http://ai.internal AI_AGENT_KEY=bai_v1... \ + cargo run -p beyond-ai-agent -- run "add a CHANGELOG entry for the latest release" + +# Headless server — drive it over stdio (e.g. an SSH pipe); newline-delimited JSON +AI_GATEWAY_URL=http://ai.internal AI_AGENT_KEY=bai_v1... \ + cargo run -p beyond-ai-agent -- serve --session-file /tmp/agent.json +# then: {"type":"prompt","message":"…"} → streamed event frames, then a response +``` + +Tools: `read`, `write`, `edit`, `bash`, `ls`, `grep`, `find` (pi's coding set) plus `fork`, `sync`, `logs` (Beyond platform). See [crates/agent-core/ARCHITECTURE.md](crates/agent-core/ARCHITECTURE.md). + ## What It Does - **Managed keys** (`bai_v1…`) — Ed25519-verified, stateless. Swaps to the pool key. Attributes usage to tenant + VPC. Deny-set checked (spend/fraud). diff --git a/crates/agent-core/ARCHITECTURE.md b/crates/agent-core/ARCHITECTURE.md index 015d91d..8f2270b 100644 --- a/crates/agent-core/ARCHITECTURE.md +++ b/crates/agent-core/ARCHITECTURE.md @@ -38,8 +38,9 @@ the full [`AgentEvent`] stream — `Stream(StreamEvent)`, `ToolStart`/`ToolEnd` ## Milestone status -Built and tested: the type model, the `Tool`/`ToolRegistry` seam, the `ModelTransport` seam + -`GatewayClient` HTTP transport, `MockTransport`, the OpenAI/Anthropic wire dialects, the agent loop -(`run`/`run_events`), and `Session`. The coding tools, the `run` CLI, and the headless `serve` -control protocol live in the `beyond-ai-agent` crate. Remaining: the Beyond platform tools -(fork/sync/logs) and the full gateway e2e (M8). +Complete. Built and tested: the type model, the `Tool`/`ToolRegistry` seam, the `ModelTransport` +seam + `GatewayClient` HTTP transport, `MockTransport`, the OpenAI/Anthropic wire dialects, the agent +loop (`run`/`run_events`), and `Session`. The coding tools (read/write/edit/bash/ls/grep/find), the +Beyond platform tools (fork/sync/logs), the `run` CLI, and the headless `serve` control protocol live +in the `beyond-ai-agent` crate. End-to-end proven against the real `beyond-ai` gateway binary +(auth + key-swap + routing) through to a mock upstream. diff --git a/crates/agent/src/tools/beyond.rs b/crates/agent/src/tools/beyond.rs new file mode 100644 index 0000000..66874d3 --- /dev/null +++ b/crates/agent/src/tools/beyond.rs @@ -0,0 +1,279 @@ +//! Beyond platform tools — the agent's hooks into the platform from `../beyond/PLATFORM.md`. +//! +//! `fork` clones a running app (its real data) into an isolated branch, `sync` pushes the working +//! tree to a running instance, and `logs` queries instance logs. Each shells out to the `beyond` CLI +//! through the shared [`CommandRunner`] seam, so tests assert the exact argv without a control plane. +//! +//! **Scope boundary:** the `beyond` CLI lives in the `beyond` repo, not here. These tools are +//! verified at the argv level; real fork/sync/logs behavior is validated against a live control +//! plane, which isn't available in this repo's tests. + +use std::sync::Arc; +use std::time::Duration; + +use agent_core::ToolError; +use agent_core::tool::Tool; +use async_trait::async_trait; +use serde_json::{Value, json}; + +use super::exec::{CommandRunner, RealRunner}; + +/// Wall-clock ceiling for a `beyond` invocation. +const TIMEOUT: Duration = Duration::from_secs(120); + +/// Run `beyond ` through the runner, mapping the result to a tool output/error. +async fn run_beyond( + runner: &Arc, + args: Vec, +) -> Result { + let res = runner + .run("beyond", &args, None, TIMEOUT) + .await + .map_err(|e| ToolError::Execution(format!("spawn `beyond`: {e}")))?; + if res.timed_out { + return Err(ToolError::Execution("`beyond` timed out".into())); + } + let mut out = res.stdout.trim_end().to_string(); + if !res.stderr.trim().is_empty() { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(res.stderr.trim_end()); + } + match res.code { + Some(0) => Ok(out), + Some(code) => Err(ToolError::Execution(format!( + "`beyond` exited {code}: {out}" + ))), + None => Err(ToolError::Execution("`beyond` was killed".into())), + } +} + +macro_rules! runner_ctor { + () => { + /// A tool that runs the real `beyond` CLI. + pub fn real() -> Self { + Self { + runner: Arc::new(RealRunner), + } + } + /// A tool over a custom runner (tests inject one to capture the argv). + #[cfg(test)] + pub fn with_runner(runner: Arc) -> Self { + Self { runner } + } + }; +} + +/// `fork` — branch a Beyond app, getting an isolated copy of its real data (the agent sandbox). +pub struct Fork { + runner: Arc, +} +impl Fork { + runner_ctor!(); +} + +#[async_trait] +impl Tool for Fork { + fn name(&self) -> &str { + "fork" + } + fn description(&self) -> &str { + "Fork a Beyond app into an isolated branch with a copy of its real data — a sandbox to make \ + and verify changes before promoting or destroying." + } + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "app": { "type": "string", "description": "App to fork." }, + "name": { "type": "string", "description": "Optional name for the branch." } + }, + "required": ["app"] + }) + } + async fn run(&self, input: Value) -> Result { + let app = input + .get("app") + .and_then(Value::as_str) + .ok_or_else(|| ToolError::InvalidInput("missing `app`".into()))?; + let mut args = vec!["fork".to_string(), app.to_string()]; + if let Some(name) = input.get("name").and_then(Value::as_str) { + args.push("--name".into()); + args.push(name.to_string()); + } + run_beyond(&self.runner, args).await + } +} + +/// `sync` — push the working tree to the running instance (the tight edit→run loop). +pub struct Sync { + runner: Arc, +} +impl Sync { + runner_ctor!(); +} + +#[async_trait] +impl Tool for Sync { + fn name(&self) -> &str { + "sync" + } + fn description(&self) -> &str { + "Push the working tree to the running Beyond instance so changes take effect immediately." + } + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "Directory to sync (default: project root)." } + } + }) + } + async fn run(&self, input: Value) -> Result { + let mut args = vec!["sync".to_string()]; + if let Some(path) = input.get("path").and_then(Value::as_str) { + args.push(path.to_string()); + } + run_beyond(&self.runner, args).await + } +} + +/// `logs` — query a Beyond instance's logs (optionally a Lucene query). +pub struct Logs { + runner: Arc, +} +impl Logs { + runner_ctor!(); +} + +#[async_trait] +impl Tool for Logs { + fn name(&self) -> &str { + "logs" + } + fn description(&self) -> &str { + "Read or search a Beyond instance's logs. Optionally filter by `app` and a Lucene `query`." + } + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "app": { "type": "string", "description": "App whose logs to read." }, + "query": { "type": "string", "description": "Lucene query to filter log lines." } + } + }) + } + async fn run(&self, input: Value) -> Result { + let mut args = vec!["logs".to_string()]; + if let Some(app) = input.get("app").and_then(Value::as_str) { + args.push("--app".into()); + args.push(app.to_string()); + } + if let Some(query) = input.get("query").and_then(Value::as_str) { + args.push("--query".into()); + args.push(query.to_string()); + } + run_beyond(&self.runner, args).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::exec::ExecResult; + + struct RecordingRunner { + last: std::sync::Mutex)>>, + } + #[async_trait] + impl CommandRunner for RecordingRunner { + async fn run( + &self, + program: &str, + args: &[String], + _cwd: Option<&str>, + _t: Duration, + ) -> std::io::Result { + *self.last.lock().unwrap() = Some((program.to_string(), args.to_vec())); + Ok(ExecResult { + code: Some(0), + stdout: "ok".into(), + stderr: String::new(), + timed_out: false, + }) + } + } + + fn recorder() -> Arc { + Arc::new(RecordingRunner { + last: std::sync::Mutex::new(None), + }) + } + + #[tokio::test] + async fn fork_builds_argv() { + let r = recorder(); + let out = Fork::with_runner(r.clone()) + .run(json!({ "app": "myapp", "name": "branch1" })) + .await + .unwrap(); + assert_eq!(out, "ok"); + let (prog, args) = r.last.lock().unwrap().clone().unwrap(); + assert_eq!(prog, "beyond"); + assert_eq!(args, vec!["fork", "myapp", "--name", "branch1"]); + } + + #[tokio::test] + async fn sync_builds_argv() { + let r = recorder(); + Sync::with_runner(r.clone()) + .run(json!({ "path": "./web" })) + .await + .unwrap(); + assert_eq!( + r.last.lock().unwrap().clone().unwrap().1, + vec!["sync", "./web"] + ); + } + + #[tokio::test] + async fn logs_builds_argv_with_query() { + let r = recorder(); + Logs::with_runner(r.clone()) + .run(json!({ "app": "api", "query": "level:error" })) + .await + .unwrap(); + assert_eq!( + r.last.lock().unwrap().clone().unwrap().1, + vec!["logs", "--app", "api", "--query", "level:error"] + ); + } + + #[tokio::test] + async fn nonzero_exit_is_error() { + struct Fail; + #[async_trait] + impl CommandRunner for Fail { + async fn run( + &self, + _p: &str, + _a: &[String], + _c: Option<&str>, + _t: Duration, + ) -> std::io::Result { + Ok(ExecResult { + code: Some(1), + stdout: String::new(), + stderr: "no such app".into(), + timed_out: false, + }) + } + } + let err = Fork::with_runner(Arc::new(Fail)) + .run(json!({ "app": "ghost" })) + .await + .unwrap_err(); + assert!(matches!(err, ToolError::Execution(_))); + } +} diff --git a/crates/agent/src/tools/mod.rs b/crates/agent/src/tools/mod.rs index 2a1f17c..35bb7a4 100644 --- a/crates/agent/src/tools/mod.rs +++ b/crates/agent/src/tools/mod.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use agent_core::ToolRegistry; pub mod bash; +pub mod beyond; pub mod edit; pub mod exec; pub mod find; @@ -16,7 +17,8 @@ pub mod ls; pub mod read; pub mod write; -/// The default coding tool set: read, write, edit, bash, ls, grep, find. +/// The default tool set: pi's seven coding tools (read, write, edit, bash, ls, grep, find) plus the +/// Beyond platform tools (fork, sync, logs). pub fn default_registry() -> ToolRegistry { let mut reg = ToolRegistry::new(); reg.register(Arc::new(read::Read)); @@ -26,6 +28,9 @@ pub fn default_registry() -> ToolRegistry { reg.register(Arc::new(grep::Grep)); reg.register(Arc::new(find::Find)); reg.register(Arc::new(bash::Bash::real())); + reg.register(Arc::new(beyond::Fork::real())); + reg.register(Arc::new(beyond::Sync::real())); + reg.register(Arc::new(beyond::Logs::real())); reg } @@ -34,11 +39,16 @@ mod tests { use super::*; #[test] - fn default_registry_has_the_pi_tool_set() { + fn default_registry_has_coding_and_beyond_tools() { let reg = default_registry(); + // pi's coding tools … for name in ["read", "write", "edit", "bash", "ls", "grep", "find"] { - assert!(reg.get(name).is_some(), "missing tool: {name}"); + assert!(reg.get(name).is_some(), "missing coding tool: {name}"); } - assert_eq!(reg.len(), 7); + // … plus the Beyond platform tools. + for name in ["fork", "sync", "logs"] { + assert!(reg.get(name).is_some(), "missing beyond tool: {name}"); + } + assert_eq!(reg.len(), 10); } } diff --git a/crates/agent/tests/common/mod.rs b/crates/agent/tests/common/mod.rs index aaf7fc4..cf23548 100644 --- a/crates/agent/tests/common/mod.rs +++ b/crates/agent/tests/common/mod.rs @@ -49,15 +49,17 @@ fn read_http_request(stream: &mut TcpStream) -> String { .map(|v| v.trim().parse::().unwrap_or(0)) }) .unwrap_or(0); - let mut body = buf[pos + 4..].to_vec(); - while body.len() < len { + // Keep reading until the full body has arrived, then return the WHOLE raw request + // (headers + body) so callers can assert on both (e.g. a swapped-in pool key). + let need = pos + 4 + len; + while buf.len() < need { let n = stream.read(&mut tmp).unwrap_or(0); if n == 0 { break; } - body.extend_from_slice(&tmp[..n]); + buf.extend_from_slice(&tmp[..n]); } - return String::from_utf8_lossy(&body).into_owned(); + return String::from_utf8_lossy(&buf).into_owned(); } let n = stream.read(&mut tmp).unwrap_or(0); if n == 0 { @@ -67,18 +69,18 @@ fn read_http_request(stream: &mut TcpStream) -> String { } } -/// Spawn a model server answering `responses` in order, recording each request body. Returns the -/// base URL and the shared record of request bodies. +/// Spawn a model server answering `responses` in order, recording each full raw request (headers + +/// body). Returns the base URL and the shared record of requests. pub fn spawn_model_server(responses: Vec) -> (String, Arc>>) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); - let bodies = Arc::new(Mutex::new(Vec::new())); - let recorder = bodies.clone(); + let requests = Arc::new(Mutex::new(Vec::new())); + let recorder = requests.clone(); thread::spawn(move || { for resp in responses { if let Ok((mut stream, _)) = listener.accept() { - let body = read_http_request(&mut stream); - recorder.lock().unwrap().push(body); + let req = read_http_request(&mut stream); + recorder.lock().unwrap().push(req); let http = format!( "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n{resp}" ); @@ -87,5 +89,26 @@ pub fn spawn_model_server(responses: Vec) -> (String, Arc u16 { + TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +/// Block until `port` accepts a TCP connection, or panic after ~5s. +pub fn wait_for_port(port: u16) { + for _ in 0..500 { + if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() { + return; + } + thread::sleep(std::time::Duration::from_millis(10)); + } + panic!("port {port} never came up"); } diff --git a/crates/agent/tests/gateway_e2e.rs b/crates/agent/tests/gateway_e2e.rs new file mode 100644 index 0000000..2fbfe72 --- /dev/null +++ b/crates/agent/tests/gateway_e2e.rs @@ -0,0 +1,138 @@ +//! The full stack: the real `beyond-ai-agent` binary → the real `beyond-ai` gateway binary → +//! a scripted mock upstream. +//! +//! This is the end-to-end proof that the harness works *through the actual gateway*: the agent +//! authenticates with a `bai_v1` virtual key, the gateway verifies it, swaps in the pool key, routes +//! to the (mock) provider, and relays the streamed tool round-trip back. No NATS (the gateway fails +//! open), no real provider, no TLS (plaintext upstream) — but every byte flows through the gateway. +//! +//! The signing key is the gateway's deterministic dev key (`mise run ai:mint-dev-key`), so the +//! public key + token are fixed constants — no dependency on the gateway crate. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod common; + +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use common::{free_port, spawn_model_server, turn_text, turn_tool_use, wait_for_port}; +use serde_json::json; + +/// Deterministic dev signing public key (standard base64), for `[signing_keys] 1 = …`. +const DEV_PUBKEY_B64: &str = "6kpsY+KcUgq+9VB7Ey7F+ZVHdq6+vnuSQh7qaRRG0iw="; +/// The matching dev `bai_v1` token (tenant 1 / vpc 1, kid 1). +const DEV_TOKEN: &str = "bai_v1.1.AQAAAAAAAAABAAAAAAAAAA.WrWcPbklu91PS-4WuR6GnBNF3h4nROpH0EQQlfJf06f7_lEnlQOCSBimhH2JMwXFJgw40BniTB7-yIdFnpldDw"; + +/// Locate the gateway binary (built beside the agent binary); build it on demand if absent. +fn gateway_bin() -> PathBuf { + let agent = PathBuf::from(env!("CARGO_BIN_EXE_beyond-ai-agent")); + let dir = agent.parent().unwrap(); + let gw = dir.join("beyond-ai"); + if !gw.exists() { + let mut args = vec!["build", "-q", "-p", "beyond-ai", "--bin", "beyond-ai"]; + if agent.to_string_lossy().contains("/release/") { + args.push("--release"); + } + let status = Command::new(env!("CARGO")) + .args(&args) + .status() + .expect("build gateway"); + assert!(status.success(), "failed to build the gateway binary"); + } + gw +} + +#[test] +fn agent_through_real_gateway_to_mock_upstream() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("hello.txt"), "secret-marker-99\n").unwrap(); + let abs = dir.path().join("hello.txt").to_string_lossy().into_owned(); + + // Scripted upstream: the model calls `read`, then replies and ends. + let turn1 = turn_tool_use("toolu_1", "read", &json!({ "path": abs }).to_string()); + let turn2 = turn_text("Read complete."); + let (mock_base, requests) = spawn_model_server(vec![turn1, turn2]); + let mock_authority = mock_base.strip_prefix("http://").unwrap().to_string(); + + // Gateway config: Anthropic provider → the mock (plaintext), pool key to swap in, dev signing key. + let gw_port = free_port(); + let metrics_port = free_port(); + let config = format!( + "listen = \"127.0.0.1:{gw_port}\"\n\ + metrics_listen = \"127.0.0.1:{metrics_port}\"\n\ + nats_url = \"nats://127.0.0.1:59321\"\n\ + config_bucket = \"ai-gateway\"\n\ + upstream_tls = false\n\ + \n[provider_authorities]\nanthropic = \"{mock_authority}\"\n\ + \n[pool_keys]\nanthropic = \"sk-pool-secret\"\n\ + \n[signing_keys]\n1 = \"{DEV_PUBKEY_B64}\"\n" + ); + let config_path = dir.path().join("gateway.toml"); + std::fs::write(&config_path, config).unwrap(); + + // Boot the real gateway binary. + let mut gateway = Command::new(gateway_bin()) + .arg("run") + .arg("-c") + .arg(&config_path) + .env("AI_LOG", "warn") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn gateway"); + wait_for_port(gw_port); + + // Drive the agent through the gateway. + let output = Command::new(env!("CARGO_BIN_EXE_beyond-ai-agent")) + .args([ + "run", + "read hello.txt and report it", + "--gateway-url", + &format!("http://127.0.0.1:{gw_port}"), + "--key", + DEV_TOKEN, + "--model", + "claude-test", + "--max-steps", + "4", + ]) + .current_dir(dir.path()) + .output() + .expect("spawn agent"); + + let _ = gateway.kill(); + let _ = gateway.wait(); // reap the child so it doesn't linger as a zombie + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "agent failed through gateway.\nstdout: {stdout}\nstderr: {stderr}" + ); + assert!( + stdout.contains("[tool: read]"), + "expected the tool round-trip.\nstdout: {stdout}" + ); + assert!( + stdout.contains("Read complete."), + "expected the final turn.\nstdout: {stdout}" + ); + + // The gateway relayed two upstream requests, swapped the key, and never leaked the virtual key. + let reqs = requests.lock().unwrap(); + assert_eq!(reqs.len(), 2, "gateway should relay two upstream requests"); + let all = reqs.join("\n---\n"); + assert!( + all.contains("sk-pool-secret"), + "gateway must swap in the pool key.\n{all}" + ); + assert!( + !all.contains("bai_v1.1."), + "the bai_v1 virtual key must NOT reach the upstream.\n{all}" + ); + assert!( + reqs[1].contains("secret-marker-99"), + "the tool result must be fed back to the model through the gateway.\n{}", + reqs[1] + ); +}