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
//! Tasks List Tool (#1160)
//!
//! One agent-facing roster of BOTH background systems: spawned sub-agents
//! (previously visible only through wait_agent's error path) and detached
//! bash commands (previously invisible to the model entirely). Both managers
//! already ride in `ToolExecutionContext`, so this tool only reads.
use super::error::Result;
use super::r#trait::{Tool, ToolCapability, ToolExecutionContext, ToolHints, ToolResult};
use async_trait::async_trait;
use serde_json::Value;
/// Tool listing in-flight sub-agents and detached commands.
#[derive(Default)]
pub struct TasksListTool;
impl TasksListTool {
pub fn new() -> Self {
Self
}
}
/// One sub-agent roster row, render-ready.
pub(crate) struct SubagentRow {
pub id: String,
pub label: String,
pub state: String,
/// Path of the agent's JSON status file, as built by
/// [`subagent_status_file`], so the model can read live progress directly.
pub status_file: Option<String>,
}
/// Advertised status-file path for a sub-agent — the same path writers use.
///
/// Resolved through [`crate::brain::agent::service::work_status`], the single
/// entry point every writer persists through (`subagent/spawn.rs` →
/// `work_status::status_path`). Extracted from `execute()` so that agreement
/// is expressed once and can be pinned by a test, rather than recomputed
/// inline where any drift in the resolver would silently hand the model a
/// path that reads ENOENT. An empty read from a wrong path is
/// indistinguishable from "this sub-agent never existed".
pub(crate) fn subagent_status_file(id: &str) -> String {
crate::brain::agent::service::work_status::status_path(id)
.display()
.to_string()
}
/// One detached-command roster row.
pub(crate) struct DetachedRow {
pub label: String,
pub elapsed_secs: u64,
}
/// Pure renderer so tests pin output shape without live managers.
///
/// Empty everything renders the explicit "No background tasks." line — the
/// model must never have to infer emptiness from absent sections.
pub(crate) fn render_tasks(subagents: &[SubagentRow], detached: &[DetachedRow]) -> String {
if subagents.is_empty() && detached.is_empty() {
return "No background tasks.".to_string();
}
let mut out = String::from("Background tasks:");
if !subagents.is_empty() {
out.push_str(&format!("\n\nSub-agents ({}):", subagents.len()));
for a in subagents {
out.push_str(&format!("\n- {} [{}] {}", a.id, a.label, a.state));
if let Some(sf) = &a.status_file {
out.push_str(&format!("\n status file: {sf}"));
}
}
}
if !detached.is_empty() {
out.push_str(&format!("\n\nDetached commands ({}):", detached.len()));
for d in detached {
out.push_str(&format!("\n- {} (elapsed {}s)", d.label, d.elapsed_secs));
}
}
out
}
fn state_label(state: &super::subagent::manager::SubAgentState) -> &'static str {
use super::subagent::manager::SubAgentState as S;
match state {
S::Running => "running",
S::AwaitingInput => "awaiting input",
S::Completed => "completed",
S::Failed(_) => "failed",
S::Cancelled => "cancelled",
}
}
#[async_trait]
impl Tool for TasksListTool {
fn name(&self) -> &str {
"tasks_list"
}
fn description(&self) -> &str {
"List in-flight background work: spawned sub-agents (id, label, \
state, status-file path) and detached shell commands (label, \
elapsed). Read-only. Use it to check what is running instead of \
re-spawning or busy-waiting; results are pushed to you on \
completion either way."
}
fn input_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {},
"required": []
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadFiles]
}
fn hints(&self) -> ToolHints {
ToolHints {
read_only: true,
destructive: false,
idempotent: true,
open_world: false,
}
}
async fn execute(&self, _input: Value, context: &ToolExecutionContext) -> Result<ToolResult> {
let mut subagents = Vec::new();
if let Some(mgr) = context.subagent_manager.as_ref() {
// Scoped to the CALLER's children (#191). The manager is
// process-global (one instance per channel factory), so an
// unfiltered `list()` reported other sessions' sub-agents as this
// caller's in-flight work — and this tool's framing ("do not
// spawn duplicates") makes that a silent suppressor, not just
// noise. The detached half below is already scoped the same way.
for (id, label, state) in mgr.list_for_parent(context.session_id) {
let status_file = subagent_status_file(&id);
subagents.push(SubagentRow {
id,
label,
state: state_label(&state).to_string(),
status_file: Some(status_file),
});
}
}
let mut detached = Vec::new();
if let Some(bm) = context.background_manager.as_ref() {
for t in bm.running_tasks(context.session_id) {
detached.push(DetachedRow {
label: t.label,
elapsed_secs: t.started.elapsed().as_secs(),
});
}
}
Ok(ToolResult::success(render_tasks(&subagents, &detached)))
}
}