feat(agents): session-boundary classification for sub-agents (#405)

\`agent_list\` previously surfaced every persisted sub-agent the manager
had on disk — including agents from prior sessions still hanging around
in \`subagents.v1.json\`. In long-lived sessions this piled up and the
model had to reason past 13 listed agents when only four were live.

Now: each \`SubAgentManager\` assigns a fresh \`session_boot_id\` at
construction. Every agent it spawns is stamped with that id, persisted
alongside the existing fields, and reloaded as-is by future managers.
At list time the manager classifies any agent whose stamp doesn't match
the current id (or that loaded with no stamp at all from pre-#405
records) as \`from_prior_session\`.

\`agent_list\` defaults to the current-session view: prior-session
agents are dropped from the listing **unless** they're still
\`Running\` (which can happen after a process restart — the manager
flagged them as \`Interrupted\` on load). Pass \`include_archived=true\`
to surface every record, with the \`from_prior_session\` flag on each
result so the model can tell live vs archived apart at a glance.

### What's wired

- \`SubAgentManager::current_session_boot_id\` — UUID-derived,
  generated in \`new\`.
- \`SubAgent::session_boot_id\` and \`PersistedSubAgent::session_boot_id\` —
  the latter \`#[serde(default)]\` for backward compat (pre-#405 records
  load with empty string and classify as prior-session).
- \`SubAgentResult::from_prior_session\` — \`#[serde(default,
  skip_serializing_if = "is_false")]\` so today's clients reading
  archived snapshots see the field, while default-false snapshots
  serialize without an extra noisy key.
- \`SubAgentManager::list_filtered(include_archived)\` — the new
  user-facing API. \`SubAgent::snapshot()\` still defaults the flag
  to \`false\`; \`snapshot_for_listing\` (manager-only) fills it in.
- \`AgentListTool\` accepts \`include_archived: bool\` (default
  false) and routes through the filter. The model-facing description
  explains the behaviour.

### Tests

- \`session_boot_ids_are_unique_per_manager\` — each manager mints
  its own id.
- \`list_filtered_drops_prior_session_terminals_by_default\` — the
  three-agent matrix (current running / prior completed / prior
  running) collapses to the right two with the right flags.
- \`list_filtered_with_include_archived_returns_everything\` —
  archived view returns all records with correct flags.
- \`agents_with_empty_boot_id_classify_as_prior_session\` — pre-#405
  records load and behave as expected.
- \`persist_round_trip_preserves_session_boot_id\` — write with one
  manager, reload with a fresh manager, confirm the agent flips to
  prior-session in the new manager's view.

### Verification

cargo fmt --all -- --check                                          ✓
cargo clippy --workspace --all-targets --all-features --locked --   -D warnings   ✓
cargo test --workspace --all-features --locked                      ✓ 1847 + supporting

Closes #405

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-05-03 03:57:06 -05:00
parent b54a708cf7
commit 256f59dd33
3 changed files with 315 additions and 10 deletions
+124 -10
View File
@@ -318,6 +318,16 @@ pub struct SubAgentResult {
pub result: Option<String>,
pub steps_taken: u32,
pub duration_ms: u64,
/// `true` when this agent was loaded from a prior-session persisted
/// state file rather than spawned in the current session (#405).
/// Lets `agent_list` filter out historical noise by default while
/// keeping the records reachable via `include_archived=true`.
#[serde(default, skip_serializing_if = "is_false")]
pub from_prior_session: bool,
}
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Debug, Clone, Default)]
@@ -405,6 +415,14 @@ struct PersistedSubAgent {
duration_ms: u64,
allowed_tools: Vec<String>,
updated_at_ms: u64,
/// Stable id of the manager / process boot that spawned this agent
/// (#405). Lets a fresh manager filter out agents that were
/// persisted by a prior session. Optional with `#[serde(default)]`
/// for backward compatibility — older records lack the field and
/// load with an empty string, which the manager treats as
/// "from_prior_session" because it can't match any current id.
#[serde(default)]
session_boot_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -587,12 +605,17 @@ pub struct SubAgent {
/// `None` = full registry inheritance (v0.6.6 default).
/// `Some(list)` = explicit narrow allowlist (Custom agents, legacy).
pub allowed_tools: Option<Vec<String>>,
/// Stable id of the manager that spawned this agent (#405). Compared
/// against the manager's `current_session_boot_id` to classify the
/// agent as in-session vs prior-session at list time.
pub session_boot_id: String,
input_tx: Option<mpsc::UnboundedSender<SubAgentInput>>,
task_handle: Option<JoinHandle<()>>,
}
impl SubAgent {
/// Create a new sub-agent.
#[allow(clippy::too_many_arguments)]
fn new(
agent_type: SubAgentType,
prompt: String,
@@ -601,6 +624,7 @@ impl SubAgent {
nickname: Option<String>,
allowed_tools: Option<Vec<String>>,
input_tx: mpsc::UnboundedSender<SubAgentInput>,
session_boot_id: String,
) -> Self {
let id = format!("agent_{}", &Uuid::new_v4().to_string()[..8]);
@@ -616,6 +640,7 @@ impl SubAgent {
steps_taken: 0,
started_at: Instant::now(),
allowed_tools,
session_boot_id,
input_tx: Some(input_tx),
task_handle: None,
}
@@ -634,6 +659,11 @@ impl SubAgent {
result: self.result.clone(),
steps_taken: self.steps_taken,
duration_ms: u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
// Snapshots from the agent itself don't know the manager's
// current boot id, so default to false. The manager fills
// this in when it produces a snapshot via its own
// `snapshot_for_listing` helper (#405).
from_prior_session: false,
}
}
}
@@ -646,6 +676,13 @@ pub struct SubAgentManager {
state_path: Option<PathBuf>,
max_steps: u32,
max_agents: usize,
/// Stable id assigned at manager construction (#405). Stamped on
/// every agent the manager spawns; agents loaded from the
/// persisted state file carry whatever id the prior session
/// stamped (or empty for pre-#405 records). The manager classifies
/// agents whose `session_boot_id` doesn't match this value as
/// "from prior session" so `agent_list` can hide them by default.
current_session_boot_id: String,
}
impl SubAgentManager {
@@ -658,9 +695,27 @@ impl SubAgentManager {
state_path: None,
max_steps: DEFAULT_MAX_STEPS,
max_agents,
// Fresh boot id per manager. Used by #405 to classify
// re-loaded persisted agents as "prior session".
current_session_boot_id: format!("boot_{}", &Uuid::new_v4().to_string()[..12]),
}
}
/// Return the boot id this manager stamps on agents it spawns.
/// Exposed for tests; internal callers use the field directly.
#[cfg(test)]
pub fn session_boot_id(&self) -> &str {
&self.current_session_boot_id
}
/// Classify an agent by its `session_boot_id`: `true` when the
/// agent was either (a) loaded from disk with no id, or (b) carries
/// a different id than the manager's current boot. Filters
/// `agent_list` output by default (#405).
fn is_from_prior_session(&self, agent: &SubAgent) -> bool {
agent.session_boot_id.is_empty() || agent.session_boot_id != self.current_session_boot_id
}
#[must_use]
fn with_state_path(mut self, path: PathBuf) -> Self {
self.state_path = Some(path);
@@ -690,6 +745,7 @@ impl SubAgentManager {
// Reload converts empty vec back to None (full inheritance).
allowed_tools: agent.allowed_tools.clone().unwrap_or_default(),
updated_at_ms: now_ms,
session_boot_id: agent.session_boot_id.clone(),
});
}
agents.sort_by(|a, b| a.id.cmp(&b.id));
@@ -755,6 +811,10 @@ impl SubAgentManager {
steps_taken: persisted.steps_taken,
started_at,
allowed_tools,
// Empty string when loading pre-#405 records; the
// manager treats that the same as a non-matching id —
// i.e. agent classified as prior-session.
session_boot_id: persisted.session_boot_id,
input_tx: None,
task_handle: None,
};
@@ -863,6 +923,7 @@ impl SubAgentManager {
nickname,
tools.clone(),
input_tx,
self.current_session_boot_id.clone(),
);
let agent_id = agent.id.clone();
let started_at = agent.started_at;
@@ -1147,8 +1208,50 @@ impl SubAgentManager {
/// List all agents and their status.
#[must_use]
/// Snapshot a single agent and tag it with the manager's
/// classification. The bare `SubAgent::snapshot` defaults
/// `from_prior_session` to `false`; only the manager knows the
/// matching boot id, so listing goes through here.
fn snapshot_for_listing(&self, agent: &SubAgent) -> SubAgentResult {
let mut snap = agent.snapshot();
snap.from_prior_session = self.is_from_prior_session(agent);
snap
}
/// List all agents currently held by the manager, regardless of
/// session origin. Use [`Self::list_filtered`] in user-facing tool
/// paths so prior-session agents stay hidden by default (#405).
pub fn list(&self) -> Vec<SubAgentResult> {
self.agents.values().map(SubAgent::snapshot).collect()
self.agents
.values()
.map(|agent| self.snapshot_for_listing(agent))
.collect()
}
/// List agents respecting the session-boundary filter (#405).
///
/// `include_archived = false` (the default for `agent_list`) drops
/// any prior-session agent that is no longer running. Prior-session
/// agents that are still `Running` (e.g. interrupted by a process
/// restart) stay visible — they may matter for ongoing recovery.
///
/// `include_archived = true` returns everything, with the
/// `from_prior_session` flag on each `SubAgentResult` so the model
/// can tell active and archived apart at a glance.
pub fn list_filtered(&self, include_archived: bool) -> Vec<SubAgentResult> {
self.agents
.values()
.filter(|agent| {
if include_archived {
return true;
}
if agent.status == SubAgentStatus::Running {
return true;
}
!self.is_from_prior_session(agent)
})
.map(|agent| self.snapshot_for_listing(agent))
.collect()
}
/// Clean up completed agents older than the given duration.
@@ -1767,14 +1870,22 @@ impl ToolSpec for AgentListTool {
}
fn description(&self) -> &'static str {
"List all active and recently completed sub-agents with their status, type, assignment, \
steps taken, and duration."
"List sub-agents from the current session with their status, type, assignment, steps, \
and duration. Pass `include_archived=true` to also see agents that were spawned in a \
prior session (e.g. before the TUI restarted) and persisted on disk; those carry \
`from_prior_session: true` in the result. Default is the current-session view because \
prior-session agents almost never matter for the live turn."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {}
"properties": {
"include_archived": {
"type": "boolean",
"description": "When true, include agents from prior sessions in the listing. Default false."
}
}
})
}
@@ -1782,14 +1893,14 @@ impl ToolSpec for AgentListTool {
vec![ToolCapability::ReadOnly]
}
async fn execute(
&self,
_input: Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
let include_archived = input
.get("include_archived")
.and_then(Value::as_bool)
.unwrap_or(false);
let mut manager = self.manager.write().await;
manager.cleanup(COMPLETED_AGENT_RETENTION);
let results = manager.list();
let results = manager.list_filtered(include_archived);
ToolResult::json(&results).map_err(|e| ToolError::execution_failed(e.to_string()))
}
}
@@ -2431,6 +2542,7 @@ async fn run_subagent(
result: None,
steps_taken: steps,
duration_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
from_prior_session: false,
});
}
@@ -2504,6 +2616,7 @@ async fn run_subagent(
steps_taken: steps,
duration_ms: u64::try_from(started_at.elapsed().as_millis())
.unwrap_or(u64::MAX),
from_prior_session: false,
});
}
api = tokio::time::timeout(STEP_API_TIMEOUT, runtime.client.create_message(request)) => {
@@ -2637,6 +2750,7 @@ async fn run_subagent(
result: final_result,
steps_taken: steps,
duration_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
from_prior_session: false,
})
}
+190
View File
@@ -16,6 +16,7 @@ fn make_snapshot(status: SubAgentStatus) -> SubAgentResult {
result: None,
steps_taken: 0,
duration_ms: 0,
from_prior_session: false,
}
}
@@ -310,6 +311,7 @@ async fn test_wait_for_result_reports_timeout_when_still_running() {
Some("Blue".to_string()),
Some(vec!["read_file".to_string()]),
input_tx,
"boot_test".to_string(),
);
let agent_id = agent.id.clone();
{
@@ -336,6 +338,7 @@ async fn test_running_count_counts_only_agents_with_live_task_handles() {
Some("Blue".to_string()),
Some(vec!["read_file".to_string()]),
input_tx,
"boot_test".to_string(),
);
agent.status = SubAgentStatus::Running;
let handle = tokio::spawn(async {
@@ -366,6 +369,7 @@ fn test_running_count_ignores_running_status_without_task_handle() {
Some("Blue".to_string()),
Some(vec!["read_file".to_string()]),
input_tx,
"boot_test".to_string(),
);
agent.status = SubAgentStatus::Running;
manager.agents.insert(agent.id.clone(), agent);
@@ -385,6 +389,7 @@ async fn test_running_count_ignores_finished_task_handles() {
Some("Blue".to_string()),
Some(vec!["read_file".to_string()]),
input_tx,
"boot_test".to_string(),
);
agent.status = SubAgentStatus::Running;
let handle = tokio::spawn(async {});
@@ -412,6 +417,7 @@ fn test_assign_updates_running_agent_and_sends_message() {
Some("Blue".to_string()),
Some(vec!["read_file".to_string()]),
input_tx,
"boot_test".to_string(),
);
let agent_id = agent.id.clone();
manager.agents.insert(agent_id.clone(), agent);
@@ -448,6 +454,7 @@ fn test_assign_rejects_message_for_non_running_agent() {
Some("Blue".to_string()),
Some(vec!["read_file".to_string()]),
input_tx,
"boot_test".to_string(),
);
agent.status = SubAgentStatus::Completed;
let agent_id = agent.id.clone();
@@ -471,6 +478,7 @@ fn test_assign_updates_non_running_metadata_without_message() {
Some("Blue".to_string()),
Some(vec!["read_file".to_string()]),
input_tx,
"boot_test".to_string(),
);
agent.status = SubAgentStatus::Completed;
let agent_id = agent.id.clone();
@@ -505,6 +513,7 @@ fn test_persist_and_reload_marks_running_agent_as_interrupted() {
Some("Blue".to_string()),
Some(vec!["read_file".to_string()]),
input_tx,
"boot_test".to_string(),
);
let running_id = running.id.clone();
manager.agents.insert(running_id.clone(), running);
@@ -1000,3 +1009,184 @@ fn stub_client() -> DeepSeekClient {
};
DeepSeekClient::new(&config).expect("stub client should construct")
}
// ---- #405 session-boundary classification ----
//
// Each manager assigns a fresh session_boot_id; agents stamp the id at
// spawn time. After persist + reload by a *new* manager, those agents
// carry the prior boot id and are classified as `from_prior_session`.
// `agent_list` defaults to current-session only; `include_archived=true`
// surfaces the prior-session records with the flag set.
fn insert_prior_session_agent(
manager: &mut SubAgentManager,
id: &str,
status: SubAgentStatus,
boot_id: &str,
) {
let (input_tx, _input_rx) = mpsc::unbounded_channel();
let mut agent = SubAgent::new(
SubAgentType::General,
"old prompt".to_string(),
make_assignment(),
"deepseek-v4-flash".to_string(),
None,
None,
input_tx,
boot_id.to_string(),
);
agent.status = status;
agent.id = id.to_string();
manager.agents.insert(id.to_string(), agent);
}
#[test]
fn session_boot_ids_are_unique_per_manager() {
let a = SubAgentManager::new(PathBuf::from("."), 1);
let b = SubAgentManager::new(PathBuf::from("."), 1);
assert_ne!(a.session_boot_id(), b.session_boot_id());
}
#[test]
fn list_filtered_drops_prior_session_terminals_by_default() {
let mut manager = SubAgentManager::new(PathBuf::from("."), 5);
let current_boot = manager.session_boot_id().to_string();
insert_prior_session_agent(
&mut manager,
"current_running",
SubAgentStatus::Running,
&current_boot,
);
insert_prior_session_agent(
&mut manager,
"prior_completed",
SubAgentStatus::Completed,
"boot_old_session",
);
insert_prior_session_agent(
&mut manager,
"prior_running",
SubAgentStatus::Running,
"boot_old_session",
);
let listed = manager.list_filtered(false);
let ids: Vec<&str> = listed.iter().map(|s| s.agent_id.as_str()).collect();
assert!(ids.contains(&"current_running"), "{ids:?}");
assert!(
ids.contains(&"prior_running"),
"still-running prior-session agents stay visible: {ids:?}"
);
assert!(
!ids.contains(&"prior_completed"),
"completed prior-session agents are hidden by default: {ids:?}"
);
let prior = listed
.iter()
.find(|s| s.agent_id == "prior_running")
.unwrap();
assert!(prior.from_prior_session);
let current = listed
.iter()
.find(|s| s.agent_id == "current_running")
.unwrap();
assert!(!current.from_prior_session);
}
#[test]
fn list_filtered_with_include_archived_returns_everything() {
let mut manager = SubAgentManager::new(PathBuf::from("."), 5);
let current_boot = manager.session_boot_id().to_string();
insert_prior_session_agent(
&mut manager,
"current_done",
SubAgentStatus::Completed,
&current_boot,
);
insert_prior_session_agent(
&mut manager,
"prior_done",
SubAgentStatus::Completed,
"boot_old",
);
insert_prior_session_agent(
&mut manager,
"prior_failed",
SubAgentStatus::Failed("boom".to_string()),
"boot_old",
);
let listed = manager.list_filtered(true);
assert_eq!(listed.len(), 3, "{listed:?}");
let prior = listed.iter().find(|s| s.agent_id == "prior_done").unwrap();
assert!(prior.from_prior_session);
let current = listed
.iter()
.find(|s| s.agent_id == "current_done")
.unwrap();
assert!(!current.from_prior_session);
}
#[test]
fn agents_with_empty_boot_id_classify_as_prior_session() {
// Records persisted before #405 land with an empty `session_boot_id`
// due to `#[serde(default)]`. The manager treats those the same as
// a non-matching id — i.e. prior session.
let mut manager = SubAgentManager::new(PathBuf::from("."), 5);
insert_prior_session_agent(&mut manager, "legacy", SubAgentStatus::Completed, "");
let listed_default = manager.list_filtered(false);
assert!(
listed_default.iter().all(|s| s.agent_id != "legacy"),
"legacy completed agents are hidden by default"
);
let listed_archived = manager.list_filtered(true);
let legacy = listed_archived
.iter()
.find(|s| s.agent_id == "legacy")
.unwrap();
assert!(legacy.from_prior_session);
}
#[test]
fn persist_round_trip_preserves_session_boot_id() {
let dir = tempdir().expect("tempdir");
let state_path = dir.path().join(SUBAGENT_STATE_FILE);
let original_boot;
{
let mut writer =
SubAgentManager::new(dir.path().to_path_buf(), 2).with_state_path(state_path.clone());
original_boot = writer.session_boot_id().to_string();
insert_prior_session_agent(
&mut writer,
"agent_persist",
SubAgentStatus::Completed,
&original_boot,
);
writer
.persist_state()
.expect("persist round-trip should write");
}
// A fresh manager comes up with a *different* boot id and reloads
// the persisted state; the agent should now be classified prior.
let mut reader =
SubAgentManager::new(dir.path().to_path_buf(), 2).with_state_path(state_path.clone());
reader.load_state().expect("reload should succeed");
assert_ne!(reader.session_boot_id(), original_boot);
let listed_default = reader.list_filtered(false);
assert!(
!listed_default.iter().any(|s| s.agent_id == "agent_persist"),
"completed prior-session agent hidden after reload: {listed_default:?}"
);
let listed_all = reader.list_filtered(true);
let snap = listed_all
.iter()
.find(|s| s.agent_id == "agent_persist")
.unwrap();
assert!(snap.from_prior_session);
}
+1
View File
@@ -777,6 +777,7 @@ fn make_subagent(
result: None,
steps_taken: 0,
duration_ms: 0,
from_prior_session: false,
}
}