Show a session's subagents as subcards, each with a read-only transcript
A subagent is a second transcript owned by a session, in the same event model, with no process and no controls. The claude translator routes lines carrying parent_tool_use_id to a per-subagent translator and transcript under <session>/subagents/<tool_use_id>; three routes expose the list, a transcript page and the SSE stream. Echo grows /subagent [n] as the rig. On the phone a card with subagents ends in a chevron expander, collapsed by default, opening to outlined subcards styled like dev-updater's components; a subcard opens SessionScreen in read-only form, addressed through TranscriptAddress so paging, cache and stream are shared. Design in SUBAGENTS.md; choices awaiting review in DECISIONS.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
eff5c8b0c0
commit
9fa09b0af1
21 files changed
+1953
-332
No files matched your search
@@ -0,0 +1,490 @@
|
||||
//! A session's subagents -- see `SUBAGENTS.md`.
|
||||
//!
|
||||
//! **A subagent is a second transcript owned by a session, in the same event
|
||||
//! model, with no process and no controls.** It shares the transcript file
|
||||
//! format, the paging routes, and the SSE stream with a session by
|
||||
//! addressing, not by copying: `Transcript`, `read_window` and `catch_up`
|
||||
//! work on a subagent's file unchanged.
|
||||
//!
|
||||
//! Storage is `<session dir>/subagents/<id>/{meta.json,transcript.jsonl}`,
|
||||
//! where `<id>` is the Task tool_use id that started it -- unique, stable
|
||||
//! across a backend restart, and already the key the parent side uses. Only
|
||||
//! ids matching [`is_subagent_id`] are ever turned into a path.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use super::driver::{Event, SessionStatus};
|
||||
use super::transcript::{SeqEvent, Transcript};
|
||||
|
||||
/// Fan-out buffer for one subagent's SSE subscribers. Smaller than a
|
||||
/// session's: a subagent's whole conversation is usually a handful of tool
|
||||
/// calls, not an hours-long session.
|
||||
const EVENT_BUFFER: usize = 64;
|
||||
|
||||
/// Whether `id` is safe to become a path segment under a session's
|
||||
/// `subagents/` directory. Mirrors `import::is_session_id`'s reasoning: the
|
||||
/// id arrives as a value inside JSON the CLI sent, and it becomes a
|
||||
/// directory name, so a `/` or `..` in it must never be trusted.
|
||||
fn is_subagent_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 200
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
|
||||
}
|
||||
|
||||
/// What a subagent's directory holds beside its transcript. Small and
|
||||
/// separate from `Subagent` itself because this is exactly what survives a
|
||||
/// backend restart on disk, and nothing else does.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Meta {
|
||||
title: String,
|
||||
/// Epoch seconds. Absent from `SubagentInfo`'s sort key deliberately:
|
||||
/// `list` sorts by this rather than by directory order, which a
|
||||
/// filesystem does not promise.
|
||||
created: f64,
|
||||
}
|
||||
|
||||
/// One row of `GET /sessions/{id}/subagents`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubagentInfo {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub status: SessionStatus,
|
||||
pub created: f64,
|
||||
pub last_activity: f64,
|
||||
}
|
||||
|
||||
/// One subagent: its own transcript and broadcast, same shape as a
|
||||
/// session's but with no driver behind it.
|
||||
pub struct Subagent {
|
||||
dir: PathBuf,
|
||||
transcript: Mutex<Transcript>,
|
||||
events: broadcast::Sender<SeqEvent>,
|
||||
/// Mirrors the transcript's last `Status` event, kept live rather than
|
||||
/// read back from `Transcript::last_status` -- that answers "as of
|
||||
/// opening" (see its own doc comment) and never moves for an append made
|
||||
/// through *this* object, which is every append a live subagent ever
|
||||
/// makes. Without this, `finish` immediately after `start` in the same
|
||||
/// process read the file's stale opening status and reported itself
|
||||
/// still open.
|
||||
status: Mutex<SessionStatus>,
|
||||
}
|
||||
|
||||
impl Subagent {
|
||||
pub fn transcript_path(&self) -> PathBuf {
|
||||
self.dir.join("transcript.jsonl")
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<SeqEvent> {
|
||||
self.events.subscribe()
|
||||
}
|
||||
|
||||
/// Whether this subagent's last recorded status is not `Exited` --
|
||||
/// what decides whether a further child line still belongs in its
|
||||
/// transcript. See `SUBAGENTS.md`'s lifecycle: "a child line whose
|
||||
/// subagent finished already... is ignored".
|
||||
pub fn is_open(&self) -> bool {
|
||||
*self.status.lock().unwrap() != SessionStatus::Exited
|
||||
}
|
||||
|
||||
fn append(&self, event: Event) {
|
||||
let mut transcript = self.transcript.lock().unwrap();
|
||||
match transcript.append(event, super::now()) {
|
||||
Ok(entry) => {
|
||||
if let Event::Status { state } = &entry.event {
|
||||
*self.status.lock().unwrap() = *state;
|
||||
}
|
||||
// No subscribers is fine; the transcript already has it,
|
||||
// same as a session's pump.
|
||||
let _ = self.events.send(entry);
|
||||
}
|
||||
Err(err) => tracing::error!("subagent transcript append failed: {err:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every subagent one session has started, keyed by the Task tool_use id
|
||||
/// that names it.
|
||||
///
|
||||
/// Lives beside a session's driver rather than inside it: a claude driver
|
||||
/// holds an `Arc` to this and routes child lines into it; echo uses it for
|
||||
/// its `/subagent` rig; llama ignores it, since it has no notion of a Task
|
||||
/// call. One instance per live session, built at launch and handed to
|
||||
/// whichever driver replaces it across a stop/start.
|
||||
pub struct Subagents {
|
||||
/// The session's own directory; subagents live under `<dir>/subagents`.
|
||||
dir: PathBuf,
|
||||
live: Mutex<HashMap<String, Arc<Subagent>>>,
|
||||
}
|
||||
|
||||
impl Subagents {
|
||||
pub fn new(session_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
dir: session_dir,
|
||||
live: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn subagents_dir(&self) -> PathBuf {
|
||||
self.dir.join("subagents")
|
||||
}
|
||||
|
||||
/// Opens the subagent named `id`, creating it if this is the first
|
||||
/// anyone has heard of it -- on disk as well as in memory, so a
|
||||
/// subagent from before a backend restart is reopened rather than
|
||||
/// recreated. `title`/`prompt` are used only at creation: reopening an
|
||||
/// existing one keeps its original title and never repeats the prompt
|
||||
/// into its transcript a second time.
|
||||
fn open_or_create(&self, id: &str, title: &str, prompt: Option<&str>) -> Result<Arc<Subagent>> {
|
||||
let dir = self.subagents_dir().join(id);
|
||||
let meta_path = dir.join("meta.json");
|
||||
let existed = meta_path.is_file();
|
||||
let meta = if existed {
|
||||
let text = fs::read_to_string(&meta_path)
|
||||
.with_context(|| format!("read {}", meta_path.display()))?;
|
||||
serde_json::from_str::<Meta>(&text).context("parse subagent meta")?
|
||||
} else {
|
||||
wg_app_link::private::create_dir(&dir)?;
|
||||
let meta = Meta {
|
||||
title: title.to_string(),
|
||||
created: super::now(),
|
||||
};
|
||||
wg_app_link::private::write_file(
|
||||
&meta_path,
|
||||
serde_json::to_string(&meta)
|
||||
.context("serialize subagent meta")?
|
||||
.as_bytes(),
|
||||
)?;
|
||||
meta
|
||||
};
|
||||
let mut transcript = Transcript::open(&dir.join("transcript.jsonl"))?;
|
||||
if !existed {
|
||||
// First lines, in order: the subagent is running the moment it
|
||||
// exists, and its prompt -- when known -- is genuinely its first
|
||||
// user turn. Written once, here, so a reopen never repeats them.
|
||||
transcript.append(
|
||||
Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
},
|
||||
meta.created,
|
||||
)?;
|
||||
if let Some(prompt) = prompt {
|
||||
transcript.append(
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: prompt.to_string(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
meta.created,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// A freshly created subagent is running by construction (its only
|
||||
// lines so far are `Status::Running` and maybe its prompt); a
|
||||
// reopened one takes whatever the file last said, since this
|
||||
// `Transcript` has not been appended to yet in this process.
|
||||
let status = if existed {
|
||||
transcript.last_status().unwrap_or(SessionStatus::Running)
|
||||
} else {
|
||||
SessionStatus::Running
|
||||
};
|
||||
let (events, _) = broadcast::channel(EVENT_BUFFER);
|
||||
Ok(Arc::new(Subagent {
|
||||
dir,
|
||||
transcript: Mutex::new(transcript),
|
||||
events,
|
||||
status: Mutex::new(status),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Starts a subagent unless one is already known by this id -- see
|
||||
/// `SUBAGENTS.md`'s lifecycle: created at the Task call or at the first
|
||||
/// child line, whichever comes first, and never twice. A bad id is
|
||||
/// refused rather than turned into a path.
|
||||
pub fn start(&self, id: &str, title: &str, prompt: Option<&str>) {
|
||||
if !is_subagent_id(id) {
|
||||
tracing::debug!("refusing to start a subagent with a bad id {id:?}");
|
||||
return;
|
||||
}
|
||||
let mut live = self.live.lock().unwrap();
|
||||
if live.contains_key(id) {
|
||||
return;
|
||||
}
|
||||
match self.open_or_create(id, title, prompt) {
|
||||
Ok(subagent) => {
|
||||
live.insert(id.to_string(), subagent);
|
||||
}
|
||||
Err(err) => tracing::error!("couldn't start subagent {id}: {err:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The subagent named `id`, reopening it from disk on first use in this
|
||||
/// process if one is there. `None` for an id nothing has ever started --
|
||||
/// deliberately not created here, since a route or a routing decision is
|
||||
/// not the Task call that is supposed to be the only way one begins.
|
||||
pub fn get(&self, id: &str) -> Option<Arc<Subagent>> {
|
||||
if !is_subagent_id(id) {
|
||||
return None;
|
||||
}
|
||||
if let Some(existing) = self.live.lock().unwrap().get(id).cloned() {
|
||||
return Some(existing);
|
||||
}
|
||||
if !self.subagents_dir().join(id).join("meta.json").is_file() {
|
||||
return None;
|
||||
}
|
||||
// Title and prompt are ignored: the directory already exists, so
|
||||
// `open_or_create` reads its own meta rather than using either.
|
||||
match self.open_or_create(id, "", None) {
|
||||
Ok(subagent) => {
|
||||
self.live
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(id.to_string(), Arc::clone(&subagent));
|
||||
Some(subagent)
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("couldn't reopen subagent {id}: {err:#}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends one event to a subagent's own transcript. A no-op, with a
|
||||
/// debug log, for an id nothing was started under -- a child line for a
|
||||
/// subagent this registry never opened is dropped rather than guessed
|
||||
/// at.
|
||||
pub fn record(&self, id: &str, event: Event) {
|
||||
match self.live.lock().unwrap().get(id).cloned() {
|
||||
Some(subagent) => subagent.append(event),
|
||||
None => tracing::debug!("dropping an event for unknown subagent {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The parent's `tool_result` for this Task id arrived: the subagent's
|
||||
/// own `Status::Exited`. A no-op for an id that is not a subagent's, so
|
||||
/// callers can call this for every `tool_result` without first checking
|
||||
/// whether it belongs to one.
|
||||
pub fn finish(&self, id: &str) {
|
||||
if let Some(subagent) = self.live.lock().unwrap().get(id).cloned()
|
||||
&& subagent.is_open()
|
||||
{
|
||||
subagent.append(Event::Status {
|
||||
state: SessionStatus::Exited,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The parent session's process is gone, so nothing still open here has
|
||||
/// a process behind it either -- see `SUBAGENTS.md`'s lifecycle #4.
|
||||
pub fn finish_all(&self) {
|
||||
let subagents: Vec<Arc<Subagent>> = self.live.lock().unwrap().values().cloned().collect();
|
||||
for subagent in subagents {
|
||||
if subagent.is_open() {
|
||||
subagent.append(Event::Status {
|
||||
state: SessionStatus::Exited,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every subagent under this session's directory, oldest first --
|
||||
/// `GET /sessions/{id}/subagents`. Read straight from disk rather than
|
||||
/// from `live`, so a subagent from before this process started (or one
|
||||
/// this run has not yet touched) still shows up; one file read per
|
||||
/// subagent, which is fine at the handful a session usually has.
|
||||
///
|
||||
/// `session_running` is what turns a subagent whose last status is
|
||||
/// `Running` into `Unknown`: its process was the session's, and the
|
||||
/// session has none.
|
||||
pub fn list(&self, session_running: bool) -> Vec<SubagentInfo> {
|
||||
let mut rows: Vec<SubagentInfo> = match fs::read_dir(self.subagents_dir()) {
|
||||
Ok(entries) => entries
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|entry| info_of(&entry.path(), session_running))
|
||||
.collect(),
|
||||
// No directory is no subagents, not a fault worth reporting.
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
rows.sort_by(|a, b| {
|
||||
a.created
|
||||
.partial_cmp(&b.created)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
rows
|
||||
}
|
||||
}
|
||||
|
||||
fn info_of(subagent_dir: &Path, session_running: bool) -> Option<SubagentInfo> {
|
||||
let id = subagent_dir.file_name()?.to_str()?.to_string();
|
||||
let meta_path = subagent_dir.join("meta.json");
|
||||
let text = fs::read_to_string(&meta_path).ok()?;
|
||||
let meta: Meta = serde_json::from_str(&text).ok()?;
|
||||
let transcript = Transcript::open(&subagent_dir.join("transcript.jsonl")).ok()?;
|
||||
// The subagent's first line is always `Status::Running`, written before
|
||||
// this directory is discoverable at all, so `None` here is not a state a
|
||||
// reader can actually observe -- but it is not this function's place to
|
||||
// invent one, so a status this build does not expect to see falls back
|
||||
// to the word the lifecycle promises it started in.
|
||||
let last_status = transcript.last_status().unwrap_or(SessionStatus::Running);
|
||||
let status = if last_status == SessionStatus::Running && !session_running {
|
||||
SessionStatus::Unknown
|
||||
} else {
|
||||
last_status
|
||||
};
|
||||
Some(SubagentInfo {
|
||||
id,
|
||||
title: meta.title,
|
||||
status,
|
||||
created: meta.created,
|
||||
last_activity: transcript.last_activity().unwrap_or(meta.created),
|
||||
})
|
||||
}
|
||||
|
||||
/// How many subagents a session has, for `SessionInfo::subagents`: a
|
||||
/// directory listing, so the session list stays cheap and only the
|
||||
/// dedicated route pays for reading a status out of each one.
|
||||
pub fn count(session_dir: &Path) -> usize {
|
||||
fs::read_dir(session_dir.join("subagents"))
|
||||
.map(|entries| entries.filter_map(Result::ok).count())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_bad_id_is_refused_rather_than_turned_into_a_path() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||
subagents.start("../../etc", "escape", None);
|
||||
assert!(subagents.get("../../etc").is_none());
|
||||
assert!(!dir.path().join("subagents").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starting_twice_keeps_the_first_title_and_does_not_repeat_the_prompt() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||
subagents.start("toolu_1", "first title", Some("do the thing"));
|
||||
subagents.start("toolu_1", "second title", Some("do the thing"));
|
||||
|
||||
let rows = subagents.list(true);
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].title, "first title");
|
||||
|
||||
let events = crate::session::transcript::read_after(
|
||||
&subagents.get("toolu_1").unwrap().transcript_path(),
|
||||
0,
|
||||
)
|
||||
.expect("read");
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.filter(|e| matches!(e.event, Event::UserMessage { .. }))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reopened_subagent_continues_its_own_transcript() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
{
|
||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||
subagents.start("toolu_2", "helper", Some("go"));
|
||||
subagents.record(
|
||||
"toolu_2",
|
||||
Event::AssistantText {
|
||||
delta: "working".to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
// A fresh registry, the way a backend restart builds one.
|
||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||
let subagent = subagents.get("toolu_2").expect("reopened");
|
||||
assert!(subagent.is_open());
|
||||
subagents.record(
|
||||
"toolu_2",
|
||||
Event::AssistantText {
|
||||
delta: " more".to_string(),
|
||||
},
|
||||
);
|
||||
let events =
|
||||
crate::session::transcript::read_after(&subagent.transcript_path(), 0).expect("read");
|
||||
// Status, UserMessage, two AssistantText deltas, seq continuing.
|
||||
assert_eq!(events.len(), 4);
|
||||
assert_eq!(events.last().unwrap().seq, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finishing_appends_exited_and_further_lines_are_droppable() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||
subagents.start("toolu_3", "helper", None);
|
||||
subagents.finish("toolu_3");
|
||||
let subagent = subagents.get("toolu_3").unwrap();
|
||||
assert!(!subagent.is_open());
|
||||
// On disk too, not only in the live cache `is_open` reads.
|
||||
assert_eq!(
|
||||
Transcript::open(&subagent.transcript_path())
|
||||
.expect("reopen")
|
||||
.last_status(),
|
||||
Some(SessionStatus::Exited)
|
||||
);
|
||||
|
||||
// Finishing an id that was never a subagent is a no-op, not a panic.
|
||||
subagents.finish("never-started");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_all_closes_only_what_is_still_open() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||
subagents.start("toolu_4", "one", None);
|
||||
subagents.start("toolu_5", "two", None);
|
||||
subagents.finish("toolu_4");
|
||||
subagents.finish_all();
|
||||
|
||||
let rows = subagents.list(false);
|
||||
assert_eq!(rows.len(), 2);
|
||||
for row in rows {
|
||||
assert_eq!(row.status, SessionStatus::Exited);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subagent_still_running_when_the_session_is_not_reports_unknown() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||
subagents.start("toolu_6", "helper", None);
|
||||
|
||||
assert_eq!(subagents.list(true)[0].status, SessionStatus::Running);
|
||||
assert_eq!(subagents.list(false)[0].status, SessionStatus::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_is_oldest_first_and_the_count_matches_the_directory() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||
assert_eq!(count(dir.path()), 0);
|
||||
subagents.start("toolu_a", "a", None);
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
subagents.start("toolu_b", "b", None);
|
||||
let rows = subagents.list(true);
|
||||
assert_eq!(
|
||||
rows.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
|
||||
["toolu_a", "toolu_b"]
|
||||
);
|
||||
assert_eq!(count(dir.path()), 2);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user