Skip to main content

netget/client/socket_file/
actions.rs

1//! Socket File client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// Socket File client connected event.
15///
16/// Carries `socket_path` and attaches the two actions a client may take on connect, so a model
17/// meant to speak first has a vocabulary to do it with.
18pub static SOCKET_FILE_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
19    EventType::new(
20        "socket_file_connected",
21        "Socket File client successfully connected to Unix domain socket",
22        json!({
23            "type": "send_socket_file_data",
24            "data": "PING\n"
25        }),
26    )
27    .with_parameters(vec![Parameter {
28        name: "socket_path".to_string(),
29        type_hint: "string".to_string(),
30        description: "Unix domain socket path".to_string(),
31        required: true,
32    }])
33    .with_actions(vec![
34        send_socket_file_data_action("Send data to the Unix domain socket"),
35        disconnect_action(),
36    ])
37});
38
39/// Socket File client data received event.
40///
41/// `data` + `encoding` mirror the socket_file *server*, so echoing a payload back means passing
42/// the same two fields to `send_socket_file_data`. The event used to carry `data_hex` only, which
43/// made every text exchange a hex-encoding exercise for the model - the shape CLAUDE.md's "never
44/// put raw bytes in event data" rule exists to prevent.
45pub static SOCKET_FILE_CLIENT_DATA_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
46    EventType::new(
47        "socket_file_data_received",
48        "Data received from Unix domain socket",
49        json!({
50            "type": "send_socket_file_data",
51            "data": "PONG\n"
52        }),
53    )
54    .with_parameters(vec![
55        Parameter {
56            name: "data".to_string(),
57            type_hint: "string".to_string(),
58            description: "The bytes received from the socket. Read it according to 'encoding'."
59                .to_string(),
60            required: true,
61        },
62        Parameter {
63            name: "encoding".to_string(),
64            type_hint: "string".to_string(),
65            description:
66                "How to read 'data': \"utf8\" means literal text, \"hex\" means the bytes \
67                hex-encoded (used whenever they are not all printable ASCII). To echo the bytes \
68                back unchanged, pass the same 'data' and 'encoding' to send_socket_file_data."
69                    .to_string(),
70            required: true,
71        },
72        Parameter {
73            name: "data_length".to_string(),
74            type_hint: "number".to_string(),
75            description: "Length of the received data in bytes".to_string(),
76            required: true,
77        },
78    ])
79    .with_actions(vec![
80        send_socket_file_data_action("Send data in response to the bytes just received"),
81        wait_for_more_action(),
82        disconnect_action(),
83    ])
84});
85
86/// Turn an outbound action's payload into bytes.
87///
88/// `data` plus an optional `encoding` (`"utf8"` default, `"hex"`) is the declared shape and
89/// mirrors the socket_file server. `data_hex` is still accepted because it was the only shape
90/// this client ever advertised and models reach for it out of habit; it means exactly
91/// `{"data": ..., "encoding": "hex"}`.
92fn decode_outbound_data(action: &serde_json::Value) -> Result<Vec<u8>> {
93    if let Some(data) = action.get("data").and_then(|v| v.as_str()) {
94        let encoding = action
95            .get("encoding")
96            .and_then(|v| v.as_str())
97            .unwrap_or("utf8");
98        return match encoding {
99            "utf8" => Ok(data.as_bytes().to_vec()),
100            "hex" => hex::decode(data).map_err(|e| {
101                anyhow::anyhow!(
102                    "'data' was declared as \"encoding\": \"hex\" but is not valid hex ({e}). \
103                     Hex payloads are two hex digits per byte with no separators. To send this \
104                     value as literal text, omit 'encoding' or set it to \"utf8\"."
105                )
106            }),
107            other => Err(anyhow::anyhow!(
108                "Invalid 'encoding' value {other:?}. Valid values are \"utf8\" (default, send \
109                 the characters of 'data' as-is) and \"hex\" (decode 'data' as hex-encoded \
110                 bytes)."
111            )),
112        };
113    }
114    if let Some(hex_str) = action.get("data_hex").and_then(|v| v.as_str()) {
115        return hex::decode(hex_str).map_err(|e| {
116            anyhow::anyhow!(
117                "'data_hex' is not valid hex ({e}). Prefer 'data' with the optional 'encoding' \
118                 field: text as {{\"data\": \"PING\"}}, bytes as \
119                 {{\"data\": \"48656c6c6f\", \"encoding\": \"hex\"}}."
120            )
121        });
122    }
123    Err(anyhow::anyhow!(
124        "Missing 'data' field. Send text as {{\"data\": \"PING\"}} or bytes as \
125         {{\"data\": \"48656c6c6f\", \"encoding\": \"hex\"}}."
126    ))
127}
128
129/// Shared `encoding` parameter for the outbound `data` field.
130fn encoding_parameter() -> Parameter {
131    Parameter {
132        name: "encoding".to_string(),
133        type_hint: "string".to_string(),
134        description: "How to convert 'data' into the bytes put on the socket. \"utf8\" (the \
135            default when omitted) sends the characters of 'data' unchanged - use it for text. \
136            \"hex\" decodes 'data' as hex-encoded bytes, two hex digits per byte - use it for \
137            binary, e.g. {\"data\": \"48656c6c6f\", \"encoding\": \"hex\"} sends the 5 bytes \
138            'Hello'. No other values are accepted"
139            .to_string(),
140        required: false,
141    }
142}
143
144/// `send_socket_file_data`, with a caller-supplied lead line so the async (user-triggered) and
145/// sync (event response) copies read correctly without diverging in their fields.
146fn send_socket_file_data_action(description: &str) -> ActionDefinition {
147    ActionDefinition {
148        name: "send_socket_file_data".to_string(),
149        description: format!(
150            "{description}. The 'data' field holds the payload and the optional 'encoding' field \
151             says how to turn it into bytes: omit 'encoding' (or use \"utf8\") to send the \
152             characters as-is, or set \"encoding\": \"hex\" to send 'data' decoded from hex. \
153             There is no auto-detection."
154        ),
155        parameters: vec![
156            Parameter {
157                name: "data".to_string(),
158                type_hint: "string".to_string(),
159                description: "Data to send. Interpreted according to 'encoding': by default the \
160                    characters of this string are sent as-is (UTF-8)."
161                    .to_string(),
162                required: true,
163            },
164            encoding_parameter(),
165        ],
166        example: json!({
167            "type": "send_socket_file_data",
168            "data": "PING\n",
169            "encoding": "utf8"
170        }),
171        log_template: Some(
172            crate::protocol::log_template::LogTemplate::new()
173                .with_info("-> SOCK {data_len}B")
174                .with_debug("SOCK send_socket_file_data: data_len={data_len}"),
175        ),
176    }
177}
178
179/// `disconnect`: close the Unix socket connection and end the read loop.
180fn disconnect_action() -> ActionDefinition {
181    ActionDefinition {
182        name: "disconnect".to_string(),
183        description: "Close the Unix domain socket connection and stop reading from it."
184            .to_string(),
185        parameters: vec![],
186        example: json!({
187            "type": "disconnect"
188        }),
189        log_template: Some(
190            crate::protocol::log_template::LogTemplate::new()
191                .with_info("-> SOCK disconnect")
192                .with_debug("SOCK disconnect"),
193        ),
194    }
195}
196
197/// `wait_for_more`: the bytes just received are an incomplete message; say nothing yet.
198fn wait_for_more_action() -> ActionDefinition {
199    ActionDefinition {
200        name: "wait_for_more".to_string(),
201        description: "Wait for more data before responding (the bytes received so far are an \
202            incomplete message)."
203            .to_string(),
204        parameters: vec![],
205        example: json!({
206            "type": "wait_for_more"
207        }),
208        log_template: None,
209    }
210}
211
212/// Socket File client protocol action handler
213pub struct SocketFileClientProtocol;
214
215impl SocketFileClientProtocol {
216    pub fn new() -> Self {
217        Self
218    }
219}
220
221impl Default for SocketFileClientProtocol {
222    fn default() -> Self {
223        Self::new()
224    }
225}
226
227// Implement Protocol trait (common functionality)
228impl Protocol for SocketFileClientProtocol {
229    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
230        vec![
231            send_socket_file_data_action("Send data to the Unix domain socket"),
232            disconnect_action(),
233        ]
234    }
235    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
236        vec![
237            send_socket_file_data_action("Send data in response to the bytes just received"),
238            wait_for_more_action(),
239            disconnect_action(),
240        ]
241    }
242    fn protocol_name(&self) -> &'static str {
243        "SocketFile"
244    }
245    /// The two event types this client raises - the very statics its read loop emits.
246    ///
247    /// This used to build two *separate* `EventType`s here whose example action was
248    /// `{"type": "placeholder"}` and which declared neither the real parameters nor any actions,
249    /// so the model was shown one description of an event and handed another at runtime.
250    fn get_event_types(&self) -> Vec<EventType> {
251        vec![
252            SOCKET_FILE_CLIENT_CONNECTED_EVENT.clone(),
253            SOCKET_FILE_CLIENT_DATA_RECEIVED_EVENT.clone(),
254        ]
255    }
256    fn stack_name(&self) -> &'static str {
257        "UnixSocket"
258    }
259    fn keywords(&self) -> Vec<&'static str> {
260        vec![
261            "socket file",
262            "unix socket",
263            "domain socket",
264            "socket-file",
265            "socketfile",
266        ]
267    }
268    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
269        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
270
271        ProtocolMetadataV2::builder()
272            .state(DevelopmentState::Experimental)
273            .implementation("Tokio UnixStream for Unix domain socket connections")
274            .llm_control("Full control over sent/received bytes")
275            .e2e_testing("A real tokio UnixListener peer in-process; no third-party client exists")
276            .notes(
277                "Unix domain socket client. The peer is a local descriptor, so there is no \
278                 independent third-party implementation to validate against and no route past \
279                 Experimental on the usual evidence. The tests drive a real UnixListener peer \
280                 in-process: connect, connect-time send, the peer's reply, the client's answer.",
281            )
282            .build()
283    }
284    fn description(&self) -> &'static str {
285        "Socket File client for connecting to Unix domain sockets"
286    }
287    fn example_prompt(&self) -> &'static str {
288        "Connect to socket file at ./app.sock and send 'HELLO'"
289    }
290    fn group_name(&self) -> &'static str {
291        "Core"
292    }
293
294    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
295        use crate::llm::actions::StartupExamples;
296        use serde_json::json;
297
298        StartupExamples::new(
299            // LLM mode: LLM handles Unix socket client
300            json!({
301                "type": "open_client",
302                "remote_addr": "./app.sock",
303                "base_stack": "socket-file",
304                "instruction": "Connect to Unix socket and send 'PING', echo responses"
305            }),
306            // Script mode: Code-based socket handling
307            json!({
308                "type": "open_client",
309                "remote_addr": "./app.sock",
310                "base_stack": "socket-file",
311                "event_handlers": [{
312                    "event_pattern": "socket_file_data_received",
313                    "handler": {
314                        "type": "script",
315                        "language": "python",
316                        "code": "<socket_file_handler>"
317                    }
318                }]
319            }),
320            // Static mode: Fixed socket response
321            json!({
322                "type": "open_client",
323                "remote_addr": "./app.sock",
324                "base_stack": "socket-file",
325                "event_handlers": [{
326                    "event_pattern": "socket_file_connected",
327                    "handler": {
328                        "type": "static",
329                        "actions": [{
330                            "type": "send_socket_file_data",
331                            "data": "HELLO\n"
332                        }]
333                    }
334                }]
335            }),
336        )
337    }
338}
339
340// Implement Client trait (client-specific functionality)
341impl Client for SocketFileClientProtocol {
342    fn connect(
343        &self,
344        ctx: crate::protocol::ConnectContext,
345    ) -> std::pin::Pin<
346        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
347    > {
348        Box::pin(async move {
349            use crate::client::socket_file::SocketFileClient;
350            SocketFileClient::connect_with_llm_actions(
351                ctx.remote_addr,
352                ctx.llm_client,
353                ctx.state,
354                ctx.status_tx,
355                ctx.client_id,
356            )
357            .await
358        })
359    }
360    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
361        let action_type = action
362            .get("type")
363            .and_then(|v| v.as_str())
364            .context("Missing 'type' field in action")?;
365
366        match action_type {
367            "send_socket_file_data" => {
368                Ok(ClientActionResult::SendData(decode_outbound_data(&action)?))
369            }
370            "disconnect" => Ok(ClientActionResult::Disconnect),
371            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
372            _ => Err(anyhow::anyhow!(
373                "Unknown Socket File client action: {}",
374                action_type
375            )),
376        }
377    }
378}