recursive-agent 0.8.3

A Rust coding-agent platform: kernel, tools, multi-agent orchestration, HTTP API, MCP, and TUI
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
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
//! Tool abstraction: any side effect the model can request.
//!
//! Tools are orthogonal to the agent and to each other. To add a capability
//! you implement `Tool` and register it; no other file changes.

// ── Sub-modules ────────────────────────────────────────────────────────────

pub mod a2a;
pub mod agent;
pub mod agent_defs;
pub mod audit;
pub mod checkpoint;
pub mod client_fs;
pub mod count_lines;
pub mod dispatch;
#[cfg(feature = "cloud-runtime")]
pub mod docker_provider;
#[cfg(feature = "cloud-runtime")]
pub mod docker_sandbox;
#[cfg(feature = "e2b-sandbox")]
pub mod e2b_provider;
pub mod edit;
pub mod episodic_recall;
pub mod estimate_tokens;
pub mod facts;
pub mod fs;
pub mod glob;
#[cfg(feature = "skill-hub")]
pub mod install_skill;
pub mod load_skill;
pub mod memory;
pub mod permission_pipeline;
pub mod plan_mode;
pub mod policy;
pub mod policy_sandbox;
pub mod registry;
pub mod run_background;
pub mod schedule_wakeup;
pub mod search;
pub mod send_message;
pub mod shell;
pub mod stop_loop;
#[cfg(feature = "coordinator-mode")]
pub mod task_create;
#[cfg(feature = "coordinator-mode")]
pub mod task_get;
#[cfg(feature = "coordinator-mode")]
pub mod task_list;
#[cfg(feature = "coordinator-mode")]
pub mod task_output;
#[cfg(feature = "coordinator-mode")]
pub mod task_stop;
#[cfg(feature = "coordinator-mode")]
pub mod task_update;
#[cfg(feature = "coordinator-mode")]
pub mod team_create;
#[cfg(feature = "coordinator-mode")]
pub mod team_delete;
pub mod todo;
pub mod tool_search;
pub mod transport;
pub(crate) mod url_guard;
pub mod watch_file;
#[cfg(feature = "web_fetch")]
pub mod web_fetch;
#[cfg(feature = "web_search")]
pub mod web_search;

// ── Re-exports from registry ────────────────────────────────────────────────

pub use registry::{
    build_standard_tools, build_standard_tools_with_roots, PermissionHook, SpecWithHint, Tool,
    ToolRegistry,
};

// ── Re-exports from audit ───────────────────────────────────────────────────

pub use audit::{
    AuditKey, AuditMeta, ExitStatus, ToolSideEffect, TouchedFiles, AUDIT_ERR_MAX_BYTES,
};

// ── Re-exports from dispatch ────────────────────────────────────────────────

pub use dispatch::resolve_within;
pub use dispatch::{args_preview_for_permission, ToolDispatch};
pub use dispatch::{new_shared_sandbox_roots, resolve_within_any, AccessTier, SharedSandboxRoots};

// ── Re-exports from individual tool modules ─────────────────────────────────

pub use a2a::{A2aCallTool, A2aCardTool, A2aTaskCheckTool};
pub use agent::{AgentTool, SharedMemoryRead, SharedMemoryWrite};
pub use agent_defs::{AgentDefinition, AgentDefinitions};
pub use checkpoint::{
    build_checkpoint_save_tool, build_checkpoint_tools, CheckpointDiff, CheckpointList,
    CheckpointSave, CheckpointSaveCtx, CheckpointToolCtx,
};
pub use client_fs::{AcpClientFsState, ClientReadFile, ClientWriteFile};
pub use count_lines::CountLines;
pub use edit::EditTool;
pub use episodic_recall::{episodic_recall_summary, EpisodicRecall};
pub use estimate_tokens::EstimateTokens;
pub use facts::{
    facts_path, facts_summary, load_facts, search_facts, Fact, FactStore, ForgetFact, RecallFact,
    RememberFact, ScoredFact, UpdateFact,
};
pub use fs::{ReadFile, ReadFileState, WriteFile};
pub use glob::GlobTool;
#[cfg(feature = "skill-hub")]
pub use install_skill::InstallSkill;
pub use load_skill::LoadSkill;
pub use memory::{
    load_scratchpad, scratchpad_path, scratchpad_summary, Scratchpad, ScratchpadDelete,
    ScratchpadGet, ScratchpadList, WorkingMemoryTool,
};
pub use memory::{Forget, Recall, Remember};
pub use permission_pipeline::{CheckOutcome, PermissionPipeline};
pub use plan_mode::{
    EnterPlanModeTool, ExitPlanModeTool, PlanApprovalGate, PlanApprovalResult, PlanModeRequestGate,
    PlanModeRequestResult, RequestPlanModeTool,
};
pub use policy_sandbox::{FsPolicy, PolicyConfig, ShellPolicy};
pub use run_background::LoopControl;
pub use run_background::{
    BackgroundJobManager, CheckBackground, Job, JobState, RunBackground, WatchTarget,
};
pub use schedule_wakeup::{ScheduleWakeup, WakeupRequest, WakeupSlot};
pub use search::SearchFiles;
pub use send_message::{ListWorkersTool, SendMessageTool, WorkerMailbox, WorkerRegistry};
pub use shell::RunShell;
pub use stop_loop::StopLoop;
pub use watch_file::WatchFile;

#[cfg(feature = "coordinator-mode")]
pub use task_create::TaskCreateTool;
#[cfg(feature = "coordinator-mode")]
pub use task_get::TaskGetTool;
#[cfg(feature = "coordinator-mode")]
pub use task_list::TaskListTool;
#[cfg(feature = "coordinator-mode")]
pub use task_output::TaskOutputTool;
#[cfg(feature = "coordinator-mode")]
pub use task_stop::TaskStopTool;
#[cfg(feature = "coordinator-mode")]
pub use task_update::TaskUpdateTool;
#[cfg(feature = "coordinator-mode")]
pub use team_create::TeamCreateTool;
#[cfg(feature = "coordinator-mode")]
pub use team_delete::TeamDeleteTool;
pub use todo::{TodoItem, TodoStatus, TodoWriteTool};
pub use tool_search::{DeferredCatalog, ToolSearchTool, TOOL_SEARCH_TOOL_NAME};
pub use transport::{DirEntry, ExecResult, LocalTransport, ReadResult, ToolTransport};
#[cfg(feature = "web_fetch")]
pub use web_fetch::WebFetch;
#[cfg(feature = "web_search")]
pub use web_search::WebSearch;

// ── Tests ───────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::registry::{PermissionHook, Tool, ToolRegistry};
    use crate::error::{Error, Result};
    use crate::llm::ToolSpec;
    use crate::permissions::PermissionMode;
    use async_trait::async_trait;
    use serde_json::Value;
    use std::sync::Arc;

    struct Echo;

    #[async_trait]
    impl Tool for Echo {
        fn spec(&self) -> ToolSpec {
            ToolSpec {
                name: "echo".into(),
                description: "echo".into(),
                parameters: serde_json::json!({"type":"object","properties":{"msg":{"type":"string"}}}),
            }
        }
        async fn execute(&self, args: Value) -> Result<String> {
            Ok(args["msg"].as_str().unwrap_or("").into())
        }
    }

    #[tokio::test]
    async fn registry_dispatches_and_errors_on_unknown() {
        let reg = ToolRegistry::local().register(Arc::new(Echo));
        let out = reg
            .invoke("echo", serde_json::json!({"msg":"hi"}))
            .await
            .unwrap();
        assert_eq!(out, "hi");
        let err = reg.invoke("nope", serde_json::json!({})).await.unwrap_err();
        assert!(matches!(err, Error::UnknownTool(_)));
    }

    #[test]
    fn resolve_within_rejects_escape() {
        let root = std::path::Path::new("/work");
        assert!(super::dispatch::resolve_within(root, "../etc/passwd").is_err());
        assert!(super::dispatch::resolve_within(root, "/elsewhere").is_err());
        assert!(super::dispatch::resolve_within(root, "src/lib.rs").is_ok());
    }

    #[test]
    fn resolve_within_handles_relative_root() {
        // Regression: `--workspace .` (relative) used to fail the prefix check.
        let cwd = std::env::current_dir().unwrap();
        let resolved =
            super::dispatch::resolve_within(std::path::Path::new("."), "src/lib.rs").unwrap();
        assert!(resolved.starts_with(&cwd));
        assert!(resolved.ends_with("src/lib.rs"));
    }

    #[tokio::test]
    async fn test_permission_deny_blocks_invoke() {
        let config = crate::permissions::LayeredPermissionsConfig {
            mode: PermissionMode::Default,
            layers: vec![crate::permissions::PermissionLayer {
                source: crate::permissions::RuleSource::User,
                allow: vec!["echo".into()],
                deny: vec!["echo".into()],
                ..Default::default()
            }],
        };
        let reg = ToolRegistry::local()
            .with_permissions(config)
            .register(Arc::new(Echo));
        let err = reg
            .invoke("echo", serde_json::json!({"msg":"hi"}))
            .await
            .unwrap_err();
        assert!(matches!(err, Error::PermissionDenied { .. }));
    }

    // ── Goal-161: PermissionHook tests ───────────────────────────────────

    struct AllowHook;
    struct DenyHook;

    #[async_trait]
    impl PermissionHook for AllowHook {
        async fn check(
            &self,
            _name: &str,
            _args: &serde_json::Value,
        ) -> crate::agent::PermissionDecision {
            crate::agent::PermissionDecision::Allow
        }
    }

    #[async_trait]
    impl PermissionHook for DenyHook {
        async fn check(
            &self,
            _name: &str,
            _args: &serde_json::Value,
        ) -> crate::agent::PermissionDecision {
            crate::agent::PermissionDecision::Deny("denied by test hook".to_string())
        }
    }

    #[tokio::test]
    async fn permission_hook_allow_lets_tool_run() {
        let reg = ToolRegistry::local()
            .with_permission_hook(Arc::new(AllowHook))
            .register(Arc::new(Echo));
        let result = reg
            .invoke("echo", serde_json::json!({"msg": "hello"}))
            .await
            .unwrap();
        assert_eq!(result, "hello");
    }

    #[tokio::test]
    async fn permission_hook_deny_blocks_invoke() {
        let reg = ToolRegistry::local()
            .with_permission_hook(Arc::new(DenyHook))
            .register(Arc::new(Echo));
        let err = reg
            .invoke("echo", serde_json::json!({"msg": "blocked"}))
            .await
            .unwrap_err();
        assert!(matches!(err, Error::PermissionDenied { .. }));
    }

    #[test]
    fn args_preview_truncates_long_strings() {
        let big_val = "x".repeat(200);
        let args = serde_json::json!({"command": big_val});
        let preview = super::dispatch::args_preview_for_permission(&args);
        assert!(preview.chars().count() <= 81); // 80 chars + ellipsis
    }

    // ── Goal-199: Headless mode tests ────────────────────────────────────

    /// headless=true, no hooks, interactive tool → PermissionDenied
    #[tokio::test]
    async fn headless_interactive_tool_denied_without_hooks() {
        let config = crate::permissions::LayeredPermissionsConfig {
            mode: PermissionMode::Default,
            layers: vec![crate::permissions::PermissionLayer {
                source: crate::permissions::RuleSource::User,
                interactive: vec!["echo".into()],
                ..Default::default()
            }],
        };
        let reg = ToolRegistry::local()
            .with_permissions(config)
            .with_headless(true)
            .register(Arc::new(Echo));
        let err = reg
            .invoke("echo", serde_json::json!({"msg": "hi"}))
            .await
            .unwrap_err();
        assert!(matches!(err, Error::PermissionDenied { .. }));
    }

    /// headless=true, mock hook returns Continue → interactive tool allowed
    #[tokio::test]
    async fn headless_interactive_tool_allowed_by_hook() {
        use tempfile::tempdir;
        let tmp = tempdir().unwrap();
        let hook_path = tmp.path().join("allow.sh");
        let script = "#!/bin/sh\nread -r _\necho '{\"action\":\"continue\"}'\n";
        std::fs::write(&hook_path, script).unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
            perms.set_mode(0o755);
            std::fs::set_permissions(&hook_path, perms).unwrap();
        }
        let hook_runner = crate::hooks::ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);

        let config = crate::permissions::LayeredPermissionsConfig {
            mode: PermissionMode::Default,
            layers: vec![crate::permissions::PermissionLayer {
                source: crate::permissions::RuleSource::User,
                interactive: vec!["echo".into()],
                ..Default::default()
            }],
        };
        let reg = ToolRegistry::local()
            .with_permissions(config)
            .with_headless(true)
            .with_hook_runner(hook_runner)
            .register(Arc::new(Echo));

        #[cfg(unix)]
        {
            let result = reg
                .invoke("echo", serde_json::json!({"msg": "allowed"}))
                .await
                .unwrap();
            assert_eq!(result, "allowed");
        }
        #[cfg(not(unix))]
        {
            let err = reg
                .invoke("echo", serde_json::json!({"msg": "blocked"}))
                .await
                .unwrap_err();
            assert!(matches!(err, Error::PermissionDenied { .. }));
        }
    }

    /// headless=false → interactive tools go through normal path (Passthrough)
    #[tokio::test]
    async fn non_headless_interactive_not_auto_denied() {
        let config = crate::permissions::LayeredPermissionsConfig {
            mode: PermissionMode::Default,
            layers: vec![crate::permissions::PermissionLayer {
                source: crate::permissions::RuleSource::User,
                interactive: vec!["echo".into()],
                ..Default::default()
            }],
        };
        let reg = ToolRegistry::local()
            .with_permissions(config)
            .with_headless(false)
            .register(Arc::new(Echo));
        let result = reg
            .invoke("echo", serde_json::json!({"msg": "hello"}))
            .await
            .unwrap();
        assert_eq!(result, "hello");
    }

    // ── Goal-212: Permission::Unknown semantics ──────────────────────────────

    /// Smoke test: Permission::Unknown variant compiles and can be constructed.
    #[test]
    fn permission_unknown_variant_exists() {
        use crate::permissions::Permission;
        let u = Permission::Unknown;
        assert!(!u.is_allowed());
        assert!(!u.is_denied());
    }

    /// Unknown + non-headless + interactive + DenyHook → PermissionDenied
    /// (invoke_with_audit called directly, bypassing invoke()).
    #[tokio::test]
    async fn unknown_interactive_tool_deny_hook_blocks_invoke_with_audit() {
        let config = crate::permissions::LayeredPermissionsConfig {
            mode: PermissionMode::Default,
            layers: vec![crate::permissions::PermissionLayer {
                source: crate::permissions::RuleSource::User,
                interactive: vec!["echo".into()],
                ..Default::default()
            }],
        };
        let reg = ToolRegistry::local()
            .with_permissions(config)
            .with_permission_hook(Arc::new(DenyHook))
            .with_headless(false)
            .register(Arc::new(Echo));
        let dispatch = reg
            .invoke_with_audit("echo", serde_json::json!({"msg": "hi"}))
            .await;
        assert!(
            matches!(dispatch.result, Err(Error::PermissionDenied { .. })),
            "hook-deny should block interactive Unknown tool via invoke_with_audit"
        );
    }

    /// Unknown + non-headless + interactive + no hook → allowed (library default).
    #[tokio::test]
    async fn unknown_interactive_tool_no_hook_is_allowed() {
        let config = crate::permissions::LayeredPermissionsConfig {
            mode: PermissionMode::Default,
            layers: vec![crate::permissions::PermissionLayer {
                source: crate::permissions::RuleSource::User,
                interactive: vec!["echo".into()],
                ..Default::default()
            }],
        };
        let reg = ToolRegistry::local()
            .with_permissions(config)
            .with_headless(false)
            .register(Arc::new(Echo));
        let dispatch = reg
            .invoke_with_audit("echo", serde_json::json!({"msg": "ok"}))
            .await;
        assert_eq!(
            dispatch.result.unwrap(),
            "ok",
            "no hook = allow for Unknown interactive tool"
        );
    }

    /// Unknown + non-headless + non-interactive + DenyHook → allowed (hook not consulted).
    #[tokio::test]
    async fn unknown_non_interactive_tool_hook_not_consulted() {
        // echo is NOT in the interactive list; DenyHook should not fire.
        let config = crate::permissions::LayeredPermissionsConfig {
            mode: PermissionMode::Default,
            layers: vec![],
        };
        let reg = ToolRegistry::local()
            .with_permissions(config)
            .with_permission_hook(Arc::new(DenyHook))
            .with_headless(false)
            .register(Arc::new(Echo));
        let dispatch = reg
            .invoke_with_audit("echo", serde_json::json!({"msg": "pass"}))
            .await;
        assert_eq!(
            dispatch.result.unwrap(),
            "pass",
            "DenyHook must not fire for non-interactive Unknown tools"
        );
    }

    /// Allowed (explicit allow rule) + interactive + DenyHook
    /// → allowed (no hook fired because perm_is_unknown=false).
    #[tokio::test]
    async fn allowed_interactive_tool_hook_not_consulted() {
        let config = crate::permissions::LayeredPermissionsConfig {
            mode: PermissionMode::Default,
            layers: vec![crate::permissions::PermissionLayer {
                source: crate::permissions::RuleSource::User,
                allow: vec!["echo".into()],
                interactive: vec!["echo".into()],
                ..Default::default()
            }],
        };
        let reg = ToolRegistry::local()
            .with_permissions(config)
            .with_permission_hook(Arc::new(DenyHook))
            .with_headless(false)
            .register(Arc::new(Echo));
        // invoke_with_audit directly: check_static returns Allowed, not Unknown,
        // so the Goal-212 hook block must NOT fire.
        let dispatch = reg
            .invoke_with_audit("echo", serde_json::json!({"msg": "explicit-allow"}))
            .await;
        assert_eq!(
            dispatch.result.unwrap(),
            "explicit-allow",
            "Explicitly Allowed tools must not be re-checked via hook"
        );
    }

    // ── Goal-201: plan mode tools are opt-in (not in default registry) ──────

    #[test]
    fn default_registry_has_no_plan_mode_tools() {
        // build_standard_tools() must NOT register enter_plan_mode / exit_plan_mode.
        // These are channel capabilities owned exclusively by AgentRuntimeBuilder.
        let workspace = std::path::PathBuf::from(".");
        let registry = super::registry::build_standard_tools(&workspace, &[], 30);
        assert!(
            registry.get("enter_plan_mode").is_none(),
            "enter_plan_mode must not be in the default registry"
        );
        assert!(
            registry.get("exit_plan_mode").is_none(),
            "exit_plan_mode must not be in the default registry"
        );
    }

    // ── Goal-247: ToolRegistry::fork() ──────────────────────────────────────

    #[test]
    fn fork_returns_usable_registry() {
        let reg = ToolRegistry::local().register(Arc::new(Echo));
        let forked = reg.fork();
        // fork() should return a registry that can invoke tools
        assert!(
            forked.get("echo").is_some(),
            "forked registry should contain 'echo'"
        );
        assert!(
            forked.get("nope").is_none(),
            "forked registry should not contain unknown tools"
        );
    }
}