Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/crates/assembly/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,16 @@ them under `product-full`. `PathManager::with_user_root_for_tests` derives produ
home from the user root's parent, so a fixture that relocates managed paths must
place its user root below the directory it means to exercise.

For direct user follow-ups to reusable subagent sessions, legacy route defaults,
source binding, and FreshOnly admission:

```bash
cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib subagent_conversation_
```

These fixtures cover local turn admission and SSH-workspace agent resolution;
they do not exercise live SSH, relay, peer, or dispatch connections.

For disk-backed history paging and legacy sessions without a catalog:
`cargo test --locked -p openbitfun-core --no-default-features --features remote-connect,git --lib history_page_`.
Also run the `staged_revert_catalog_projection` and `load_relay_session_turns_`
Expand Down
21 changes: 21 additions & 0 deletions src/crates/assembly/core/src/agentic/agents/registry/external.rs
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,27 @@ impl AgentRegistry {
.map(|entry| local_binding(logical_id, entry.agent.id()))
}

/// Resolve the saved local identity of an existing reusable child. The
/// coordinator checks session kind and continuation policy before using
/// this path; it does not make subagents available as new session modes.
/// Workspace route changes must not replace this identity with an external
/// generation, and a saved source key must still match the local entry.
pub(crate) fn resolve_local_agent_for_subagent_turn(
&self,
agent_type: &str,
workspace_id: Option<&str>,
expected_route_key: Option<&str>,
) -> Option<ExternalPrimaryAgentTurnBinding> {
let entry = self.find_agent_entry(agent_type, workspace_id)?;
if entry.source == AgentSource::External {
return None;
}
let binding = local_primary_binding(&entry);
expected_route_key
.is_none_or(|key| binding.route_key.as_deref() == Some(key))
.then_some(binding)
}

/// Resolve a user-facing main-agent id to the exact generation that owns
/// the next turn. The returned lease keeps prompt, tools, permissions, and
/// model metadata stable until that turn settles.
Expand Down
151 changes: 151 additions & 0 deletions src/crates/assembly/core/src/agentic/agents/registry/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2290,6 +2290,157 @@ fn non_session_primary_subagents_and_unknown_ids_do_not_resolve() {
}
}

#[test]
fn subagent_conversation_resolves_local_children_without_promoting_them_to_modes() {
let registry = AgentRegistry::new();
let workspace = "subagent-conversation-workspace";
registry.install_external_subagent_routes(
workspace,
Vec::new(),
[(
"GeneralPurpose".to_string(),
ExternalSubagentRoute::Unavailable,
)]
.into_iter()
.collect(),
);

for agent_type in ["GeneralPurpose", "Explore", "Standard"] {
let binding = registry
.resolve_local_agent_for_subagent_turn(agent_type, Some(workspace), None)
.expect("an existing local child must keep its saved identity");
assert_eq!(binding.runtime_agent_key, agent_type);
assert_eq!(
binding.route_owner,
openbitfun_core_types::SessionAgentRouteOwner::Local
);
assert!(registry
.resolve_local_agent_for_subagent_turn(
agent_type,
Some(workspace),
binding.route_key.as_deref(),
)
.is_some());
}
for agent_type in ["GeneralPurpose", "Explore"] {
assert!(registry
.resolve_primary_agent_for_turn(agent_type, Some(workspace), false, None)
.is_none());
}
let external_key = "external::new-general-purpose";
registry.install_external_subagent_routes(
workspace,
vec![ExternalSubagentRegistration {
runtime_key: external_key.to_string(),
logical_id: "GeneralPurpose".to_string(),
route_key: "opencode:new-general-purpose".to_string(),
ecosystem_id: EcosystemId::new("opencode").unwrap(),
provider_label: "OpenCode".to_string(),
model_binding: super::ExternalSubagentModelBinding::InheritParent,
hidden: false,
mode: ExternalSubagentMode::All,
agent: Arc::new(TestAgent {
id: external_key.to_string(),
}),
}],
[(
"GeneralPurpose".to_string(),
ExternalSubagentRoute::External(external_key.to_string()),
)]
.into_iter()
.collect(),
);
assert_eq!(
registry
.resolve_subagent_for_fresh_invocation("GeneralPurpose", Some(workspace), true)
.expect("new tasks use the new external route")
.runtime_agent_key,
external_key
);
assert_eq!(
registry
.resolve_local_agent_for_subagent_turn("GeneralPurpose", Some(workspace), None)
.expect("the existing local child must keep its own agent")
.runtime_agent_key,
"GeneralPurpose"
);
assert!(registry
.resolve_local_agent_for_subagent_turn(external_key, Some(workspace), None)
.is_none());
}

#[test]
fn subagent_conversation_preserves_custom_source_and_workspace_binding() {
let registry = AgentRegistry::new();
let workspace = "custom-child-workspace";
registry.write_agents().insert(
"user-child".to_string(),
test_source_custom_entry("user-child", "user prompt", CustomSubagentKind::User),
);
insert_project_subagent(&registry, workspace, "project-child", "fast");

for agent_type in ["user-child", "project-child"] {
let binding = registry
.resolve_local_agent_for_subagent_turn(agent_type, Some(workspace), None)
.expect("custom child must resolve in its owning workspace");
assert!(registry
.resolve_local_agent_for_subagent_turn(
agent_type,
Some(workspace),
binding.route_key.as_deref(),
)
.is_some());
assert!(registry
.resolve_local_agent_for_subagent_turn(
agent_type,
Some(workspace),
Some("wrong-source")
)
.is_none());
assert!(registry
.resolve_primary_agent_for_turn(agent_type, Some(workspace), false, None)
.is_none());
}
assert!(registry
.resolve_local_agent_for_subagent_turn("project-child", Some("other-workspace"), None)
.is_none());
assert!(registry
.resolve_local_agent_for_subagent_turn("missing-child", Some(workspace), None)
.is_none());

let saved_key = registry
.resolve_local_agent_for_subagent_turn("user-child", Some(workspace), None)
.expect("original user source")
.route_key;
registry.write_agents().insert(
"user-child".to_string(),
test_source_custom_entry(
"user-child",
"replacement prompt",
CustomSubagentKind::Project,
),
);
assert!(
registry
.resolve_local_agent_for_subagent_turn(
"user-child",
Some(workspace),
saved_key.as_deref()
)
.is_none(),
"a same-name definition from another source must not replace the saved binding"
);

let mut external_entry = test_project_entry("external-child", "fast");
external_entry.source = AgentSource::External;
registry
.write_agents()
.insert("external-child".to_string(), external_entry);
assert!(registry
.resolve_local_agent_for_subagent_turn("external-child", Some(workspace), None)
.is_none());
}

#[test]
fn non_builtin_same_name_review_agent_does_not_resolve_as_session_primary() {
let registry = AgentRegistry::new();
Expand Down
Loading
Loading