use crate::runtime::config::AgentLoopConfig;
use bamboo_agent_core::tools::{
FrameworkSessionToolKind as SessionToolKind, ToolExecutor, ToolSchema,
};
use bamboo_agent_core::Session;
use bamboo_domain::{
resolve_disabled_tool_reference_name, CapabilityLoadingClass, CapabilityLoadingMode,
ClassifiedToolIdentity, ClassifiedToolSchema, EffectiveCallableSet, SessionKind,
};
#[cfg(test)]
use bamboo_skills::runtime_metadata::{
SKILL_RUNTIME_SELECTED_SKILL_IDS_KEY, SKILL_RUNTIME_SELECTION_SOURCE_KEY,
};
use bamboo_tools::exposure::{activated_discoverable_tools, expandable_tool_short_description};
const EXPOSURE_SIGNATURE: &str = "prompt_tool_exposure_signature";
const EXPOSURE_ACTIVATED: &str = "prompt_tool_exposure_activated";
const PHYSICAL_SUBAGENT_TOOLS: [&str; 3] = ["ask_agent", "deploy_agent", "cluster"];
pub(crate) fn effective_guide_activation(
config: &AgentLoopConfig,
session: &Session,
) -> std::collections::BTreeSet<String> {
let mut activated = if config.freeze_tool_exposure_for_cache {
session
.metadata
.get(EXPOSURE_ACTIVATED)
.and_then(|raw| serde_json::from_str(raw).ok())
.unwrap_or_else(|| activated_discoverable_tools(session))
} else {
activated_discoverable_tools(session)
};
if session.model_orchestration_only_enabled() {
activated.insert("SubAgent".to_string());
}
activated
}
pub(crate) fn resolve_tool_schemas_for_round(
config: &AgentLoopConfig,
tools: &dyn ToolExecutor,
session: &mut Session,
) -> Vec<ToolSchema> {
if config.freeze_tool_exposure_for_cache {
use sha2::{Digest, Sha256};
let catalog = resolve_catalog_with_activation(
config,
tools,
session,
&std::collections::BTreeSet::new(),
);
let schemas = catalog
.iter()
.map(|entry| entry.schema())
.collect::<Vec<_>>();
let value = serde_json::to_value(schemas).expect("tool schemas serialize");
let bytes =
bamboo_llm::providers::common::tool_schema::canonicalize_json_value(&value).to_string();
let signature = Sha256::digest(bytes.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
if session.metadata.get(EXPOSURE_SIGNATURE) != Some(&signature) {
session
.metadata
.insert(EXPOSURE_SIGNATURE.into(), signature);
session.metadata.insert(
EXPOSURE_ACTIVATED.into(),
serde_json::to_string(&activated_discoverable_tools(session))
.expect("activation names serialize"),
);
}
} else {
session.metadata.remove(EXPOSURE_SIGNATURE);
session.metadata.remove(EXPOSURE_ACTIVATED);
}
resolve_available_tool_schemas_for_session(config, tools, session)
}
fn prefer_delegated_plan_tool(
session: &Session,
catalog: &mut std::collections::BTreeMap<String, ClassifiedToolSchema>,
) {
if !catalog.contains_key("Plan") {
return;
}
let legacy_plan_active = session
.agent_runtime_state
.as_ref()
.is_some_and(|state| state.plan_mode.is_some());
if legacy_plan_active {
catalog.remove("Plan");
} else {
catalog.remove("ExitPlanMode");
}
}
pub(crate) fn resolve_available_tool_schemas_for_session(
config: &AgentLoopConfig,
tools: &dyn ToolExecutor,
session: &Session,
) -> Vec<ToolSchema> {
let catalog = resolve_classified_tool_catalog_for_session(config, tools, session);
let effective = EffectiveCallableSet::from_catalog(
&catalog,
CapabilityLoadingMode::LegacyFullCatalog,
std::iter::empty::<&str>(),
);
catalog
.into_iter()
.filter(|entry| effective.contains_execution_name(entry.execution_name()))
.map(ClassifiedToolSchema::into_schema)
.collect()
}
pub(crate) fn resolve_classified_tool_catalog_for_session(
config: &AgentLoopConfig,
tools: &dyn ToolExecutor,
session: &Session,
) -> Vec<ClassifiedToolSchema> {
resolve_catalog_with_activation(
config,
tools,
session,
&effective_guide_activation(config, session),
)
}
fn resolve_catalog_with_activation(
config: &AgentLoopConfig,
tools: &dyn ToolExecutor,
session: &Session,
activated: &std::collections::BTreeSet<String>,
) -> Vec<ClassifiedToolSchema> {
let bound = tools.for_session(session);
let tools = bound.as_deref().unwrap_or(tools);
let mut tool_schemas = config.tool_registry.list_tools();
if tool_schemas.is_empty() {
tool_schemas = tools.list_tools();
}
tool_schemas.extend(config.additional_tool_schemas.clone());
let framework = SessionToolKind::ALL
.into_iter()
.filter_map(|kind| {
tools
.framework_session_tool_for_exact(kind.execution_name())
.filter(|proof| proof.kind() == kind && proof.is_current())
.map(|proof| (kind.execution_name(), proof))
})
.collect::<std::collections::BTreeMap<_, _>>();
tool_schemas.retain(|schema| {
let name = schema.function.name.as_str();
!framework.contains_key(name)
&& name != "spawn_session"
&& !((session.model_orchestration_only_enabled() || session.kind == SessionKind::Child)
&& SessionToolKind::from_execution_name(name).is_some())
});
tool_schemas.extend(framework.values().map(|proof| proof.schema()));
tool_schemas.sort_by(|left, right| left.function.name.cmp(&right.function.name));
tool_schemas.dedup_by(|left, right| left.function.name == right.function.name);
if config.ticket_worker_plan.is_some() {
tool_schemas = tools.list_tools();
for schema in tool_schemas
.iter_mut()
.filter(|s| s.function.name == "Task")
{
schema.function.description = "Update only this Assignment's private Steps. Include question:{prompt} with tasks to durably ask a User question and end this one-shot run. Answers arrive in a fresh Assignment's versioned context; a question never confers approval.".into();
schema.function.parameters["properties"]["question"] = serde_json::json!({"type":"object","additionalProperties":false,"required":["prompt"],"properties":{"prompt":{"type":"string","minLength":1,"maxLength":2048}}});
}
}
let (disabled_tools, _disabled_skill_ids) = config.resolve_disabled_filters();
if config.loop_control.is_none() {
tool_schemas.retain(|schema| {
schema.function.name != bamboo_tools::tools::loop_control::LOOP_CONTROL_TOOL_NAME
});
}
if !config.goal_loop_active() {
tool_schemas.retain(|schema| {
schema.function.name != bamboo_tools::tools::goal::UPDATE_GOAL_TOOL_NAME
});
}
super::legacy_instruction::retain_terminal_activation_tools(session, &mut tool_schemas);
for schema in &mut tool_schemas {
let Some(identity) = ClassifiedToolIdentity::from_schema_name(&schema.function.name) else {
continue;
};
if schema.function.name == "spawn_session" {
continue;
}
let guide_name = identity.alias_fallback_name();
if identity.loading_class() == CapabilityLoadingClass::Deferred
&& !activated.contains(guide_name)
{
if let Some(short) = expandable_tool_short_description(guide_name) {
schema.function.description =
format!("[Discoverable — not fully activated] {}", short);
}
}
}
if session.supervisor_incarnation_id().is_some() {
for schema in tool_schemas
.iter_mut()
.filter(|schema| schema.function.name == "SubAgent")
{
schema.function.description = format!(
"Supervisor: delegate execution to existing managed Sessions first. Use session_history list and bounded inspect before main execution, then continue the same authorized independent Root. Use SubAgent only for auxiliary planning, review or verification; retain only necessary dedicated read-only acquisition and orchestration here. If no suitable authorized target is available, report the actual recoverable blocker instead of self-executing or repeatedly creating children. {}",
schema.function.description,
);
}
}
let mut by_execution_name = std::collections::BTreeMap::<String, ClassifiedToolSchema>::new();
for entry in tool_schemas
.into_iter()
.filter_map(ClassifiedToolSchema::new)
{
let key = entry.execution_name().to_string();
match by_execution_name.entry(key) {
std::collections::btree_map::Entry::Vacant(slot) => {
slot.insert(entry);
}
std::collections::btree_map::Entry::Occupied(_) => {}
}
}
let disabled_execution_names = disabled_tools
.iter()
.filter_map(|reference| {
if bamboo_domain::independent_root_spawn_reference(reference) {
bamboo_domain::resolve_session_tool_reference(
session.session_version,
reference,
|name| by_execution_name.contains_key(name),
)
} else {
resolve_disabled_tool_reference_name(reference, |name| {
by_execution_name.contains_key(name)
})
}
})
.flat_map(|name| {
let replacement = bamboo_domain::legacy_shell_replacement(&name).map(str::to_owned);
std::iter::once(name).chain(replacement)
})
.collect::<std::collections::BTreeSet<_>>();
by_execution_name.retain(|name, _| !disabled_execution_names.contains(name));
prefer_delegated_plan_tool(session, &mut by_execution_name);
if matches!(session.kind, SessionKind::Root | SessionKind::Child) {
by_execution_name
.retain(|_, entry| !PHYSICAL_SUBAGENT_TOOLS.contains(&entry.alias_fallback_name()));
}
by_execution_name.retain(|name, _| session.allows_model_tool_execution(name));
let mut catalog = by_execution_name.into_values().collect::<Vec<_>>();
catalog.sort_by(|left, right| {
left.schema()
.function
.name
.cmp(&right.schema().function.name)
});
catalog
}
#[cfg(test)]
mod live_disabled_tests {
use super::*;
use bamboo_agent_core::tools::{
FunctionSchema, ToolCall, ToolError, ToolExecutionContext, ToolResult,
};
use std::collections::BTreeSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
fn schema(name: &str) -> ToolSchema {
ToolSchema {
schema_type: "function".into(),
function: FunctionSchema {
name: name.into(),
description: String::new(),
parameters: serde_json::json!({ "type": "object" }),
},
}
}
fn plan_catalog() -> std::collections::BTreeMap<String, ClassifiedToolSchema> {
["Plan", "ExitPlanMode", "Read"]
.into_iter()
.map(schema)
.filter_map(ClassifiedToolSchema::new)
.map(|entry| (entry.execution_name().to_string(), entry))
.collect()
}
#[test]
fn delegated_plan_hides_legacy_exit_for_inactive_sessions() {
let session = Session::new("s", "m");
let mut catalog = plan_catalog();
prefer_delegated_plan_tool(&session, &mut catalog);
assert_eq!(
catalog.keys().map(String::as_str).collect::<Vec<_>>(),
vec!["Plan", "Read"]
);
}
#[test]
fn delegated_plan_preserves_exit_for_an_active_legacy_session() {
let mut session = Session::new("s", "m");
let runtime = session
.agent_runtime_state
.get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
runtime.plan_mode = Some(bamboo_domain::PlanModeState {
entered_at: chrono::Utc::now(),
pre_permission_mode: "default".to_string(),
plan_file_path: None,
status: bamboo_domain::PlanModeStatus::Exploring,
});
let mut catalog = plan_catalog();
prefer_delegated_plan_tool(&session, &mut catalog);
assert_eq!(
catalog.keys().map(String::as_str).collect::<Vec<_>>(),
vec!["ExitPlanMode", "Read"]
);
}
#[test]
fn legacy_exit_remains_when_plan_is_not_available() {
let session = Session::new("s", "m");
let mut catalog = plan_catalog();
catalog.remove("Plan");
prefer_delegated_plan_tool(&session, &mut catalog);
assert!(catalog.contains_key("ExitPlanMode"));
}
struct TwoTools;
#[async_trait::async_trait]
impl ToolExecutor for TwoTools {
async fn execute(&self, _call: &ToolCall) -> Result<ToolResult, ToolError> {
unreachable!("not invoked in this test")
}
async fn execute_with_context(
&self,
call: &ToolCall,
_ctx: ToolExecutionContext<'_>,
) -> Result<ToolResult, ToolError> {
self.execute(call).await
}
fn list_tools(&self) -> Vec<ToolSchema> {
[
"alpha_tool",
"beta_tool",
"load_skill",
"Bash",
"BashOutput",
"exec_command",
"write_stdin",
"vendor::Bash",
]
.into_iter()
.map(schema)
.collect()
}
}
fn offered(
config: &AgentLoopConfig,
tools: &dyn ToolExecutor,
session: &Session,
name: &str,
) -> bool {
resolve_available_tool_schemas_for_session(config, tools, session)
.iter()
.any(|s| s.function.name == name)
}
#[test]
fn live_disabled_resolver_filters_tools_on_the_next_round() {
let disabled = Arc::new(AtomicBool::new(false));
let d = disabled.clone();
let config = AgentLoopConfig {
disabled_filter_resolver: Some(Arc::new(move || {
let tools = if d.load(Ordering::SeqCst) {
BTreeSet::from(["beta_tool".to_string()])
} else {
BTreeSet::new()
};
(tools, BTreeSet::new())
})),
..Default::default()
};
let session = Session::new("s", "m");
let tools = TwoTools;
assert!(offered(&config, &tools, &session, "beta_tool"));
disabled.store(true, Ordering::SeqCst);
assert!(!offered(&config, &tools, &session, "beta_tool"));
assert!(offered(&config, &tools, &session, "alpha_tool"));
}
#[test]
fn legacy_disabled_shell_names_filter_replacements_without_foreign_suffix_fallback() {
let tools = TwoTools;
let session = Session::new("shell-policy", "m");
let mut config = AgentLoopConfig::default();
let present = |config: &AgentLoopConfig, name: &str| {
resolve_classified_tool_catalog_for_session(config, &tools, &session)
.iter()
.any(|entry| entry.execution_name() == name)
};
config.disabled_tools = BTreeSet::from(["Bash".into(), "BashOutput".into()]);
assert!(!present(&config, "exec_command"));
assert!(!present(&config, "write_stdin"));
config.disabled_tools = BTreeSet::from(["vendor::Bash".into()]);
assert!(present(&config, "exec_command"));
assert!(present(&config, "write_stdin"));
}
struct CanonicalShellTools;
#[async_trait::async_trait]
impl ToolExecutor for CanonicalShellTools {
async fn execute(&self, _call: &ToolCall) -> Result<ToolResult, ToolError> {
unreachable!("catalog projection only")
}
fn list_tools(&self) -> Vec<ToolSchema> {
["exec_command", "write_stdin", "vendor::Bash"]
.into_iter()
.map(schema)
.collect()
}
}
#[test]
fn exact_legacy_denials_constrain_a_canonical_only_catalog() {
let tools = CanonicalShellTools;
let session = Session::new("canonical-shell-policy", "m");
for (denied, exec_allowed, stdin_allowed, foreign_allowed) in [
("Bash", false, true, true),
("BashInput", true, false, true),
("BashOutput", true, false, true),
("KillShell", true, false, true),
("vendor::Bash", true, true, false),
("default::Bash", true, true, true),
("unregistered::Bash", true, true, true),
] {
let config = AgentLoopConfig {
disabled_tools: BTreeSet::from([denied.to_owned()]),
..Default::default()
};
let catalog = resolve_classified_tool_catalog_for_session(&config, &tools, &session);
let present = |name| catalog.iter().any(|entry| entry.execution_name() == name);
assert_eq!(present("exec_command"), exec_allowed, "{denied}");
assert_eq!(present("write_stdin"), stdin_allowed, "{denied}");
assert_eq!(present("vendor::Bash"), foreign_allowed, "{denied}");
}
}
struct PositiveShellCatalog(&'static [&'static str]);
#[async_trait::async_trait]
impl ToolExecutor for PositiveShellCatalog {
async fn execute(&self, _call: &ToolCall) -> Result<ToolResult, ToolError> {
unreachable!("catalog projection only")
}
fn list_tools(&self) -> Vec<ToolSchema> {
self.0.iter().map(|name| schema(name)).collect()
}
}
fn shell_policy_catalog(
tools: &dyn ToolExecutor,
denied: &str,
) -> (BTreeSet<String>, EffectiveCallableSet) {
let config = AgentLoopConfig {
disabled_tools: BTreeSet::from([denied.to_owned()]),
..Default::default()
};
let session = Session::new("positive-shell-policy", "m");
let catalog = resolve_classified_tool_catalog_for_session(&config, tools, &session);
let callable = EffectiveCallableSet::from_catalog(
&catalog,
CapabilityLoadingMode::LegacyFullCatalog,
std::iter::empty::<&str>(),
);
let names = catalog
.iter()
.map(|entry| entry.execution_name().to_owned())
.collect();
(names, callable)
}
#[test]
fn supported_unqualified_legacy_alias_denials_constrain_pair_only_catalog_and_admission() {
let tools = PositiveShellCatalog(&["exec_command", "write_stdin", "Read"]);
for (denied, legacy, replacement) in [
("bash", "Bash", "exec_command"),
("bAsH", "Bash", "exec_command"),
("execute_command", "Bash", "exec_command"),
("bashinput", "BashInput", "write_stdin"),
("bAsHiNpUt", "BashInput", "write_stdin"),
("bashoutput", "BashOutput", "write_stdin"),
("bAsHoUtPuT", "BashOutput", "write_stdin"),
("killshell", "KillShell", "write_stdin"),
("kIlLsHeLl", "KillShell", "write_stdin"),
] {
assert_eq!(
bamboo_domain::resolve_tool_reference_name(denied, |name| name == legacy),
Some(legacy.to_owned()),
"unsupported test reference {denied}",
);
let (names, callable) = shell_policy_catalog(&tools, denied);
assert!(!names.contains(replacement), "{denied}: {names:?}");
assert!(
callable.resolve_callable_reference(replacement).is_none(),
"{denied} left {replacement} callable",
);
let other = if replacement == "exec_command" {
"write_stdin"
} else {
"exec_command"
};
assert!(names.contains(other), "{denied} also disabled {other}");
assert!(names.contains("Read"));
}
}
#[test]
fn missing_qualified_legacy_references_do_not_disable_pair_only_catalog_by_suffix() {
let tools = PositiveShellCatalog(&["exec_command", "write_stdin", "Read"]);
for denied in [
"vendor::Bash",
"vendor::bash",
"vendor::execute_command",
"default::Bash",
"default::bashoutput",
"vendor::KillShell",
"EXECUTE_COMMAND",
] {
let (names, _) = shell_policy_catalog(&tools, denied);
for name in ["exec_command", "write_stdin", "Read"] {
assert!(names.contains(name), "{denied} disabled {name}");
}
}
}
#[test]
fn exact_custom_legacy_spellings_win_over_disabled_alias_fallback() {
let tools = PositiveShellCatalog(&[
"exec_command",
"write_stdin",
"Read",
"bash",
"execute_command",
"vendor::Bash",
"default::Bash",
"default::execute_command",
]);
for denied in [
"bash",
"execute_command",
"vendor::Bash",
"default::Bash",
"default::execute_command",
] {
let (names, _) = shell_policy_catalog(&tools, denied);
assert!(
!names.contains(denied),
"exact denial was ignored: {denied}"
);
for name in ["exec_command", "write_stdin", "Read"] {
assert!(names.contains(name), "{denied} disabled {name}");
}
}
}
#[test]
fn explicit_degraded_activation_hides_load_skill_after_one_attempt() {
let config = AgentLoopConfig::default();
let tools = TwoTools;
let mut session = Session::new("degraded", "m");
session.metadata.insert(
SKILL_RUNTIME_SELECTION_SOURCE_KEY.to_string(),
"explicit".to_string(),
);
session.metadata.insert(
SKILL_RUNTIME_SELECTED_SKILL_IDS_KEY.to_string(),
r#"["review"]"#.to_string(),
);
assert!(offered(&config, &tools, &session, "load_skill"));
session.metadata.insert(
bamboo_skills::runtime_metadata::SKILL_RUNTIME_ACTIVATION_ERROR_KEY.to_string(),
r#"{"code":"provider_failed"}"#.to_string(),
);
assert!(!offered(&config, &tools, &session, "load_skill"));
assert!(!super::super::skill_context::explicit_activation_pending(
&session
));
}
struct VersionedCatalog {
root_installed: bool,
}
#[async_trait::async_trait]
impl ToolExecutor for VersionedCatalog {
async fn execute(&self, _: &ToolCall) -> Result<ToolResult, ToolError> {
unreachable!()
}
fn for_session(&self, _session: &Session) -> Option<Arc<dyn ToolExecutor>> {
Some(Arc::new(Self {
root_installed: self.root_installed,
}))
}
fn exact_tool_owner(&self, name: &str) -> Option<&dyn ToolExecutor> {
self.owns_exact_tool(name)
.then_some(self as &dyn ToolExecutor)
}
fn local_execution_contract(&self, name: &str) -> Option<&'static str> {
(self.root_installed && name == "spawn_session")
.then_some(bamboo_agent_core::tools::FRAMEWORK_ROOT_SPAWN_CONTRACT_V1)
}
fn list_tools(&self) -> Vec<ToolSchema> {
let mut names = vec!["SubAgent", "session_control", "work_dispatch"];
if self.root_installed {
names.push("spawn_session");
}
names
.into_iter()
.map(|name| {
let mut s = schema(name);
s.function.description = format!("actual {name} owner contract");
s
})
.collect()
}
}
#[test]
fn fresh_spawn_catalog_does_not_promote_opaque_or_custom_canonical_names() {
use bamboo_domain::{SessionAuthorityIdentity, DEFAULT_SUPERVISOR_SESSION_ID};
struct Unproven(bool);
#[async_trait::async_trait]
impl ToolExecutor for Unproven {
async fn execute(&self, _: &ToolCall) -> Result<ToolResult, ToolError> {
unreachable!()
}
fn list_tools(&self) -> Vec<ToolSchema> {
vec![schema("spawn_session")]
}
fn exact_tool_owner(&self, name: &str) -> Option<&dyn ToolExecutor> {
(self.0 && name == "spawn_session").then_some(self as &dyn ToolExecutor)
}
}
for version in [None, Some(1), Some(2)] {
for concrete in [false, true] {
let mut session = Session::new("unproven-root-name", "model");
session.session_version = version;
assert!(!offered(
&AgentLoopConfig::default(),
&Unproven(concrete),
&session,
"spawn_session"
));
}
for ultra in [false, true] {
for supervisor in [false, true] {
let mut session = Session::new(
if supervisor {
DEFAULT_SUPERVISOR_SESSION_ID
} else {
"contract-only-root-name"
},
"model",
);
session.session_version = version;
session.set_root_orchestration_only(ultra).unwrap();
if supervisor {
session.authority_identity = SessionAuthorityIdentity::Supervisor {
incarnation_id: uuid::Uuid::new_v4(),
};
}
assert!(!offered(
&AgentLoopConfig::default(),
&VersionedCatalog {
root_installed: true
},
&session,
"spawn_session"
));
}
}
}
}
#[test]
fn fresh_spawn_catalog_keeps_root_schema_separate_and_native_disabled_exact() {
use bamboo_domain::{SessionAuthorityIdentity, DEFAULT_SUPERVISOR_SESSION_ID};
let tools = crate::session_app::approval_replay::framework_fixture::FixtureTools::new(&[
"SubAgent",
"session_control",
"work_dispatch",
"spawn_session",
]);
let expected = tools
.framework_session_tool_for_exact("spawn_session")
.expect("actual fixture Host instance")
.schema();
for version in [None, Some(1), Some(2)] {
for ultra in [false, true] {
for supervisor in [false, true] {
let mut session = Session::new(
if supervisor {
DEFAULT_SUPERVISOR_SESSION_ID
} else {
"ordinary"
},
"model",
);
session.session_version = version;
session.set_root_orchestration_only(ultra).unwrap();
if supervisor {
session.authority_identity = SessionAuthorityIdentity::Supervisor {
incarnation_id: uuid::Uuid::new_v4(),
};
}
let config = AgentLoopConfig::default();
let schemas = resolve_available_tool_schemas_for_session(
&config,
tools.as_ref(),
&session,
);
let root = schemas.iter().find(|s| s.function.name == "spawn_session");
assert!(root.is_some());
if let Some(root) = root {
assert_eq!(root.function.description, expected.function.description);
assert_eq!(root.function.parameters, expected.function.parameters);
}
for reference in ["spawn_session", "spawnSession", "default::spawn_session"] {
let config = AgentLoopConfig {
disabled_tools: BTreeSet::from([reference.to_owned()]),
..Default::default()
};
let schemas = resolve_available_tool_schemas_for_session(
&config,
tools.as_ref(),
&session,
);
assert!(schemas.iter().all(|s| s.function.name != "spawn_session"));
assert!(schemas.iter().any(|s| s.function.name == "SubAgent"));
}
}
}
}
let session = Session::new("no-root-port", "model");
let mut config = AgentLoopConfig::default();
config.additional_tool_schemas.push(schema("spawn_session"));
assert!(!offered(
&config,
&VersionedCatalog {
root_installed: false
},
&session,
"spawn_session"
));
}
}