10
use std::sync::{Arc, Mutex, OnceLock};
11
12
use crate::backend::{InferenceBackend, ToolResult, ToolSpec};
13
+use crate::subagents;
14
15
const WEBSITE_READ_CHAR_LIMIT: usize = 20_000;
16
const WEBSITE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
469
// toolset is strictly read-only and never includes `task` itself (no
470
// recursion), so a delegated agent can research but not mutate state.
471
//
472
+// A caller may optionally pick a *configurable subagent type* (see
473
+// `crate::subagents`, `.sigit/agents/*.md`) via the `subagent_type` argument.
474
+// A type can only ever replace the system prompt and *narrow* the toolset —
475
+// never grant tools outside `SUBAGENT_TOOL_NAMES` — so this stays exactly as
476
+// safe as the default research subagent: a `.sigit/agents/*.md` file cannot
477
+// grant itself `edit_file` or `run_command` and bypass the permission system.
478
+//
479
// This module is backend-agnostic and cannot construct a backend, so the
480
// surface that knows the active provider (`run_acp_server` / `run_interactive`
481
// in `main.rs`) registers a factory at startup. The factory returns `None`
484
485
pub const TASK_TOOL_NAME: &str = "task";
486
479
-/// The tool names a subagent may call, filtered from [`all_tools`].
487
+/// Hard cap on how many configurable subagent types are embedded in the
488
+/// `task` tool's description, to bound the tool description size — same
489
+/// rationale as `skills::MAX_ADVERTISED_SKILLS`.
490
+const MAX_ADVERTISED_AGENT_TYPES: usize = 100;
491
+
492
+/// The tool names a subagent may call, filtered from [`all_tools`]. This is
493
+/// the hard ceiling for every subagent, default or custom-typed — see the
494
+/// module doc above.
495
const SUBAGENT_TOOL_NAMES: &[&str] = &[
496
"read_file",
497
"list_directory",
500
"read_website",
501
];
502
488
-/// System prompt seeding every subagent conversation.
503
+/// System prompt seeding a subagent conversation when no `subagent_type` is
504
+/// given (or it doesn't resolve to a configured type).
505
pub const SUBAGENT_SYSTEM_PROMPT: &str = "You are a focused research subagent. \
506
You are given one self-contained task by a calling agent. Investigate it using \
507
the read-only tools available to you (read_file, list_directory, search_files, \
520
yet: on-device inference has a single conversation context. Do the research \
521
yourself with the read-only tools.";
522
507
-/// Builds a fresh backend for one subagent run, or `None` when the active
508
-/// inference cannot host a second conversation (on-device).
509
-pub type SubagentFactory = Box<dyn Fn() -> Option<Arc<dyn InferenceBackend>> + Send + Sync>;
523
+/// Builds a fresh backend for one subagent run seeded with the given system
524
+/// prompt, or `None` when the active inference cannot host a second
525
+/// conversation (on-device). Takes the prompt per call (rather than baking
526
+/// one in at registration) so a configurable subagent type's prompt can
527
+/// replace [`SUBAGENT_SYSTEM_PROMPT`] on a call-by-call basis.
528
+pub type SubagentFactory = Box<dyn Fn(&str) -> Option<Arc<dyn InferenceBackend>> + Send + Sync>;
529
530
static SUBAGENT_FACTORY: OnceLock<SubagentFactory> = OnceLock::new();
531
538
/// Whether a `task` call could run right now: a factory is registered and it
539
/// can build a backend. The spec builders (`agent_tools_as_specs` /
540
/// `build_tool_specs`) use this to offer the tool only when it works, the same
522
-/// conditional pattern as the `skill` tool.
541
+/// conditional pattern as the `skill` tool. Building a backend just to probe
542
+/// is cheap (no I/O happens until the first message), so this passes a
543
+/// placeholder prompt and discards the result.
544
pub fn subagent_available() -> bool {
545
SUBAGENT_FACTORY
546
.get()
526
- .is_some_and(|factory| factory().is_some())
547
+ .is_some_and(|factory| factory(SUBAGENT_SYSTEM_PROMPT).is_some())
548
}
549
550
/// The read-only toolset offered to a subagent, filtered from [`all_tools`] by
530
-/// name. Never contains `task` (recursion) or any mutating tool.
531
-pub fn subagent_tool_specs() -> Vec<ToolSpec> {
551
+/// name. `allowed` must already be a subset of [`SUBAGENT_TOOL_NAMES`] —
552
+/// see [`effective_subagent_tools`], the only intended way to build one.
553
+fn subagent_tool_specs(allowed: &[String]) -> Vec<ToolSpec> {
554
all_tools()
555
.into_iter()
534
- .filter(|tool| SUBAGENT_TOOL_NAMES.contains(&tool.name))
556
+ .filter(|tool| allowed.iter().any(|name| name == tool.name))
557
.map(|tool| ToolSpec {
558
name: tool.name.to_string(),
559
description: tool.description.to_string(),
562
.collect()
563
}
564
565
+/// The toolset a subagent call should actually get: [`SUBAGENT_TOOL_NAMES`]
566
+/// by default, or — when `agent_type` sets a `tools:` allow-list — its
567
+/// *intersection* with that ceiling. A type can never add a tool outside the
568
+/// base read-only set; anything else it lists is dropped with a warning
569
+/// rather than silently granted, since that's the whole security property
570
+/// this function exists to enforce (see the module doc above).
571
+fn effective_subagent_tools(agent_type: Option<&subagents::AgentType>) -> Vec<String> {
572
+ let Some(agent_type) = agent_type else {
573
+ return SUBAGENT_TOOL_NAMES.iter().map(|s| s.to_string()).collect();
574
+ };
575
+ let Some(requested) = &agent_type.tools else {
576
+ // No `tools:` field: inherit the full default read-only set.
577
+ return SUBAGENT_TOOL_NAMES.iter().map(|s| s.to_string()).collect();
578
+ };
579
+
580
+ let mut effective = Vec::new();
581
+ for name in requested {
582
+ if SUBAGENT_TOOL_NAMES.contains(&name.as_str()) {
583
+ effective.push(name.clone());
584
+ } else {
585
+ log::warn!(
586
+ "agent type \"{}\" at {} lists tool \"{name}\", which is not in the \
587
+ subagent read-only allow-list ({}) — ignoring it",
588
+ agent_type.name,
589
+ agent_type.path.display(),
590
+ SUBAGENT_TOOL_NAMES.join(", ")
591
+ );
592
+ }
593
+ }
594
+ effective
595
+}
596
+
597
/// Spec for the `task` tool. Lives in the `*_as_specs`/`build_tool_specs`
598
/// layer (like `skill` and MCP tools), not in [`all_tools`], because it is
599
/// only offered when [`subagent_available`] is true.
600
pub fn task_tool_spec() -> ToolSpec {
601
+ let types = subagents::discover_agent_types();
602
+ let mut description = "Delegate a self-contained task to a subagent that \
603
+ runs in a fresh conversation and returns only its final answer. The \
604
+ subagent's tools are read-only by default (read files, list \
605
+ directories, search, glob, read websites) — it cannot modify \
606
+ anything. Use this for exploratory questions whose intermediate \
607
+ file reads would otherwise clutter this conversation (e.g. \"where \
608
+ is X implemented and how does it work?\"). The subagent cannot see \
609
+ this conversation, so the prompt must be fully self-contained: \
610
+ include absolute paths, symbol names, and exactly what the answer \
611
+ should contain."
612
+ .to_string();
613
+ if !types.is_empty() {
614
+ description.push_str(
615
+ "\n\nOptionally pass `subagent_type` to use one of these specialized \
616
+ subagent types instead of the general-purpose default:\n",
617
+ );
618
+ // Same cap as `skills::MAX_ADVERTISED_SKILLS`: this list is baked into
619
+ // every turn's tool spec, so bound its context cost even though a
620
+ // pathological number of type files is unlikely. `/agents` (see
621
+ // `subagents::format_agent_types_list`) shows the full list on request.
622
+ for agent_type in types.iter().take(MAX_ADVERTISED_AGENT_TYPES) {
623
+ description.push_str("- ");
624
+ description.push_str(&agent_type.name);
625
+ description.push_str(": ");
626
+ description.push_str(&agent_type.description);
627
+ description.push('\n');
628
+ }
629
+ }
630
+
631
ToolSpec {
632
name: TASK_TOOL_NAME.to_string(),
549
- description: "Delegate a self-contained research task to a subagent that \
550
- runs in a fresh conversation and returns only its final answer. The \
551
- subagent can read files, list directories, search, glob, and read \
552
- websites, but cannot modify anything. Use this for exploratory \
553
- questions whose intermediate file reads would otherwise clutter this \
554
- conversation (e.g. \"where is X implemented and how does it work?\"). \
555
- The subagent cannot see this conversation, so the prompt must be \
556
- fully self-contained: include absolute paths, symbol names, and \
557
- exactly what the answer should contain."
558
- .to_string(),
633
+ description,
634
parameters_schema: json!({
635
"type": "object",
636
"properties": {
641
"prompt": {
642
"type": "string",
643
"description": "The full task for the subagent. Must be self-contained: the subagent sees nothing of this conversation."
644
+ },
645
+ "subagent_type": {
646
+ "type": "string",
647
+ "description": "Optional: the name of a configured subagent type (listed in this tool's description) to use instead of the general-purpose default."
648
}
649
},
650
"required": ["description", "prompt"],
675
.get("description")
676
.and_then(Value::as_str)
677
.unwrap_or("(no description)");
678
+ let subagent_type_name = args
679
+ .get("subagent_type")
680
+ .and_then(Value::as_str)
681
+ .filter(|s| !s.trim().is_empty());
682
+
683
+ let agent_type = match subagent_type_name {
684
+ Some(name) => match subagents::resolve_agent_type(name) {
685
+ Some(agent_type) => Some(agent_type),
686
+ None => {
687
+ let available = subagents::discover_agent_types()
688
+ .into_iter()
689
+ .map(|t| t.name)
690
+ .collect::<Vec<_>>();
691
+ return if available.is_empty() {
692
+ format!(
693
+ "Error: no subagent type named \"{name}\" (none are configured). \
694
+ Omit subagent_type to use the default research subagent."
695
+ )
696
+ } else {
697
+ format!(
698
+ "Error: no subagent type named \"{name}\". Available: {}. Omit \
699
+ subagent_type to use the default research subagent.",
700
+ available.join(", ")
701
+ )
702
+ };
703
+ }
704
+ },
705
+ None => None,
706
+ };
707
+
708
+ let system_prompt = agent_type
709
+ .as_ref()
710
+ .map(|t| t.system_prompt.as_str())
711
+ .unwrap_or(SUBAGENT_SYSTEM_PROMPT);
712
+ let allowed_tools = effective_subagent_tools(agent_type.as_ref());
713
600
- let Some(backend) = factory.and_then(|build| build()) else {
714
+ let Some(backend) = factory.and_then(|build| build(system_prompt)) else {
715
return SUBAGENT_UNAVAILABLE.to_string();
716
};
717
604
- log::info!("task: running subagent — {description}");
605
- run_subagent(backend.as_ref(), prompt).await
718
+ log::info!(
719
+ "task: running subagent — {description} (type: {})",
720
+ agent_type
721
+ .as_ref()
722
+ .map(|t| t.name.as_str())
723
+ .unwrap_or("default")
724
+ );
725
+ run_subagent(backend.as_ref(), prompt, &allowed_tools).await
726
}
727
608
-/// The nested agent loop: a fresh conversation, read-only tools, a round cap,
609
-/// and only the final text returned. Mirrors the main loops in `main.rs` /
610
-/// `chat.rs`: offer tools each round, and pass `tools = None` on the last
611
-/// round to force a text answer.
612
-async fn run_subagent(backend: &dyn InferenceBackend, prompt: &str) -> String {
613
- let specs = subagent_tool_specs();
728
+/// The nested agent loop: a fresh conversation, a round cap, and only the
729
+/// final text returned. Mirrors the main loops in `main.rs` / `chat.rs`:
730
+/// offer tools each round, and pass `tools = None` on the last round to
731
+/// force a text answer. `allowed` is the toolset for this run — the default
732
+/// read-only set, or a configured type's narrowed subset of it (see
733
+/// [`effective_subagent_tools`]).
734
+async fn run_subagent(backend: &dyn InferenceBackend, prompt: &str, allowed: &[String]) -> String {
735
+ let specs = subagent_tool_specs(allowed);
736
737
let mut result = match backend.send_message_with_tools(prompt, &specs, None).await {
738
Ok(r) => r,
750
let mut tool_results = Vec::with_capacity(result.tool_calls.len());
751
for call in &result.tool_calls {
752
// Hard gate, not just advertisement: even if the model asks for a
631
- // tool outside the offered set, only read-only tools execute here.
632
- let content = if SUBAGENT_TOOL_NAMES.contains(&call.name.as_str()) {
753
+ // tool outside the offered set, only the allowed tools execute here.
754
+ let content = if allowed.iter().any(|name| name == &call.name) {
755
// Boxed to break the async cycle: execute_tool → task →
756
// run_subagent → execute_tool.
757
Box::pin(execute_tool(&call.name, &call.arguments)).await
758
} else {
759
format!(
638
- "Error: `{}` is not available to a subagent. Only these \
639
- read-only tools are: {}.",
760
+ "Error: `{}` is not available to this subagent. Only these \
761
+ tools are: {}.",
762
call.name,
641
- SUBAGENT_TOOL_NAMES.join(", ")
763
+ allowed.join(", ")
764
)
765
};
766
tool_results.push(ToolResult {
3210
3211
#[test]
3212
fn test_subagent_toolset_is_read_only() {
3091
- let specs = subagent_tool_specs();
3213
+ let specs = subagent_tool_specs(&effective_subagent_tools(None));
3214
let mut names: Vec<&str> = specs.iter().map(|spec| spec.name.as_str()).collect();
3215
names.sort_unstable();
3216
assert_eq!(
3447
let file = dir.join("notes.txt");
3448
fs::write(&file, "subagent secret: 4217").unwrap();
3449
3328
- // Script: one read_file tool call, then a final text answer.
3450
+ // Script: one read_file tool call, a final text answer, then a third
3451
+ // response for the custom-agent-type phase further down.
3452
let (port, requests) = start_scripted_endpoint(vec![
3453
completion_tool_call(
3454
"call_1",
3456
&serde_json::json!({ "path": file }).to_string(),
3457
),
3458
completion_text("The file contains the number 4217."),
3459
+ completion_text("Custom type answer."),
3460
]);
3461
3462
// Register the real process-global factory, pointing a fresh
3463
// OpenAiBackend at the scripted endpoint — exactly what the surfaces
3340
- // do at startup. This is the only test that touches the OnceLock.
3341
- set_subagent_factory(Box::new(move || {
3464
+ // do at startup. This is the only test that touches the OnceLock
3465
+ // (it's a `OnceLock`: later `set_subagent_factory` calls from other
3466
+ // tests are silently ignored, so every test needing a real factory
3467
+ // has to share this one registration — see the custom-agent-type
3468
+ // phase later in this same test).
3469
+ set_subagent_factory(Box::new(move |system_prompt: &str| {
3470
Some(Arc::new(crate::backend::OpenAiBackend::new(
3471
format!("http://127.0.0.1:{port}"),
3472
"test-key",
3473
"scripted-model",
3346
- Some(SUBAGENT_SYSTEM_PROMPT.to_string()),
3474
+ Some(system_prompt.to_string()),
3475
)) as Arc<dyn InferenceBackend>)
3476
}));
3477
assert!(subagent_available());
3486
// Only the subagent's final text comes back.
3487
assert_eq!(result, "The file contains the number 4217.");
3488
3361
- let recorded = requests.lock().unwrap();
3362
- assert_eq!(recorded.len(), 2, "expected exactly two completions");
3489
+ // Scoped so the `MutexGuard` is dropped before phase 2's `.await` —
3490
+ // holding a `std::sync::MutexGuard` across an await point is a bug
3491
+ // (it can deadlock or block the executor), and clippy's
3492
+ // `await_holding_lock` lint doesn't reliably see through a manual
3493
+ // `drop()` call, so use an explicit block instead.
3494
+ {
3495
+ let recorded = requests.lock().unwrap();
3496
+ assert_eq!(recorded.len(), 2, "expected exactly two completions");
3497
3364
- // The subagent conversation is fresh (subagent system prompt) and was
3365
- // offered only the read-only toolset.
3366
- let first = &recorded[0];
3367
- assert_eq!(first["messages"][0]["role"], "system");
3368
- assert!(
3369
- first["messages"][0]["content"]
3370
- .as_str()
3498
+ // The subagent conversation is fresh (subagent system prompt) and
3499
+ // was offered only the read-only toolset.
3500
+ let first = &recorded[0];
3501
+ assert_eq!(first["messages"][0]["role"], "system");
3502
+ assert!(
3503
+ first["messages"][0]["content"]
3504
+ .as_str()
3505
+ .unwrap()
3506
+ .contains("research subagent")
3507
+ );
3508
+ let offered: Vec<&str> = first["tools"]
3509
+ .as_array()
3510
.unwrap()
3372
- .contains("research subagent")
3511
+ .iter()
3512
+ .map(|tool| tool["function"]["name"].as_str().unwrap())
3513
+ .collect();
3514
+ for name in &offered {
3515
+ assert!(
3516
+ SUBAGENT_TOOL_NAMES.contains(name),
3517
+ "non-read-only tool offered: {name}"
3518
+ );
3519
+ }
3520
+ assert!(!offered.contains(&TASK_TOOL_NAME));
3521
+
3522
+ // The read-only tool actually executed: its output travelled back
3523
+ // to the endpoint as a tool result on the second request.
3524
+ let messages = recorded[1]["messages"].as_array().unwrap();
3525
+ let tool_message = messages
3526
+ .iter()
3527
+ .find(|message| message["role"] == "tool")
3528
+ .expect("no tool result in second request");
3529
+ assert_eq!(tool_message["tool_call_id"], "call_1");
3530
+ assert!(
3531
+ tool_message["content"]
3532
+ .as_str()
3533
+ .unwrap()
3534
+ .contains("subagent secret: 4217")
3535
+ );
3536
+ }
3537
+
3538
+ // ── Phase 2: a configured subagent type, on the same registered
3539
+ // factory and endpoint (the third scripted response above). Proves
3540
+ // `subagent_type` actually swaps the system prompt and narrows the
3541
+ // offered toolset end to end, not just at the pure-function level
3542
+ // (see `effective_subagent_tools_*` below for that).
3543
+ let agents_root = dir.join(".sigit").join("agents");
3544
+ fs::create_dir_all(&agents_root).unwrap();
3545
+ fs::write(
3546
+ agents_root.join("notes-reader.md"),
3547
+ "---\nname: notes-reader\ndescription: Reads notes files only\ntools: read_file\n---\n\nYou only ever read notes files and report exactly what they say.\n",
3548
+ )
3549
+ .unwrap();
3550
+
3551
+ let prev_cwd = std::env::current_dir().unwrap();
3552
+ std::env::set_current_dir(&dir).unwrap();
3553
+
3554
+ let args = serde_json::json!({
3555
+ "description": "read a note",
3556
+ "prompt": "What does the notes file say?",
3557
+ "subagent_type": "notes-reader",
3558
+ })
3559
+ .to_string();
3560
+ let result = execute_tool(TASK_TOOL_NAME, &args).await;
3561
+ assert_eq!(result, "Custom type answer.");
3562
+
3563
+ let recorded = requests.lock().unwrap();
3564
+ assert_eq!(recorded.len(), 3, "expected a third recorded request");
3565
+ let last = &recorded[2];
3566
+ assert_eq!(last["messages"][0]["role"], "system");
3567
+ assert_eq!(
3568
+ last["messages"][0]["content"].as_str().unwrap(),
3569
+ "You only ever read notes files and report exactly what they say."
3570
);
3374
- let offered: Vec<&str> = first["tools"]
3571
+ let offered: Vec<&str> = last["tools"]
3572
.as_array()
3573
.unwrap()
3574
.iter()
3575
.map(|tool| tool["function"]["name"].as_str().unwrap())
3576
.collect();
3380
- for name in &offered {
3381
- assert!(
3382
- SUBAGENT_TOOL_NAMES.contains(name),
3383
- "non-read-only tool offered: {name}"
3384
- );
3385
- }
3386
- assert!(!offered.contains(&TASK_TOOL_NAME));
3387
-
3388
- // The read-only tool actually executed: its output travelled back to
3389
- // the endpoint as a tool result on the second request.
3390
- let messages = recorded[1]["messages"].as_array().unwrap();
3391
- let tool_message = messages
3392
- .iter()
3393
- .find(|message| message["role"] == "tool")
3394
- .expect("no tool result in second request");
3395
- assert_eq!(tool_message["tool_call_id"], "call_1");
3396
- assert!(
3397
- tool_message["content"]
3398
- .as_str()
3399
- .unwrap()
3400
- .contains("subagent secret: 4217")
3577
+ assert_eq!(
3578
+ offered,
3579
+ vec!["read_file"],
3580
+ "notes-reader's tools: read_file should narrow the offered toolset to just that"
3581
);
3582
+ drop(recorded);
3583
3584
+ std::env::set_current_dir(prev_cwd).unwrap();
3585
let _ = fs::remove_dir_all(&dir);
3586
}
3587
+
3588
+ #[test]
3589
+ fn effective_subagent_tools_defaults_to_full_read_only_set_without_a_type() {
3590
+ let mut tools = effective_subagent_tools(None);
3591
+ tools.sort();
3592
+ let mut expected: Vec<String> = SUBAGENT_TOOL_NAMES.iter().map(|s| s.to_string()).collect();
3593
+ expected.sort();
3594
+ assert_eq!(tools, expected);
3595
+ }
3596
+
3597
+ #[test]
3598
+ fn effective_subagent_tools_inherits_full_set_when_type_omits_tools_field() {
3599
+ let agent_type = subagents::AgentType {
3600
+ name: "generalist".to_string(),
3601
+ description: "does anything".to_string(),
3602
+ tools: None,
3603
+ system_prompt: "Be helpful.".to_string(),
3604
+ path: PathBuf::from("/x/generalist.md"),
3605
+ };
3606
+ let mut tools = effective_subagent_tools(Some(&agent_type));
3607
+ tools.sort();
3608
+ let mut expected: Vec<String> = SUBAGENT_TOOL_NAMES.iter().map(|s| s.to_string()).collect();
3609
+ expected.sort();
3610
+ assert_eq!(tools, expected);
3611
+ }
3612
+
3613
+ #[test]
3614
+ fn effective_subagent_tools_narrows_to_the_types_allow_list() {
3615
+ let agent_type = subagents::AgentType {
3616
+ name: "notes-reader".to_string(),
3617
+ description: "reads notes".to_string(),
3618
+ tools: Some(vec!["read_file".to_string(), "glob".to_string()]),
3619
+ system_prompt: "Read notes.".to_string(),
3620
+ path: PathBuf::from("/x/notes-reader.md"),
3621
+ };
3622
+ let tools = effective_subagent_tools(Some(&agent_type));
3623
+ assert_eq!(tools, vec!["read_file".to_string(), "glob".to_string()]);
3624
+ }
3625
+
3626
+ #[test]
3627
+ fn effective_subagent_tools_drops_anything_outside_the_read_only_ceiling() {
3628
+ // The security-critical case: a type file cannot grant itself a
3629
+ // mutating tool by listing it in `tools:` — it's silently dropped,
3630
+ // never passed through to the offered/gated toolset.
3631
+ let agent_type = subagents::AgentType {
3632
+ name: "sneaky".to_string(),
3633
+ description: "tries to grant itself write access".to_string(),
3634
+ tools: Some(vec![
3635
+ "read_file".to_string(),
3636
+ "edit_file".to_string(),
3637
+ "run_command".to_string(),
3638
+ "task".to_string(),
3639
+ ]),
3640
+ system_prompt: "...".to_string(),
3641
+ path: PathBuf::from("/x/sneaky.md"),
3642
+ };
3643
+ let tools = effective_subagent_tools(Some(&agent_type));
3644
+ assert_eq!(tools, vec!["read_file".to_string()]);
3645
+ assert!(!tools.contains(&"edit_file".to_string()));
3646
+ assert!(!tools.contains(&"run_command".to_string()));
3647
+ assert!(!tools.contains(&"task".to_string()));
3648
+ }
3649
}