1#![cfg(unix)]
7
8use crate::llm::actions::{
9 protocol_trait::{ActionResult, Protocol, Server},
10 ActionDefinition, Parameter,
11};
12use crate::protocol::log_template::LogTemplate;
13use crate::protocol::EventType;
14use crate::state::app_state::AppState;
15use anyhow::{Context, Result};
16use serde_json::json;
17use std::sync::LazyLock;
18
19pub struct StdioProtocol;
26
27impl StdioProtocol {
28 pub fn new() -> Self {
29 Self
30 }
31}
32
33impl Default for StdioProtocol {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl Protocol for StdioProtocol {
40 fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
42 Some(crate::protocol::BindingDefaults {
43 mac_address: None,
44 interface: None,
45 host: None,
46 port: None,
47 })
48 }
49
50 fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
51 vec![crate::llm::actions::ParameterDefinition {
52 name: "send_first".to_string(),
53 type_hint: "boolean".to_string(),
54 description: "If true, raise a stdio_started event as soon as stdin is claimed so the \
55 model can emit an initial banner/prompt on stdout before any input arrives."
56 .to_string(),
57 required: false,
58 example: serde_json::json!(true),
59 default: None,
60 }]
61 }
62
63 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
64 Vec::new()
66 }
67
68 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
69 vec![
70 write_stdout_action(),
71 write_stderr_action(),
72 close_stdio_action(),
73 ]
74 }
75
76 fn protocol_name(&self) -> &'static str {
77 "STDIO"
78 }
79
80 fn get_event_types(&self) -> Vec<EventType> {
81 get_stdio_event_types()
82 }
83
84 fn stack_name(&self) -> &'static str {
85 "STDIO"
86 }
87
88 fn keywords(&self) -> Vec<&'static str> {
89 vec!["standard_io", "stdin_stdout", "pipe_filter"]
90 }
91
92 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
93 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
94
95 ProtocolMetadataV2::builder()
96 .state(DevelopmentState::Experimental)
97 .implementation("tokio stdin/stdout/stderr; refuses under a TTY or MCP-stdio")
98 .llm_control("Full control of stdout/stderr bytes in response to stdin lines")
99 .e2e_testing("Real child process: netget spawned with piped stdin/stdout")
100 .notes(
101 "Speaks over the process's own stdin/stdout/stderr, so NetGet drops in as a pipe \
102 filter (prog | netget ... | prog). REFUSES to start under an interactive terminal \
103 (the TUI owns the terminal) and under --mcp stdio (JSON-RPC owns stdin/stdout); \
104 only one stdio server per process. Intended for headless/one-shot piped use. \
105 Validated by spawning the netget binary as a child with piped stdin/stdout.",
106 )
107 .build()
108 }
109
110 fn description(&self) -> &'static str {
111 "Standard I/O server: NetGet acts as a pipe filter, the LLM drives stdout/stderr"
112 }
113
114 fn example_prompt(&self) -> &'static str {
115 "Act as a stdio filter: for each line on stdin, uppercase it and print it to stdout"
116 }
117
118 fn group_name(&self) -> &'static str {
119 "Core"
120 }
121
122 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
123 use crate::llm::actions::StartupExamples;
124
125 let script = r#"import json, sys
127data = json.load(sys.stdin)
128event = data["event"]
129if data["event_type_id"] == "stdio_input_received":
130 actions = [{"type": "write_stdout",
131 "data": "you said: " + str(event.get("data", ""))}]
132else:
133 actions = []
134print(json.dumps({"actions": actions}))"#;
135
136 StartupExamples::new(
137 json!({
138 "type": "open_server",
139 "base_stack": "stdio",
140 "instruction": "For each stdin line, echo it back uppercased on stdout"
141 }),
142 json!({
143 "type": "open_server",
144 "base_stack": "stdio",
145 "event_handlers": [{
146 "event_pattern": "stdio_input_received",
147 "handler": {
148 "type": "script",
149 "language": "python",
150 "code": script
151 }
152 }]
153 }),
154 json!({
155 "type": "open_server",
156 "base_stack": "stdio",
157 "event_handlers": [{
158 "event_pattern": "stdio_input_received",
159 "handler": {
160 "type": "static",
161 "actions": [{
162 "type": "write_stdout",
163 "data": "ACK\n"
164 }]
165 }
166 }]
167 }),
168 )
169 }
170}
171
172impl Server for StdioProtocol {
173 fn spawn(
174 &self,
175 ctx: crate::protocol::SpawnContext,
176 ) -> std::pin::Pin<
177 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
178 > {
179 Box::pin(async move {
180 let send_first = ctx
181 .startup_params
182 .as_ref()
183 .map(|p| p.get_optional_bool("send_first"))
184 .transpose()?
185 .flatten()
186 .unwrap_or(false);
187
188 use crate::server::stdio::StdioServer;
189 StdioServer::spawn_with_llm_actions(
190 send_first,
191 ctx.llm_client,
192 ctx.state,
193 ctx.status_tx,
194 ctx.server_id,
195 )
196 .await?;
197
198 Ok("127.0.0.1:0".parse().unwrap())
200 })
201 }
202
203 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
204 let action_type = action
205 .get("type")
206 .and_then(|v| v.as_str())
207 .context("Missing 'type' field in action")?;
208
209 match action_type {
210 "write_stdout" => {
211 let data = action
212 .get("data")
213 .and_then(|v| v.as_str())
214 .context("Missing 'data' parameter")?;
215 Ok(ActionResult::Output(decode_outbound_data(data, &action)?))
216 }
217 "write_stderr" => {
218 let data = action
219 .get("data")
220 .and_then(|v| v.as_str())
221 .context("Missing 'data' parameter")?;
222 let bytes = decode_outbound_data(data, &action)?;
223 Ok(ActionResult::Custom {
227 name: "stdio_stderr".to_string(),
228 data: json!({ "hex": hex::encode(bytes) }),
229 })
230 }
231 "close_stdio" => Ok(ActionResult::CloseConnection),
232 _ => Err(anyhow::anyhow!("Unknown stdio action: {action_type}")),
233 }
234 }
235}
236
237fn decode_outbound_data(data: &str, action: &serde_json::Value) -> Result<Vec<u8>> {
240 let encoding = action
241 .get("encoding")
242 .and_then(|v| v.as_str())
243 .unwrap_or("utf8");
244
245 match encoding {
246 "utf8" => Ok(data.as_bytes().to_vec()),
247 "hex" => hex::decode(data).map_err(|e| {
248 anyhow::anyhow!(
249 "'data' was declared as \"encoding\": \"hex\" but is not valid hex ({e}). \
250 Hex payloads are two hex digits per byte with no separators. To write this \
251 value as literal text, omit 'encoding' or set it to \"utf8\"."
252 )
253 }),
254 other => Err(anyhow::anyhow!(
255 "Invalid 'encoding' value {other:?}. Valid values are \"utf8\" (default) and \"hex\"."
256 )),
257 }
258}
259
260fn encoding_parameter() -> Parameter {
262 Parameter {
263 name: "encoding".to_string(),
264 type_hint: "string".to_string(),
265 description: "How to convert 'data' into the bytes emitted. \"utf8\" (the default when \
266 omitted) emits the characters of 'data' unchanged - use it for text. \"hex\" decodes \
267 'data' as hex-encoded bytes, two hex digits per byte - use it for binary. No other \
268 values are accepted"
269 .to_string(),
270 required: false,
271 }
272}
273
274fn write_stdout_action() -> ActionDefinition {
276 ActionDefinition {
277 name: "write_stdout".to_string(),
278 description: "Write bytes to the process's stdout (fd 1), i.e. to the downstream program \
279 in the pipe. 'data' holds the payload; optional 'encoding' (\"utf8\" default, \"hex\") \
280 says how to turn it into bytes. Include your own newlines."
281 .to_string(),
282 parameters: vec![
283 Parameter {
284 name: "data".to_string(),
285 type_hint: "string".to_string(),
286 description: "Data to write to stdout. Interpreted according to 'encoding'."
287 .to_string(),
288 required: true,
289 },
290 encoding_parameter(),
291 ],
292 example: json!({
293 "type": "write_stdout",
294 "data": "ACK\n",
295 "encoding": "utf8"
296 }),
297 log_template: Some(
298 LogTemplate::new()
299 .with_info("-> STDOUT {data_len}B")
300 .with_debug("STDIO write_stdout: data_len={data_len}"),
301 ),
302 }
303}
304
305fn write_stderr_action() -> ActionDefinition {
307 ActionDefinition {
308 name: "write_stderr".to_string(),
309 description: "Write bytes to the process's stderr (fd 2), i.e. diagnostics that do not go \
310 down the stdout pipe. Same 'data' / 'encoding' fields as write_stdout."
311 .to_string(),
312 parameters: vec![
313 Parameter {
314 name: "data".to_string(),
315 type_hint: "string".to_string(),
316 description: "Data to write to stderr. Interpreted according to 'encoding'."
317 .to_string(),
318 required: true,
319 },
320 encoding_parameter(),
321 ],
322 example: json!({
323 "type": "write_stderr",
324 "data": "warning: bad input\n",
325 "encoding": "utf8"
326 }),
327 log_template: Some(
328 LogTemplate::new()
329 .with_info("-> STDERR {data_len}B")
330 .with_debug("STDIO write_stderr: data_len={data_len}"),
331 ),
332 }
333}
334
335fn close_stdio_action() -> ActionDefinition {
337 ActionDefinition {
338 name: "close_stdio".to_string(),
339 description: "Stop reading stdin and end the stdio session (as if the filter reached EOF)."
340 .to_string(),
341 parameters: vec![],
342 example: json!({ "type": "close_stdio" }),
343 log_template: Some(
344 LogTemplate::new()
345 .with_info("-> STDIO close")
346 .with_debug("STDIO close_stdio"),
347 ),
348 }
349}
350
351pub static WRITE_STDOUT_ACTION: LazyLock<ActionDefinition> = LazyLock::new(write_stdout_action);
356pub static WRITE_STDERR_ACTION: LazyLock<ActionDefinition> = LazyLock::new(write_stderr_action);
357pub static CLOSE_STDIO_ACTION: LazyLock<ActionDefinition> = LazyLock::new(close_stdio_action);
358
359fn responder_actions() -> Vec<ActionDefinition> {
361 vec![
362 WRITE_STDOUT_ACTION.clone(),
363 WRITE_STDERR_ACTION.clone(),
364 CLOSE_STDIO_ACTION.clone(),
365 ]
366}
367
368pub static STDIO_STARTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
371 EventType::new(
372 "stdio_started",
373 "stdin has been claimed (emit an initial banner/prompt on stdout if desired)",
374 json!({
375 "type": "write_stdout",
376 "data": "ready\n"
377 }),
378 )
379 .with_parameters(vec![])
380 .with_actions(responder_actions())
381});
382
383pub static STDIO_INPUT_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
385 EventType::new(
386 "stdio_input_received",
387 "A chunk/line was read from stdin (the upstream program in the pipe)",
388 json!({
389 "type": "write_stdout",
390 "data": "ACK\n"
391 }),
392 )
393 .with_parameters(vec![
394 Parameter {
395 name: "data".to_string(),
396 type_hint: "string".to_string(),
397 description: "The bytes read from stdin. Read it according to 'encoding'.".to_string(),
398 required: true,
399 },
400 Parameter {
401 name: "encoding".to_string(),
402 type_hint: "string".to_string(),
403 description:
404 "How to read 'data': \"utf8\" means literal text, \"hex\" means the bytes \
405 hex-encoded (used when they are not all printable ASCII)."
406 .to_string(),
407 required: true,
408 },
409 ])
410 .with_actions(responder_actions())
411});
412
413pub static STDIO_INPUT_CLOSED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
415 EventType::new(
416 "stdio_input_closed",
417 "stdin reached EOF (upstream closed the pipe); emit any final output",
418 json!({
419 "type": "write_stdout",
420 "data": "bye\n"
421 }),
422 )
423 .with_parameters(vec![])
424 .with_actions(vec![
425 WRITE_STDOUT_ACTION.clone(),
426 WRITE_STDERR_ACTION.clone(),
427 ])
428});
429
430pub fn get_stdio_event_types() -> Vec<EventType> {
432 vec![
433 STDIO_STARTED_EVENT.clone(),
434 STDIO_INPUT_RECEIVED_EVENT.clone(),
435 STDIO_INPUT_CLOSED_EVENT.clone(),
436 ]
437}