feat: reload v2 agents on delivery (#26623)

## Summary

This is the first small step toward making multi-agent v2 agents durable
logical agents whose `ThreadManager` residency is only an implementation
detail.

This PR adds a narrow v2 reload-on-delivery hook:

- If a known v2 agent target is already loaded, delivery is unchanged.
- If the target is still registered but missing from `ThreadManager`,
delivery reloads that exact v2 thread from durable rollout history
before submitting the message.
- If the target is unknown, closed, missing from storage, or not a v2
thread, delivery still fails as not found.

The reload is wired only into existing-agent delivery paths: v2
`send_message` / `followup_task`, and legacy `send_input` when its
target is a known v2 agent.

## Stack

1. **Reload on delivery**: load known unloaded v2 agents before
`followup_task`, `send_message`, or `send_input` delivery. This PR.
2. **Residency LRU**: unload idle resident v2 agents from
`ThreadManager` without making them closed or unreachable.
3. **Execution concurrency**: count active non-root turns, not logical
agents or resident idle threads.
4. **Close semantics**: make v2 close interrupt-only and leave durable
agent identity intact.
5. **Resume cleanup**: remove user-facing v2 resume semantics;
addressing an unloaded durable agent reloads it implicitly.

## Validation

- Ran `just fmt`.
- Left broader tests and clippy to CI.
This commit is contained in:
jif
2026-06-05 18:18:29 +02:00
committed by GitHub
Unverified
parent e5af672d73
commit d5e4f01af4
4 changed files with 206 additions and 6 deletions
@@ -42,8 +42,17 @@ impl ToolExecutor<ToolInvocation> for Handler {
let receiver_agent = session
.services
.agent_control
.get_agent_metadata(receiver_thread_id)
.unwrap_or_default();
.get_agent_metadata(receiver_thread_id);
if receiver_agent.is_some() {
let resume_config = build_agent_resume_config(turn.as_ref())?;
session
.services
.agent_control
.ensure_v2_agent_loaded(resume_config, receiver_thread_id)
.await
.map_err(|err| collab_agent_error(receiver_thread_id, err))?;
}
let receiver_agent = receiver_agent.unwrap_or_default();
if args.interrupt {
session
.services
@@ -75,7 +75,11 @@ pub(crate) async fn handle_message_string_tool(
.services
.agent_control
.get_agent_metadata(receiver_thread_id)
.unwrap_or_default();
.ok_or_else(|| {
FunctionCallError::RespondToModel(format!(
"agent with id {receiver_thread_id} not found"
))
})?;
if mode == MessageDeliveryMode::TriggerTurn
&& receiver_agent
.agent_path
@@ -86,6 +90,16 @@ pub(crate) async fn handle_message_string_tool(
"Follow-up tasks can't target the root agent".to_string(),
));
}
let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| {
FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string())
})?;
let resume_config = build_agent_resume_config(turn.as_ref())?;
session
.services
.agent_control
.ensure_v2_agent_loaded(resume_config, receiver_thread_id)
.await
.map_err(|err| collab_agent_error(receiver_thread_id, err))?;
session
.send_event(
&turn,
@@ -99,9 +113,6 @@ pub(crate) async fn handle_message_string_tool(
.into(),
)
.await;
let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| {
FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string())
})?;
let author = turn
.session_source
.get_agent_path()