use std::borrow::Cow;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::runtime::config::AgentLoopConfig;
use crate::runtime::stream::handler::StreamHandlingOutput;
use bamboo_agent_core::tools::ToolSchema;
use bamboo_agent_core::{AgentError, AgentEvent, Session};
use bamboo_llm::LLMProvider;
use bamboo_metrics::TokenUsage as MetricsTokenUsage;
use token_estimation::estimate_completion_tokens;
mod context_ledger;
mod context_preparation;
mod prefix_drift;
mod stream_execution;
mod token_budget;
pub(crate) use token_budget::resolve_known_token_ceilings;
mod token_estimation;
pub(crate) use context_preparation::force_overflow_context_recovery;
pub(crate) use stream_execution::discard_latest_interrupted_assistant_output;
pub(in crate::runtime::runner) use stream_execution::{
effective_tool_schemas, required_tool_for_session,
};
pub(in crate::runtime::runner) fn is_openai_client_tool_search_boundary(
items: &[bamboo_domain::ProviderTranscriptItem],
) -> bool {
items.iter().any(|item| {
item.family() == bamboo_domain::ProviderFamily::OpenAi
&& item.protocol() == bamboo_domain::ProviderProtocol::OpenAiResponsesV1
&& item.kind() == bamboo_domain::ProviderTranscriptItemKind::OpenAiToolSearchCall
&& item.payload()["execution"].as_str() == Some("client")
})
}
fn request_tool_schemas_for_loading_mode<'a>(
session: &Session,
tool_schemas: &'a [ToolSchema],
mode: bamboo_domain::CapabilityLoadingMode,
required_tool: Option<&str>,
) -> Cow<'a, [ToolSchema]> {
if session.model_orchestration_only_enabled() {
let mut bounded = tool_schemas
.iter()
.filter(|schema| {
bamboo_domain::ClassifiedToolSchema::new((**schema).clone()).is_some_and(|entry| {
session.allows_model_tool_execution(entry.execution_name())
})
})
.cloned()
.collect::<Vec<_>>();
if session.supervisor_incarnation_id().is_some()
&& mode == bamboo_domain::CapabilityLoadingMode::StickyFallback
{
bounded.retain(|schema| {
bamboo_domain::ClassifiedToolSchema::new(schema.clone()).is_some_and(|entry| {
entry.loading_class() == bamboo_domain::CapabilityLoadingClass::Core
})
});
bounded.push(bamboo_domain::discovery_control_fallback_schema());
}
return Cow::Owned(bounded);
}
if mode == bamboo_domain::CapabilityLoadingMode::LegacyFullCatalog {
if !crate::runtime::runner::loop_execution::legacy_browser_needs_discovery(
session,
tool_schemas,
required_tool,
) {
return Cow::Borrowed(tool_schemas);
}
let mut projected = tool_schemas
.iter()
.filter(|schema| schema.function.name != "browser")
.cloned()
.collect::<Vec<_>>();
let mut discovery = bamboo_domain::discovery_control_fallback_schema();
discovery.function.description = "Search for and load the browser page shared with this chat. Use query `browser`; its full action schema becomes available after discovery.".into();
discovery.function.parameters = serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {"query": {"type": "string", "enum": ["browser"]}},
"required": ["query"]
});
projected.push(discovery);
return Cow::Owned(projected);
}
if mode != bamboo_domain::CapabilityLoadingMode::StickyFallback {
return Cow::Borrowed(tool_schemas);
}
let mut projected = tool_schemas
.iter()
.cloned()
.filter_map(bamboo_domain::ClassifiedToolSchema::new)
.filter(|entry| entry.loading_class() == bamboo_domain::CapabilityLoadingClass::Core)
.map(bamboo_domain::ClassifiedToolSchema::into_schema)
.collect::<Vec<_>>();
projected.push(bamboo_domain::discovery_control_fallback_schema());
Cow::Owned(projected)
}
pub(crate) async fn request_tool_schemas_for_session<'a>(
session: &Session,
llm: &Arc<dyn LLMProvider>,
model_name: &str,
tool_schemas: &'a [ToolSchema],
) -> Cow<'a, [ToolSchema]> {
let required_tool = required_tool_for_session(session);
let capability_loading_mode = llm.capability_loading_mode(model_name, required_tool).await;
request_tool_schemas_for_loading_mode(
session,
tool_schemas,
capability_loading_mode,
required_tool,
)
}
pub(crate) async fn context_tool_schemas_for_session<'a>(
session: &Session,
llm: &Arc<dyn LLMProvider>,
model_name: &str,
tool_schemas: &'a [ToolSchema],
) -> Cow<'a, [ToolSchema]> {
if session.supervisor_incarnation_id().is_some() {
return Cow::Owned(
tool_schemas
.iter()
.filter(|schema| {
bamboo_domain::ClassifiedToolSchema::new((**schema).clone()).is_some_and(
|entry| session.allows_model_tool_execution(entry.execution_name()),
)
})
.cloned()
.collect(),
);
}
request_tool_schemas_for_session(session, llm, model_name, tool_schemas).await
}
pub(crate) struct RoundLlmExecutionOutput {
pub stream_output: StreamHandlingOutput,
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub attempt_usage: MetricsTokenUsage,
pub terminal_validation_error: Option<AgentError>,
pub(crate) host_gold_attempt: Option<Arc<crate::runtime::host_gold::HostGoldAttempt>>,
}
#[derive(Clone, Copy)]
pub(crate) struct PromptMemoryExposureFrame<'a> {
pub round_id: &'a str,
pub provenance: &'a crate::runtime::runner::prompt_context::PromptMemoryExposureProvenance,
}
pub(crate) fn canonical_attempt_usage(
stream_output: &StreamHandlingOutput,
estimated_prompt_tokens: u64,
estimated_completion_tokens: u64,
) -> MetricsTokenUsage {
canonical_token_usage(
stream_output.provider_usage,
stream_output.input_tokens,
stream_output.output_tokens,
estimated_prompt_tokens,
estimated_completion_tokens,
)
}
pub(crate) fn canonical_token_usage(
provider_usage: Option<crate::runtime::stream::handler::ProviderUsageSnapshot>,
legacy_prompt_tokens: u64,
legacy_completion_tokens: u64,
estimated_prompt_tokens: u64,
estimated_completion_tokens: u64,
) -> MetricsTokenUsage {
let prompt_tokens = provider_usage
.and_then(|usage| usage.input_tokens)
.or_else(|| (legacy_prompt_tokens > 0).then_some(legacy_prompt_tokens))
.unwrap_or(estimated_prompt_tokens);
let completion_tokens = provider_usage
.and_then(|usage| usage.output_tokens)
.or_else(|| (legacy_completion_tokens > 0).then_some(legacy_completion_tokens))
.unwrap_or(estimated_completion_tokens);
MetricsTokenUsage {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens.saturating_add(completion_tokens),
}
.clamped_for_durable_metrics()
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn execute_llm_round(
session: &mut Session,
config: &AgentLoopConfig,
llm: &Arc<dyn LLMProvider>,
event_tx: &mpsc::Sender<AgentEvent>,
cancel_token: &CancellationToken,
session_id: &str,
model_name: &str,
tool_schemas: &[ToolSchema],
observation_progress_hint: Option<&str>,
prompt_memory_exposure: Option<PromptMemoryExposureFrame<'_>>,
) -> Result<RoundLlmExecutionOutput, AgentError> {
let host_gold_attempt = config.host_gold_run.as_ref().map(|run| run.begin());
let request_tool_schemas =
context_tool_schemas_for_session(session, llm, model_name, tool_schemas).await;
let tool_schemas = request_tool_schemas.as_ref();
let prepared = context_preparation::prepare_round_context(
session,
config,
model_name,
session_id,
tool_schemas,
llm,
Some(event_tx),
)
.await?;
let model = config
.model_name
.as_deref()
.ok_or_else(|| AgentError::LLM("model_name is required in AgentLoopConfig".to_string()))?;
let frame = stream_execution::LlmStreamFrame {
event_tx,
cancel_token,
session_id,
model,
provider_name: config.provider_name.as_deref(),
provider_type: config.provider_type.as_deref(),
reasoning_effort: config.reasoning_effort,
max_context_tokens: prepared.budget.max_context_tokens,
max_output_tokens: prepared.budget.max_output_tokens,
observation_progress_hint,
prompt_memory_exposure,
};
let execution = stream_execution::execute_llm_stream(
session,
config,
llm,
&prepared.prepared_context,
tool_schemas,
&frame,
);
let (stream_output, llm_duration, prompt_tokens) = match host_gold_attempt.as_ref() {
Some(attempt) => {
bamboo_llm::provider::scope_main_dispatch(attempt.observer(), execution).await?
}
None => execution.await?,
};
let openai_client_search_boundary =
is_openai_client_tool_search_boundary(&stream_output.provider_transcript_items);
let terminal_validation_error = (stream_output.tool_calls.is_empty()
&& stream_output.content.trim().is_empty()
&& !openai_client_search_boundary)
.then(|| AgentError::EmptyAssistantResponse {
response_id: stream_output.response_id.clone(),
});
let completion_tokens =
estimate_completion_tokens(&stream_output.content, &stream_output.tool_calls);
let attempt_usage = canonical_attempt_usage(&stream_output, prompt_tokens, completion_tokens);
tracing::debug!(
"[{}] LLM response completed in {}ms, answer_chars={}, reasoning_chars={}, estimated_tokens={}, canonical_tokens={}",
session_id,
llm_duration,
stream_output.token_count,
stream_output.reasoning_content.len(),
prompt_tokens.saturating_add(completion_tokens),
attempt_usage.total_tokens,
);
Ok(RoundLlmExecutionOutput {
stream_output,
prompt_tokens,
completion_tokens,
attempt_usage,
terminal_validation_error,
host_gold_attempt,
})
}
pub(crate) async fn maybe_apply_mid_turn_context_compression(
session: &mut Session,
config: &AgentLoopConfig,
llm: &Arc<dyn LLMProvider>,
event_tx: &mpsc::Sender<AgentEvent>,
session_id: &str,
model_name: &str,
tool_schemas: &[ToolSchema],
) -> Result<bool, AgentError> {
context_preparation::maybe_apply_host_context_compression(
session,
config,
model_name,
session_id,
tool_schemas,
llm,
Some(event_tx),
"mid-turn",
)
.await
}
#[cfg(test)]
mod tests {
use super::{
execute_llm_round, is_openai_client_tool_search_boundary,
request_tool_schemas_for_loading_mode, request_tool_schemas_for_session,
};
use async_trait::async_trait;
use bamboo_agent_core::tools::{FunctionCall, FunctionSchema, ToolCall, ToolSchema};
use bamboo_agent_core::{AgentEvent, Message, Session};
use bamboo_domain::{
CapabilityLoadingMode, ProviderFamily, ProviderProtocol, ProviderTranscriptAuthor,
ProviderTranscriptItem, ProviderTranscriptOrigin,
};
use bamboo_llm::{LLMChunk, LLMProvider, LLMRequestOptions, LLMStream};
use futures::stream;
use serde_json::json;
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::runtime::config::AgentLoopConfig;
fn client_search_item(family: ProviderFamily) -> ProviderTranscriptItem {
ProviderTranscriptItem::try_from_payload(
family,
ProviderProtocol::OpenAiResponsesV1,
ProviderTranscriptOrigin::Provider,
ProviderTranscriptAuthor::Model,
json!({
"type":"tool_search_call","id":"tsc_boundary","execution":"client",
"call_id":"search_boundary","status":"completed",
"arguments":{"query":"files"}
}),
)
.unwrap()
}
#[test]
fn client_search_boundary_is_exactly_openai_responses() {
assert!(is_openai_client_tool_search_boundary(&[
client_search_item(ProviderFamily::OpenAi)
]));
assert!(!is_openai_client_tool_search_boundary(&[
client_search_item(ProviderFamily::Copilot)
]));
}
struct StickyCapturingProvider {
requests: Mutex<Vec<Vec<ToolSchema>>>,
}
#[async_trait]
impl LLMProvider for StickyCapturingProvider {
async fn capability_loading_mode(
&self,
_model: &str,
required_tool: Option<&str>,
) -> CapabilityLoadingMode {
if required_tool.is_none() {
CapabilityLoadingMode::StickyFallback
} else {
CapabilityLoadingMode::LegacyFullCatalog
}
}
async fn chat_stream(
&self,
_messages: &[Message],
_tools: &[ToolSchema],
_max_output_tokens: Option<u32>,
_model: &str,
) -> bamboo_llm::provider::Result<LLMStream> {
panic!("the engine must dispatch PromptIR")
}
async fn chat_stream_ir(
&self,
_ir: &bamboo_llm::PromptIR,
tools: &[ToolSchema],
_max_output_tokens: Option<u32>,
_model: &str,
_options: Option<&LLMRequestOptions>,
) -> bamboo_llm::provider::Result<LLMStream> {
self.requests.lock().unwrap().push(tools.to_vec());
Ok(Box::pin(stream::iter(vec![Ok(LLMChunk::Done)])))
}
}
fn schema(name: &str) -> ToolSchema {
ToolSchema {
schema_type: "function".to_string(),
function: FunctionSchema {
name: name.to_string(),
description: format!("{name} description"),
parameters: json!({"type":"object","properties":{}}),
},
}
}
#[test]
fn supervisor_delegation_first_provider_projection_keeps_only_bounded_tools_and_readonly_loading(
) {
use bamboo_domain::{SessionAuthorityIdentity, DEFAULT_SUPERVISOR_SESSION_ID};
let mut supervisor = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
supervisor.authority_identity = SessionAuthorityIdentity::Supervisor {
incarnation_id: uuid::Uuid::new_v4(),
};
let schemas = [
"Read",
"SubAgent",
"session_control",
"session_history",
"browser",
"exec_command",
"discover_capabilities",
]
.into_iter()
.map(schema)
.collect::<Vec<_>>();
for ultra in [false, true] {
supervisor.set_root_orchestration_only(ultra).unwrap();
for mode in [
CapabilityLoadingMode::LegacyFullCatalog,
CapabilityLoadingMode::StickyFallback,
CapabilityLoadingMode::Progressive,
] {
let offered =
request_tool_schemas_for_loading_mode(&supervisor, &schemas, mode, None);
let expected = if mode == CapabilityLoadingMode::StickyFallback {
vec!["discover_capabilities"]
} else {
vec!["SubAgent", "session_control", "session_history"]
};
assert_eq!(
offered
.iter()
.map(|s| s.function.name.as_str())
.collect::<Vec<_>>(),
expected
);
}
}
}
#[test]
fn legacy_browser_projection_is_browser_only_and_explicit_required_tool_bypasses_it() {
let session = Session::new("legacy-browser", "chat-model");
let mut browser = schema("browser");
browser.function.parameters = json!({
"type":"object",
"properties":{
"action":{"type":"string","enum":["navigate","snapshot","click","fill","press","scroll","screenshot"]},
"url":{"type":"string"},
"selector":{"type":"string"},
"text":{"type":"string"},
"expected_epoch":{"type":"integer"}
},
"required":["action"],
"additionalProperties":false
});
let tools = vec![schema("Read"), schema("Glob"), browser];
let initial = request_tool_schemas_for_loading_mode(
&session,
&tools,
CapabilityLoadingMode::LegacyFullCatalog,
None,
);
let names = initial
.iter()
.map(|tool| tool.function.name.as_str())
.collect::<Vec<_>>();
assert_eq!(names, vec!["Read", "Glob", "discover_capabilities"]);
assert_eq!(
serde_json::to_value(&initial[1]).unwrap(),
serde_json::to_value(&tools[1]).unwrap(),
"other Deferred tools stay unchanged"
);
assert!(initial[2].function.description.contains("browser page"));
assert_eq!(
initial[2].function.parameters["properties"]["query"]["enum"],
json!(["browser"])
);
let explicit = request_tool_schemas_for_loading_mode(
&session,
&tools,
CapabilityLoadingMode::LegacyFullCatalog,
Some("browser"),
);
assert_eq!(
serde_json::to_value(explicit.as_ref()).unwrap(),
serde_json::to_value(&tools).unwrap()
);
let other_required = request_tool_schemas_for_loading_mode(
&session,
&tools,
CapabilityLoadingMode::LegacyFullCatalog,
Some("load_skill"),
);
assert!(other_required
.iter()
.all(|tool| tool.function.name != "browser"));
let native = request_tool_schemas_for_loading_mode(
&session,
&tools,
CapabilityLoadingMode::Progressive,
None,
);
assert_eq!(
serde_json::to_value(native.as_ref()).unwrap(),
serde_json::to_value(&tools).unwrap()
);
assert_eq!(
bamboo_domain::ClassifiedToolSchema::new(tools[2].clone())
.unwrap()
.loading_class(),
bamboo_domain::CapabilityLoadingClass::Deferred
);
let no_browser = request_tool_schemas_for_loading_mode(
&session,
&tools[..2],
CapabilityLoadingMode::LegacyFullCatalog,
None,
);
assert_eq!(
serde_json::to_value(no_browser.as_ref()).unwrap(),
serde_json::to_value(&tools[..2]).unwrap()
);
}
#[tokio::test]
async fn sticky_overflow_projection_uses_core_plus_discovery_catalog() {
let provider = Arc::new(StickyCapturingProvider {
requests: Mutex::new(Vec::new()),
});
let llm: Arc<dyn LLMProvider> = provider;
let session = Session::new("sticky-overflow-tools", "chat-model");
let tools = vec![
schema("Read"),
schema("ReadArchive"),
schema("Workspace"),
schema("exec_command"),
];
let request_tools =
request_tool_schemas_for_session(&session, &llm, "chat-model", &tools).await;
assert_eq!(
request_tools
.iter()
.map(|tool| tool.function.name.as_str())
.collect::<Vec<_>>(),
vec!["Read", "exec_command", "discover_capabilities"]
);
assert!(request_tools.iter().all(|tool| {
tool.function.name != "ReadArchive" && tool.function.name != "Workspace"
}));
}
#[tokio::test]
async fn selected_root_provider_never_receives_discovery_or_denied_tools() {
let provider = Arc::new(StickyCapturingProvider {
requests: Mutex::new(Vec::new()),
});
let llm: Arc<dyn LLMProvider> = provider.clone();
let mut session = Session::new("selected-root-tools", "chat-model");
session.set_root_orchestration_only(true).unwrap();
session.add_message(Message::user("coordinate this task"));
let config = AgentLoopConfig {
model_name: Some("chat-model".to_string()),
..Default::default()
};
let tools = vec![
schema("Read"),
schema("SubAgent"),
schema("Plan"),
schema("Task"),
schema("exec_command"),
schema("discover_capabilities"),
];
let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(16);
execute_llm_round(
&mut session,
&config,
&llm,
&event_tx,
&CancellationToken::new(),
"selected-root-tools",
"chat-model",
&tools,
None,
None,
)
.await
.unwrap();
assert_eq!(
provider.requests.lock().unwrap()[0]
.iter()
.map(|tool| tool.function.name.as_str())
.collect::<Vec<_>>(),
vec!["Read", "SubAgent", "Plan", "Task"]
);
}
#[tokio::test]
async fn sticky_first_and_next_round_use_byte_identical_core_plus_discovery_tools() {
let provider = Arc::new(StickyCapturingProvider {
requests: Mutex::new(Vec::new()),
});
let llm: Arc<dyn LLMProvider> = provider.clone();
let mut session = Session::new("sticky-round-tools", "chat-model");
session.add_message(Message::user("find an archive reader"));
let config = AgentLoopConfig {
model_name: Some("chat-model".to_string()),
..Default::default()
};
let tools = vec![
schema("Read"),
schema("ReadArchive"),
schema("Workspace"),
schema("exec_command"),
];
let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(16);
let cancel = CancellationToken::new();
execute_llm_round(
&mut session,
&config,
&llm,
&event_tx,
&cancel,
"sticky-round-tools",
"chat-model",
&tools,
None,
None,
)
.await
.unwrap();
let mut discovery_call = Message::assistant(
"",
Some(vec![ToolCall {
id: "sticky-loaded-read-archive".to_string(),
tool_type: "function".to_string(),
function: FunctionCall {
name: bamboo_domain::DISCOVERY_CONTROL_FALLBACK_TOOL_NAME.to_string(),
arguments: r#"{"query":"archive","kinds":["tool"],"limit":1}"#.to_string(),
},
}]),
);
discovery_call.never_compress = true;
discovery_call.metadata = Some(json!({
"runtime_kind":"sticky_capability_discovery",
"version":1
}));
session.add_message(discovery_call);
let payload = serde_json::to_string(&json!({
"tools":[serde_json::to_value(schema("ReadArchive")).unwrap()]
}))
.unwrap();
let mut discovery_result = Message::tool_result_with_status(
"sticky-loaded-read-archive",
format!("<loaded_tools>{payload}</loaded_tools>"),
true,
);
discovery_result.never_compress = true;
discovery_result.metadata = Some(json!({
"runtime_kind":"sticky_capability_discovery",
"version":1,
"canonical_new_names":["ReadArchive"]
}));
session.add_message(discovery_result);
execute_llm_round(
&mut session,
&config,
&llm,
&event_tx,
&cancel,
"sticky-round-tools",
"chat-model",
&tools,
None,
None,
)
.await
.unwrap();
let requests = provider.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(
serde_json::to_vec(&requests[0]).unwrap(),
serde_json::to_vec(&requests[1]).unwrap(),
"loaded history must not rewrite the sticky top-level tools array"
);
assert_eq!(
requests[0]
.iter()
.map(|tool| tool.function.name.as_str())
.collect::<Vec<_>>(),
vec!["Read", "exec_command", "discover_capabilities"]
);
assert_eq!(
requests[0][2].function.parameters["properties"]["query"]["maxLength"],
bamboo_domain::MAX_DISCOVERY_QUERY_CHARS
);
assert!(requests[0]
.iter()
.all(|tool| tool.function.name != "ReadArchive" && tool.function.name != "Workspace"));
}
}