recursive-agent 0.6.0

A minimal, orthogonal, self-improving coding agent kernel in Rust
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
//! Integration tests for Goal 152 — Incremental transcript writes.
//!
//! Verifies that `SessionPersistenceSink` + `AgentRuntime` persist each
//! completed message to `transcript.jsonl` as it is committed, rather than
//! in a single batch after the run.

use std::sync::Arc;

use recursive::test_util::PinnedRecursiveHome;
use recursive::{
    compact::Compactor,
    event::{CompositeSink, EventSink, NullSink},
    llm::{Completion, MockProvider, ToolCall},
    message::{Message, Role},
    session::{SessionReader, SessionWriter},
    AgentRuntime, ChannelSink, SessionPersistenceSink,
};
use serde_json::json;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn workspace() -> std::path::PathBuf {
    std::path::PathBuf::from("/tmp/g152-test-ws")
}

struct HomePin {
    _dir: tempfile::TempDir,
    _pin: PinnedRecursiveHome,
}

impl HomePin {
    fn new() -> Self {
        let dir = tempfile::tempdir().expect("tempdir");
        let pin = PinnedRecursiveHome::new(dir.path());
        Self {
            _dir: dir,
            _pin: pin,
        }
    }
}

fn make_session(_home: &HomePin) -> Arc<std::sync::Mutex<SessionWriter>> {
    let ws = workspace();
    let w = SessionWriter::create(&ws, "test goal", "mock-model", "mock")
        .expect("create session writer");
    Arc::new(std::sync::Mutex::new(w))
}

fn get_session_dir(sw: &Arc<std::sync::Mutex<SessionWriter>>) -> std::path::PathBuf {
    sw.lock().unwrap().session_dir().to_path_buf()
}

fn load_messages_from(sw: &Arc<std::sync::Mutex<SessionWriter>>) -> Vec<Message> {
    let dir = get_session_dir(sw);
    SessionReader::load_messages(&dir).expect("load messages")
}

fn simple_completion(text: &str) -> Completion {
    Completion {
        content: text.into(),
        tool_calls: vec![],
        finish_reason: Some("stop".into()),
        usage: None,
        reasoning_content: None,
    }
}

fn make_persistence_composite(sw: &Arc<std::sync::Mutex<SessionWriter>>) -> Arc<dyn EventSink> {
    let (channel_sink, _rx) = ChannelSink::new();
    Arc::new(CompositeSink::new(vec![
        Box::new(channel_sink) as Box<dyn EventSink>,
        Box::new(SessionPersistenceSink::new(sw.clone())) as Box<dyn EventSink>,
    ]))
}

// ---------------------------------------------------------------------------
// Test 1: messages written to disk BEFORE finalize_session_writer is called
// ---------------------------------------------------------------------------

/// After `runtime.run()` returns, `transcript.jsonl` already contains the
/// committed messages — even though `finalize_session_writer` (which sets
/// the final meta-status) has not been called yet.
///
/// This is the core property of Goal 152: "completed-or-bust" persistence is
/// replaced by per-message incremental writes.
#[tokio::test]
async fn messages_persisted_before_finalize() {
    let home = HomePin::new();
    let sw = make_session(&home);

    let llm = Arc::new(MockProvider::new(vec![simple_completion("ok")]));
    let sink = make_persistence_composite(&sw);

    let mut rt = AgentRuntime::builder()
        .llm(llm)
        .event_sink(sink)
        .build()
        .unwrap();

    rt.run("do something").await.unwrap();
    drop(rt); // runtime dropped; finalize_session_writer NOT called yet

    let transcript = load_messages_from(&sw);
    // Expect at minimum: user message + assistant message.
    assert!(
        transcript.len() >= 2,
        "expected ≥2 messages before finalize, got {}",
        transcript.len()
    );
    assert_eq!(transcript[0].role, Role::User);
    assert_eq!(transcript[0].content, "do something");
    assert_eq!(transcript[1].role, Role::Assistant);
    assert_eq!(transcript[1].content, "ok");
}

// ---------------------------------------------------------------------------
// Test 2: message with all three fields written as a single jsonl line
// ---------------------------------------------------------------------------

/// An assistant message that carries `content`, `tool_calls`, **and**
/// `reasoning_content` round-trips through the jsonl without data loss.
#[tokio::test]
async fn assistant_with_reasoning_and_tool_calls_one_line() {
    use recursive::llm::ToolSpec;
    use recursive::tools::ToolRegistry;
    use recursive::Tool;

    let home = HomePin::new();
    let sw = make_session(&home);

    let llm = Arc::new(MockProvider::new(vec![
        Completion {
            content: "thinking out loud".into(),
            tool_calls: vec![ToolCall {
                id: "call_42".into(),
                name: "echo_tool".into(),
                arguments: json!({"x": 1}),
            }],
            finish_reason: Some("tool_calls".into()),
            usage: None,
            reasoning_content: Some("my deep reasoning".into()),
        },
        simple_completion("done"),
    ]));

    // Minimal tool so the runtime can execute the tool call.
    struct EchoTool;
    #[async_trait::async_trait]
    impl Tool for EchoTool {
        fn spec(&self) -> ToolSpec {
            ToolSpec {
                name: "echo_tool".into(),
                description: "echo".into(),
                parameters: json!({"type":"object","properties":{}}),
            }
        }
        async fn execute(&self, _args: serde_json::Value) -> recursive::error::Result<String> {
            Ok("echoed".into())
        }
    }
    let reg = ToolRegistry::local().register(Arc::new(EchoTool));

    let sink = make_persistence_composite(&sw);
    let mut rt = AgentRuntime::builder()
        .llm(llm)
        .tools(reg)
        .event_sink(sink)
        .build()
        .unwrap();

    rt.run("run the tool").await.unwrap();
    drop(rt);

    let transcript = load_messages_from(&sw);
    // user + assistant(tool_call+reasoning) + tool_result + assistant("done") = 4
    assert!(transcript.len() >= 3, "got {} messages", transcript.len());

    let with_reasoning = transcript
        .iter()
        .find(|m| m.reasoning_content.is_some())
        .expect("no message with reasoning_content");
    assert_eq!(with_reasoning.content, "thinking out loud");
    assert_eq!(
        with_reasoning.reasoning_content.as_deref(),
        Some("my deep reasoning")
    );
    assert_eq!(with_reasoning.tool_calls.len(), 1);
    assert_eq!(with_reasoning.tool_calls[0].name, "echo_tool");
}

// ---------------------------------------------------------------------------
// Test 3: streaming partial tokens do NOT produce extra jsonl lines
// ---------------------------------------------------------------------------

/// When streaming mode is enabled, `PartialToken` events are emitted but
/// must not produce separate jsonl lines.  Only complete `Message`s are
/// persisted — the jsonl line count equals the committed-message count.
#[tokio::test]
async fn streaming_partial_tokens_dont_persist() {
    let home = HomePin::new();
    let sw = make_session(&home);

    let llm = Arc::new(MockProvider::new(vec![simple_completion("streamed reply")]));
    let sink = Arc::new(CompositeSink::new(vec![
        Box::new(NullSink) as Box<dyn EventSink>,
        Box::new(SessionPersistenceSink::new(sw.clone())) as Box<dyn EventSink>,
    ]));

    let mut rt = AgentRuntime::builder()
        .llm(llm)
        .event_sink(sink)
        .streaming(true)
        .build()
        .unwrap();

    rt.run("stream something").await.unwrap();
    drop(rt);

    let transcript = load_messages_from(&sw);
    // Exactly 2: user + assistant — no extra lines from streaming chunks.
    assert_eq!(
        transcript.len(),
        2,
        "expected exactly 2 messages (user + assistant), got {}",
        transcript.len()
    );
    assert_eq!(transcript[0].content, "stream something");
    assert_eq!(transcript[1].content, "streamed reply");
}

// ---------------------------------------------------------------------------
// Test 4: compaction summary appears in jsonl
// ---------------------------------------------------------------------------

/// When cross-turn compaction fires, the synthesised summary message is
/// appended to the jsonl (even though it is inserted at index 0 in memory).
/// Pre-compaction messages remain in the file (append-only log).
#[tokio::test]
async fn compaction_summary_appears_in_jsonl() {
    let home = HomePin::new();
    let sw = make_session(&home);

    // Three turns: first two fill transcript, compactor fires on turn 3.
    // MockProvider needs replies for: turn1, turn2, compactor-summarise, turn3.
    let llm = Arc::new(MockProvider::new(vec![
        simple_completion("reply1"),
        simple_completion("reply2"),
        simple_completion("compact summary"),
        simple_completion("reply3"),
    ]));

    let sink = Arc::new(CompositeSink::new(vec![
        Box::new(NullSink) as Box<dyn EventSink>,
        Box::new(SessionPersistenceSink::new(sw.clone())) as Box<dyn EventSink>,
    ]));

    // Threshold=1 char forces compaction every turn; keep_recent_n=2.
    let compactor = Compactor::new(1).keep_recent_n(2);

    let mut rt = AgentRuntime::builder()
        .llm(llm)
        .event_sink(sink)
        .compactor(compactor)
        .build()
        .unwrap();

    rt.run("turn1").await.unwrap();
    rt.run("turn2").await.unwrap();
    rt.run("turn3").await.unwrap();
    drop(rt);

    let transcript = load_messages_from(&sw);

    // Compaction summary must appear in jsonl.
    // The compactor prepends "[compacted: N messages → M chars]\n" before the
    // model's text, so we search by substring rather than exact match.
    let has_summary = transcript
        .iter()
        .any(|m| m.content.contains("compact summary"));
    assert!(
        has_summary,
        "compaction summary not found; contents: {:?}",
        transcript.iter().map(|m| &m.content).collect::<Vec<_>>()
    );
    // g157: The compact_boundary marker causes load_transcript to skip
    // pre-compaction messages. "reply1" lives before the boundary and is
    // therefore not returned by the reader — this is the intended behaviour
    // (the summary replaced it). The file still contains reply1 physically
    // (append-only), but the reader won't surface it on resume.
    let post_boundary_count = transcript.len();
    assert!(
        post_boundary_count >= 1,
        "expected at least the compaction summary after boundary, got 0"
    );
}

// ---------------------------------------------------------------------------
// Test 5: resume after clean run — no duplicate messages
// ---------------------------------------------------------------------------

/// Load an existing session, run a second turn on the same writer — no
/// message is duplicated and the count grows by exactly 2 (user + assistant).
#[tokio::test]
async fn resume_after_clean_run_no_double_write() {
    let home = HomePin::new();
    let sw = make_session(&home);

    // --- First run ---
    {
        let llm = Arc::new(MockProvider::new(vec![simple_completion("first answer")]));
        let sink = make_persistence_composite(&sw);
        let mut rt = AgentRuntime::builder()
            .llm(llm)
            .event_sink(sink)
            .build()
            .unwrap();
        rt.run("first turn").await.unwrap();
    }

    let count_after_first = load_messages_from(&sw).len();
    assert_eq!(count_after_first, 2); // user + assistant

    // --- Second run (seed from disk, same writer) ---
    {
        let seed = load_messages_from(&sw);
        let llm = Arc::new(MockProvider::new(vec![simple_completion("second answer")]));
        let sink = make_persistence_composite(&sw);
        let mut rt = AgentRuntime::builder()
            .llm(llm)
            .event_sink(sink)
            .seed_transcript(seed)
            .build()
            .unwrap();
        rt.run("second turn").await.unwrap();
    }

    let transcript = load_messages_from(&sw);
    assert_eq!(
        transcript.len(),
        count_after_first + 2,
        "expected exactly 2 new messages; before={count_after_first} after={}",
        transcript.len()
    );

    // No content duplicates.
    let contents: Vec<&str> = transcript.iter().map(|m| m.content.as_str()).collect();
    let unique: std::collections::HashSet<_> = contents.iter().copied().collect();
    assert_eq!(
        contents.len(),
        unique.len(),
        "duplicate content: {contents:?}"
    );
}

// ---------------------------------------------------------------------------
// Test 6: orphan shape survives reload
// ---------------------------------------------------------------------------

/// A session whose last on-disk message is an `assistant` with `tool_calls`
/// but no matching `tool` reply (orphan shape) can be loaded without data
/// loss.  This is the input g153 orphan detection will consume.
#[tokio::test]
async fn resume_after_crash_orphan_visible() {
    let home = HomePin::new();
    let sw = make_session(&home);

    // Manually write an orphan: assistant with tool_calls, crash before result.
    let session_dir = {
        let mut w = sw.lock().unwrap();
        w.append(&Message::user("do something"), None, None)
            .unwrap();
        w.append(
            &Message {
                role: Role::Assistant,
                content: "I will call the tool".into(),
                tool_calls: vec![ToolCall {
                    id: "orphan_call_1".into(),
                    name: "run_shell".into(),
                    arguments: json!({"cmd": "dangerous"}),
                }],
                tool_call_id: None,
                reasoning_content: None,
            },
            None,
            None,
        )
        .unwrap();
        // "Crash" here — no tool result written.
        w.session_dir().to_path_buf()
    };

    let transcript = SessionReader::load_messages(&session_dir).expect("load");

    assert_eq!(transcript.len(), 2, "expected user + orphan assistant");
    let last = &transcript[1];
    assert_eq!(last.role, Role::Assistant);
    assert_eq!(last.tool_calls.len(), 1);
    assert_eq!(last.tool_calls[0].id, "orphan_call_1");
    assert!(
        transcript.iter().all(|m| m.role != Role::Tool),
        "unexpected tool result in orphan transcript"
    );
}