choreo-daemon 0.1.0

Agentic coding assistant — daemon, TUI, and bridges
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
use crate::daemon::DaemonCommand;
use crate::tools::context::ToolContext;
use crate::tools::{AllowedCaller, Tool, ToolExecError, groups_enum_schema, unknown_group_names};
use choreo_keystore::ServiceCredential;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::Path;
use std::sync::{Weak, mpsc};
use tracing::info;

// ── Args ───────────────────────────────────────────────────────────────────

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub(crate) struct UnloadToolsArgs {
    /// Tool groups to deactivate.
    pub(crate) groups: Vec<String>,
}

// ── Execute ────────────────────────────────────────────────────────────────

/// Apply an `unload_tools` request to the session's active group set,
/// returning a human-readable summary of what changed.  The "core" group is
/// protected and cannot be removed.
///
/// Pure function (no I/O, no channels) so it can be unit-tested directly and
/// reused by the session main loop, which holds the authoritative group set.
pub(crate) fn apply_unload_tools(
    active_tool_groups: &mut HashSet<String>,
    groups: &[String],
) -> String {
    let mut unloaded = Vec::new();
    let mut protected = Vec::new();
    for g in groups {
        if g == "core" {
            protected.push(g.clone());
        } else if active_tool_groups.remove(g) {
            unloaded.push(g.clone());
        }
    }

    let mut parts = Vec::new();
    if !unloaded.is_empty() {
        parts.push(format!(
            "Deactivated tool groups: {}",
            humfmt::list(&unloaded)
        ));
    }
    if !protected.is_empty() {
        parts.push("The 'core' group cannot be unloaded.".to_string());
    }
    if parts.is_empty() {
        parts.push("None of the specified groups were active.".to_string());
    }
    parts.join(" ")
}

fn execute_unload_tools(
    args: &UnloadToolsArgs,
    _working_dir: Option<&Path>,
    ctx: Option<&ToolContext>,
) -> Result<String, ToolExecError> {
    let ctx = ctx.ok_or_else(|| ToolExecError("no session context".into()))?;
    if args.groups.is_empty() {
        return Err(ToolExecError("missing required argument: groups".into()));
    }

    info!(
        session_id = ctx.session_id,
        groups = ?args.groups,
        "deactivating tool groups",
    );

    // Synchronous round-trip: the daemon forwards UnloadTools to the
    // session's main loop, which applies the change to the AUTHORITATIVE
    // active-group set (not this worker's throwaway copy) and replies with
    // a summary of what actually changed.  Blocking on the reply is safe:
    // the daemon replies immediately if the session is inactive, and the
    // session main loop is a dedicated message pump that always answers.
    let (reply, rx) = mpsc::channel();
    ctx.daemon_tx
        .send(DaemonCommand::UnloadTools {
            session_id: ctx.session_id,
            groups: args.groups.clone(),
            reply,
        })
        .map_err(|e| ToolExecError(format!("daemon communication failed: {e}")))?;
    let outcome = rx
        .recv()
        .map_err(|e| ToolExecError(format!("daemon did not respond: {e}")))?;
    outcome.map_err(ToolExecError)
}

pub fn describe_invocation(args: &UnloadToolsArgs) -> String {
    format!("Deactivating tool groups: {}.", args.groups.join(", "))
}

// ── Tool impl ──────────────────────────────────────────────────────────────

/// `unload_tools` tool.  Holds a weak reference to the registry so the JSON
/// Schema's `groups` enum can be derived from the live group catalog at
/// definition time (including dynamic MCP groups registered after startup),
/// mirroring how the pre-registry meta-tool definitions were built.
pub(crate) struct UnloadTools {
    registry: Weak<crate::tools::ToolRegistry>,
}

impl UnloadTools {
    pub fn new(registry: Weak<crate::tools::ToolRegistry>) -> Self {
        UnloadTools { registry }
    }

    /// Group names advertised in the schema (excluding "core", which is
    /// always active).  Falls back to an empty enum if the registry is
    /// gone — the model can still pass any valid group name.
    fn group_names(&self) -> Vec<String> {
        self.registry
            .upgrade()
            .map(|r| r.group_names())
            .unwrap_or_default()
    }
}

impl Tool for UnloadTools {
    type Args = UnloadToolsArgs;
    type Return = String;
    type Error = ToolExecError;

    fn name(&self) -> &'static str {
        "unload_tools"
    }

    fn group(&self) -> &'static str {
        "core"
    }

    fn description(&self) -> &'static str {
        "Deactivate one or more tool groups. Tools in deactivated \
         groups will no longer be available to call in this session. \
         The 'core' group cannot be unloaded."
    }

    fn describe_invocation(&self, args: &Self::Args) -> String {
        describe_invocation(args)
    }

    fn return_string(ret: &Self::Return) -> String {
        ret.clone()
    }

    // Session-config mutation: only the model (Direct) may change the
    // session's tool surface — not programmatic callers.
    fn allowed_callers(&self) -> Vec<AllowedCaller> {
        vec![AllowedCaller::Direct]
    }

    fn schema(&self) -> serde_json::Value {
        // Build the schema by hand (rather than deriving it from schemars)
        // so the `groups` enum reflects the live registry group catalog.
        groups_enum_schema(self.group_names(), "Tool groups to deactivate")
    }

    fn execute(
        &self,
        args: Self::Args,
        _x_credentials: Option<&ServiceCredential>,
        working_dir: Option<&Path>,
        ctx: Option<&ToolContext>,
    ) -> Result<Self::Return, Self::Error> {
        // Reject unknown group names against the live catalog (the schema
        // enum is advisory — the model may pass anything).  "core" is a known
        // name here even though it is protected from unload; it reaches
        // apply_unload_tools and produces the "cannot be unloaded" reply.
        if let Some(known) = self.registry.upgrade().map(|r| r.known_group_names())
            && let Some(unknown) = unknown_group_names(&args.groups, &known)
        {
            return Err(ToolExecError(format!(
                "Unknown tool group(s): {}",
                unknown.join(", ")
            )));
        }
        execute_unload_tools(&args, working_dir, ctx)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tools::ToolRegistry;
    use crate::tools::context::ToolContext;
    use std::sync::Arc;

    /// Build a ToolContext with a mock daemon channel, plus the receiver so
    /// tests can intercept the DaemonCommand and reply to it.
    fn test_context() -> (
        ToolContext,
        std::sync::mpsc::Sender<DaemonCommand>,
        std::sync::mpsc::Receiver<DaemonCommand>,
    ) {
        let (daemon_tx, daemon_rx) = std::sync::mpsc::channel::<DaemonCommand>();
        let dir = tempfile::tempdir().unwrap();
        let db_path = dir.keep(); // Leak: prevent early cleanup of the temp directory.
        let db = Arc::new(redb::Database::create(db_path.join("test.redb")).unwrap());
        let ctx = ToolContext::new(42, db, daemon_tx.clone());
        (ctx, daemon_tx, daemon_rx)
    }

    /// Run execute_unload_tools on a thread (it blocks waiting for the
    /// daemon's reply), intercept the DaemonCommand on the main thread, send
    /// the reply, and join.  Deterministic — no time-based waits: the tool
    /// blocks on the reply channel until this test sends it.  Takes owned
    /// args because the spawned thread must own everything it touches
    /// (`'static` bound).
    fn run_with_daemon_reply(
        args: UnloadToolsArgs,
        reply: Result<String, String>,
    ) -> (Result<String, ToolExecError>, DaemonCommand) {
        let (ctx, _daemon_tx, daemon_rx) = test_context();
        let handle = std::thread::spawn(move || execute_unload_tools(&args, None, Some(&ctx)));
        let cmd = daemon_rx.recv().unwrap();
        match &cmd {
            DaemonCommand::UnloadTools { reply: tx, .. } => {
                tx.send(reply).unwrap();
            }
            other => panic!(
                "expected UnloadTools, got {:?}",
                std::mem::discriminant(other)
            ),
        }
        (handle.join().unwrap(), cmd)
    }

    // -- apply_unload_tools (pure logic) -----------------------------------

    #[test]
    fn apply_removes_groups() {
        let mut active: HashSet<String> = ["core".into(), "git".into(), "shell".into(), "x".into()]
            .into_iter()
            .collect();
        let result = apply_unload_tools(&mut active, &["x".into()]);
        assert_eq!(result, "Deactivated tool groups: x");
        assert!(!active.contains("x"));
        assert!(active.contains("core"));
        assert!(active.contains("git"));
    }

    #[test]
    fn apply_protects_core() {
        let mut active: HashSet<String> = ["core".into(), "git".into()].into_iter().collect();
        let result = apply_unload_tools(&mut active, &["core".into()]);
        assert_eq!(result, "The 'core' group cannot be unloaded.");
        assert!(active.contains("core"));
    }

    #[test]
    fn apply_skips_inactive() {
        let mut active: HashSet<String> = ["core".into()].into_iter().collect();
        let result = apply_unload_tools(&mut active, &["x".into(), "vm".into()]);
        assert_eq!(result, "None of the specified groups were active.");
    }

    #[test]
    fn apply_protected_and_unloaded() {
        let mut active: HashSet<String> = ["core".into(), "shell".into()].into_iter().collect();
        let result = apply_unload_tools(&mut active, &["core".into(), "shell".into()]);
        assert!(result.contains("Deactivated tool groups: shell"));
        assert!(result.contains("The 'core' group cannot be unloaded."));
        assert!(active.contains("core"));
        assert!(!active.contains("shell"));
    }

    // -- execute -----------------------------------------------------------

    #[test]
    fn execute_sends_daemon_command_and_returns_reply() {
        let args = UnloadToolsArgs {
            groups: vec!["x".into()],
        };
        let (result, cmd) = run_with_daemon_reply(args, Ok("Deactivated tool groups: x".into()));
        assert_eq!(result.unwrap(), "Deactivated tool groups: x");
        match cmd {
            DaemonCommand::UnloadTools {
                session_id, groups, ..
            } => {
                assert_eq!(session_id, 42);
                assert_eq!(groups, vec!["x"]);
            }
            _ => panic!("expected UnloadTools command"),
        }
    }

    #[test]
    fn execute_forwards_daemon_error() {
        let args = UnloadToolsArgs {
            groups: vec!["x".into()],
        };
        let (result, _cmd) = run_with_daemon_reply(args, Err("session is not active".into()));
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("session is not active")
        );
    }

    #[test]
    fn execute_no_context_returns_error() {
        let args = UnloadToolsArgs {
            groups: vec!["x".into()],
        };
        let result = execute_unload_tools(&args, None, None);
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("no session context")
        );
    }

    #[test]
    fn execute_empty_groups_returns_error() {
        let (ctx, _daemon_tx, _daemon_rx) = test_context();
        let args = UnloadToolsArgs { groups: vec![] };
        let result = execute_unload_tools(&args, None, Some(&ctx));
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("missing required argument: groups")
        );
    }

    // -- schema ------------------------------------------------------------

    #[test]
    fn schema_has_groups_enum_excluding_core() {
        let registry = ToolRegistry::new().build();
        let tool = UnloadTools::new(Arc::downgrade(&registry));
        let schema = tool.schema();
        let items = schema["properties"]["groups"]["items"].as_object().unwrap();
        let enum_vals = items["enum"].as_array().unwrap();
        let names: Vec<&str> = enum_vals.iter().filter_map(|v| v.as_str()).collect();
        assert!(names.contains(&"git"), "enum should include git: {names:?}");
        assert!(
            !names.contains(&"core"),
            "core must not appear in unload_tools enum: {names:?}"
        );
        let required = schema["required"].as_array().unwrap();
        assert!(required.iter().any(|v| v == "groups"));
    }

    #[test]
    fn tool_restricted_to_direct_callers() {
        let registry = ToolRegistry::new().build();
        let tool = UnloadTools::new(Arc::downgrade(&registry));
        let callers = tool.allowed_callers();
        assert_eq!(callers, vec![AllowedCaller::Direct]);
        assert!(!callers.contains(&AllowedCaller::Programmatic));
    }

    #[test]
    fn execute_rejects_unknown_group() {
        let registry = ToolRegistry::new().build();
        let tool = UnloadTools::new(Arc::downgrade(&registry));
        let args = UnloadToolsArgs {
            groups: vec!["not-a-real-group".into()],
        };

        let result = tool.execute(args, None, None, None);
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("Unknown tool group(s): not-a-real-group")
        );
    }

    #[test]
    fn execute_accepts_core_and_known_groups() {
        let registry = ToolRegistry::new().build();
        let tool = UnloadTools::new(Arc::downgrade(&registry));
        // "core" is known (protected from unload) and "git" is a real group —
        // validation must not reject either before apply runs.
        let args = UnloadToolsArgs {
            groups: vec!["core".into(), "git".into()],
        };
        let result = tool.execute(args, None, None, None);
        assert!(
            result
                .err()
                .map(|e| e.to_string())
                .is_some_and(|e| e.contains("no session context"))
        );
    }

    #[test]
    fn describe_invocation_includes_groups() {
        let args = UnloadToolsArgs {
            groups: vec!["git".into(), "shell".into()],
        };
        let desc = describe_invocation(&args);
        assert_eq!(desc, "Deactivating tool groups: git, shell.");
    }

    #[test]
    fn execute_postcard_args_round_trip() {
        // Verify the args can be serialised/deserialised via postcard,
        // which is the wire format used by the VM execution path.
        let args = UnloadToolsArgs {
            groups: vec!["x".into()],
        };
        let args_bytes = postcard::to_allocvec(&args).unwrap();
        let decoded: UnloadToolsArgs = postcard::from_bytes(&args_bytes).unwrap();
        assert_eq!(decoded.groups, vec!["x"]);
    }
}