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
+127
-2
@@ -15,6 +15,7 @@ pub mod import;
|
||||
pub mod llama;
|
||||
pub mod pending;
|
||||
pub mod process;
|
||||
pub mod subagent;
|
||||
pub mod transcript;
|
||||
pub mod transport;
|
||||
|
||||
@@ -37,6 +38,7 @@ use driver::{
|
||||
};
|
||||
use echo::EchoDriver;
|
||||
use llama::LlamaDriver;
|
||||
use subagent::Subagents;
|
||||
use transcript::{SeqEvent, Transcript};
|
||||
use transport::Transport;
|
||||
|
||||
@@ -265,6 +267,11 @@ pub struct SessionInfo {
|
||||
pub status: SessionStatus,
|
||||
pub last_activity: f64,
|
||||
pub created: f64,
|
||||
/// How many subagents this session has started, from a directory
|
||||
/// listing rather than reading each one's status -- see
|
||||
/// `GET /sessions/{id}/subagents` for that. 0 when it has none, not
|
||||
/// absent: every session can say this without asking anything.
|
||||
pub subagents: usize,
|
||||
}
|
||||
|
||||
/// What is running a session at this moment, and `None` when nothing is.
|
||||
@@ -293,6 +300,10 @@ pub struct LiveSession {
|
||||
events: broadcast::Sender<SeqEvent>,
|
||||
transcript_path: PathBuf,
|
||||
shared: Arc<Shared>,
|
||||
/// This session's subagents -- see `SUBAGENTS.md`. Built once at launch
|
||||
/// and handed to whichever driver replaces it across a stop/start, so a
|
||||
/// subagent started before a Stop is still there to read after a Start.
|
||||
subagents: Arc<Subagents>,
|
||||
}
|
||||
|
||||
/// Commands waiting for the session to be between turns.
|
||||
@@ -502,6 +513,19 @@ impl LiveSession {
|
||||
&self.transcript_path
|
||||
}
|
||||
|
||||
pub fn subagents(&self) -> &Arc<Subagents> {
|
||||
&self.subagents
|
||||
}
|
||||
|
||||
/// What this session is doing right now, as the pump last recorded it --
|
||||
/// the same word `SessionInfo::status` reports. Read here rather than
|
||||
/// only through `SessionManager::sessions` for
|
||||
/// `GET /sessions/{id}/subagents`, which needs exactly this and nothing
|
||||
/// else `SessionInfo` carries.
|
||||
pub fn status(&self) -> SessionStatus {
|
||||
*self.shared.status.lock().unwrap()
|
||||
}
|
||||
|
||||
/// The session's directory (attachments in, produced files out live in
|
||||
/// `attachments/` and `files/` under it).
|
||||
pub fn dir(&self) -> &Path {
|
||||
@@ -578,6 +602,7 @@ impl LiveSession {
|
||||
status: *self.shared.status.lock().unwrap(),
|
||||
last_activity: *self.shared.last_activity.lock().unwrap(),
|
||||
created: self.meta.created,
|
||||
subagents: subagent::count(self.dir()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1065,6 +1090,7 @@ impl SessionManager {
|
||||
status: status_of_unlaunched(&self.data_dir.join(&meta.id)),
|
||||
last_activity: meta.created,
|
||||
created: meta.created,
|
||||
subagents: subagent::count(&self.data_dir.join(&meta.id)),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
@@ -1828,6 +1854,7 @@ impl SessionManager {
|
||||
session.dir(),
|
||||
session.transcript_path(),
|
||||
&session.sink,
|
||||
session.subagents(),
|
||||
)?);
|
||||
}
|
||||
// Nothing is live for this one -- a session whose launch failed
|
||||
@@ -2289,6 +2316,11 @@ fn launch(
|
||||
|
||||
let (sink, source) = mpsc::unbounded_channel();
|
||||
let (events, _) = broadcast::channel(EVENT_BUFFER);
|
||||
// Built once per session, here, rather than per driver: a subagent
|
||||
// started before a Stop has to still be there to read after a Start,
|
||||
// and only `launch` runs once across that boundary -- `start_if_exited`
|
||||
// replaces the driver alone.
|
||||
let subagents = Arc::new(subagent::Subagents::new(dir.clone()));
|
||||
let shared = Arc::new(Shared {
|
||||
// What it was last known to be doing, not an assumption. A driver
|
||||
// that has something to say corrects this within its first poll.
|
||||
@@ -2350,7 +2382,18 @@ fn launch(
|
||||
|
||||
let driver = Arc::new(Mutex::new(
|
||||
driving
|
||||
.then(|| make_driver(&meta, setup, provider, env, &dir, &transcript_path, &sink))
|
||||
.then(|| {
|
||||
make_driver(
|
||||
&meta,
|
||||
setup,
|
||||
provider,
|
||||
env,
|
||||
&dir,
|
||||
&transcript_path,
|
||||
&sink,
|
||||
&subagents,
|
||||
)
|
||||
})
|
||||
.transpose()?,
|
||||
));
|
||||
|
||||
@@ -2368,6 +2411,7 @@ fn launch(
|
||||
events.clone(),
|
||||
Arc::clone(&commands),
|
||||
announce,
|
||||
Arc::clone(&subagents),
|
||||
));
|
||||
|
||||
Ok(Arc::new(LiveSession {
|
||||
@@ -2378,6 +2422,7 @@ fn launch(
|
||||
events,
|
||||
transcript_path,
|
||||
shared,
|
||||
subagents,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -2388,6 +2433,7 @@ fn launch(
|
||||
/// what [`SessionManager::start_session`] builds. That path replaces the
|
||||
/// driver and nothing else, so it has to construct one the same way rather
|
||||
/// than becoming a second answer to "what runs this".
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn make_driver(
|
||||
meta: &SessionConfig,
|
||||
setup: &SetupConfig,
|
||||
@@ -2396,13 +2442,17 @@ fn make_driver(
|
||||
dir: &Path,
|
||||
transcript_path: &Path,
|
||||
sink: &EventSink,
|
||||
subagents: &Arc<Subagents>,
|
||||
) -> Result<Arc<dyn Driver>> {
|
||||
Ok(match provider.kind {
|
||||
DriverKind::Echo => Arc::new(EchoDriver::new(
|
||||
sink.clone(),
|
||||
dir.to_path_buf(),
|
||||
env.usage.clone(),
|
||||
Arc::clone(subagents),
|
||||
)),
|
||||
// llama.cpp has no notion of a Task call, so it takes the registry
|
||||
// and never touches it -- see `SUBAGENTS.md`'s "Server layout".
|
||||
DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch(
|
||||
meta,
|
||||
provider,
|
||||
@@ -2411,6 +2461,7 @@ fn make_driver(
|
||||
transcript_path,
|
||||
dir,
|
||||
sink.clone(),
|
||||
Arc::clone(subagents),
|
||||
)?),
|
||||
DriverKind::ClaudeCli => Arc::new(ClaudeDriver::launch(
|
||||
meta,
|
||||
@@ -2418,6 +2469,7 @@ fn make_driver(
|
||||
&Transport::for_setup(setup),
|
||||
dir,
|
||||
sink.clone(),
|
||||
Arc::clone(subagents),
|
||||
)?),
|
||||
})
|
||||
}
|
||||
@@ -2482,6 +2534,7 @@ fn notification_for(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn pump(
|
||||
id: String,
|
||||
mut transcript: Transcript,
|
||||
@@ -2490,6 +2543,7 @@ async fn pump(
|
||||
events: broadcast::Sender<SeqEvent>,
|
||||
commands: Arc<Commands>,
|
||||
announce: Announcements,
|
||||
subagents: Arc<Subagents>,
|
||||
) {
|
||||
// Messages the session has been given and not started reading, which is
|
||||
// what makes a turn ending not the same thing as the work ending.
|
||||
@@ -2611,7 +2665,12 @@ async fn pump(
|
||||
} => commands.take_one(),
|
||||
Event::Status {
|
||||
state: SessionStatus::Exited,
|
||||
} => commands.abandon("this session's process has exited"),
|
||||
} => {
|
||||
commands.abandon("this session's process has exited");
|
||||
// The process behind every open subagent was this
|
||||
// session's own -- see `SUBAGENTS.md`'s lifecycle #4.
|
||||
subagents.finish_all();
|
||||
}
|
||||
// The two ends of a message's wait. A `UserMessage` with
|
||||
// no id never waited -- it was sent between turns, and
|
||||
// counting it would take the total below zero.
|
||||
@@ -2752,6 +2811,7 @@ mod tests {
|
||||
sink.clone(),
|
||||
dir.path().to_path_buf(),
|
||||
crate::usage::Fixture::new(),
|
||||
Arc::new(subagent::Subagents::new(dir.path().to_path_buf())),
|
||||
))))),
|
||||
sink,
|
||||
waiting: Mutex::new(VecDeque::new()),
|
||||
@@ -4399,4 +4459,69 @@ mod tests {
|
||||
let seen = collect_turn(&mut rx).await;
|
||||
assert!(seen.first().expect("events").seq > last_seq);
|
||||
}
|
||||
|
||||
/// `/subagent 2` is the test rig for `SUBAGENTS.md`'s whole feature:
|
||||
/// each helper gets its own transcript with its prompt as its first
|
||||
/// user message, `SessionInfo::subagents` counts them from the
|
||||
/// directory, and each finishes on its own a few seconds later.
|
||||
#[tokio::test]
|
||||
async fn subagent_helpers_get_their_own_transcripts_and_finish() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let config_path = dir.path().join("config.ron");
|
||||
let data_dir = dir.path().join("sessions");
|
||||
seed_echo_only(&config_path);
|
||||
let manager = SessionManager::new(config_path, data_dir.clone(), data_dir.join("models"))
|
||||
.expect("manager");
|
||||
let info = manager.spawn_session(echo_spec()).expect("spawn");
|
||||
let session = manager.session(&info.id).expect("live");
|
||||
|
||||
session.send_message("/subagent 2".to_string(), Vec::new());
|
||||
// Both helpers exist as soon as their Task calls go out, well before
|
||||
// either finishes.
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
if session.subagents().list(true).len() == 2 {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
tokio::time::Instant::now() < deadline,
|
||||
"both helpers should have started by now"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
let rows = session.subagents().list(true);
|
||||
let mut titles: Vec<&str> = rows.iter().map(|row| row.title.as_str()).collect();
|
||||
titles.sort_unstable();
|
||||
assert_eq!(titles, ["helper 1", "helper 2"]);
|
||||
assert!(rows.iter().all(|row| row.status == SessionStatus::Running));
|
||||
assert_eq!(manager.sessions()[0].subagents, 2);
|
||||
|
||||
// Each subagent's own transcript opens with its prompt.
|
||||
let first = session.subagents().get(&rows[0].id).expect("subagent");
|
||||
let events =
|
||||
transcript::read_after(&first.transcript_path(), 0).expect("read subagent transcript");
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|entry| matches!(&entry.event, Event::UserMessage { text, .. } if text.contains("helper")))
|
||||
);
|
||||
|
||||
// Each finishes on its own about three seconds after it started.
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
if session
|
||||
.subagents()
|
||||
.list(true)
|
||||
.iter()
|
||||
.all(|row| row.status == SessionStatus::Exited)
|
||||
{
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
tokio::time::Instant::now() < deadline,
|
||||
"both helpers should have finished by now"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user