Skip to main content

netget/server/stdio/
actions.rs

1//! Standard I/O (stdin/stdout/stderr) protocol actions implementation.
2//!
3//! Platform: Unix/Linux/macOS only. NetGet itself becomes the child process behind a pipe
4//! (`someprogram | netget ... | otherprogram`): it reads lines from its own stdin, and the model
5//! decides what to emit on stdout/stderr in response.
6#![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
19/// Standard I/O protocol action handler.
20///
21/// Stateless: the server ([`crate::server::stdio::StdioServer`]) owns the process's stdin/stdout/
22/// stderr. The model reads lines the upstream program pipes in and decides the bytes emitted to
23/// the downstream program — "the model owns the payload, NetGet owns the plumbing", over the
24/// process's own standard streams.
25pub 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    /// stdio has no host and no port; opt into the port-optional startup path.
41    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        // No user-triggered actions: output is produced in reaction to stdin.
65        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        // Deterministic: echo each line back on stdout, no LLM call.
126        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            // stdio has no IP address; report the placeholder used by other pathless protocols.
199            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                // stderr goes out-of-band from the stdout Output channel, carried as a Custom
224                // result the server writes to fd 2. The hex here is internal server<->executor
225                // plumbing, never model-facing.
226                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
237/// Turn the `data` field of an outbound action into bytes, honouring the optional `encoding`
238/// field. Mirrors `socket_file` so behaviour is symmetric with inbound `encoding`.
239fn 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
260/// Shared `encoding` parameter for every action carrying an outbound `data` field.
261fn 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
274/// Action definition for write_stdout (sync).
275fn 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
305/// Action definition for write_stderr (sync).
306fn 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
335/// Action definition for close_stdio (sync).
336fn 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
351// ============================================================================
352// stdio Action / Event Constants
353// ============================================================================
354
355pub 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
359/// The two output actions plus close, attached to input-bearing events.
360fn responder_actions() -> Vec<ActionDefinition> {
361    vec![
362        WRITE_STDOUT_ACTION.clone(),
363        WRITE_STDERR_ACTION.clone(),
364        CLOSE_STDIO_ACTION.clone(),
365    ]
366}
367
368/// stdio started event - raised once at startup when `send_first` is set, so the model can emit a
369/// banner before any input (same conditional pattern as `socket_file_connection_opened`).
370pub 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
383/// stdio input received event - raised for each chunk read from stdin.
384pub 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
413/// stdio input closed event - raised when stdin reaches EOF (the upstream program closed the pipe).
414pub 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
430/// Get stdio event types.
431pub 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}