ag-agent 0.12.6

Agentty is an ADE (Agentic Development Environment) for structured, controllable AI-assisted software development.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
//! Shared stdio JSON-RPC transport utilities for app-server protocols.
//!
//! Provides low-level helpers for NDJSON-over-stdio communication used by
//! persistent app-server backends such as Codex app-server. Each helper is
//! protocol-agnostic — it operates on raw JSON values and async stdio handles
//! without knowledge of specific method names or event shapes.

use std::time::Duration;

use serde_json::Value;
use tokio::io::{AsyncWriteExt, BufReader, Lines};

use crate::app_server::AppServerError;

/// Typed error returned by shared app-server transport operations.
///
/// Covers the low-level stdio communication failures that can occur when
/// writing JSON-RPC payloads to a child process or reading responses from
/// its stdout stream.
#[derive(Debug, thiserror::Error)]
pub enum AppServerTransportError {
    /// An IO error occurred during app-server stdio communication.
    #[error("{context}: {source}")]
    Io {
        /// Human-readable description of the operation that failed.
        context: String,
        /// Underlying IO error.
        #[source]
        source: std::io::Error,
    },

    /// The app-server process terminated before sending the expected response.
    #[error("App-server terminated before sending expected response")]
    ProcessTerminated,

    /// Timed out waiting for a JSON-RPC response from the app-server.
    #[error(
        "Timed out waiting for app-server response `{response_id}` after {timeout_seconds} seconds"
    )]
    Timeout {
        /// The JSON-RPC request identifier that was being awaited.
        response_id: String,
        /// Number of seconds elapsed before the timeout fired.
        timeout_seconds: u64,
    },
}

/// Default timeout for initialization handshakes and session creation.
///
/// App-server cold starts can take materially longer than a typical
/// request/response round trip while the runtime initializes tools and model
/// state, so the shared startup window stays measured in minutes rather than
/// seconds to avoid aborting healthy app-server bootstraps.
pub const STARTUP_TIMEOUT: Duration = Duration::from_mins(5);

/// Default timeout for a single prompt turn.
///
/// App-server turns may legitimately run for long periods while agents plan,
/// execute tools, and compact context, so the shared turn window is aligned
/// with the long-running Codex behavior instead of the shorter bootstrap
/// timeout.
pub const TURN_TIMEOUT: Duration = Duration::from_hours(4);

/// Writes one JSON-RPC payload as a newline-delimited line to `stdin`.
///
/// # Errors
///
/// Returns an error when the write or flush to stdin fails.
pub async fn write_json_line(
    stdin: &mut tokio::process::ChildStdin,
    payload: &Value,
) -> Result<(), AppServerTransportError> {
    let serialized_payload = payload.to_string();

    stdin
        .write_all(serialized_payload.as_bytes())
        .await
        .map_err(|source| AppServerTransportError::Io {
            context: "Failed writing to app-server stdin".to_string(),
            source,
        })?;
    stdin
        .write_all(b"\n")
        .await
        .map_err(|source| AppServerTransportError::Io {
            context: "Failed writing newline to app-server stdin".to_string(),
            source,
        })?;
    stdin
        .flush()
        .await
        .map_err(|source| AppServerTransportError::Io {
            context: "Failed flushing app-server stdin".to_string(),
            source,
        })
}

/// Reads stdout lines until a JSON-RPC response carrying `response_id` arrives.
///
/// Non-matching lines (notifications, other responses) are silently skipped.
/// Times out after [`STARTUP_TIMEOUT`].
///
/// # Errors
///
/// Returns an error when the read times out or the child process terminates
/// before a matching response is received.
pub async fn wait_for_response_line<R>(
    stdout_lines: &mut Lines<BufReader<R>>,
    response_id: &str,
) -> Result<String, AppServerTransportError>
where
    R: tokio::io::AsyncRead + Unpin,
{
    tokio::time::timeout(STARTUP_TIMEOUT, async {
        loop {
            let stdout_line = stdout_lines
                .next_line()
                .await
                .map_err(|source| AppServerTransportError::Io {
                    context: "Failed reading app-server stdout".to_string(),
                    source,
                })?
                .ok_or(AppServerTransportError::ProcessTerminated)?;

            let Ok(response_value) = serde_json::from_str::<Value>(&stdout_line) else {
                continue;
            };
            if response_id_matches(&response_value, response_id) {
                return Ok(stdout_line);
            }
        }
    })
    .await
    .map_err(|_| AppServerTransportError::Timeout {
        response_id: response_id.to_string(),
        timeout_seconds: STARTUP_TIMEOUT.as_secs(),
    })?
}

/// Returns whether a JSON-RPC response line carries the expected `id`.
pub fn response_id_matches(response_value: &Value, response_id: &str) -> bool {
    response_value
        .get("id")
        .and_then(Value::as_str)
        .is_some_and(|line_id| line_id == response_id)
}

/// Extracts a top-level `error.message` string from a JSON-RPC error response.
pub fn extract_json_error_message(response_value: &Value) -> Option<String> {
    response_value
        .get("error")
        .and_then(|error| error.get("message"))
        .and_then(Value::as_str)
        .map(ToString::to_string)
}

/// Gracefully shuts down a child process by closing stdin, waiting briefly,
/// then killing if the process has not exited.
pub async fn shutdown_child(child: &mut tokio::process::Child) {
    // Closing stdin signals the child to exit cleanly.
    drop(child.stdin.take());

    if tokio::time::timeout(Duration::from_secs(1), child.wait())
        .await
        .is_err()
    {
        // Best-effort: process may have already exited.
        let _ = child.kill().await;
        // Best-effort: process may have already exited.
        let _ = child.wait().await;
    }
}

/// Spawns one app-server child process with piped stdin/stdout and hidden
/// stderr, returning the child plus owned stdio handles.
///
/// Runtime bootstraps require line-delimited JSON-RPC over stdin/stdout, no
/// interactive stderr stream, and `kill_on_drop(true)` so abandoned runtimes
/// do not leak.
///
/// # Errors
///
/// Returns a provider error when the command cannot be spawned or either
/// required stdio pipe is unavailable.
pub fn spawn_runtime_command(
    command: std::process::Command,
    runtime_name: &str,
) -> Result<
    (
        tokio::process::Child,
        tokio::process::ChildStdin,
        tokio::process::ChildStdout,
    ),
    AppServerError,
> {
    let mut command = tokio::process::Command::from(command);
    command
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::null())
        .kill_on_drop(true);

    let mut child = command.spawn().map_err(|error| {
        AppServerError::Provider(format!("Failed to spawn `{runtime_name}`: {error}"))
    })?;
    let stdin = child
        .stdin
        .take()
        .ok_or_else(|| AppServerError::Provider(format!("{runtime_name} stdin is unavailable")))?;
    let stdout = child
        .stdout
        .take()
        .ok_or_else(|| AppServerError::Provider(format!("{runtime_name} stdout is unavailable")))?;

    Ok((child, stdin, stdout))
}

#[cfg(test)]
mod tests {
    use std::process::Stdio;

    use tokio::io::AsyncBufReadExt;

    use super::*;

    /// Spawns a simple echo process that mirrors stdin to stdout for transport
    /// write tests.
    fn spawn_cat_process() -> tokio::process::Child {
        let mut command = tokio::process::Command::new("cat");
        command
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .kill_on_drop(true);

        command.spawn().expect("failed to spawn `cat`")
    }

    #[test]
    fn response_id_matches_returns_true_for_matching_string_id() {
        // Arrange
        let response_value = serde_json::json!({"id": "init-123", "result": {}});

        // Act / Assert
        assert!(response_id_matches(&response_value, "init-123"));
    }

    #[test]
    fn response_id_matches_returns_false_for_different_id() {
        // Arrange
        let response_value = serde_json::json!({"id": "init-123", "result": {}});

        // Act / Assert
        assert!(!response_id_matches(&response_value, "init-456"));
    }

    #[test]
    fn response_id_matches_returns_false_when_id_is_missing() {
        // Arrange
        let response_value = serde_json::json!({"method": "session/update", "params": {}});

        // Act / Assert
        assert!(!response_id_matches(&response_value, "init-123"));
    }

    #[test]
    fn response_id_matches_returns_false_for_integer_id() {
        // Arrange
        let response_value = serde_json::json!({"id": 1, "result": {}});

        // Act / Assert
        assert!(!response_id_matches(&response_value, "1"));
    }

    #[test]
    fn extract_json_error_message_returns_message_string() {
        // Arrange
        let response_value = serde_json::json!({
            "id": "req-1",
            "error": {"code": -32600, "message": "Invalid request"}
        });

        // Act
        let message = extract_json_error_message(&response_value);

        // Assert
        assert_eq!(message, Some("Invalid request".to_string()));
    }

    #[test]
    fn extract_json_error_message_returns_none_without_error() {
        // Arrange
        let response_value = serde_json::json!({"id": "req-1", "result": {}});

        // Act
        let message = extract_json_error_message(&response_value);

        // Assert
        assert_eq!(message, None);
    }

    #[test]
    fn extract_json_error_message_returns_none_without_message_field() {
        // Arrange
        let response_value = serde_json::json!({
            "id": "req-1",
            "error": {"code": -32600}
        });

        // Act
        let message = extract_json_error_message(&response_value);

        // Assert
        assert_eq!(message, None);
    }

    /// Verifies `write_json_line()` serializes one compact JSON line followed
    /// by a newline.
    #[tokio::test]
    async fn write_json_line_writes_serialized_payload_with_newline() {
        // Arrange
        let mut child = spawn_cat_process();
        let mut stdin = child.stdin.take().expect("`cat` stdin should be piped");
        let stdout = child.stdout.take().expect("`cat` stdout should be piped");
        let payload = serde_json::json!({
            "id": "req-1",
            "method": "initialize",
            "params": {"value": 1}
        });

        // Act
        write_json_line(&mut stdin, &payload)
            .await
            .expect("write should succeed");
        drop(stdin);
        let echoed_line = BufReader::new(stdout)
            .lines()
            .next_line()
            .await
            .expect("stdout read should succeed")
            .expect("echoed payload line should exist");

        // Assert
        assert_eq!(echoed_line, payload.to_string());
        shutdown_child(&mut child).await;
    }

    /// Verifies `wait_for_response_line()` skips unrelated or invalid lines
    /// until the matching response id arrives.
    #[tokio::test]
    async fn wait_for_response_line_skips_invalid_and_non_matching_lines() {
        // Arrange
        let (reader, mut writer) = tokio::io::duplex(512);
        let writer_task = tokio::spawn(async move {
            writer
                .write_all(
                    b"not-json\n{\"id\":\"other\",\"result\":{}}\n{\"id\":\"req-1\",\"result\":{\"ok\":true}}\n",
                )
                .await
                .expect("test writer should succeed");
        });
        let mut stdout_lines = BufReader::new(reader).lines();

        // Act
        let response_line = wait_for_response_line(&mut stdout_lines, "req-1")
            .await
            .expect("matching response should be returned");

        // Assert
        assert_eq!(response_line, "{\"id\":\"req-1\",\"result\":{\"ok\":true}}");
        writer_task.await.expect("writer task should finish");
    }

    /// Verifies `wait_for_response_line()` reports early process termination
    /// when the stream ends before the expected response arrives.
    #[tokio::test]
    async fn wait_for_response_line_returns_error_when_stream_ends() {
        // Arrange
        let (reader, mut writer) = tokio::io::duplex(256);
        let writer_task = tokio::spawn(async move {
            writer
                .write_all(b"{\"id\":\"other\",\"result\":{}}\n")
                .await
                .expect("test writer should succeed");
            drop(writer);
        });
        let mut stdout_lines = BufReader::new(reader).lines();

        // Act
        let response_result = wait_for_response_line(&mut stdout_lines, "req-1").await;

        // Assert
        assert!(
            matches!(
                response_result,
                Err(AppServerTransportError::ProcessTerminated)
            ),
            "expected ProcessTerminated, got: {response_result:?}"
        );
        writer_task.await.expect("writer task should finish");
    }

    #[test]
    fn io_error_display_includes_context_and_source() {
        // Arrange
        let error = AppServerTransportError::Io {
            context: "Failed writing to app-server stdin".to_string(),
            source: std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe closed"),
        };

        // Act
        let display = error.to_string();

        // Assert
        assert_eq!(display, "Failed writing to app-server stdin: pipe closed");
    }

    #[test]
    fn process_terminated_display_message() {
        // Arrange
        let error = AppServerTransportError::ProcessTerminated;

        // Act / Assert
        assert_eq!(
            error.to_string(),
            "App-server terminated before sending expected response"
        );
    }

    #[test]
    fn timeout_display_includes_response_id_and_seconds() {
        // Arrange
        let error = AppServerTransportError::Timeout {
            response_id: "init-123".to_string(),
            timeout_seconds: 300,
        };

        // Act / Assert
        assert_eq!(
            error.to_string(),
            "Timed out waiting for app-server response `init-123` after 300 seconds"
        );
    }

    /// Verifies `shutdown_child()` closes stdin and waits for a cooperative
    /// child process to exit.
    #[tokio::test]
    async fn shutdown_child_exits_cleanly_after_closing_stdin() {
        // Arrange
        let mut child = spawn_cat_process();

        // Act
        shutdown_child(&mut child).await;
        let exit_status = child.wait().await.expect("child wait should succeed");

        // Assert
        assert!(exit_status.success());
    }
}