Skip to main content

sharpebench_sim/
external.rs

1//! External-process agent — speak the JSON protocol to an any-language agent.
2//!
3//! The adoption surface: an agent is just a subprocess that reads one
4//! [`MarketObservation`] (JSON) per line on stdin and writes one [`Decision`]
5//! (JSON) per line on stdout. Python, TS, a hosted shim — anything that honors
6//! the contract competes. On any I/O or parse error the agent is treated as
7//! holding (never panics the harness).
8
9use std::io::{BufRead, BufReader, Read, Write};
10use std::net::TcpStream;
11use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
12
13use sharpebench_protocol::{Decision, MarketObservation};
14
15use crate::agent::Agent;
16
17/// Cap on bytes read from an external agent's HTTP response, so a hostile or buggy
18/// endpoint can't exhaust the harness's memory.
19const MAX_AGENT_RESPONSE: u64 = 8 * 1024 * 1024;
20
21/// Drives an external agent subprocess over newline-delimited JSON.
22pub struct ExternalAgent {
23    child: Child,
24    stdin: ChildStdin,
25    stdout: BufReader<ChildStdout>,
26}
27
28impl ExternalAgent {
29    /// Spawn `program args...` as an agent subprocess.
30    pub fn spawn(program: &str, args: &[&str]) -> std::io::Result<Self> {
31        let mut child = Command::new(program)
32            .args(args)
33            .stdin(Stdio::piped())
34            .stdout(Stdio::piped())
35            .spawn()?;
36        let stdin = child
37            .stdin
38            .take()
39            .ok_or_else(|| std::io::Error::other("no stdin"))?;
40        let stdout = child
41            .stdout
42            .take()
43            .ok_or_else(|| std::io::Error::other("no stdout"))?;
44        Ok(Self {
45            child,
46            stdin,
47            stdout: BufReader::new(stdout),
48        })
49    }
50
51    fn hold() -> Decision {
52        Decision {
53            orders: Vec::new(),
54            reasoning: "external agent error → hold".to_string(),
55        }
56    }
57}
58
59impl Agent for ExternalAgent {
60    fn decide(&mut self, obs: &MarketObservation) -> Decision {
61        let Ok(line) = serde_json::to_string(obs) else {
62            return Self::hold();
63        };
64        if writeln!(self.stdin, "{line}").is_err() || self.stdin.flush().is_err() {
65            return Self::hold();
66        }
67        let mut resp = String::new();
68        match self.stdout.read_line(&mut resp) {
69            Ok(0) | Err(_) => Self::hold(),
70            Ok(_) => serde_json::from_str(&resp).unwrap_or_else(|_| Self::hold()),
71        }
72    }
73}
74
75impl Drop for ExternalAgent {
76    fn drop(&mut self) {
77        let _ = self.child.kill();
78        let _ = self.child.wait();
79    }
80}
81
82/// Drives an external agent over HTTP/1.1 — one request/response per decision.
83///
84/// Targets a plain-HTTP `host:port` endpoint that accepts `POST /decide` with a
85/// JSON [`MarketObservation`] body and returns a JSON [`Decision`]. Loopback /
86/// in-sandbox only (no TLS), so this is a dependency-free `std::net` client — the
87/// benchmark sim keeps its minimal, audited dependency tree. As with the stdio
88/// transport, any connection/parse error degrades to a hold (never panics).
89pub struct HttpAgent {
90    host: String,
91    port: u16,
92}
93
94impl HttpAgent {
95    /// `addr` is `host:port` (e.g. `"127.0.0.1:8080"`); each decision POSTs to
96    /// `/decide`. A bare host defaults to port 80.
97    pub fn new(addr: impl Into<String>) -> Self {
98        let addr = addr.into();
99        match addr.rsplit_once(':') {
100            Some((h, p)) => Self {
101                host: h.to_string(),
102                port: p.parse().unwrap_or(80),
103            },
104            None => Self {
105                host: addr,
106                port: 80,
107            },
108        }
109    }
110
111    fn decide_checked(&self, obs: &MarketObservation) -> std::io::Result<Decision> {
112        let body = serde_json::to_string(obs).map_err(std::io::Error::other)?;
113        let mut stream = TcpStream::connect((self.host.as_str(), self.port))?;
114        // Bound time so a slow/stalled agent endpoint can't hang the harness.
115        let timeout = std::time::Duration::from_secs(30);
116        stream.set_read_timeout(Some(timeout))?;
117        stream.set_write_timeout(Some(timeout))?;
118        // `Connection: close` lets us read the whole response to EOF — no need to
119        // parse Content-Length / chunked encoding for a one-shot request.
120        let req = format!(
121            "POST /decide HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\n\
122             Content-Length: {}\r\nConnection: close\r\n\r\n{}",
123            self.host,
124            body.len(),
125            body
126        );
127        stream.write_all(req.as_bytes())?;
128        stream.flush()?;
129        // Cap the response size so a hostile endpoint can't exhaust memory.
130        let mut raw = String::new();
131        (&stream)
132            .take(MAX_AGENT_RESPONSE)
133            .read_to_string(&mut raw)?;
134        let json = raw
135            .split_once("\r\n\r\n")
136            .map(|(_, b)| b)
137            .ok_or_else(|| std::io::Error::other("malformed HTTP response"))?;
138        serde_json::from_str(json).map_err(std::io::Error::other)
139    }
140}
141
142impl Agent for HttpAgent {
143    fn decide(&mut self, obs: &MarketObservation) -> Decision {
144        self.decide_checked(obs).unwrap_or_else(|_| Decision {
145            orders: Vec::new(),
146            reasoning: "http agent error → hold".to_string(),
147        })
148    }
149}