use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::runtime::config::AgentLoopConfig;
use crate::runtime::guardian_state::{
ensure_guardian_state, guardian_read_only_disabled_tools, write_guardian_config,
write_guardian_state, GuardianPhase, GUARDIAN_REVIEW_RUBRIC,
};
use crate::runtime::runner::loop_execution::startup::{
resolve_auxiliary_models, InFlightTaskEvaluation, LoopRunState,
};
use crate::runtime::runner::prompt_context::PromptMemoryRuntimeContext;
use crate::runtime::runner::session_setup::tool_schemas::resolve_available_tool_schemas_for_session;
use crate::runtime::stream::handler::StreamHandlingOutput;
use crate::runtime::task_context::TaskLoopContext;
use bamboo_agent_core::tools::ToolExecutor;
use bamboo_agent_core::{AgentError, AgentEvent, Message, Session};
use bamboo_domain::session::runtime_state::{
AgentRuntimeState, AgentStatusState, ChildWaitPolicy, SuspensionState, WaitingForBashState,
WaitingForChildrenState,
};
use bamboo_llm::LLMProvider;
use bamboo_metrics::{
MetricsCollector, RoundStatus as MetricsRoundStatus, SessionStatus as MetricsSessionStatus,
TokenUsage as MetricsTokenUsage,
};
use super::super::to_event_token_usage;
use super::gold::{
apply_completed_gold_evaluation, evaluate_gold_terminal, poll_completed_gold_evaluation,
spawn_gold_evaluation_if_needed, start_queued_gold_evaluation_if_idle, GoldTerminalDecision,
};
use crate::runtime::runner::state_bridge;
const MAX_LLM_TURN_ATTEMPTS: usize = 3;
const LLM_RETRY_BASE_DELAY_MS: u64 = 400;
fn should_retry_turn_error(error: &AgentError) -> bool {
let AgentError::LLM(message) = error else {
return false;
};
let message = message.trim().to_ascii_lowercase();
if message.is_empty() {
return false;
}
let non_retryable_patterns = [
"authentication error",
"invalid api key",
"invalid_request_error",
"unsupported model",
"model_name is required",
"http 400",
"http 401",
"http 403",
"http 404",
];
!non_retryable_patterns
.iter()
.any(|pattern| message.contains(pattern))
}
fn is_overflow_recoverable(error: &AgentError) -> bool {
matches!(error, AgentError::LLMOverflow(_))
}
struct TurnOutcome {
should_break: bool,
sent_complete: bool,
}
const SUBAGENT_TOOL_NAME: &str = "SubAgent";
fn is_subagent_create_call(call: &bamboo_agent_core::tools::ToolCall) -> bool {
if call.function.name != SUBAGENT_TOOL_NAME {
return false;
}
serde_json::from_str::<serde_json::Value>(&call.function.arguments)
.ok()
.and_then(|value| {
value
.get("action")
.and_then(|a| a.as_str())
.map(str::to_string)
})
.is_none_or(|action| action == "create")
}
struct RunBudgetExceeded {
kind: &'static str,
limit: u64,
actual: u64,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct RoundActivity {
prompt_tokens: u64,
completion_tokens: u64,
tool_call_count: u32,
subagent_spawn_count: u32,
}
impl RoundActivity {
fn absorb_attempt(&mut self, stream_output: &StreamHandlingOutput) {
self.prompt_tokens = self
.prompt_tokens
.saturating_add(stream_output.input_tokens);
self.completion_tokens = self
.completion_tokens
.saturating_add(stream_output.output_tokens);
self.tool_call_count = self
.tool_call_count
.saturating_add(stream_output.tool_calls.len() as u32);
self.subagent_spawn_count = self.subagent_spawn_count.saturating_add(
stream_output
.tool_calls
.iter()
.filter(|call| is_subagent_create_call(call))
.count() as u32,
);
}
}
fn check_run_budget_exceeded(
round: &bamboo_domain::session::runtime_state::RoundRuntimeState,
budget: &bamboo_config::RunBudgetConfig,
) -> Option<RunBudgetExceeded> {
let total_tokens = round
.total_prompt_tokens
.saturating_add(round.total_completion_tokens);
if let Some(limit) = budget.max_total_tokens {
if total_tokens >= limit {
return Some(RunBudgetExceeded {
kind: "max_total_tokens",
limit,
actual: total_tokens,
});
}
}
if let Some(limit) = budget.max_tool_calls {
if round.total_tool_calls >= limit {
return Some(RunBudgetExceeded {
kind: "max_tool_calls",
limit: limit as u64,
actual: round.total_tool_calls as u64,
});
}
}
if let Some(limit) = budget.max_subagents {
if round.total_subagents_spawned >= limit {
return Some(RunBudgetExceeded {
kind: "max_subagents",
limit: limit as u64,
actual: round.total_subagents_spawned as u64,
});
}
}
None
}
fn is_terminal_child_status(status: &str) -> bool {
matches!(
status,
"completed" | "error" | "timeout" | "cancelled" | "skipped"
)
}
async fn suspend_to_wait_for_children(
session: &mut Session,
runtime_state: &mut AgentRuntimeState,
persistence: Option<&Arc<dyn bamboo_domain::RuntimeSessionPersistence>>,
child_session_ids: Vec<String>,
wait_for: ChildWaitPolicy,
) -> TurnOutcome {
let now = Utc::now();
let count = child_session_ids.len();
runtime_state.waiting_for_children = Some(WaitingForChildrenState::for_children(
child_session_ids,
wait_for,
now,
));
state_bridge::write_runtime_state(session, runtime_state);
session.metadata.insert(
"runtime.suspend_reason".to_string(),
"waiting_for_children".to_string(),
);
session.updated_at = now;
if let Some(persistence) = persistence {
if let Err(error) = persistence.save_runtime_session(session).await {
tracing::warn!(
"[{}] suspend-to-wait failed to persist parent wait on {} child(ren): {}",
session.id,
count,
error
);
}
}
TurnOutcome {
should_break: true,
sent_complete: false,
}
}
async fn maybe_suspend_for_orphaned_children(
session: &mut Session,
config: &AgentLoopConfig,
runtime_state: &mut AgentRuntimeState,
) -> Option<TurnOutcome> {
if runtime_state.waiting_for_children.is_some() {
return None;
}
let storage = config.storage.as_ref()?;
let mut active: Vec<String> = storage
.list_child_run_statuses(&session.id)
.await
.unwrap_or_default()
.into_iter()
.filter(|(_, status)| !status.as_deref().is_some_and(is_terminal_child_status))
.map(|(id, _)| id)
.collect();
if active.is_empty() {
return None;
}
active.sort();
active.dedup();
tracing::info!(
"[{}] end-of-turn safety net: suspending to wait for {} orphaned child session(s) the model did not explicitly wait on",
session.id,
active.len(),
);
Some(
suspend_to_wait_for_children(
session,
runtime_state,
config.persistence.as_ref(),
active,
ChildWaitPolicy::All,
)
.await,
)
}
async fn suspend_to_wait_for_bash(
session: &mut Session,
runtime_state: &mut AgentRuntimeState,
persistence: Option<&Arc<dyn bamboo_domain::RuntimeSessionPersistence>>,
bash_ids: Vec<String>,
) -> TurnOutcome {
let now = Utc::now();
let count = bash_ids.len();
runtime_state.waiting_for_bash = Some(WaitingForBashState::for_bash(bash_ids, now));
state_bridge::write_runtime_state(session, runtime_state);
session.metadata.insert(
"runtime.suspend_reason".to_string(),
"waiting_for_bash".to_string(),
);
session.updated_at = now;
if let Some(persistence) = persistence {
if let Err(error) = persistence.save_runtime_session(session).await {
tracing::warn!(
"[{}] suspend-to-wait-bash failed to persist bash wait on {} shell(s): {}",
session.id,
count,
error
);
}
}
TurnOutcome {
should_break: true,
sent_complete: false,
}
}
async fn maybe_suspend_for_outstanding_bash(
session: &mut Session,
config: &AgentLoopConfig,
runtime_state: &mut AgentRuntimeState,
) -> Option<TurnOutcome> {
if runtime_state.waiting_for_bash.is_some() {
return None;
}
config.persistence.as_ref()?;
let hook = config.bash_resume_hook.as_ref()?;
let mut bash_ids = bamboo_tools::tools::bash_runtime::running_shells_for_session(&session.id);
if bash_ids.is_empty() {
return None;
}
bash_ids.sort();
bash_ids.dedup();
if bamboo_tools::tools::bash_runtime::running_shells_for_session(&session.id).is_empty() {
tracing::info!(
"[{}] end-of-turn bash gate: all {} shell(s) finished during the snapshot window; not suspending",
session.id,
bash_ids.len(),
);
return None;
}
tracing::info!(
"[{}] end-of-turn safety net: suspending to wait for {} background bash shell(s) still running",
session.id,
bash_ids.len(),
);
let hook_ids = bash_ids.clone();
let outcome = suspend_to_wait_for_bash(
session,
runtime_state,
config.persistence.as_ref(),
bash_ids,
)
.await;
hook.arrange_bash_self_resume(session.id.clone(), hook_ids);
Some(outcome)
}
fn build_guardian_review_prompt(
task_context: &Option<TaskLoopContext>,
config: &AgentLoopConfig,
final_assistant_content: Option<&str>,
) -> String {
let mut prompt = String::from(GUARDIAN_REVIEW_RUBRIC);
let criteria: Vec<String> = task_context
.as_ref()
.and_then(|ctx| {
ctx.items
.iter()
.find(|item| Some(&item.id) == ctx.active_item_id.as_ref())
})
.map(|item| item.completion_criteria.clone())
.unwrap_or_default();
if !criteria.is_empty() {
prompt.push_str("\n\n## Completion criteria (verify EACH against real evidence)\n");
for (idx, criterion) in criteria.iter().enumerate() {
prompt.push_str(&format!("{}. {}\n", idx + 1, criterion));
}
}
let goal = config.active_goal();
if let Some(goal) = goal {
prompt.push_str("\n\n## Session goal\n");
prompt.push_str(goal);
prompt.push('\n');
}
if criteria.is_empty() && goal.is_none() {
prompt.push_str(
"\n\n(No explicit completion criteria or goal were provided; review the diff for correctness, completeness, and obvious bugs.)\n",
);
}
if let Some(content) = final_assistant_content {
let trimmed = content.trim();
if !trimmed.is_empty() {
prompt.push_str(
"\n\n## Agent's final message (context only — not yet part of the session transcript)\n",
);
prompt.push_str(trimmed);
prompt.push('\n');
}
}
prompt
}
async fn maybe_spawn_guardian_review(
session: &mut Session,
config: &AgentLoopConfig,
task_context: &Option<TaskLoopContext>,
runtime_state: &mut AgentRuntimeState,
iteration: u32,
final_assistant_content: Option<&str>,
) -> Option<TurnOutcome> {
if runtime_state.waiting_for_children.is_some() {
return None;
}
if !config.guardian_active() {
return None;
}
let spawner = config.guardian_spawner.as_ref()?;
let max_reviews = config.guardian_max_reviews();
let mut guardian_state = ensure_guardian_state(session);
match guardian_state.phase {
GuardianPhase::Pending => return None,
GuardianPhase::Reviewed => {
if guardian_state.last_approved() {
return None;
}
if guardian_state.budget_exhausted(max_reviews) {
tracing::warn!(
"[{}] guardian: review budget ({}) exhausted with unresolved findings; allowing completion",
session.id,
max_reviews
);
return None;
}
}
GuardianPhase::None => {
if guardian_state.budget_exhausted(max_reviews) {
return None;
}
}
}
if let Some(guardian_config) = config.guardian_config.as_ref() {
write_guardian_config(session, guardian_config);
}
let review_prompt = build_guardian_review_prompt(task_context, config, final_assistant_content);
let Some(model) = config
.guardian_model()
.map(str::to_string)
.or_else(|| config.model_name.clone())
.map(|model| model.trim().to_string())
.filter(|model| !model.is_empty())
else {
tracing::warn!(
"[{}] guardian: no reviewer model resolved; skipping review at this terminal",
session.id
);
return None;
};
let disabled_tools = Some(guardian_read_only_disabled_tools());
match spawner
.spawn_guardian_review(session, review_prompt, model, disabled_tools)
.await
{
Ok(child_id) => {
guardian_state.record_spawn(&child_id);
guardian_state.last_reviewed_at_round = iteration;
let pass = guardian_state.review_count;
write_guardian_state(session, guardian_state);
tracing::info!(
"[{}] guardian: spawned read-only review child {} (pass {}/{}); suspending until verdict",
session.id,
child_id,
pass,
max_reviews
);
Some(
suspend_to_wait_for_children(
session,
runtime_state,
config.persistence.as_ref(),
vec![child_id],
ChildWaitPolicy::All,
)
.await,
)
}
Err(error) => {
tracing::warn!(
"[{}] guardian: failed to spawn review child: {}; allowing completion",
session.id,
error
);
None
}
}
}
fn map_turn_error_status(error: &AgentError) -> (MetricsRoundStatus, MetricsSessionStatus) {
if matches!(error, AgentError::Cancelled) {
(
MetricsRoundStatus::Cancelled,
MetricsSessionStatus::Cancelled,
)
} else {
(MetricsRoundStatus::Error, MetricsSessionStatus::Error)
}
}
fn record_turn_failure(
metrics_collector: Option<&MetricsCollector>,
round_id: &str,
session_id: &str,
message_count: u32,
error: &AgentError,
) {
let (round_status, session_status) = map_turn_error_status(error);
crate::runtime::runner::metrics_lifecycle::record_round_and_session_error(
metrics_collector,
round_id,
session_id,
message_count,
round_status,
Some(error.to_string()),
session_status,
);
}
async fn poll_completed_task_evaluation(state: &mut LoopRunState) {
let finished = state
.task_evaluation
.in_flight
.as_ref()
.is_some_and(|in_flight| in_flight.join_handle.is_finished());
if !finished {
return;
}
let Some(in_flight) = state.task_evaluation.in_flight.take() else {
return;
};
match in_flight.join_handle.await {
Ok(Some(result)) => {
state.task_evaluation.completed = Some(result);
}
Ok(None) => {
tracing::debug!(
"[{}] Async task evaluation cancelled for round {}",
state.session_id,
in_flight.request.round_number
);
}
Err(error) => {
tracing::warn!(
"[{}] Async task evaluation join failed for round {}: {}",
state.session_id,
in_flight.request.round_number,
error
);
}
}
}
async fn apply_completed_task_evaluation(
session: &mut Session,
event_tx: &mpsc::Sender<AgentEvent>,
config: &AgentLoopConfig,
state: &mut LoopRunState,
) {
let Some(result) = state.task_evaluation.completed.take() else {
return;
};
let apply_outcome = crate::runtime::runner::task_lifecycle::apply_task_evaluation_result(
&mut state.task_context,
session,
&state.session_id,
result.clone(),
);
let synthetic_round_id = format!(
"{}-task-evaluation-round-{}",
state.session_id, result.round_number
);
crate::runtime::runner::metrics_lifecycle::record_round_started(
state.metrics_collector.as_ref(),
&synthetic_round_id,
&state.session_id,
result.model_name.as_str(),
);
crate::runtime::runner::metrics_lifecycle::record_round_completed(
state.metrics_collector.as_ref(),
&synthetic_round_id,
&state.session_id,
session.messages.len() as u32,
if apply_outcome.stale {
MetricsRoundStatus::Cancelled
} else {
MetricsRoundStatus::Success
},
apply_outcome.usage,
session
.token_usage
.as_ref()
.map(|usage| usage.prompt_cached_tool_outputs)
.unwrap_or(0)
.min(u32::MAX as usize) as u32,
session
.token_usage
.as_ref()
.map(|usage| usage.prompt_cached_tool_tokens_saved)
.unwrap_or(0),
None,
);
if !apply_outcome.stale && apply_outcome.applied_updates > 0 {
if let Some(ref ctx) = state.task_context {
let task_list_title = result
.task_list_title
.or_else(|| {
session
.task_list
.as_ref()
.map(|task_list| task_list.title.clone())
})
.unwrap_or_else(|| "Agent Tasks".to_string());
session.set_task_list_version_meta(ctx.version.to_string());
let task_list = ctx.to_task_list_with_title(task_list_title);
session.set_task_list(task_list.clone());
crate::runtime::runner::tool_execution::persist_shared_task_list(
config,
session,
&result.shared_session_id,
&state.session_id,
&task_list,
)
.await;
let _ = event_tx
.send(AgentEvent::TaskListUpdated { task_list })
.await;
}
}
}
fn spawn_task_evaluation_request(
state: &mut LoopRunState,
event_tx: &mpsc::Sender<AgentEvent>,
request: crate::runtime::runner::task_lifecycle::AsyncTaskEvaluationRequest,
llm: Arc<dyn LLMProvider>,
cancel_token: CancellationToken,
) {
let task_round = request.round_number;
let session_id = state.session_id.clone();
let event_tx = event_tx.clone();
let request_for_spawn = request.clone();
let join_handle = tokio::spawn(async move {
tokio::select! {
biased;
_ = cancel_token.cancelled() => None,
result = crate::runtime::runner::task_lifecycle::execute_async_task_evaluation(
request_for_spawn,
llm,
event_tx,
) => Some(result),
}
});
tracing::debug!(
"[{}] Spawned async task evaluation for round {}",
session_id,
task_round
);
state.task_evaluation.in_flight = Some(InFlightTaskEvaluation {
request,
join_handle,
});
}
async fn abort_in_flight_evaluations(
state: &mut LoopRunState,
event_tx: &mpsc::Sender<AgentEvent>,
reason: &'static str,
) {
let task_was_running = state.task_evaluation.in_flight.is_some();
let task_generation = state
.task_evaluation
.in_flight
.as_ref()
.map(|in_flight| in_flight.request.based_on_task_context_version);
let gold_was_running = state.gold_evaluation.in_flight.is_some();
if let Some(in_flight) = state.task_evaluation.in_flight.take() {
in_flight.join_handle.abort();
}
if let Some(in_flight) = state.gold_evaluation.in_flight.take() {
in_flight.join_handle.abort();
}
state.task_evaluation.queued_request = None;
state.gold_evaluation.queued_request = None;
if task_was_running {
let _ = event_tx
.send(AgentEvent::TaskEvaluationCancelled {
session_id: state.session_id.clone(),
reason: reason.to_string(),
generation: task_generation,
})
.await;
}
if gold_was_running {
let _ = event_tx
.send(AgentEvent::GoldEvaluationCancelled {
session_id: state.session_id.clone(),
reason: reason.to_string(),
})
.await;
}
}
fn spawn_task_evaluation_if_needed(
turn: usize,
session: &Session,
event_tx: &mpsc::Sender<AgentEvent>,
config: &AgentLoopConfig,
state: &mut LoopRunState,
llm: Arc<dyn LLMProvider>,
cancel_token: CancellationToken,
) -> Result<(), AgentError> {
let task_list_dirty = state
.task_context
.as_ref()
.is_some_and(|ctx| ctx.task_list_dirty);
if !task_list_dirty {
return Ok(());
}
if let Some(ctx) = state.task_context.as_mut() {
ctx.task_list_dirty = false;
}
let eval_model = state
.auxiliary_models
.fast_model_name
.as_deref()
.or(Some(state.model_name.as_str()));
let request = crate::runtime::runner::task_lifecycle::build_async_task_evaluation_request(
&state.task_context,
session,
&state.session_id,
turn + 1,
eval_model,
config.reasoning_effort,
crate::runtime::stream::handler::StreamTimeoutContext::new(
config.stream_timeout,
config.provider_name.as_deref(),
eval_model,
),
)?;
let Some(request) = request else {
return Ok(());
};
if state.task_evaluation.in_flight.is_some() {
state.task_evaluation.queued_request = Some(request);
tracing::debug!(
"[{}] Queued latest async task evaluation snapshot for round {} while another evaluation is still in flight",
state.session_id,
turn + 1
);
return Ok(());
}
spawn_task_evaluation_request(state, event_tx, request, llm, cancel_token);
Ok(())
}
fn refresh_auxiliary_models_for_round(state: &mut LoopRunState, config: &AgentLoopConfig) {
state.auxiliary_models = resolve_auxiliary_models(config);
state.runtime_state.llm.fast_model_name = state.auxiliary_models.fast_model_name.clone();
state.runtime_state.llm.background_model_name =
state.auxiliary_models.background_model_name.clone();
}
fn record_no_tool_calls_round_completed(
metrics_collector: Option<&MetricsCollector>,
round_id: &str,
session_id: &str,
session: &Session,
round_usage: MetricsTokenUsage,
) {
crate::runtime::runner::metrics_lifecycle::record_round_completed(
metrics_collector,
round_id,
session_id,
session.messages.len() as u32,
MetricsRoundStatus::Success,
round_usage,
session
.token_usage
.as_ref()
.map(|usage| usage.prompt_cached_tool_outputs)
.unwrap_or(0)
.min(u32::MAX as usize) as u32,
session
.token_usage
.as_ref()
.map(|usage| usage.prompt_cached_tool_tokens_saved)
.unwrap_or(0),
None,
);
}
#[allow(clippy::too_many_arguments)]
async fn handle_no_tool_calls(
content: String,
reasoning: Option<String>,
reasoning_signature: Option<String>,
prompt_tokens: u64,
completion_tokens: u64,
round_usage: MetricsTokenUsage,
session: &mut Session,
runtime_state: &mut AgentRuntimeState,
event_tx: &mpsc::Sender<AgentEvent>,
metrics_collector: Option<&MetricsCollector>,
round_id: &str,
session_id: &str,
config: &AgentLoopConfig,
task_context: &Option<TaskLoopContext>,
eval_model: &str,
iteration: u32,
llm: Arc<dyn LLMProvider>,
) -> TurnOutcome {
let add_message_before_gold = config.goal_loop_active();
let mut deferred_assistant_message = Some(
Message::assistant_with_reasoning(content, None, reasoning)
.with_reasoning_signature(reasoning_signature),
);
if add_message_before_gold {
if let Some(message) = deferred_assistant_message.take() {
session.add_message(message);
}
}
let decision = evaluate_gold_terminal(
session,
task_context,
config,
eval_model,
config.reasoning_effort,
session_id,
iteration,
llm,
event_tx,
)
.await;
if let GoldTerminalDecision::Continue { continuation_count } = decision {
tracing::info!(
"[{}] Goal terminal gate: continuing toward goal (continuation {})",
session_id,
continuation_count
);
record_no_tool_calls_round_completed(
metrics_collector,
round_id,
session_id,
session,
round_usage,
);
return TurnOutcome {
should_break: false,
sent_complete: false,
};
}
let final_assistant_content_for_guardian = deferred_assistant_message
.as_ref()
.map(|message| message.content.as_str());
if let Some(review) = maybe_spawn_guardian_review(
session,
config,
task_context,
runtime_state,
iteration,
final_assistant_content_for_guardian,
)
.await
{
return review;
}
if let Some(message) = deferred_assistant_message.take() {
session.add_message(message);
}
let _ = event_tx
.send(AgentEvent::Complete {
usage: to_event_token_usage(prompt_tokens, completion_tokens),
})
.await;
record_no_tool_calls_round_completed(
metrics_collector,
round_id,
session_id,
session,
round_usage,
);
TurnOutcome {
should_break: true,
sent_complete: true,
}
}
#[allow(clippy::too_many_arguments)]
async fn handle_tool_calls_path(
frame: &crate::runtime::runner::round_frame::RoundFrame<'_>,
stream_output: StreamHandlingOutput,
mut round_usage: MetricsTokenUsage,
session: &mut Session,
auxiliary_models: &crate::runtime::config::AuxiliaryModelConfig,
model_name: &str,
task_context: &mut Option<TaskLoopContext>,
cancel_token: &CancellationToken,
) -> Result<TurnOutcome, AgentError> {
let reasoning = (!stream_output.reasoning_content.trim().is_empty())
.then_some(stream_output.reasoning_content);
let reasoning_signature = reasoning
.as_ref()
.and_then(|_| stream_output.reasoning_signature.clone());
session.add_message(
Message::assistant_with_reasoning(
stream_output.content,
Some(stream_output.tool_calls.clone()),
reasoning,
)
.with_reasoning_signature(reasoning_signature),
);
if let Some(persistence) = frame.config.persistence.as_ref() {
persistence
.save_runtime_session(session)
.await
.map_err(|error| {
AgentError::Tool(format!(
"assistant tool-call checkpoint could not be persisted: {error}"
))
})?;
}
let compression_model = Some(model_name.to_string())
.or_else(|| (!session.model.trim().is_empty()).then_some(session.model.trim().to_string()));
if compression_model.is_none() {
tracing::warn!(
"[{}] Skipping mid-turn context compression after tool execution: missing model name",
frame.session_id
);
}
let tool_schemas =
resolve_available_tool_schemas_for_session(frame.config, frame.tools.as_ref(), session);
let tool_execution = tokio::select! {
biased;
_ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
result = crate::runtime::runner::tool_execution::execute_round_tool_calls(
&stream_output.tool_calls,
frame,
session,
task_context,
compression_model
.as_deref()
.or(auxiliary_models.background_model_name.as_deref()),
auxiliary_models
.summarization_model_provider
.as_ref()
.or(auxiliary_models.background_model_provider.as_ref()),
&tool_schemas,
) => result?,
};
let mut awaiting_clarification = false;
let mut waiting_for_children = false;
let mut round_status = MetricsRoundStatus::Success;
let mut round_error: Option<String> = None;
if tool_execution.round_status != MetricsRoundStatus::Success {
round_status = tool_execution.round_status;
}
if let Some(e) = tool_execution.round_error {
round_error = Some(e);
}
if tool_execution.awaiting_clarification {
awaiting_clarification = true;
}
if tool_execution.waiting_for_children {
waiting_for_children = true;
}
if awaiting_clarification || waiting_for_children {
crate::runtime::runner::metrics_lifecycle::record_round_completed(
frame.metrics_collector,
frame.round_id,
frame.session_id,
session.messages.len() as u32,
round_status,
round_usage,
session
.token_usage
.as_ref()
.map(|usage| usage.prompt_cached_tool_outputs)
.unwrap_or(0)
.min(u32::MAX as usize) as u32,
session
.token_usage
.as_ref()
.map(|usage| usage.prompt_cached_tool_tokens_saved)
.unwrap_or(0),
round_error,
);
return Ok(TurnOutcome {
should_break: true,
sent_complete: false,
});
}
if frame.debug_enabled {
tracing::debug!(
"[{}] round_complete: {}",
frame.session_id,
serde_json::json!({
"round": frame.turn + 1,
"message_count": session.messages.len(),
})
);
}
let _complexity = if frame.config.features_dynamic_model_routing {
let round_tool_calls = &stream_output.tool_calls;
let classifier_model = auxiliary_models
.fast_model_name
.as_deref()
.or(Some(model_name));
let _classifier_provider = auxiliary_models
.fast_model_provider
.clone()
.unwrap_or_else(|| frame.llm.clone());
if let Some(_model) = classifier_model {
let complexity = heuristic_complexity(round_tool_calls);
tracing::info!(
"[{}] Dynamic model routing: round {} complexity={:?}",
frame.session_id,
frame.turn + 1,
complexity
);
session.metadata.insert(
"last_round_complexity".to_string(),
format!("{:?}", complexity),
);
Some(complexity)
} else {
None
}
} else {
None
};
round_usage.recompute_total();
crate::runtime::runner::metrics_lifecycle::record_round_completed(
frame.metrics_collector,
frame.round_id,
frame.session_id,
session.messages.len() as u32,
round_status,
round_usage,
session
.token_usage
.as_ref()
.map(|usage| usage.prompt_cached_tool_outputs)
.unwrap_or(0)
.min(u32::MAX as usize) as u32,
session
.token_usage
.as_ref()
.map(|usage| usage.prompt_cached_tool_tokens_saved)
.unwrap_or(0),
round_error,
);
Ok(TurnOutcome {
should_break: false,
sent_complete: false,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ExplicitActivationAttempt {
call_id: String,
skill_id: String,
}
fn validate_explicit_activation_first_step(
session: &Session,
tool_calls: &[bamboo_agent_core::tools::ToolCall],
) -> Result<Option<ExplicitActivationAttempt>, AgentError> {
if !crate::runtime::runner::session_setup::skill_context::explicit_activation_pending(session) {
return Ok(None);
}
let selected_skill_id = session
.metadata
.get(bamboo_skills::runtime_metadata::SKILL_RUNTIME_SELECTED_SKILL_IDS_KEY)
.and_then(|raw| serde_json::from_str::<Vec<String>>(raw).ok())
.and_then(|ids| ids.into_iter().next())
.ok_or_else(|| {
AgentError::Tool(format!(
"[{}] explicit workflow activation is missing its selected skill",
session.id
))
})?;
let valid_call = tool_calls.len() == 1
&& bamboo_tools::normalize_tool_ref(&tool_calls[0].function.name)
.is_some_and(|name| name == "load_skill");
if !valid_call {
return Err(AgentError::Tool(format!(
"[{}] explicit workflow activation was not completed: the first model step must be exactly one load_skill call",
session.id
)));
}
let called_skill_id =
serde_json::from_str::<serde_json::Value>(&tool_calls[0].function.arguments)
.ok()
.and_then(|arguments| {
arguments
.get("skill_id")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.map(str::to_string)
});
if called_skill_id.as_deref() != Some(selected_skill_id.as_str()) {
return Err(AgentError::Tool(format!(
"[{}] explicit workflow activation must load selected skill '{}'",
session.id, selected_skill_id
)));
}
Ok(Some(ExplicitActivationAttempt {
call_id: tool_calls[0].id.clone(),
skill_id: selected_skill_id,
}))
}
fn apply_successful_explicit_activation(
session: &mut Session,
attempt: &ExplicitActivationAttempt,
) -> Result<(), AgentError> {
let tool_succeeded = session.messages.iter().rev().any(|message| {
message.tool_call_id.as_deref() == Some(attempt.call_id.as_str())
&& message.tool_success == Some(true)
});
if !tool_succeeded
|| crate::runtime::runner::session_setup::skill_context::explicit_activation_pending(
session,
)
{
return Err(AgentError::Tool(format!(
"[{}] explicit workflow '{}' failed to activate; refusing to continue to a user-facing answer",
session.id, attempt.skill_id
)));
}
Ok(())
}
pub(super) async fn run_pipeline(
session: &mut Session,
event_tx: &mpsc::Sender<AgentEvent>,
llm: Arc<dyn LLMProvider>,
tools: Arc<dyn ToolExecutor>,
cancel_token: &CancellationToken,
config: &AgentLoopConfig,
state: &mut LoopRunState,
) -> super::super::Result<bool> {
let mut sent_complete = false;
let mut turn_counter: u32 = 0;
let mut max_rounds_summary_used = false;
let mut budget_summary_used = false;
session.metadata.remove("runtime.completion_reason");
session.metadata.remove("runtime.budget_exceeded_kind");
loop {
refresh_auxiliary_models_for_round(state, config);
poll_completed_task_evaluation(state).await;
apply_completed_task_evaluation(session, event_tx, config, state).await;
if state.task_evaluation.in_flight.is_none() {
if let Some(request) = state.task_evaluation.queued_request.take() {
let eval_provider = state
.auxiliary_models
.fast_model_provider
.clone()
.unwrap_or_else(|| llm.clone());
spawn_task_evaluation_request(
state,
event_tx,
request,
eval_provider,
cancel_token.clone(),
);
}
}
poll_completed_gold_evaluation(state).await;
apply_completed_gold_evaluation(session, config, state).await;
start_queued_gold_evaluation_if_idle(
state,
event_tx,
state
.auxiliary_models
.fast_model_provider
.clone()
.unwrap_or_else(|| llm.clone()),
cancel_token.clone(),
);
state.runtime_state.round.current_round = turn_counter;
let round_id = format!("{}-round-{}", state.session_id, turn_counter + 1);
state.runtime_state.round.last_round_id = Some(round_id.clone());
let runtime_context = PromptMemoryRuntimeContext {
llm: state
.auxiliary_models
.background_model_provider
.clone()
.unwrap_or_else(|| llm.clone()),
background_model_name: state.auxiliary_models.background_model_name.clone(),
};
crate::runtime::runner::round_prelude::refresh_round_prompt_context(
session,
config.prompt_memory_flags,
Some(&runtime_context),
)
.await;
if let Some(ctx) = state.task_context.as_mut() {
ctx.current_round = turn_counter;
ctx.max_rounds = config.max_rounds as u32;
}
if state.debug_logger.enabled {
tracing::debug!(
"[{}] round_start: {}",
state.session_id,
serde_json::json!({
"round": turn_counter + 1,
"total_rounds": config.max_rounds,
"message_count": session.messages.len(),
})
);
}
let _ = event_tx
.send(AgentEvent::RunnerProgress {
session_id: state.session_id.clone(),
round_count: turn_counter,
})
.await;
let turn_refresh = state_bridge::refresh_turn_boundary_from_disk(
session,
config.storage.as_ref(),
config.persistence.as_ref(),
)
.await;
if let Some(disk_bypass) = turn_refresh.disk_bypass_permissions {
state.runtime_state.bypass_permissions = disk_bypass;
session
.agent_runtime_state
.get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
.bypass_permissions = disk_bypass;
}
if cancel_token.is_cancelled() {
crate::runtime::runner::metrics_lifecycle::record_session_cancelled(
state.metrics_collector.as_ref(),
&state.session_id,
session.messages.len() as u32,
);
abort_in_flight_evaluations(state, event_tx, "run_cancelled").await;
return Err(AgentError::Cancelled);
}
crate::runtime::runner::metrics_lifecycle::record_round_started(
state.metrics_collector.as_ref(),
&round_id,
&state.session_id,
&state.model_name,
);
let tool_schemas =
resolve_available_tool_schemas_for_session(config, tools.as_ref(), session);
let mut overflow_recovery_attempted = false;
let mut turn_outcome: Option<TurnOutcome> = None;
let mut terminal_error: Option<AgentError> = None;
let mut round_activity = RoundActivity::default();
for attempt in 1..=MAX_LLM_TURN_ATTEMPTS {
let llm_output = match crate::runtime::runner::round_lifecycle::execute_llm_round(
session,
config,
&llm,
event_tx,
cancel_token,
&state.session_id,
&state.model_name,
&tool_schemas,
)
.await
{
Ok(output) => output,
Err(error) => {
if is_overflow_recoverable(&error) && !overflow_recovery_attempted {
overflow_recovery_attempted = true;
if !state.overflow_recovery.can_attempt_recovery() {
let breaker_error = AgentError::LLMOverflow(format!(
"overflow recovery circuit breaker opened after {} consecutive recoveries",
state.overflow_recovery.consecutive_recoveries
));
tracing::error!(
"[{}] Turn {} overflow recovery skipped by circuit breaker: {}",
state.session_id,
turn_counter + 1,
breaker_error,
);
terminal_error = Some(breaker_error);
break;
}
tracing::warn!(
"[{}] Turn {} detected overflow error (attempt {}/{}): {}. Trying forced overflow recovery.",
state.session_id,
turn_counter + 1,
attempt,
MAX_LLM_TURN_ATTEMPTS,
error,
);
let recovered =
match crate::runtime::runner::round_lifecycle::force_overflow_context_recovery(
session,
config,
&state.model_name,
&state.session_id,
&llm,
Some(event_tx),
)
.await
{
Ok(recovered) => recovered,
Err(error) => {
abort_in_flight_evaluations(
state,
event_tx,
"terminal_error",
)
.await;
return Err(error);
}
};
if recovered {
state
.overflow_recovery
.record_recovery(turn_counter as usize);
tracing::info!(
"[{}] Overflow recovery applied: total_recoveries={}, consecutive_recoveries={}, turn={}",
state.session_id,
state.overflow_recovery.total_recoveries,
state.overflow_recovery.consecutive_recoveries,
turn_counter + 1,
);
let tool_schemas_after_recovery =
resolve_available_tool_schemas_for_session(
config,
tools.as_ref(),
session,
);
match crate::runtime::runner::round_lifecycle::execute_llm_round(
session,
config,
&llm,
event_tx,
cancel_token,
&state.session_id,
&state.model_name,
&tool_schemas_after_recovery,
)
.await
{
Ok(output) => output,
Err(recovery_error) => {
tracing::error!(
"[{}] Turn {} overflow recovery retry failed: {}",
state.session_id,
turn_counter + 1,
recovery_error,
);
terminal_error = Some(recovery_error);
break;
}
}
} else {
tracing::error!(
"[{}] Turn {} overflow recovery was attempted but no compression was applied.",
state.session_id,
turn_counter + 1,
);
terminal_error = Some(error);
break;
}
} else if should_retry_turn_error(&error) && attempt < MAX_LLM_TURN_ATTEMPTS {
let delay_ms = LLM_RETRY_BASE_DELAY_MS * (1u64 << (attempt - 1));
tracing::warn!(
"[{}] Turn {} LLM call failed (attempt {}/{}): {}. Retrying in {}ms",
state.session_id,
turn_counter + 1,
attempt,
MAX_LLM_TURN_ATTEMPTS,
error,
delay_ms
);
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
continue;
} else {
tracing::error!(
"[{}] Turn {} LLM call failed terminally (attempt {}/{}): {}",
state.session_id,
turn_counter + 1,
attempt,
MAX_LLM_TURN_ATTEMPTS,
error,
);
terminal_error = Some(error);
break;
}
}
};
let stream_output = llm_output.stream_output;
round_activity.absorb_attempt(&stream_output);
let activation_attempt =
match validate_explicit_activation_first_step(session, &stream_output.tool_calls) {
Ok(attempt) => attempt,
Err(error) => {
terminal_error = Some(error);
break;
}
};
if stream_output.tool_calls.is_empty() {
if let Some(suspend) =
maybe_suspend_for_orphaned_children(session, config, &mut state.runtime_state)
.await
{
turn_outcome = Some(suspend);
break;
}
if let Some(suspend) =
maybe_suspend_for_outstanding_bash(session, config, &mut state.runtime_state)
.await
{
turn_outcome = Some(suspend);
break;
}
let reasoning = (!stream_output.reasoning_content.trim().is_empty())
.then_some(stream_output.reasoning_content);
let reasoning_signature = reasoning
.as_ref()
.and_then(|_| stream_output.reasoning_signature.clone());
let eval_model = state
.auxiliary_models
.fast_model_name
.clone()
.unwrap_or_else(|| state.model_name.clone());
turn_outcome = Some(
handle_no_tool_calls(
stream_output.content,
reasoning,
reasoning_signature,
llm_output.prompt_tokens,
llm_output.completion_tokens,
llm_output.round_usage,
session,
&mut state.runtime_state,
event_tx,
state.metrics_collector.as_ref(),
&round_id,
&state.session_id,
config,
&state.task_context,
&eval_model,
turn_counter + 1,
llm.clone(),
)
.await,
);
break;
}
let frame = crate::runtime::runner::round_frame::RoundFrame {
session_id: &state.session_id,
round_id: &round_id,
turn: turn_counter as usize,
debug_enabled: state.debug_logger.enabled,
event_tx,
metrics_collector: state.metrics_collector.as_ref(),
config,
llm: &llm,
tools: &tools,
};
match handle_tool_calls_path(
&frame,
stream_output,
llm_output.round_usage,
session,
&state.auxiliary_models,
&state.model_name,
&mut state.task_context,
cancel_token,
)
.await
{
Ok(outcome) => {
if let Some(attempt) = activation_attempt.as_ref() {
if !outcome.should_break {
if let Err(error) =
apply_successful_explicit_activation(session, attempt)
{
terminal_error = Some(error);
break;
}
}
}
turn_outcome = Some(outcome);
break;
}
Err(error) => {
if should_retry_turn_error(&error) && attempt < MAX_LLM_TURN_ATTEMPTS {
let delay_ms = LLM_RETRY_BASE_DELAY_MS * (1u64 << (attempt - 1));
tracing::warn!(
"[{}] Turn {} post-LLM handling failed (attempt {}/{}): {}. Retrying in {}ms",
state.session_id,
turn_counter + 1,
attempt,
MAX_LLM_TURN_ATTEMPTS,
error,
delay_ms
);
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
continue;
}
tracing::error!(
"[{}] Turn {} post-LLM handling failed terminally (attempt {}/{}): {}",
state.session_id,
turn_counter + 1,
attempt,
MAX_LLM_TURN_ATTEMPTS,
error,
);
terminal_error = Some(error);
break;
}
}
}
if let Some(error) = terminal_error {
record_turn_failure(
state.metrics_collector.as_ref(),
&round_id,
&state.session_id,
session.messages.len() as u32,
&error,
);
abort_in_flight_evaluations(state, event_tx, "terminal_error").await;
return Err(error);
}
let Some(outcome) = turn_outcome else {
let error = AgentError::LLM(format!(
"[{}] turn {} completed without outcome",
state.session_id,
turn_counter + 1
));
record_turn_failure(
state.metrics_collector.as_ref(),
&round_id,
&state.session_id,
session.messages.len() as u32,
&error,
);
abort_in_flight_evaluations(state, event_tx, "run_stopped").await;
return Err(error);
};
if !overflow_recovery_attempted {
state.overflow_recovery.reset_after_stable_round();
}
state.runtime_state.memory.overflow_recovery_total =
state.overflow_recovery.total_recoveries as u32;
state.runtime_state.memory.overflow_recovery_consecutive =
state.overflow_recovery.consecutive_recoveries as u32;
match session
.metadata
.get("runtime.suspend_reason")
.map(String::as_str)
{
Some("awaiting_clarification") => {
state.runtime_state.status = AgentStatusState::Suspended;
state.runtime_state.suspension = Some(SuspensionState {
reason: "awaiting_clarification".to_string(),
suspended_at: Utc::now(),
resumable: true,
hook_point: Some("AfterToolExecution".to_string()),
});
}
Some("awaiting_parent_approval") => {
state.runtime_state.status = AgentStatusState::Suspended;
state.runtime_state.suspension = Some(SuspensionState {
reason: "awaiting_parent_approval".to_string(),
suspended_at: Utc::now(),
resumable: true,
hook_point: Some("AfterToolExecution".to_string()),
});
}
Some("waiting_for_children") => {
state.runtime_state.status = AgentStatusState::Suspended;
state.runtime_state.suspension = Some(SuspensionState {
reason: "waiting_for_children".to_string(),
suspended_at: Utc::now(),
resumable: true,
hook_point: Some("AfterToolExecution".to_string()),
});
if let Some(storage) = config.storage.as_ref() {
if let Ok(Some(persisted)) = storage.load_session(&state.session_id).await {
if let Some(runtime_state) = persisted.agent_runtime_state {
state.runtime_state.waiting_for_children =
runtime_state.waiting_for_children;
}
let existing_ids: std::collections::HashSet<String> = session
.messages
.iter()
.map(|message| message.id.clone())
.collect();
let mut appended = 0usize;
for message in persisted.messages {
let hidden_runtime_resume = message
.metadata
.as_ref()
.and_then(|metadata| metadata.get("runtime_kind"))
.and_then(|value| value.as_str())
.is_some_and(|kind| {
matches!(
kind,
"child_completion_resume" | "guardian_review_resume"
)
});
if hidden_runtime_resume && !existing_ids.contains(message.id.as_str())
{
session.messages.push(message);
appended += 1;
}
}
if appended > 0 {
tracing::info!(
"[{}] Preserved {} hidden child-completion resume message(s) during parent suspension save",
state.session_id,
appended
);
}
}
}
}
Some("waiting_for_bash") => {
state.runtime_state.status = AgentStatusState::Suspended;
state.runtime_state.suspension = Some(SuspensionState {
reason: "waiting_for_bash".to_string(),
suspended_at: Utc::now(),
resumable: true,
hook_point: Some("AfterToolExecution".to_string()),
});
if let Some(storage) = config.storage.as_ref() {
if let Ok(Some(persisted)) = storage.load_session(&state.session_id).await {
if let Some(runtime_state) = persisted.agent_runtime_state {
if runtime_state.waiting_for_bash.is_some() {
state.runtime_state.waiting_for_bash =
runtime_state.waiting_for_bash;
}
}
let existing_ids: std::collections::HashSet<String> = session
.messages
.iter()
.map(|message| message.id.clone())
.collect();
let mut appended = 0usize;
for message in persisted.messages {
let hidden_runtime_resume = message
.metadata
.as_ref()
.and_then(|metadata| metadata.get("runtime_kind"))
.and_then(|value| value.as_str())
.is_some_and(|kind| {
kind == crate::runtime::config::BASH_COMPLETION_RESUME_KIND
});
if hidden_runtime_resume && !existing_ids.contains(message.id.as_str())
{
session.messages.push(message);
appended += 1;
}
}
if appended > 0 {
tracing::info!(
"[{}] Preserved {} hidden bash-completion resume message(s) during suspension save",
state.session_id,
appended
);
}
}
}
}
_ => {}
}
state.runtime_state.round.total_prompt_tokens = state
.runtime_state
.round
.total_prompt_tokens
.saturating_add(round_activity.prompt_tokens);
state.runtime_state.round.total_completion_tokens = state
.runtime_state
.round
.total_completion_tokens
.saturating_add(round_activity.completion_tokens);
state.runtime_state.round.total_tool_calls = state
.runtime_state
.round
.total_tool_calls
.saturating_add(round_activity.tool_call_count);
state.runtime_state.round.total_subagents_spawned = state
.runtime_state
.round
.total_subagents_spawned
.saturating_add(round_activity.subagent_spawn_count);
state_bridge::write_runtime_state(session, &state.runtime_state);
sent_complete = sent_complete || outcome.sent_complete;
if outcome.should_break {
break;
}
if let Err(error) = spawn_task_evaluation_if_needed(
turn_counter as usize,
session,
event_tx,
config,
state,
state
.auxiliary_models
.fast_model_provider
.clone()
.unwrap_or_else(|| llm.clone()),
cancel_token.clone(),
) {
tracing::warn!(
"[{}] Failed to spawn async task evaluation after round {}: {}",
state.session_id,
turn_counter + 1,
error
);
}
if let Err(error) = spawn_gold_evaluation_if_needed(
turn_counter as usize,
session,
event_tx,
config,
state,
state
.auxiliary_models
.fast_model_provider
.clone()
.unwrap_or_else(|| llm.clone()),
cancel_token.clone(),
) {
tracing::warn!(
"[{}] Failed to spawn async Gold evaluation after round {}: {}",
state.session_id,
turn_counter + 1,
error
);
}
turn_counter += 1;
if let Some(exceeded) =
check_run_budget_exceeded(&state.runtime_state.round, &config.run_budget)
{
if !budget_summary_used {
tracing::warn!(
"[{}] Run budget exceeded ({} limit={} actual={}) — granting one summary turn before stopping.",
state.session_id,
exceeded.kind,
exceeded.limit,
exceeded.actual,
);
session.metadata.insert(
"runtime.completion_reason".to_string(),
"budget_exceeded".to_string(),
);
session.metadata.insert(
"runtime.budget_exceeded_kind".to_string(),
exceeded.kind.to_string(),
);
session.add_message(Message::user(format!(
"The run's resource budget ({}, limit={}, reached={}) was exceeded; the \
task was stopped before completion. Stop working now and summarize your \
progress so far and what remains.",
exceeded.kind, exceeded.limit, exceeded.actual
)));
let _ = event_tx
.send(AgentEvent::BudgetExceeded {
session_id: state.session_id.clone(),
kind: exceeded.kind.to_string(),
limit: exceeded.limit,
actual: exceeded.actual,
})
.await;
budget_summary_used = true;
continue;
}
tracing::warn!(
"[{}] Run budget exceeded ({} limit={} actual={}) — stopping the run before completion.",
state.session_id,
exceeded.kind,
exceeded.limit,
exceeded.actual,
);
break;
}
if turn_counter >= config.max_rounds as u32 {
if !max_rounds_summary_used {
tracing::warn!(
"[{}] Reached max rounds ({}) — granting one summary turn before stopping.",
state.session_id,
config.max_rounds
);
session.metadata.insert(
"runtime.completion_reason".to_string(),
"max_rounds_reached".to_string(),
);
session.add_message(Message::user(format!(
"Reached the maximum of {0} rounds; the task was stopped before \
completion. Stop working now and summarize your progress so far \
and what remains.",
config.max_rounds
)));
max_rounds_summary_used = true;
continue;
}
tracing::warn!(
"[{}] Reached max rounds ({}) — stopping the run before completion.",
state.session_id,
config.max_rounds
);
break;
}
}
poll_completed_task_evaluation(state).await;
apply_completed_task_evaluation(session, event_tx, config, state).await;
poll_completed_gold_evaluation(state).await;
apply_completed_gold_evaluation(session, config, state).await;
let evaluation_stop_reason = if session.metadata.contains_key("runtime.suspend_reason") {
"run_suspended"
} else {
"run_completed"
};
abort_in_flight_evaluations(state, event_tx, evaluation_stop_reason).await;
Ok(sent_complete)
}
fn heuristic_complexity(
tool_calls: &[bamboo_agent_core::tools::ToolCall],
) -> crate::runtime::complexity_classifier::TaskComplexity {
use crate::runtime::complexity_classifier::TaskComplexity;
let simple_tools = ["Read", "Glob", "Grep", "Bash"];
let complex_tools = ["Agent", "SubAgent", "TodoWrite"];
let names: Vec<&str> = tool_calls
.iter()
.map(|tc| tc.function.name.as_str())
.collect();
if names.iter().any(|n| complex_tools.contains(n)) {
return TaskComplexity::Complex;
}
if names.iter().all(|n| simple_tools.contains(n)) && !names.is_empty() {
return TaskComplexity::Simple;
}
TaskComplexity::Standard
}
#[cfg(test)]
mod tests {
use super::super::startup::OverflowRecoveryState;
use super::{
apply_successful_explicit_activation, build_guardian_review_prompt,
check_run_budget_exceeded, is_overflow_recoverable, is_subagent_create_call,
is_terminal_child_status, map_turn_error_status, maybe_spawn_guardian_review,
maybe_suspend_for_orphaned_children, maybe_suspend_for_outstanding_bash,
should_retry_turn_error, suspend_to_wait_for_bash, validate_explicit_activation_first_step,
};
use crate::runtime::config::{AgentLoopConfig, GuardianConfig, GuardianSpawner};
use crate::runtime::goal_state::{
ensure_goal_state, read_goal_state, write_goal_state, GoalDeclaredStatus, GoalRuntimeStatus,
};
use crate::runtime::guardian_state::{
ensure_guardian_state, read_guardian_state, write_guardian_state, GuardianPhase,
GuardianVerdict,
};
use crate::runtime::runner::state_bridge;
use bamboo_agent_core::storage::Storage;
use bamboo_agent_core::{AgentError, AgentEvent, Message, Session};
use bamboo_domain::AgentRuntimeState;
use bamboo_llm::{LLMChunk, LLMError, LLMProvider, LLMStream};
use bamboo_metrics::{
RoundStatus as MetricsRoundStatus, SessionStatus as MetricsSessionStatus,
TokenUsage as MetricsTokenUsage,
};
use futures::stream;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
fn pending_explicit_session() -> Session {
let mut session = Session::new("explicit-gate", "model");
session.metadata.insert(
bamboo_skills::runtime_metadata::SKILL_RUNTIME_SELECTION_SOURCE_KEY.to_string(),
"explicit".to_string(),
);
session.metadata.insert(
bamboo_skills::runtime_metadata::SKILL_RUNTIME_SELECTED_SKILL_IDS_KEY.to_string(),
"[\"review\"]".to_string(),
);
session
}
fn activation_call(id: &str, name: &str, arguments: &str) -> ToolCall {
ToolCall {
id: id.to_string(),
tool_type: "function".to_string(),
function: FunctionCall {
name: name.to_string(),
arguments: arguments.to_string(),
},
}
}
#[test]
fn explicit_activation_first_step_gate_rejects_missing_wrong_and_multiple_calls() {
let session = pending_explicit_session();
assert!(validate_explicit_activation_first_step(&session, &[]).is_err());
assert!(validate_explicit_activation_first_step(
&session,
&[activation_call("wrong", "Read", r#"{"file_path":"x"}"#)],
)
.is_err());
assert!(validate_explicit_activation_first_step(
&session,
&[
activation_call("load", "load_skill", r#"{"skill_id":"review"}"#),
activation_call("other", "Read", r#"{"file_path":"x"}"#),
],
)
.is_err());
}
#[test]
fn explicit_activation_first_step_gate_accepts_only_matching_load_skill() {
let session = pending_explicit_session();
assert!(validate_explicit_activation_first_step(
&session,
&[activation_call(
"wrong-skill",
"load_skill",
r#"{"skill_id":"plan"}"#,
)],
)
.is_err());
let attempt = validate_explicit_activation_first_step(
&session,
&[activation_call(
"load-review",
"load_skill",
r#"{"skill_id":"review"}"#,
)],
)
.expect("matching load_skill should pass")
.expect("pending activation attempt");
assert_eq!(attempt.call_id, "load-review");
assert_eq!(attempt.skill_id, "review");
}
#[test]
fn explicit_activation_clears_pending_only_after_successful_tool_result() {
let call = activation_call("load-review", "load_skill", r#"{"skill_id":"review"}"#);
let mut failed = pending_explicit_session();
let attempt = validate_explicit_activation_first_step(&failed, std::slice::from_ref(&call))
.expect("valid first step")
.expect("activation attempt");
failed.add_message(Message::tool_result_with_status(
"load-review",
"durable save failed",
false,
));
assert!(apply_successful_explicit_activation(&mut failed, &attempt).is_err());
assert!(
crate::runtime::runner::session_setup::skill_context::explicit_activation_pending(
&failed
)
);
let mut succeeded = pending_explicit_session();
let attempt = validate_explicit_activation_first_step(&succeeded, &[call])
.expect("valid first step")
.expect("activation attempt");
succeeded.add_message(Message::tool_result_with_status(
"load-review",
"loaded",
true,
));
succeeded.metadata.insert(
bamboo_skills::runtime_metadata::LOADED_SKILL_IDS_METADATA_KEY.to_string(),
"[\"review\"]".to_string(),
);
succeeded.metadata.insert(
bamboo_skills::ACTIVE_WORKFLOW_METADATA_KEY.to_string(),
serde_json::json!({
"id": "review",
"source": "builtin",
"revision": 1,
"kind": "instruction",
"args": {},
"invoked_by": "user",
"activated_at": "2026-07-21T00:00:00Z",
"status": "active"
})
.to_string(),
);
succeeded.metadata.insert(
bamboo_skills::ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY.to_string(),
"{}".to_string(),
);
apply_successful_explicit_activation(&mut succeeded, &attempt)
.expect("successful tool result activates workflow");
assert!(
!crate::runtime::runner::session_setup::skill_context::explicit_activation_pending(
&succeeded
)
);
let mut degraded = pending_explicit_session();
let call = activation_call("load-review", "load_skill", r#"{"skill_id":"review"}"#);
let attempt = validate_explicit_activation_first_step(°raded, &[call])
.expect("valid first step")
.expect("activation attempt");
degraded.add_message(Message::tool_result_with_status(
"load-review",
r#"{"activation_status":"degraded"}"#,
true,
));
degraded.metadata.insert(
bamboo_skills::runtime_metadata::SKILL_RUNTIME_ACTIVATION_ERROR_KEY.to_string(),
r#"{"code":"provider_failed"}"#.to_string(),
);
apply_successful_explicit_activation(&mut degraded, &attempt)
.expect("typed degraded activation lets the main session continue fail-closed");
assert!(!degraded
.metadata
.contains_key(bamboo_skills::ACTIVE_WORKFLOW_METADATA_KEY));
assert!(
!crate::runtime::runner::session_setup::skill_context::explicit_activation_pending(
°raded
)
);
}
struct MockGuardianSpawner {
child_id: String,
}
#[async_trait::async_trait]
impl GuardianSpawner for MockGuardianSpawner {
async fn spawn_guardian_review(
&self,
_parent_session: &Session,
_review_prompt: String,
_model: String,
_disabled_tools: Option<std::collections::BTreeSet<String>>,
) -> Result<String, String> {
Ok(self.child_id.clone())
}
}
fn guardian_enabled_config(max_reviews: u32) -> AgentLoopConfig {
let spawner: Arc<dyn GuardianSpawner> = Arc::new(MockGuardianSpawner {
child_id: "guardian-child".to_string(),
});
AgentLoopConfig {
guardian_config: Some(GuardianConfig {
enabled: true,
model_name: Some("guardian-test-model".to_string()),
max_reviews,
}),
guardian_spawner: Some(spawner),
..Default::default()
}
}
#[tokio::test]
async fn guardian_gate_spawns_and_suspends_on_first_terminal() {
let mut session = Session::new("s1", "model");
let config = guardian_enabled_config(2);
let mut runtime_state = AgentRuntimeState::new("s1".to_string());
let outcome =
maybe_spawn_guardian_review(&mut session, &config, &None, &mut runtime_state, 1, None)
.await
.expect("guardian should engage a review and suspend");
assert!(outcome.should_break && !outcome.sent_complete);
assert!(runtime_state.waiting_for_children.is_some());
let guardian_state = read_guardian_state(&session).expect("guardian state persisted");
assert_eq!(guardian_state.phase, GuardianPhase::Pending);
assert_eq!(
guardian_state.guardian_child_id.as_deref(),
Some("guardian-child")
);
assert_eq!(guardian_state.review_count, 1);
}
#[tokio::test]
async fn guardian_gate_inert_without_config() {
let mut session = Session::new("s1", "model");
let config = AgentLoopConfig::default(); let mut runtime_state = AgentRuntimeState::new("s1".to_string());
assert!(maybe_spawn_guardian_review(
&mut session,
&config,
&None,
&mut runtime_state,
1,
None
)
.await
.is_none());
assert!(runtime_state.waiting_for_children.is_none());
}
#[tokio::test]
async fn guardian_gate_skips_when_no_model_resolves() {
let spawner: Arc<dyn GuardianSpawner> = Arc::new(MockGuardianSpawner {
child_id: "guardian-child".to_string(),
});
let config = AgentLoopConfig {
guardian_config: Some(GuardianConfig {
enabled: true,
model_name: None,
max_reviews: 2,
}),
guardian_spawner: Some(spawner),
..Default::default()
};
let mut session = Session::new("s1", "model");
let mut runtime_state = AgentRuntimeState::new("s1".to_string());
assert!(maybe_spawn_guardian_review(
&mut session,
&config,
&None,
&mut runtime_state,
1,
None
)
.await
.is_none());
assert!(runtime_state.waiting_for_children.is_none());
assert!(
read_guardian_state(&session).is_none(),
"no guardian review budget should be charged when skipped"
);
}
#[tokio::test]
async fn guardian_gate_completes_after_approval() {
let mut session = Session::new("s1", "model");
let mut guardian_state = ensure_guardian_state(&session);
guardian_state.record_spawn("guardian-child");
guardian_state.record_verdict(GuardianVerdict::approved(), 1);
write_guardian_state(&mut session, guardian_state);
let config = guardian_enabled_config(2);
let mut runtime_state = AgentRuntimeState::new("s1".to_string());
assert!(maybe_spawn_guardian_review(
&mut session,
&config,
&None,
&mut runtime_state,
2,
None
)
.await
.is_none());
assert!(runtime_state.waiting_for_children.is_none());
}
#[tokio::test]
async fn guardian_gate_re_reviews_after_reject_then_completes_on_budget() {
let mut session = Session::new("s1", "model");
let mut guardian_state = ensure_guardian_state(&session);
guardian_state.record_spawn("guardian-child");
guardian_state.record_verdict(GuardianVerdict::rejected(vec!["bug".to_string()]), 1);
write_guardian_state(&mut session, guardian_state);
let config = guardian_enabled_config(2);
let mut runtime_state = AgentRuntimeState::new("s1".to_string());
let outcome =
maybe_spawn_guardian_review(&mut session, &config, &None, &mut runtime_state, 2, None)
.await
.expect("rejected within budget → re-review (suspend)");
assert!(outcome.should_break && !outcome.sent_complete);
let after = read_guardian_state(&session).expect("state persisted");
assert_eq!(after.review_count, 2, "second review spawned");
assert_eq!(after.phase, GuardianPhase::Pending);
let mut exhausted = ensure_guardian_state(&session);
exhausted.record_verdict(GuardianVerdict::rejected(vec!["still".to_string()]), 3);
write_guardian_state(&mut session, exhausted);
let mut runtime_state2 = AgentRuntimeState::new("s1".to_string());
assert!(
maybe_spawn_guardian_review(&mut session, &config, &None, &mut runtime_state2, 4, None)
.await
.is_none(),
"budget exhausted → allow completion despite unresolved findings"
);
}
struct StubProvider;
#[async_trait::async_trait]
impl LLMProvider for StubProvider {
async fn chat_stream(
&self,
_messages: &[Message],
_tools: &[bamboo_agent_core::tools::ToolSchema],
_max_output_tokens: Option<u32>,
_model: &str,
) -> Result<LLMStream, LLMError> {
Ok(Box::pin(stream::iter(vec![Ok(LLMChunk::Done)])))
}
}
struct ScriptedGoldProvider {
decision: &'static str,
confidence: &'static str,
}
#[async_trait::async_trait]
impl LLMProvider for ScriptedGoldProvider {
async fn chat_stream(
&self,
_messages: &[Message],
_tools: &[bamboo_agent_core::tools::ToolSchema],
_max_output_tokens: Option<u32>,
_model: &str,
) -> Result<LLMStream, LLMError> {
let arguments = format!(
r#"{{"decision":"{}","confidence":"{}","reasoning":"gate test"}}"#,
self.decision, self.confidence
);
let call = bamboo_agent_core::tools::ToolCall {
id: "gold-call-1".to_string(),
tool_type: "function".to_string(),
function: bamboo_agent_core::tools::FunctionCall {
name: "report_gold_evaluation".to_string(),
arguments,
},
};
Ok(Box::pin(stream::iter(vec![
Ok(LLMChunk::ToolCalls(vec![call])),
Ok(LLMChunk::Done),
])))
}
}
fn gold_continue_config() -> crate::runtime::config::AgentLoopConfig {
crate::runtime::config::AgentLoopConfig {
gold_config: Some(crate::runtime::config::GoldConfig {
enabled: true,
auto_continue_enabled: true,
goal: Some("finish the task".to_string()),
max_auto_continuations: 3,
..crate::runtime::config::GoldConfig::default()
}),
..crate::runtime::config::AgentLoopConfig::default()
}
}
fn round_usage() -> MetricsTokenUsage {
MetricsTokenUsage {
prompt_tokens: 1,
completion_tokens: 1,
total_tokens: 2,
}
}
#[tokio::test]
async fn no_tool_calls_does_not_complete_when_gold_continues() {
let mut session = Session::new("session-1", "model");
let mut runtime_state = AgentRuntimeState::new("session-1".to_string());
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
let outcome = super::handle_no_tool_calls(
"tentative answer".to_string(),
None,
None,
5,
5,
round_usage(),
&mut session,
&mut runtime_state,
&tx,
None,
"round-1",
"session-1",
&gold_continue_config(),
&None,
"model",
1,
Arc::new(ScriptedGoldProvider {
decision: "continue",
confidence: "high",
}),
)
.await;
assert!(!outcome.should_break);
assert!(!outcome.sent_complete);
assert_eq!(session.messages.len(), 2);
let last = session.messages.last().unwrap();
assert!(matches!(last.role, bamboo_agent_core::Role::User));
let metadata = last.metadata.as_ref().expect("runtime metadata");
assert_eq!(
metadata.get("runtime_kind").and_then(|v| v.as_str()),
Some("goal_continue")
);
drop(tx);
let mut saw_complete = false;
while let Some(event) = rx.recv().await {
if matches!(event, AgentEvent::Complete { .. }) {
saw_complete = true;
}
}
assert!(
!saw_complete,
"Complete must not be emitted on gold continue"
);
}
#[tokio::test]
async fn no_tool_calls_completes_when_gold_achieved() {
let mut session = Session::new("session-1", "model");
let mut runtime_state = AgentRuntimeState::new("session-1".to_string());
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
let outcome = super::handle_no_tool_calls(
"final answer".to_string(),
None,
None,
5,
5,
round_usage(),
&mut session,
&mut runtime_state,
&tx,
None,
"round-1",
"session-1",
&gold_continue_config(),
&None,
"model",
1,
Arc::new(ScriptedGoldProvider {
decision: "achieved",
confidence: "high",
}),
)
.await;
assert!(outcome.should_break);
assert!(outcome.sent_complete);
assert_eq!(session.messages.len(), 1);
drop(tx);
let mut saw_complete = false;
while let Some(event) = rx.recv().await {
if matches!(event, AgentEvent::Complete { .. }) {
saw_complete = true;
}
}
assert!(
saw_complete,
"Complete must be emitted when gold is achieved"
);
}
#[tokio::test]
async fn e2e_goal_loop_continue_then_declare_then_complete() {
let mut session = Session::new("session-e2e", "model");
let config = gold_continue_config();
let mut runtime_state = AgentRuntimeState::new("session-e2e".to_string());
let (tx, mut rx) = tokio::sync::mpsc::channel(64);
let r1 = super::handle_no_tool_calls(
"I think that's everything.".to_string(),
None,
None,
5,
5,
round_usage(),
&mut session,
&mut runtime_state,
&tx,
None,
"round-1",
"session-e2e",
&config,
&None,
"model",
1,
Arc::new(ScriptedGoldProvider {
decision: "continue",
confidence: "high",
}),
)
.await;
assert!(!r1.should_break, "undeclared + continue → keep working");
assert!(!r1.sent_complete);
let st = read_goal_state(&session).expect("goal state persisted after round 1");
assert_eq!(st.continuation_count, 1);
assert_eq!(st.status, GoalRuntimeStatus::Active);
assert_eq!(st.eval_history.len(), 1);
assert!(session
.messages
.last()
.unwrap()
.content
.contains("update_goal"));
let mut st = ensure_goal_state(&session, "finish the task");
st.declare(GoalDeclaredStatus::Complete, 2);
write_goal_state(&mut session, st);
let r2 = super::handle_no_tool_calls(
"Done — shipped and verified.".to_string(),
None,
None,
5,
5,
round_usage(),
&mut session,
&mut runtime_state,
&tx,
None,
"round-2",
"session-e2e",
&config,
&None,
"model",
2,
Arc::new(ScriptedGoldProvider {
decision: "achieved",
confidence: "high",
}),
)
.await;
assert!(r2.should_break, "declared complete + achieved → stop");
assert!(r2.sent_complete);
let st = read_goal_state(&session).expect("goal state persisted after round 2");
assert_eq!(st.status, GoalRuntimeStatus::Complete);
assert_eq!(st.declared_status, None, "declaration cleared after acting");
assert_eq!(st.eval_history.len(), 2, "both double-checks persisted");
drop(tx);
let mut completes = 0;
while let Some(event) = rx.recv().await {
if matches!(event, AgentEvent::Complete { .. }) {
completes += 1;
}
}
assert_eq!(
completes, 1,
"exactly one terminal Complete across the whole loop"
);
}
#[tokio::test]
async fn e2e_goal_loop_double_check_vetoes_premature_complete() {
let mut session = Session::new("session-e2e2", "model");
let config = gold_continue_config();
let mut runtime_state = AgentRuntimeState::new("session-e2e2".to_string());
let (tx, _rx) = tokio::sync::mpsc::channel(16);
let mut st = ensure_goal_state(&session, "finish the task");
st.declare(GoalDeclaredStatus::Complete, 1);
write_goal_state(&mut session, st);
let outcome = super::handle_no_tool_calls(
"All done!".to_string(),
None,
None,
5,
5,
round_usage(),
&mut session,
&mut runtime_state,
&tx,
None,
"round-1",
"session-e2e2",
&config,
&None,
"model",
1,
Arc::new(ScriptedGoldProvider {
decision: "continue",
confidence: "high",
}),
)
.await;
assert!(!outcome.should_break, "premature completion vetoed");
assert!(!outcome.sent_complete);
let st = read_goal_state(&session).expect("goal state persisted");
assert_eq!(st.status, GoalRuntimeStatus::Active);
assert_eq!(
st.declared_status, None,
"stale declaration cleared on veto"
);
assert_eq!(st.continuation_count, 1);
}
fn guardian_and_gold_config(max_reviews: u32) -> crate::runtime::config::AgentLoopConfig {
let spawner: Arc<dyn GuardianSpawner> = Arc::new(MockGuardianSpawner {
child_id: "guardian-child".to_string(),
});
crate::runtime::config::AgentLoopConfig {
gold_config: Some(crate::runtime::config::GoldConfig {
enabled: true,
auto_continue_enabled: true,
goal: Some("finish the task".to_string()),
max_auto_continuations: 3,
..crate::runtime::config::GoldConfig::default()
}),
guardian_config: Some(GuardianConfig {
enabled: true,
model_name: Some("guardian-test-model".to_string()),
max_reviews,
}),
guardian_spawner: Some(spawner),
..crate::runtime::config::AgentLoopConfig::default()
}
}
#[tokio::test]
async fn gold_continue_skips_guardian_review() {
let mut session = Session::new("s343-continue", "model");
let config = guardian_and_gold_config(2);
let mut runtime_state = AgentRuntimeState::new("s343-continue".to_string());
let (tx, _rx) = tokio::sync::mpsc::channel(16);
let outcome = super::handle_no_tool_calls(
"tentative — I think that's everything".to_string(),
None,
None,
5,
5,
round_usage(),
&mut session,
&mut runtime_state,
&tx,
None,
"round-1",
"s343-continue",
&config,
&None,
"model",
1,
Arc::new(ScriptedGoldProvider {
decision: "continue",
confidence: "high",
}),
)
.await;
assert!(!outcome.should_break);
assert!(!outcome.sent_complete);
assert!(
runtime_state.waiting_for_children.is_none(),
"a premature terminal must NOT suspend on a guardian review",
);
assert!(
read_guardian_state(&session).is_none(),
"no guardian review budget may be spent before the goal is met",
);
assert_eq!(session.messages.len(), 2);
let last = session.messages.last().unwrap();
assert_eq!(
last.metadata
.as_ref()
.and_then(|m| m.get("runtime_kind"))
.and_then(|v| v.as_str()),
Some("goal_continue"),
);
}
#[tokio::test]
async fn gold_stop_reaches_guardian_review_on_final_state() {
let mut session = Session::new("s343-stop", "model");
let config = guardian_and_gold_config(2);
let mut goal = ensure_goal_state(&session, "finish the task");
goal.declare(GoalDeclaredStatus::Complete, 1);
write_goal_state(&mut session, goal);
let mut runtime_state = AgentRuntimeState::new("s343-stop".to_string());
let (tx, _rx) = tokio::sync::mpsc::channel(16);
let outcome = super::handle_no_tool_calls(
"Done — shipped and verified.".to_string(),
None,
None,
5,
5,
round_usage(),
&mut session,
&mut runtime_state,
&tx,
None,
"round-1",
"s343-stop",
&config,
&None,
"model",
1,
Arc::new(ScriptedGoldProvider {
decision: "achieved",
confidence: "high",
}),
)
.await;
assert!(outcome.should_break);
assert!(
!outcome.sent_complete,
"Gold STOP must reach the guardian and suspend, not complete outright",
);
assert!(
runtime_state.waiting_for_children.is_some(),
"the guardian must review the final state and suspend on its verdict",
);
let guardian = read_guardian_state(&session).expect("guardian state persisted");
assert_eq!(guardian.phase, GuardianPhase::Pending);
assert_eq!(guardian.review_count, 1);
}
struct GoalLoopE2eProvider {
main_calls: std::sync::atomic::AtomicUsize,
}
#[async_trait::async_trait]
impl LLMProvider for GoalLoopE2eProvider {
async fn chat_stream(
&self,
_messages: &[Message],
_tools: &[bamboo_agent_core::tools::ToolSchema],
_max_output_tokens: Option<u32>,
_model: &str,
) -> Result<LLMStream, LLMError> {
Ok(Box::pin(stream::iter(vec![Ok(LLMChunk::Done)])))
}
async fn chat_stream_with_options(
&self,
_messages: &[Message],
_tools: &[bamboo_agent_core::tools::ToolSchema],
_max_output_tokens: Option<u32>,
_model: &str,
options: Option<&bamboo_llm::LLMRequestOptions>,
) -> Result<LLMStream, LLMError> {
let purpose = options
.and_then(|o| o.request_purpose.as_deref())
.unwrap_or("agent_loop");
if purpose == "gold_evaluation" {
let call = bamboo_agent_core::tools::ToolCall {
id: "gold-1".to_string(),
tool_type: "function".to_string(),
function: bamboo_agent_core::tools::FunctionCall {
name: "report_gold_evaluation".to_string(),
arguments: r#"{"decision":"achieved","confidence":"high","reasoning":"objective verified"}"#.to_string(),
},
};
return Ok(Box::pin(stream::iter(vec![
Ok(LLMChunk::ToolCalls(vec![call])),
Ok(LLMChunk::Done),
])));
}
let n = self
.main_calls
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if n == 0 {
let call = bamboo_agent_core::tools::ToolCall {
id: "ug-1".to_string(),
tool_type: "function".to_string(),
function: bamboo_agent_core::tools::FunctionCall {
name: "update_goal".to_string(),
arguments: r#"{"status":"complete"}"#.to_string(),
},
};
Ok(Box::pin(stream::iter(vec![
Ok(LLMChunk::ToolCalls(vec![call])),
Ok(LLMChunk::Done),
])))
} else {
Ok(Box::pin(stream::iter(vec![
Ok(LLMChunk::Token("Done — shipped and verified.".to_string())),
Ok(LLMChunk::Done),
])))
}
}
}
fn e2e_loop_state(
session_id: &str,
) -> crate::runtime::runner::loop_execution::startup::LoopRunState {
use crate::runtime::runner::loop_execution::startup::{
GoldEvaluationState, LoopRunState, OverflowRecoveryState, TaskEvaluationState,
};
LoopRunState {
session_id: session_id.to_string(),
model_name: "model".to_string(),
metrics_collector: None,
debug_logger: crate::runtime::runner::logging::DebugLogger::new(false),
task_context: None,
overflow_recovery: OverflowRecoveryState::default(),
task_evaluation: TaskEvaluationState::default(),
gold_evaluation: GoldEvaluationState {
in_flight: None,
completed: None,
queued_request: None,
},
auxiliary_models: crate::runtime::config::AuxiliaryModelConfig::default(),
runtime_state: AgentRuntimeState::new(session_id),
}
}
#[tokio::test]
async fn e2e_full_loop_update_goal_tool_then_double_check_completes() {
use crate::runtime::config::PromptMemoryFlags;
let mut session = Session::new("session-full-e2e", "model");
let (tx, mut rx) = tokio::sync::mpsc::channel(64);
let llm: Arc<dyn LLMProvider> = Arc::new(GoalLoopE2eProvider {
main_calls: std::sync::atomic::AtomicUsize::new(0),
});
let tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor> =
Arc::new(bamboo_tools::BuiltinToolExecutor::new());
let config = AgentLoopConfig {
gold_config: Some(crate::runtime::config::GoldConfig {
enabled: true,
auto_continue_enabled: true,
goal: Some("ship it".to_string()),
max_auto_continuations: 3,
..crate::runtime::config::GoldConfig::default()
}),
prompt_memory_flags: PromptMemoryFlags {
project_prompt_injection: false,
relevant_recall: false,
relevant_recall_rerank: false,
project_first_dream: false,
ledger_agenda: false,
},
model_name: Some("model".to_string()),
max_rounds: 5,
..AgentLoopConfig::default()
};
let mut state = e2e_loop_state("session-full-e2e");
let cancel = tokio_util::sync::CancellationToken::new();
let sent_complete =
super::run_pipeline(&mut session, &tx, llm, tools, &cancel, &config, &mut state)
.await
.expect("pipeline runs to completion");
assert!(sent_complete, "the run emits a terminal Complete");
let goal_state = read_goal_state(&session).expect("goal state persisted");
assert_eq!(goal_state.status, GoalRuntimeStatus::Complete);
assert_eq!(
goal_state.declared_status, None,
"declaration cleared after the terminal gate acted"
);
assert!(
!goal_state.eval_history.is_empty(),
"the double-check verdict was persisted into the goal's eval trail"
);
drop(tx);
let mut completes = 0;
while let Some(event) = rx.recv().await {
if matches!(event, AgentEvent::Complete { .. }) {
completes += 1;
}
}
assert_eq!(completes, 1, "exactly one terminal Complete");
}
struct MaxRoundsProvider {
main_calls: std::sync::atomic::AtomicUsize,
}
#[async_trait::async_trait]
impl LLMProvider for MaxRoundsProvider {
async fn chat_stream(
&self,
_: &[Message],
_: &[bamboo_agent_core::tools::ToolSchema],
_: Option<u32>,
_: &str,
) -> Result<LLMStream, LLMError> {
Ok(Box::pin(stream::iter(vec![Ok(LLMChunk::Done)])))
}
async fn chat_stream_with_options(
&self,
_: &[Message],
_: &[bamboo_agent_core::tools::ToolSchema],
_: Option<u32>,
_: &str,
_: Option<&bamboo_llm::LLMRequestOptions>,
) -> Result<LLMStream, LLMError> {
let n = self
.main_calls
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let call = bamboo_agent_core::tools::ToolCall {
id: format!("tool-{n}"),
tool_type: "function".to_string(),
function: bamboo_agent_core::tools::FunctionCall {
name: "noop".to_string(),
arguments: "{}".to_string(),
},
};
Ok(Box::pin(stream::iter(vec![
Ok(LLMChunk::ToolCalls(vec![call])),
Ok(LLMChunk::Done),
])))
}
}
struct AlwaysOkExecutor;
#[async_trait::async_trait]
impl bamboo_agent_core::tools::ToolExecutor for AlwaysOkExecutor {
async fn execute(
&self,
_call: &bamboo_agent_core::tools::ToolCall,
) -> std::result::Result<
bamboo_agent_core::tools::ToolResult,
bamboo_agent_core::tools::ToolError,
> {
Ok(bamboo_agent_core::tools::ToolResult {
success: true,
result: "ok".to_string(),
display_preference: None,
images: Vec::new(),
})
}
fn list_tools(&self) -> Vec<bamboo_agent_core::tools::ToolSchema> {
Vec::new()
}
}
#[tokio::test]
async fn max_rounds_exhaustion_is_distinguishable_and_runs_one_summary_turn() {
use crate::runtime::config::PromptMemoryFlags;
use std::sync::atomic::Ordering;
const MAX_ROUNDS: usize = 3;
let mut session = Session::new("session-max-rounds", "model");
let (tx, mut rx) = tokio::sync::mpsc::channel(64);
let provider = Arc::new(MaxRoundsProvider {
main_calls: std::sync::atomic::AtomicUsize::new(0),
});
let llm: Arc<dyn LLMProvider> = provider.clone();
let tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor> = Arc::new(AlwaysOkExecutor);
let config = AgentLoopConfig {
max_rounds: MAX_ROUNDS,
prompt_memory_flags: PromptMemoryFlags {
project_prompt_injection: false,
relevant_recall: false,
relevant_recall_rerank: false,
project_first_dream: false,
ledger_agenda: false,
},
model_name: Some("model".to_string()),
..AgentLoopConfig::default()
};
let mut state = e2e_loop_state("session-max-rounds");
let cancel = tokio_util::sync::CancellationToken::new();
let sent_complete =
super::run_pipeline(&mut session, &tx, llm, tools, &cancel, &config, &mut state)
.await
.expect("pipeline runs to completion");
assert_eq!(
session
.metadata
.get("runtime.completion_reason")
.map(String::as_str),
Some("max_rounds_reached"),
"exhaustion must be stamped in session metadata"
);
assert!(
session.messages.iter().any(|m| m.content.contains(
"Reached the maximum of 3 rounds; the task was stopped before completion."
)),
"a visible max_rounds notification message must be appended"
);
assert!(
!session
.messages
.windows(2)
.any(|w| w[0].role == bamboo_domain::Role::User
&& w[1].role == bamboo_domain::Role::User),
"max_rounds injection must not produce consecutive user messages"
);
let main_calls = provider.main_calls.load(Ordering::SeqCst);
assert_eq!(
main_calls,
MAX_ROUNDS + 1,
"exactly one extra summary turn after {MAX_ROUNDS} normal rounds (got {main_calls})"
);
assert!(
!sent_complete,
"worst-case summary turn (tool calls) leaves sent_complete false"
);
drop(tx);
let mut completes = 0;
while let Some(event) = rx.recv().await {
if matches!(event, AgentEvent::Complete { .. }) {
completes += 1;
}
}
assert_eq!(
completes, 0,
"no Complete emitted during this worst-case run"
);
}
struct UsageProvider {
calls: std::sync::atomic::AtomicUsize,
prompt_tokens_per_round: u64,
completion_tokens_per_round: u64,
subagent_calls: bool,
}
#[async_trait::async_trait]
impl LLMProvider for UsageProvider {
async fn chat_stream(
&self,
_: &[Message],
_: &[bamboo_agent_core::tools::ToolSchema],
_: Option<u32>,
_: &str,
) -> Result<LLMStream, LLMError> {
Ok(Box::pin(stream::iter(vec![Ok(LLMChunk::Done)])))
}
async fn chat_stream_with_options(
&self,
_: &[Message],
_: &[bamboo_agent_core::tools::ToolSchema],
_: Option<u32>,
_: &str,
_: Option<&bamboo_llm::LLMRequestOptions>,
) -> Result<LLMStream, LLMError> {
let n = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let (name, arguments) = if self.subagent_calls {
(
"SubAgent".to_string(),
r#"{"action":"create","prompt":"do work"}"#.to_string(),
)
} else {
("noop".to_string(), "{}".to_string())
};
let call = bamboo_agent_core::tools::ToolCall {
id: format!("tool-{n}"),
tool_type: "function".to_string(),
function: bamboo_agent_core::tools::FunctionCall { name, arguments },
};
Ok(Box::pin(stream::iter(vec![
Ok(LLMChunk::CacheUsage {
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
input_tokens: self.prompt_tokens_per_round,
}),
Ok(LLMChunk::ToolCalls(vec![call])),
Ok(LLMChunk::UsageSummary {
output_tokens: self.completion_tokens_per_round,
thinking_tokens: 0,
}),
Ok(LLMChunk::Done),
])))
}
}
#[tokio::test]
async fn run_budget_token_limit_stops_run_gracefully() {
use crate::runtime::config::PromptMemoryFlags;
let mut session = Session::new("session-token-budget", "model");
let (tx, mut rx) = tokio::sync::mpsc::channel(64);
let provider = Arc::new(UsageProvider {
calls: std::sync::atomic::AtomicUsize::new(0),
prompt_tokens_per_round: 10,
completion_tokens_per_round: 5,
subagent_calls: false,
});
let llm: Arc<dyn LLMProvider> = provider.clone();
let tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor> = Arc::new(AlwaysOkExecutor);
let config = AgentLoopConfig {
max_rounds: 50, prompt_memory_flags: PromptMemoryFlags {
project_prompt_injection: false,
relevant_recall: false,
relevant_recall_rerank: false,
project_first_dream: false,
ledger_agenda: false,
},
model_name: Some("model".to_string()),
run_budget: bamboo_config::RunBudgetConfig {
max_total_tokens: Some(20),
max_tool_calls: None,
max_subagents: None,
},
..AgentLoopConfig::default()
};
let mut state = e2e_loop_state("session-token-budget");
let cancel = tokio_util::sync::CancellationToken::new();
let sent_complete =
super::run_pipeline(&mut session, &tx, llm, tools, &cancel, &config, &mut state)
.await
.expect("pipeline runs to completion");
assert_eq!(
session
.metadata
.get("runtime.completion_reason")
.map(String::as_str),
Some("budget_exceeded"),
"budget trip must be stamped in session metadata"
);
assert_eq!(
session
.metadata
.get("runtime.budget_exceeded_kind")
.map(String::as_str),
Some("max_total_tokens"),
);
assert!(
session
.messages
.iter()
.any(|m| m.content.contains("max_total_tokens")),
"a visible budget-exceeded notification message must be appended"
);
assert!(
!sent_complete,
"budget trip does not send a normal complete"
);
assert_eq!(provider.calls.load(std::sync::atomic::Ordering::SeqCst), 3);
drop(tx);
let mut budget_events = Vec::new();
while let Some(event) = rx.recv().await {
if let AgentEvent::BudgetExceeded {
kind,
limit,
actual,
..
} = event
{
budget_events.push((kind, limit, actual));
}
}
assert_eq!(
budget_events.len(),
1,
"exactly one structured BudgetExceeded event must be emitted"
);
let (kind, limit, actual) = &budget_events[0];
assert_eq!(kind, "max_total_tokens");
assert_eq!(*limit, 20);
assert_eq!(*actual, 30, "trips on round 2's cumulative total (15+15)");
}
#[tokio::test]
async fn run_budget_tool_call_limit_stops_run_gracefully() {
use crate::runtime::config::PromptMemoryFlags;
let mut session = Session::new("session-tool-call-budget", "model");
let (tx, mut rx) = tokio::sync::mpsc::channel(64);
let provider = Arc::new(UsageProvider {
calls: std::sync::atomic::AtomicUsize::new(0),
prompt_tokens_per_round: 0,
completion_tokens_per_round: 0,
subagent_calls: false,
});
let llm: Arc<dyn LLMProvider> = provider.clone();
let tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor> = Arc::new(AlwaysOkExecutor);
let config = AgentLoopConfig {
max_rounds: 50,
prompt_memory_flags: PromptMemoryFlags {
project_prompt_injection: false,
relevant_recall: false,
relevant_recall_rerank: false,
project_first_dream: false,
ledger_agenda: false,
},
model_name: Some("model".to_string()),
run_budget: bamboo_config::RunBudgetConfig {
max_total_tokens: None,
max_tool_calls: Some(2),
max_subagents: None,
},
..AgentLoopConfig::default()
};
let mut state = e2e_loop_state("session-tool-call-budget");
let cancel = tokio_util::sync::CancellationToken::new();
let _ = super::run_pipeline(&mut session, &tx, llm, tools, &cancel, &config, &mut state)
.await
.expect("pipeline runs to completion");
assert_eq!(
session
.metadata
.get("runtime.budget_exceeded_kind")
.map(String::as_str),
Some("max_tool_calls"),
);
assert_eq!(provider.calls.load(std::sync::atomic::Ordering::SeqCst), 3);
drop(tx);
drain(&mut rx).await;
}
#[tokio::test]
async fn run_budget_subagent_limit_counts_only_create_calls() {
use crate::runtime::config::PromptMemoryFlags;
let mut session = Session::new("session-subagent-budget", "model");
let (tx, mut rx) = tokio::sync::mpsc::channel(64);
let provider = Arc::new(UsageProvider {
calls: std::sync::atomic::AtomicUsize::new(0),
prompt_tokens_per_round: 0,
completion_tokens_per_round: 0,
subagent_calls: true,
});
let llm: Arc<dyn LLMProvider> = provider.clone();
let tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor> = Arc::new(AlwaysOkExecutor);
let config = AgentLoopConfig {
max_rounds: 50,
prompt_memory_flags: PromptMemoryFlags {
project_prompt_injection: false,
relevant_recall: false,
relevant_recall_rerank: false,
project_first_dream: false,
ledger_agenda: false,
},
model_name: Some("model".to_string()),
run_budget: bamboo_config::RunBudgetConfig {
max_total_tokens: None,
max_tool_calls: None,
max_subagents: Some(1),
},
..AgentLoopConfig::default()
};
let mut state = e2e_loop_state("session-subagent-budget");
let cancel = tokio_util::sync::CancellationToken::new();
let _ = super::run_pipeline(&mut session, &tx, llm, tools, &cancel, &config, &mut state)
.await
.expect("pipeline runs to completion");
assert_eq!(
session
.metadata
.get("runtime.budget_exceeded_kind")
.map(String::as_str),
Some("max_subagents"),
);
assert_eq!(provider.calls.load(std::sync::atomic::Ordering::SeqCst), 2);
drop(tx);
drain(&mut rx).await;
}
#[tokio::test]
async fn run_under_budget_is_unaffected() {
use crate::runtime::config::PromptMemoryFlags;
let mut session = Session::new("session-under-budget", "model");
let (tx, mut rx) = tokio::sync::mpsc::channel(64);
let provider = Arc::new(MaxRoundsProvider {
main_calls: std::sync::atomic::AtomicUsize::new(0),
});
let llm: Arc<dyn LLMProvider> = provider.clone();
let tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor> = Arc::new(AlwaysOkExecutor);
let config = AgentLoopConfig {
max_rounds: 2,
prompt_memory_flags: PromptMemoryFlags {
project_prompt_injection: false,
relevant_recall: false,
relevant_recall_rerank: false,
project_first_dream: false,
ledger_agenda: false,
},
model_name: Some("model".to_string()),
run_budget: bamboo_config::RunBudgetConfig {
max_total_tokens: Some(1_000_000),
max_tool_calls: Some(1_000_000),
max_subagents: Some(1_000_000),
},
..AgentLoopConfig::default()
};
let mut state = e2e_loop_state("session-under-budget");
let cancel = tokio_util::sync::CancellationToken::new();
let _ = super::run_pipeline(&mut session, &tx, llm, tools, &cancel, &config, &mut state)
.await
.expect("pipeline runs to completion");
assert_eq!(
session
.metadata
.get("runtime.completion_reason")
.map(String::as_str),
Some("max_rounds_reached"),
"an under-budget run still hits its real stop reason (max_rounds here), not budget_exceeded"
);
assert!(!session
.metadata
.contains_key("runtime.budget_exceeded_kind"));
drop(tx);
let mut budget_events = 0;
while let Some(event) = rx.recv().await {
if matches!(event, AgentEvent::BudgetExceeded { .. }) {
budget_events += 1;
}
}
assert_eq!(budget_events, 0, "no budget event for an under-budget run");
}
async fn drain(rx: &mut tokio::sync::mpsc::Receiver<AgentEvent>) {
while rx.recv().await.is_some() {}
}
#[test]
fn round_activity_accumulates_across_retry_attempts_instead_of_overwriting() {
use crate::runtime::stream::handler::StreamHandlingOutput;
fn attempt(input: u64, output: u64, tool_calls: Vec<&str>) -> StreamHandlingOutput {
StreamHandlingOutput {
response_id: None,
content: "x".to_string(),
reasoning_content: String::new(),
reasoning_signature: None,
token_count: 0,
tool_calls: tool_calls
.into_iter()
.enumerate()
.map(|(i, name)| bamboo_agent_core::tools::ToolCall {
id: format!("t{i}"),
tool_type: "function".to_string(),
function: bamboo_agent_core::tools::FunctionCall {
name: name.to_string(),
arguments: "{}".to_string(),
},
})
.collect(),
output_tokens: output,
thinking_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
input_tokens: input,
}
}
let mut activity = super::RoundActivity::default();
activity.absorb_attempt(&attempt(100, 50, vec!["Bash", "SubAgent"]));
assert_eq!(activity.prompt_tokens, 100);
assert_eq!(activity.completion_tokens, 50);
assert_eq!(activity.tool_call_count, 2);
assert_eq!(activity.subagent_spawn_count, 1);
activity.absorb_attempt(&attempt(120, 30, vec!["Bash"]));
assert_eq!(
activity.prompt_tokens, 220,
"attempt 1's billed prompt tokens must not be dropped on retry"
);
assert_eq!(activity.completion_tokens, 80);
assert_eq!(activity.tool_call_count, 3);
assert_eq!(activity.subagent_spawn_count, 1);
activity.absorb_attempt(&attempt(u64::MAX, u64::MAX, vec![]));
assert_eq!(activity.prompt_tokens, u64::MAX);
assert_eq!(activity.completion_tokens, u64::MAX);
}
#[tokio::test]
async fn budget_exceeded_kind_metadata_is_cleared_on_the_next_run() {
use crate::runtime::config::PromptMemoryFlags;
let flags = PromptMemoryFlags {
project_prompt_injection: false,
relevant_recall: false,
relevant_recall_rerank: false,
project_first_dream: false,
ledger_agenda: false,
};
let mut session = Session::new("session-budget-metadata-hygiene", "model");
let (tx, mut rx) = tokio::sync::mpsc::channel(64);
let provider = Arc::new(UsageProvider {
calls: std::sync::atomic::AtomicUsize::new(0),
prompt_tokens_per_round: 0,
completion_tokens_per_round: 0,
subagent_calls: false,
});
let llm: Arc<dyn LLMProvider> = provider.clone();
let tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor> = Arc::new(AlwaysOkExecutor);
let tripping_config = AgentLoopConfig {
max_rounds: 50,
prompt_memory_flags: flags,
model_name: Some("model".to_string()),
run_budget: bamboo_config::RunBudgetConfig {
max_total_tokens: None,
max_tool_calls: Some(1),
max_subagents: None,
},
..AgentLoopConfig::default()
};
let mut state = e2e_loop_state("session-budget-metadata-hygiene");
let cancel = tokio_util::sync::CancellationToken::new();
let _ = super::run_pipeline(
&mut session,
&tx,
llm,
tools.clone(),
&cancel,
&tripping_config,
&mut state,
)
.await
.expect("run 1 completes");
drop(tx);
drain(&mut rx).await;
assert_eq!(
session
.metadata
.get("runtime.budget_exceeded_kind")
.map(String::as_str),
Some("max_tool_calls"),
"run 1 must stamp the trip detail"
);
let (tx, mut rx) = tokio::sync::mpsc::channel(64);
let provider2 = Arc::new(MaxRoundsProvider {
main_calls: std::sync::atomic::AtomicUsize::new(0),
});
let llm2: Arc<dyn LLMProvider> = provider2.clone();
let unlimited_config = AgentLoopConfig {
max_rounds: 2,
prompt_memory_flags: flags,
model_name: Some("model".to_string()),
..AgentLoopConfig::default()
};
let mut state2 = e2e_loop_state("session-budget-metadata-hygiene");
let _ = super::run_pipeline(
&mut session,
&tx,
llm2,
tools,
&cancel,
&unlimited_config,
&mut state2,
)
.await
.expect("run 2 completes");
drop(tx);
drain(&mut rx).await;
assert!(
!session
.metadata
.contains_key("runtime.budget_exceeded_kind"),
"stale budget trip detail must be cleared by the next run"
);
assert_eq!(
session
.metadata
.get("runtime.completion_reason")
.map(String::as_str),
Some("max_rounds_reached"),
"run 2's own stop reason replaces run 1's budget_exceeded"
);
}
#[test]
fn is_subagent_create_call_counts_default_and_explicit_create_only() {
let call = |arguments: &str| bamboo_agent_core::tools::ToolCall {
id: "id".to_string(),
tool_type: "function".to_string(),
function: bamboo_agent_core::tools::FunctionCall {
name: "SubAgent".to_string(),
arguments: arguments.to_string(),
},
};
assert!(
is_subagent_create_call(&call(r#"{"action":"create","prompt":"x"}"#)),
"explicit action=create counts"
);
assert!(
is_subagent_create_call(&call(r#"{"prompt":"x"}"#)),
"missing action defaults to the tool's legacy create behavior"
);
assert!(
!is_subagent_create_call(&call(r#"{"action":"wait"}"#)),
"action=wait manages an existing child, not a spawn"
);
assert!(
!is_subagent_create_call(&call(r#"{"action":"list"}"#)),
"action=list is read-only, not a spawn"
);
let mut other_tool = call(r#"{"action":"create"}"#);
other_tool.function.name = "Bash".to_string();
assert!(
!is_subagent_create_call(&other_tool),
"a differently named tool is never counted, regardless of args"
);
}
#[test]
fn check_run_budget_exceeded_reports_first_tripped_kind_in_priority_order() {
use bamboo_domain::session::runtime_state::RoundRuntimeState;
let unlimited = bamboo_config::RunBudgetConfig::default();
let round = RoundRuntimeState {
total_prompt_tokens: 5,
total_completion_tokens: 5,
total_tool_calls: 3,
total_subagents_spawned: 1,
..Default::default()
};
assert!(
check_run_budget_exceeded(&round, &unlimited).is_none(),
"unlimited config never trips"
);
let all_exceeded = bamboo_config::RunBudgetConfig {
max_total_tokens: Some(5),
max_tool_calls: Some(1),
max_subagents: Some(1),
};
let exceeded =
check_run_budget_exceeded(&round, &all_exceeded).expect("some guardrail trips");
assert_eq!(exceeded.kind, "max_total_tokens");
assert_eq!(exceeded.actual, 10);
let tool_calls_only = bamboo_config::RunBudgetConfig {
max_total_tokens: None,
max_tool_calls: Some(3),
max_subagents: None,
};
let exceeded =
check_run_budget_exceeded(&round, &tool_calls_only).expect("tool-call guardrail trips");
assert_eq!(exceeded.kind, "max_tool_calls");
assert_eq!(exceeded.actual, 3);
}
#[derive(Default)]
struct TestStorage {
sessions: RwLock<HashMap<String, Session>>,
}
#[async_trait::async_trait]
impl Storage for TestStorage {
async fn save_session(&self, session: &Session) -> std::io::Result<()> {
self.sessions
.write()
.await
.insert(session.id.clone(), session.clone());
Ok(())
}
async fn load_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
Ok(self.sessions.read().await.get(session_id).cloned())
}
async fn delete_session(&self, session_id: &str) -> std::io::Result<bool> {
Ok(self.sessions.write().await.remove(session_id).is_some())
}
}
struct TestPersistence(Arc<dyn Storage>);
#[async_trait::async_trait]
impl bamboo_domain::RuntimeSessionPersistence for TestPersistence {
async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
self.0.save_session(session).await
}
}
#[tokio::test]
async fn pending_injected_messages_are_merged_once_and_cleared_from_storage() {
let storage: Arc<dyn Storage> = Arc::new(TestStorage::default());
let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> =
Arc::new(TestPersistence(storage.clone()));
let mut persisted = Session::new_child("child-merge", "parent", "model", "Child");
persisted.add_message(Message::system("system"));
persisted.add_message(Message::user("original task"));
persisted.metadata.insert(
"pending_injected_messages".to_string(),
serde_json::json!([
{
"content": "queued correction",
"created_at": chrono::Utc::now(),
}
])
.to_string(),
);
storage
.save_session(&persisted)
.await
.expect("persisted child should be saved");
let mut running = persisted.clone();
running.metadata.remove("pending_injected_messages");
state_bridge::merge_pending_injected_messages(
&mut running,
Some(&storage),
Some(&persistence),
)
.await;
assert_eq!(
running
.messages
.last()
.map(|message| message.content.as_str()),
Some("queued correction")
);
assert!(!running.metadata.contains_key("pending_injected_messages"));
let saved = storage
.load_session("child-merge")
.await
.expect("load should succeed")
.expect("session should exist");
assert!(!saved.metadata.contains_key("pending_injected_messages"));
let count_after_first_merge = running.messages.len();
state_bridge::merge_pending_injected_messages(
&mut running,
Some(&storage),
Some(&persistence),
)
.await;
assert_eq!(running.messages.len(), count_after_first_merge);
}
#[test]
fn retries_transient_llm_errors() {
assert!(should_retry_turn_error(&AgentError::LLM(
"HTTP error: timeout while connecting".to_string(),
)));
assert!(should_retry_turn_error(&AgentError::LLM(
"API error: HTTP 503: Service Unavailable".to_string(),
)));
assert!(should_retry_turn_error(&AgentError::LLM(
"empty assistant response".to_string(),
)));
}
#[test]
fn retries_reqwest_transport_errors() {
assert!(should_retry_turn_error(&AgentError::LLM(
"HTTP error: error sending request for url (https://api.githubcopilot.com/chat/completions)".to_string(),
)));
}
#[test]
fn retries_stream_decode_transport_errors() {
assert!(should_retry_turn_error(&AgentError::LLM(
"Stream error: Transport error: error decoding response body".to_string(),
)));
}
#[test]
fn retries_unknown_llm_errors_by_default() {
assert!(should_retry_turn_error(&AgentError::LLM(
"some completely unknown error".to_string(),
)));
}
#[test]
fn does_not_retry_non_retryable_llm_errors() {
assert!(!should_retry_turn_error(&AgentError::LLM(
"Authentication error: Invalid API key".to_string(),
)));
assert!(!should_retry_turn_error(&AgentError::LLM(
"API error: HTTP 400: invalid request".to_string(),
)));
}
#[test]
fn does_not_retry_non_llm_errors() {
assert!(!should_retry_turn_error(&AgentError::Cancelled));
assert!(!should_retry_turn_error(&AgentError::Tool(
"tool failed".to_string(),
)));
assert!(!should_retry_turn_error(&AgentError::Budget(
"budget exceeded".to_string(),
)));
assert!(!should_retry_turn_error(&AgentError::StreamTimeout(
"semantic_output_started=true, retry_safe=false".to_string(),
)));
}
#[test]
fn does_not_retry_empty_llm_error() {
assert!(!should_retry_turn_error(&AgentError::LLM("".to_string())));
assert!(!should_retry_turn_error(&AgentError::LLM(
" ".to_string()
)));
}
#[test]
fn overflow_errors_use_dedicated_recovery_path() {
assert!(is_overflow_recoverable(&AgentError::LLMOverflow(
"prompt too long".to_string(),
)));
assert!(!is_overflow_recoverable(&AgentError::LLM(
"timeout while connecting".to_string(),
)));
assert!(!should_retry_turn_error(&AgentError::LLMOverflow(
"maximum context length exceeded".to_string(),
)));
}
#[test]
fn overflow_recovery_state_opens_circuit_breaker_after_threshold() {
let mut state = OverflowRecoveryState::default();
assert!(state.can_attempt_recovery());
state.record_recovery(0);
state.record_recovery(1);
state.record_recovery(2);
assert!(!state.can_attempt_recovery());
}
#[test]
fn test_map_turn_error_status_cancelled() {
let error = AgentError::Cancelled;
let (round_status, session_status) = map_turn_error_status(&error);
assert_eq!(round_status, MetricsRoundStatus::Cancelled);
assert_eq!(session_status, MetricsSessionStatus::Cancelled);
}
#[test]
fn test_map_turn_error_status_tool_error() {
let error = AgentError::Tool("Tool failed".to_string());
let (round_status, session_status) = map_turn_error_status(&error);
assert_eq!(round_status, MetricsRoundStatus::Error);
assert_eq!(session_status, MetricsSessionStatus::Error);
}
#[test]
fn test_map_turn_error_status_llm_error() {
let error = AgentError::LLM("LLM provider error".to_string());
let (round_status, session_status) = map_turn_error_status(&error);
assert_eq!(round_status, MetricsRoundStatus::Error);
assert_eq!(session_status, MetricsSessionStatus::Error);
}
#[test]
fn test_map_turn_error_status_session_not_found() {
let error = AgentError::SessionNotFound("session-123".to_string());
let (round_status, session_status) = map_turn_error_status(&error);
assert_eq!(round_status, MetricsRoundStatus::Error);
assert_eq!(session_status, MetricsSessionStatus::Error);
}
#[test]
fn test_map_turn_error_status_budget_error() {
let error = AgentError::Budget("Budget exceeded".to_string());
let (round_status, session_status) = map_turn_error_status(&error);
assert_eq!(round_status, MetricsRoundStatus::Error);
assert_eq!(session_status, MetricsSessionStatus::Error);
}
#[test]
fn test_map_turn_error_status_cancelled_is_distinct() {
let cancelled_error = AgentError::Cancelled;
let other_error = AgentError::Tool("Tool error".to_string());
let (cancelled_round, cancelled_session) = map_turn_error_status(&cancelled_error);
let (other_round, other_session) = map_turn_error_status(&other_error);
assert_ne!(cancelled_round, other_round);
assert_ne!(cancelled_session, other_session);
}
#[test]
fn test_map_turn_error_only_cancelled_gets_cancelled_status() {
let errors = vec![
AgentError::LLM("error".to_string()),
AgentError::Tool("error".to_string()),
AgentError::SessionNotFound("id".to_string()),
AgentError::Budget("error".to_string()),
];
for error in errors {
let (round_status, session_status) = map_turn_error_status(&error);
assert_eq!(round_status, MetricsRoundStatus::Error);
assert_eq!(session_status, MetricsSessionStatus::Error);
}
let (round_status, session_status) = map_turn_error_status(&AgentError::Cancelled);
assert_eq!(round_status, MetricsRoundStatus::Cancelled);
assert_eq!(session_status, MetricsSessionStatus::Cancelled);
}
#[tokio::test]
async fn handle_no_tool_calls_emits_complete_and_appends_assistant_message() {
let mut session = Session::new("session-1", "model");
let mut runtime_state = AgentRuntimeState::new("session-1".to_string());
let (tx, mut rx) = tokio::sync::mpsc::channel(4);
let outcome = super::handle_no_tool_calls(
"final answer".to_string(),
Some("reasoning trace".to_string()),
None,
11,
7,
MetricsTokenUsage {
prompt_tokens: 11,
completion_tokens: 7,
total_tokens: 18,
},
&mut session,
&mut runtime_state,
&tx,
None,
"round-1",
"session-1",
&crate::runtime::config::AgentLoopConfig::default(),
&None,
"model",
1,
Arc::new(StubProvider),
)
.await;
assert!(outcome.should_break);
assert!(outcome.sent_complete);
assert_eq!(session.messages.len(), 1);
assert!(matches!(
session.messages[0].role,
bamboo_agent_core::Role::Assistant
));
assert_eq!(session.messages[0].content, "final answer");
assert_eq!(
session.messages[0].reasoning.as_deref(),
Some("reasoning trace")
);
let event = rx.recv().await.expect("complete event should be sent");
match event {
AgentEvent::Complete { usage } => {
assert_eq!(usage.prompt_tokens, 11);
assert_eq!(usage.completion_tokens, 7);
assert_eq!(usage.total_tokens, 18);
}
other => panic!("unexpected event: {other:?}"),
}
}
#[tokio::test]
async fn apply_completed_task_evaluation_updates_task_list_and_emits_event() {
let storage: Arc<dyn Storage> = Arc::new(TestStorage::default());
let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> =
Arc::new(TestPersistence(storage.clone()));
let mut session = Session::new("session-task-eval", "model");
session.set_task_list(bamboo_domain::TaskList {
session_id: "session-task-eval".to_string(),
title: "Eval Tasks".to_string(),
items: vec![bamboo_domain::TaskItem {
id: "task-1".to_string(),
description: "Do work".to_string(),
status: bamboo_domain::TaskItemStatus::InProgress,
..bamboo_domain::TaskItem::default()
}],
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
});
session
.metadata
.insert("task_list_version".to_string(), "1".to_string());
let mut state = super::super::startup::LoopRunState {
session_id: "session-task-eval".to_string(),
model_name: "model".to_string(),
metrics_collector: None,
debug_logger: crate::runtime::runner::logging::DebugLogger::new(false),
task_context: crate::runtime::task_context::TaskLoopContext::from_session(&session),
overflow_recovery: super::super::startup::OverflowRecoveryState::default(),
task_evaluation: super::super::startup::TaskEvaluationState {
in_flight: None,
completed: Some(
crate::runtime::runner::task_lifecycle::AsyncTaskEvaluationResult {
shared_session_id: "session-task-eval".to_string(),
round_number: 1,
based_on_task_context_version: 1,
task_list_title: Some("Eval Tasks".to_string()),
model_name: "fast-model".to_string(),
evaluation_result: crate::runtime::task_evaluation::TaskEvaluationResult {
needs_evaluation: true,
updates: vec![crate::runtime::task_evaluation::TaskItemUpdate {
item_id: "task-1".to_string(),
status: bamboo_domain::TaskItemStatus::Completed,
notes: Some("done".to_string()),
evidence: Some("verified".to_string()),
blocker: None,
criteria_met: None,
}],
reasoning: "complete".to_string(),
prompt_tokens: 4,
completion_tokens: 2,
},
},
),
queued_request: None,
},
gold_evaluation: super::super::startup::GoldEvaluationState::default(),
auxiliary_models: crate::runtime::config::AuxiliaryModelConfig::default(),
runtime_state: AgentRuntimeState::new("session-task-eval"),
};
let config = crate::runtime::config::AgentLoopConfig {
storage: Some(storage.clone()),
persistence: Some(persistence),
..Default::default()
};
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
super::apply_completed_task_evaluation(&mut session, &tx, &config, &mut state).await;
assert_eq!(
session.task_list.as_ref().unwrap().items[0].status,
bamboo_domain::TaskItemStatus::Completed
);
let event = rx
.recv()
.await
.expect("task update event should be emitted");
match event {
AgentEvent::TaskListUpdated { task_list } => {
assert_eq!(
task_list.items[0].status,
bamboo_domain::TaskItemStatus::Completed
);
}
other => panic!("unexpected event: {other:?}"),
}
}
#[test]
fn test_build_round_id() {
let id = format!("{}-round-{}", "session-123", 1);
assert_eq!(id, "session-123-round-1");
let id = format!("{}-round-{}", "test", 4 + 1);
assert_eq!(id, "test-round-5");
}
#[tokio::test]
async fn ensure_not_cancelled_returns_ok_when_not_cancelled() {
let token = tokio_util::sync::CancellationToken::new();
assert!(!token.is_cancelled());
}
#[tokio::test]
async fn ensure_not_cancelled_returns_error_when_cancelled() {
let token = tokio_util::sync::CancellationToken::new();
token.cancel();
assert!(token.is_cancelled());
}
#[test]
fn accumulate_round_usage_saturates_components_and_recomputes_total() {
let mut usage = MetricsTokenUsage {
prompt_tokens: u64::MAX - 5,
completion_tokens: u64::MAX - 9,
total_tokens: 0,
};
let delta = MetricsTokenUsage {
prompt_tokens: 10,
completion_tokens: 20,
total_tokens: 30,
};
usage.prompt_tokens = usage.prompt_tokens.saturating_add(delta.prompt_tokens);
usage.completion_tokens = usage
.completion_tokens
.saturating_add(delta.completion_tokens);
usage.recompute_total();
assert_eq!(usage.prompt_tokens, u64::MAX);
assert_eq!(usage.completion_tokens, u64::MAX);
assert_eq!(usage.total_tokens, u64::MAX);
}
#[test]
fn is_terminal_child_status_classifies_correctly() {
for s in ["completed", "error", "timeout", "cancelled", "skipped"] {
assert!(is_terminal_child_status(s), "{s} should be terminal");
}
for s in ["running", "pending", "queued", ""] {
assert!(!is_terminal_child_status(s), "{s} should be active");
}
}
struct ChildIndexStorage {
inner: Arc<TestStorage>,
children: Vec<(String, Option<String>)>,
}
#[async_trait::async_trait]
impl Storage for ChildIndexStorage {
async fn save_session(&self, session: &Session) -> std::io::Result<()> {
self.inner.save_session(session).await
}
async fn load_session(&self, id: &str) -> std::io::Result<Option<Session>> {
self.inner.load_session(id).await
}
async fn delete_session(&self, id: &str) -> std::io::Result<bool> {
self.inner.delete_session(id).await
}
async fn list_child_run_statuses(
&self,
_parent: &str,
) -> std::io::Result<Vec<(String, Option<String>)>> {
Ok(self.children.clone())
}
}
fn config_with_storage(storage: Arc<dyn Storage>) -> AgentLoopConfig {
let persistence: Arc<dyn bamboo_domain::RuntimeSessionPersistence> =
Arc::new(TestPersistence(storage.clone()));
AgentLoopConfig {
storage: Some(storage),
persistence: Some(persistence),
..AgentLoopConfig::default()
}
}
#[tokio::test]
async fn safety_net_suspends_on_orphaned_active_children() {
let inner = Arc::new(TestStorage::default());
let storage: Arc<dyn Storage> = Arc::new(ChildIndexStorage {
inner: inner.clone(),
children: vec![
("c-run".into(), Some("running".into())),
("c-pend".into(), None),
("c-done".into(), Some("completed".into())),
],
});
let config = config_with_storage(storage.clone());
let mut session = Session::new("parent-orphan", "model");
let mut runtime_state = AgentRuntimeState::new("parent-orphan");
let outcome =
maybe_suspend_for_orphaned_children(&mut session, &config, &mut runtime_state)
.await
.expect("must suspend when active children remain");
assert!(outcome.should_break && !outcome.sent_complete);
let wait = runtime_state
.waiting_for_children
.expect("durable wait registered");
assert_eq!(
wait.child_session_ids,
vec!["c-pend".to_string(), "c-run".to_string()]
);
assert_eq!(
session
.metadata
.get("runtime.suspend_reason")
.map(String::as_str),
Some("waiting_for_children")
);
let persisted = storage
.load_session("parent-orphan")
.await
.unwrap()
.unwrap();
assert!(persisted
.agent_runtime_state
.and_then(|s| s.waiting_for_children)
.is_some());
}
#[tokio::test]
async fn safety_net_noop_when_all_children_terminal() {
let inner = Arc::new(TestStorage::default());
let storage: Arc<dyn Storage> = Arc::new(ChildIndexStorage {
inner,
children: vec![
("a".into(), Some("completed".into())),
("b".into(), Some("error".into())),
],
});
let config = config_with_storage(storage);
let mut session = Session::new("parent-done", "model");
let mut runtime_state = AgentRuntimeState::new("parent-done");
assert!(
maybe_suspend_for_orphaned_children(&mut session, &config, &mut runtime_state)
.await
.is_none(),
"no active children → must not suspend"
);
assert!(runtime_state.waiting_for_children.is_none());
}
#[tokio::test]
async fn safety_net_noop_when_already_waiting() {
let storage: Arc<dyn Storage> = Arc::new(ChildIndexStorage {
inner: Arc::new(TestStorage::default()),
children: vec![("x".into(), Some("running".into()))],
});
let config = config_with_storage(storage);
let mut session = Session::new("parent-waiting", "model");
let mut runtime_state = AgentRuntimeState::new("parent-waiting");
runtime_state.waiting_for_children = Some(super::WaitingForChildrenState {
child_session_ids: vec!["x".into()],
wait_for: super::ChildWaitPolicy::All,
registered_at: chrono::Utc::now(),
timeout_at: None,
registered_by_tool_call_id: None,
});
assert!(
maybe_suspend_for_orphaned_children(&mut session, &config, &mut runtime_state)
.await
.is_none()
);
}
#[tokio::test]
async fn suspend_to_wait_for_bash_sets_reason_and_state() {
let mut session = Session::new("s-bash", "model");
let mut runtime_state = AgentRuntimeState::new("s-bash");
let outcome = suspend_to_wait_for_bash(
&mut session,
&mut runtime_state,
None,
vec!["bg-1".to_string(), "bg-2".to_string()],
)
.await;
assert!(outcome.should_break, "must break the turn");
assert!(!outcome.sent_complete, "must not send complete");
let wait = runtime_state
.waiting_for_bash
.expect("durable bash wait should be registered");
assert_eq!(wait.bash_ids, vec!["bg-1".to_string(), "bg-2".to_string()]);
assert_eq!(
session
.metadata
.get("runtime.suspend_reason")
.map(String::as_str),
Some("waiting_for_bash"),
"metadata reason must match the discriminant arm"
);
}
#[tokio::test]
async fn bash_safety_net_noop_when_already_waiting() {
let config = AgentLoopConfig::default();
let mut session = Session::new("s-bash-waiting", "model");
let mut runtime_state = AgentRuntimeState::new("s-bash-waiting");
runtime_state.waiting_for_bash = Some(super::WaitingForBashState {
bash_ids: vec!["bg-1".to_string()],
registered_at: chrono::Utc::now(),
timeout_at: None,
});
assert!(
maybe_suspend_for_outstanding_bash(&mut session, &config, &mut runtime_state)
.await
.is_none(),
"must not re-suspend when a bash wait is already registered"
);
}
struct StubBashPersistence;
#[async_trait::async_trait]
impl bamboo_domain::RuntimeSessionPersistence for StubBashPersistence {
async fn save_runtime_session(&self, _session: &mut Session) -> std::io::Result<()> {
Ok(())
}
}
#[derive(Clone)]
struct RecordingBashResumeHook {
calls: Arc<std::sync::Mutex<Vec<(String, Vec<String>)>>>,
}
impl crate::runtime::config::BashResumeHook for RecordingBashResumeHook {
fn arrange_bash_self_resume(&self, session_id: String, bash_ids: Vec<String>) {
self.calls
.lock()
.expect("hook mutex")
.push((session_id, bash_ids));
}
}
struct NoopBashResumeHook;
impl crate::runtime::config::BashResumeHook for NoopBashResumeHook {
fn arrange_bash_self_resume(&self, _: String, _: Vec<String>) {}
}
#[tokio::test]
async fn bash_gate_arranges_self_resume_hook_on_suspend() {
let session_id = "s-bash-liveness";
let mut config = AgentLoopConfig::default();
config.persistence = Some(Arc::new(StubBashPersistence));
let hook = RecordingBashResumeHook {
calls: Arc::new(std::sync::Mutex::new(Vec::new())),
};
config.bash_resume_hook = Some(Arc::new(hook.clone()));
let shell = bamboo_tools::tools::bash_runtime::spawn_background(
"sleep 5",
None,
None,
Some(session_id.to_string()),
false,
None,
)
.await
.expect("spawn");
let mut session = Session::new(session_id, "model");
let mut runtime_state = AgentRuntimeState::new(session_id);
let outcome =
maybe_suspend_for_outstanding_bash(&mut session, &config, &mut runtime_state).await;
let _ = shell.kill().await;
assert!(
outcome.is_some(),
"gate should suspend with a running shell"
);
assert!(
runtime_state.waiting_for_bash.is_some(),
"durable wait registered"
);
let calls = hook.calls.lock().expect("hook calls");
assert_eq!(calls.len(), 1, "hook called exactly once");
assert_eq!(calls[0].0, session_id);
assert!(!calls[0].1.is_empty(), "hook received bash ids");
}
#[tokio::test]
async fn bash_gate_no_suspend_when_all_shells_finished() {
let session_id = "s-bash-toctou";
let mut config = AgentLoopConfig::default();
config.persistence = Some(Arc::new(StubBashPersistence));
config.bash_resume_hook = Some(Arc::new(NoopBashResumeHook));
let shell = bamboo_tools::tools::bash_runtime::spawn_background(
"true",
None,
None,
Some(session_id.to_string()),
false,
None,
)
.await
.expect("spawn");
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if shell.status() != "running" {
break;
}
if tokio::time::Instant::now() >= deadline {
panic!("test shell did not finish in 5s");
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
let mut session = Session::new(session_id, "model");
let mut runtime_state = AgentRuntimeState::new(session_id);
let outcome =
maybe_suspend_for_outstanding_bash(&mut session, &config, &mut runtime_state).await;
assert!(
outcome.is_none(),
"must not suspend when no shells are running"
);
assert!(
runtime_state.waiting_for_bash.is_none(),
"no bash wait registered"
);
}
#[tokio::test]
async fn bash_suspend_reason_matches_suspended_discriminant() {
let mut session = Session::new("s-discriminant", "model");
let mut runtime_state = AgentRuntimeState::new("s-discriminant");
suspend_to_wait_for_bash(
&mut session,
&mut runtime_state,
None,
vec!["bg-1".to_string()],
)
.await;
let reason = session
.metadata
.get("runtime.suspend_reason")
.map(String::as_str);
assert_eq!(reason, Some("waiting_for_bash"));
let produces_suspended = matches!(
reason,
Some("awaiting_clarification")
| Some("awaiting_parent_approval")
| Some("waiting_for_children")
| Some("waiting_for_bash")
);
assert!(
produces_suspended,
"waiting_for_bash must be Suspended-producing"
);
}
use super::handle_tool_calls_path;
use crate::runtime::runner::round_frame::RoundFrame;
use crate::runtime::runner::tool_execution::execute_round_tool_calls;
use crate::runtime::stream::handler::StreamHandlingOutput;
use crate::runtime::task_context::TaskLoopContext;
use bamboo_agent_core::tools::{
FunctionCall, FunctionSchema, ToolCall, ToolExecutor, ToolResult, ToolSchema,
};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
struct CancelProbeToolExecutor {
block: bool,
started: Arc<AtomicBool>,
}
#[async_trait::async_trait]
impl ToolExecutor for CancelProbeToolExecutor {
async fn execute(
&self,
_call: &ToolCall,
) -> bamboo_agent_core::tools::executor::Result<ToolResult> {
self.started.store(true, Ordering::SeqCst);
if self.block {
tokio::time::sleep(Duration::from_secs(120)).await;
}
Ok(ToolResult {
success: true,
result: "tool-result-123".to_string(),
display_preference: None,
images: Vec::new(),
})
}
fn list_tools(&self) -> Vec<ToolSchema> {
vec![ToolSchema {
schema_type: "function".to_string(),
function: FunctionSchema {
name: "Read".to_string(),
description: "read tool".to_string(),
parameters: serde_json::json!({ "type": "object", "properties": {} }),
},
}]
}
}
fn single_read_call() -> ToolCall {
ToolCall {
id: "call-read".to_string(),
tool_type: "function".to_string(),
function: FunctionCall {
name: "Read".to_string(),
arguments: "{}".to_string(),
},
}
}
fn stream_output_with_tool_call(call: ToolCall) -> StreamHandlingOutput {
StreamHandlingOutput {
response_id: None,
content: String::new(),
reasoning_content: String::new(),
reasoning_signature: None,
token_count: 0,
tool_calls: vec![call],
output_tokens: 0,
thinking_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
input_tokens: 0,
}
}
#[tokio::test]
async fn tool_execution_cancel_returns_promptly() {
let started = Arc::new(AtomicBool::new(false));
let tools: Arc<dyn ToolExecutor> = Arc::new(CancelProbeToolExecutor {
block: true,
started: started.clone(),
});
let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(128);
let llm: Arc<dyn LLMProvider> = Arc::new(StubProvider);
let config = AgentLoopConfig::default();
let mut session = Session::new("s-cancel", "model");
let frame = RoundFrame {
session_id: "s-cancel",
round_id: "r1",
turn: 0,
debug_enabled: false,
event_tx: &event_tx,
metrics_collector: None,
config: &config,
llm: &llm,
tools: &tools,
};
let auxiliary_models = crate::runtime::config::AuxiliaryModelConfig::default();
let mut task_context: Option<TaskLoopContext> = None;
let cancel_token = CancellationToken::new();
let driver_started = started.clone();
let driver_token = cancel_token.clone();
let driver = tokio::spawn(async move {
for _ in 0..500 {
if driver_started.load(Ordering::SeqCst) {
break;
}
tokio::time::sleep(Duration::from_millis(2)).await;
}
assert!(
driver_started.load(Ordering::SeqCst),
"tool never started executing"
);
driver_token.cancel();
});
let t0 = std::time::Instant::now();
let result = tokio::time::timeout(
Duration::from_secs(5),
handle_tool_calls_path(
&frame,
stream_output_with_tool_call(single_read_call()),
MetricsTokenUsage {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
},
&mut session,
&auxiliary_models,
"model",
&mut task_context,
&cancel_token,
),
)
.await;
let elapsed = t0.elapsed();
let _ = driver.await;
let inner = result.expect(
"handle_tool_calls_path did not return within 5s — cancel not honored during tool execution",
);
assert!(
matches!(inner, Err(AgentError::Cancelled)),
"expected Err(AgentError::Cancelled), got {:?}",
inner.as_ref().err()
);
assert!(
elapsed < Duration::from_secs(2),
"cancel was not prompt (tool would otherwise block for ~120s): {:?}",
elapsed
);
}
#[tokio::test]
async fn normal_tool_batch_completes_unchanged() {
let tools: Arc<dyn ToolExecutor> = Arc::new(CancelProbeToolExecutor {
block: false,
started: Arc::new(AtomicBool::new(false)),
});
let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(128);
let llm: Arc<dyn LLMProvider> = Arc::new(StubProvider);
let config = AgentLoopConfig::default();
let mut session = Session::new("s-normal", "model");
let frame = RoundFrame {
session_id: "s-normal",
round_id: "r1",
turn: 0,
debug_enabled: false,
event_tx: &event_tx,
metrics_collector: None,
config: &config,
llm: &llm,
tools: &tools,
};
let tool_schemas = tools.list_tools();
let mut task_context: Option<TaskLoopContext> = None;
let result = tokio::time::timeout(
Duration::from_secs(10),
execute_round_tool_calls(
std::slice::from_ref(&single_read_call()),
&frame,
&mut session,
&mut task_context,
None,
None,
&tool_schemas,
),
)
.await
.expect("normal tool batch did not complete within 10s");
let round_result = result.expect("normal batch should return Ok");
assert!(!round_result.awaiting_clarification);
assert!(!round_result.waiting_for_children);
assert!(
session
.messages
.iter()
.any(|m| m.role == bamboo_agent_core::Role::Tool
&& m.content.contains("tool-result-123")),
"expected a tool-result message, got {} message(s)",
session.messages.len()
);
}
struct RecordingSequentialExecutor {
executed: Arc<std::sync::Mutex<Vec<String>>>,
}
#[async_trait::async_trait]
impl ToolExecutor for RecordingSequentialExecutor {
async fn execute(
&self,
call: &ToolCall,
) -> bamboo_agent_core::tools::executor::Result<ToolResult> {
self.executed
.lock()
.unwrap()
.push(call.function.name.clone());
Ok(ToolResult {
success: true,
result: format!("result-of-{}", call.function.name),
display_preference: None,
images: Vec::new(),
})
}
fn list_tools(&self) -> Vec<ToolSchema> {
["compact_context", "tool_b", "tool_c"]
.iter()
.map(|name| ToolSchema {
schema_type: "function".to_string(),
function: FunctionSchema {
name: name.to_string(),
description: "test tool".to_string(),
parameters: serde_json::json!({ "type": "object", "properties": {} }),
},
})
.collect()
}
fn call_parallel_classification(
&self,
_call: &ToolCall,
) -> (bamboo_agent_core::tools::ToolMutability, bool) {
(bamboo_agent_core::tools::ToolMutability::Mutating, false)
}
}
struct FailingCompressionProvider {
compression_calls: Arc<std::sync::atomic::AtomicUsize>,
}
#[async_trait::async_trait]
impl LLMProvider for FailingCompressionProvider {
async fn chat_stream(
&self,
_messages: &[Message],
_tools: &[bamboo_agent_core::tools::ToolSchema],
_max_output_tokens: Option<u32>,
_model: &str,
) -> Result<LLMStream, LLMError> {
Ok(Box::pin(stream::iter(vec![Ok(LLMChunk::Done)])))
}
async fn chat_stream_with_options(
&self,
_messages: &[Message],
_tools: &[bamboo_agent_core::tools::ToolSchema],
_max_output_tokens: Option<u32>,
_model: &str,
options: Option<&bamboo_llm::LLMRequestOptions>,
) -> Result<LLMStream, LLMError> {
let purpose = options
.and_then(|o| o.request_purpose.as_deref())
.unwrap_or("");
if purpose == "compression" {
self.compression_calls.fetch_add(1, Ordering::SeqCst);
return Err(LLMError::Api(
"http 500 transient upstream failure (compression summarization)".to_string(),
));
}
Ok(Box::pin(stream::iter(vec![Ok(LLMChunk::Done)])))
}
}
fn tool_call(id: &str, name: &str) -> ToolCall {
ToolCall {
id: id.to_string(),
tool_type: "function".to_string(),
function: FunctionCall {
name: name.to_string(),
arguments: "{}".to_string(),
},
}
}
#[tokio::test]
async fn mid_turn_compression_failure_is_best_effort_and_does_not_retry_turn() {
let compression_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let executed = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let llm: Arc<dyn LLMProvider> = Arc::new(FailingCompressionProvider {
compression_calls: compression_calls.clone(),
});
let tools: Arc<dyn ToolExecutor> = Arc::new(RecordingSequentialExecutor {
executed: executed.clone(),
});
let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(128);
let config = AgentLoopConfig {
model_name: Some("model".to_string()),
background_model_name: Some("summarizer".to_string()),
..AgentLoopConfig::default()
};
let mut session = Session::new("s-compress-fail", "model");
session.add_message(Message::system("system"));
session.add_message(Message::user("do the work"));
session.add_message(Message::assistant("prior assistant turn".to_string(), None));
session.add_message(Message::user("keep going"));
let frame = RoundFrame {
session_id: "s-compress-fail",
round_id: "r1",
turn: 0,
debug_enabled: false,
event_tx: &event_tx,
metrics_collector: None,
config: &config,
llm: &llm,
tools: &tools,
};
let auxiliary_models = crate::runtime::config::AuxiliaryModelConfig::default();
let mut task_context: Option<TaskLoopContext> = None;
let cancel_token = CancellationToken::new();
let stream_output = StreamHandlingOutput {
response_id: None,
content: String::new(),
reasoning_content: String::new(),
reasoning_signature: None,
token_count: 0,
tool_calls: vec![
tool_call("call-compact", "compact_context"),
tool_call("call-b", "tool_b"),
tool_call("call-c", "tool_c"),
],
output_tokens: 0,
thinking_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
input_tokens: 0,
};
let result = tokio::time::timeout(
Duration::from_secs(10),
handle_tool_calls_path(
&frame,
stream_output,
MetricsTokenUsage {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
},
&mut session,
&auxiliary_models,
"model",
&mut task_context,
&cancel_token,
),
)
.await
.expect("handle_tool_calls_path did not return within 10s");
let _outcome = result.expect(
"mid-turn compression failure must be best-effort (Ok), not a whole-turn error/retry",
);
assert!(
compression_calls.load(Ordering::SeqCst) >= 1,
"mid-turn compression summarization must have been attempted (and failed)"
);
let ran = executed.lock().unwrap().clone();
assert_eq!(
ran,
vec![
"compact_context".to_string(),
"tool_b".to_string(),
"tool_c".to_string(),
],
"all tools must execute in order despite the mid-turn compression failure"
);
let assistant_turns = session
.messages
.iter()
.filter(|m| {
m.role == bamboo_agent_core::Role::Assistant
&& m.tool_calls.as_ref().is_some_and(|calls| {
calls.iter().any(|c| c.function.name == "compact_context")
})
})
.count();
assert_eq!(
assistant_turns, 1,
"exactly one assistant message must exist for the turn (no duplicate)"
);
for (id, name) in [
("call-compact", "compact_context"),
("call-b", "tool_b"),
("call-c", "tool_c"),
] {
let count = session
.messages
.iter()
.filter(|m| {
m.role == bamboo_agent_core::Role::Tool && m.tool_call_id.as_deref() == Some(id)
})
.count();
assert_eq!(count, 1, "tool {name} must have exactly one result message");
}
}
#[derive(Clone, Copy)]
enum SecondRoundBehavior {
BlockForever,
TerminalError,
}
struct EvalAbortProbeProvider {
main_calls: std::sync::atomic::AtomicUsize,
gold_started: Arc<AtomicBool>,
gold_completed: Arc<AtomicBool>,
release: Arc<tokio::sync::Notify>,
finished: Arc<tokio::sync::Notify>,
second_round: SecondRoundBehavior,
}
#[async_trait::async_trait]
impl LLMProvider for EvalAbortProbeProvider {
async fn chat_stream(
&self,
_messages: &[Message],
_tools: &[bamboo_agent_core::tools::ToolSchema],
_max_output_tokens: Option<u32>,
_model: &str,
) -> Result<LLMStream, LLMError> {
Ok(Box::pin(stream::iter(vec![Ok(LLMChunk::Done)])))
}
async fn chat_stream_with_options(
&self,
_messages: &[Message],
_tools: &[bamboo_agent_core::tools::ToolSchema],
_max_output_tokens: Option<u32>,
_model: &str,
options: Option<&bamboo_llm::LLMRequestOptions>,
) -> Result<LLMStream, LLMError> {
let purpose = options
.and_then(|o| o.request_purpose.as_deref())
.unwrap_or("agent_loop");
if purpose == "gold_evaluation" {
self.gold_started.store(true, Ordering::SeqCst);
self.release.notified().await;
self.gold_completed.store(true, Ordering::SeqCst);
self.finished.notify_one();
let call = bamboo_agent_core::tools::ToolCall {
id: "gold-eval-async".to_string(),
tool_type: "function".to_string(),
function: bamboo_agent_core::tools::FunctionCall {
name: "report_gold_evaluation".to_string(),
arguments:
r#"{"decision":"achieved","confidence":"high","reasoning":"done"}"#
.to_string(),
},
};
return Ok(Box::pin(stream::iter(vec![
Ok(LLMChunk::ToolCalls(vec![call])),
Ok(LLMChunk::Done),
])));
}
let n = self
.main_calls
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if n == 0 {
let call = bamboo_agent_core::tools::ToolCall {
id: "noop-1".to_string(),
tool_type: "function".to_string(),
function: bamboo_agent_core::tools::FunctionCall {
name: "noop".to_string(),
arguments: "{}".to_string(),
},
};
return Ok(Box::pin(stream::iter(vec![
Ok(LLMChunk::ToolCalls(vec![call])),
Ok(LLMChunk::Done),
])));
}
for _ in 0..2000 {
if self.gold_started.load(Ordering::SeqCst) {
break;
}
tokio::time::sleep(Duration::from_millis(2)).await;
}
match self.second_round {
SecondRoundBehavior::BlockForever => Ok(Box::pin(stream::pending())),
SecondRoundBehavior::TerminalError => Err(LLMError::Auth(
"terminal error injected to exercise #347 abort".to_string(),
)),
}
}
}
fn eval_abort_config() -> AgentLoopConfig {
use crate::runtime::config::PromptMemoryFlags;
AgentLoopConfig {
gold_config: Some(crate::runtime::config::GoldConfig {
enabled: true,
auto_continue_enabled: true,
goal: Some("ship it".to_string()),
max_auto_continuations: 3,
..crate::runtime::config::GoldConfig::default()
}),
prompt_memory_flags: PromptMemoryFlags {
project_prompt_injection: false,
relevant_recall: false,
relevant_recall_rerank: false,
project_first_dream: false,
ledger_agenda: false,
},
model_name: Some("model".to_string()),
max_rounds: 5,
..AgentLoopConfig::default()
}
}
#[tokio::test]
async fn cancelled_run_does_not_complete_in_flight_gold_eval() {
let gold_started = Arc::new(AtomicBool::new(false));
let gold_completed = Arc::new(AtomicBool::new(false));
let release = Arc::new(tokio::sync::Notify::new());
let finished = Arc::new(tokio::sync::Notify::new());
let llm: Arc<dyn LLMProvider> = Arc::new(EvalAbortProbeProvider {
main_calls: std::sync::atomic::AtomicUsize::new(0),
gold_started: gold_started.clone(),
gold_completed: gold_completed.clone(),
release: release.clone(),
finished: finished.clone(),
second_round: SecondRoundBehavior::BlockForever,
});
let tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor> = Arc::new(AlwaysOkExecutor);
let config = eval_abort_config();
let mut session = Session::new("session-eval-cancel", "model");
let mut state = e2e_loop_state("session-eval-cancel");
let (tx, _rx) = tokio::sync::mpsc::channel(64);
let cancel = CancellationToken::new();
let driver_started = gold_started.clone();
let driver_token = cancel.clone();
let driver = tokio::spawn(async move {
for _ in 0..2000 {
if driver_started.load(Ordering::SeqCst) {
break;
}
tokio::time::sleep(Duration::from_millis(2)).await;
}
driver_token.cancel();
});
let result = tokio::time::timeout(
Duration::from_secs(5),
super::run_pipeline(&mut session, &tx, llm, tools, &cancel, &config, &mut state),
)
.await
.expect("run_pipeline did not return within 5s after cancel");
let _ = driver.await;
assert!(
matches!(result, Err(AgentError::Cancelled)),
"cancelled run must return Cancelled, got {result:?}"
);
assert!(
gold_started.load(Ordering::SeqCst),
"the Gold eval must have been genuinely in flight (else nothing was tested)"
);
assert!(
state.gold_evaluation.in_flight.is_none(),
"the in-flight Gold eval slot must be cleared on the cancel early-exit"
);
release.notify_one();
let finished_within =
tokio::time::timeout(Duration::from_millis(500), finished.notified()).await;
assert!(
finished_within.is_err(),
"cancelled Gold eval kept running to completion (spend not stopped)"
);
assert!(
!gold_completed.load(Ordering::SeqCst),
"cancelled Gold eval must not complete its LLM request"
);
}
#[tokio::test]
async fn terminal_error_aborts_in_flight_gold_eval() {
let gold_started = Arc::new(AtomicBool::new(false));
let gold_completed = Arc::new(AtomicBool::new(false));
let release = Arc::new(tokio::sync::Notify::new());
let finished = Arc::new(tokio::sync::Notify::new());
let llm: Arc<dyn LLMProvider> = Arc::new(EvalAbortProbeProvider {
main_calls: std::sync::atomic::AtomicUsize::new(0),
gold_started: gold_started.clone(),
gold_completed: gold_completed.clone(),
release: release.clone(),
finished: finished.clone(),
second_round: SecondRoundBehavior::TerminalError,
});
let tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor> = Arc::new(AlwaysOkExecutor);
let config = eval_abort_config();
let mut session = Session::new("session-eval-terminal", "model");
let mut state = e2e_loop_state("session-eval-terminal");
let (tx, mut rx) = tokio::sync::mpsc::channel(64);
let cancel = CancellationToken::new();
let result = tokio::time::timeout(
Duration::from_secs(5),
super::run_pipeline(&mut session, &tx, llm, tools, &cancel, &config, &mut state),
)
.await
.expect("run_pipeline did not return within 5s");
assert!(
matches!(result, Err(AgentError::LLM(_))),
"the injected terminal error must surface as Err(LLM), got {result:?}"
);
assert!(
!cancel.is_cancelled(),
"this test must NOT rely on cancellation — it isolates the abort path"
);
assert!(
gold_started.load(Ordering::SeqCst),
"the Gold eval must have been genuinely in flight (else nothing was tested)"
);
assert!(
state.gold_evaluation.in_flight.is_none(),
"the in-flight Gold eval slot must be aborted+cleared on the terminal early-exit"
);
let mut saw_cancelled = false;
while let Ok(event) = rx.try_recv() {
if matches!(
event,
AgentEvent::GoldEvaluationCancelled { ref reason, .. }
if reason == "terminal_error"
) {
saw_cancelled = true;
}
}
assert!(
saw_cancelled,
"an observed evaluation start must receive an explicit terminal cancellation event"
);
release.notify_one();
let finished_within =
tokio::time::timeout(Duration::from_millis(500), finished.notified()).await;
assert!(
finished_within.is_err(),
"in-flight Gold eval was detached, not aborted, on the terminal early-exit (#347)"
);
assert!(
!gold_completed.load(Ordering::SeqCst),
"aborted Gold eval must not complete its LLM request"
);
}
#[tokio::test]
async fn abort_helper_is_nonblocking_for_completion_and_suspension() {
let mut session = Session::new("session-eval-complete", "model");
let mut state = e2e_loop_state("session-eval-complete");
let (tx, mut rx) = tokio::sync::mpsc::channel(64);
let request = crate::runtime::gold_evaluation::AsyncGoldEvaluationRequest {
session_id: session.id.clone(),
round_number: 1,
model_name: "fast".to_string(),
reasoning_effort: None,
checkpoint: bamboo_agent_core::GoldCheckpoint::PostRound,
timeout_context: crate::runtime::stream::handler::StreamTimeoutContext::default(),
session_snapshot: session.clone(),
task_context_snapshot: None,
gold_config: crate::runtime::config::GoldConfig::default(),
};
state.gold_evaluation.in_flight = Some(super::super::startup::InFlightGoldEvaluation {
request: request.clone(),
join_handle: tokio::spawn(std::future::pending()),
});
state.gold_evaluation.queued_request = Some(request.clone());
tokio::time::timeout(
Duration::from_millis(100),
super::abort_in_flight_evaluations(&mut state, &tx, "run_completed"),
)
.await
.expect("normal finalization waited for the blocked Gold evaluation");
assert!(state.gold_evaluation.in_flight.is_none());
assert!(state.gold_evaluation.queued_request.is_none());
let mut saw_cancelled = false;
while let Ok(event) = rx.try_recv() {
if matches!(
event,
AgentEvent::GoldEvaluationCancelled { ref reason, .. }
if reason == "run_completed"
) {
saw_cancelled = true;
}
}
assert!(saw_cancelled);
state.gold_evaluation.in_flight = Some(super::super::startup::InFlightGoldEvaluation {
request: request.clone(),
join_handle: tokio::spawn(std::future::pending()),
});
tokio::time::timeout(
Duration::from_millis(100),
super::abort_in_flight_evaluations(&mut state, &tx, "run_suspended"),
)
.await
.expect("suspension finalization waited for the blocked Gold evaluation");
assert!(matches!(
rx.try_recv(),
Ok(AgentEvent::GoldEvaluationCancelled { reason, .. })
if reason == "run_suspended"
));
session.metadata.insert("verified".into(), "true".into());
}
struct RecordingGuardianSpawner {
child_id: String,
prompts: Arc<std::sync::Mutex<Vec<String>>>,
}
#[async_trait::async_trait]
impl GuardianSpawner for RecordingGuardianSpawner {
async fn spawn_guardian_review(
&self,
_parent_session: &Session,
review_prompt: String,
_model: String,
_disabled_tools: Option<std::collections::BTreeSet<String>>,
) -> Result<String, String> {
self.prompts.lock().unwrap().push(review_prompt);
Ok(self.child_id.clone())
}
}
fn guardian_only_config_with_recorder(
max_reviews: u32,
) -> (AgentLoopConfig, Arc<std::sync::Mutex<Vec<String>>>) {
let prompts = Arc::new(std::sync::Mutex::new(Vec::new()));
let spawner: Arc<dyn GuardianSpawner> = Arc::new(RecordingGuardianSpawner {
child_id: "guardian-child".to_string(),
prompts: prompts.clone(),
});
let config = AgentLoopConfig {
guardian_config: Some(GuardianConfig {
enabled: true,
model_name: Some("guardian-test-model".to_string()),
max_reviews,
}),
guardian_spawner: Some(spawner),
..Default::default()
};
(config, prompts)
}
fn guardian_and_gold_config_with_recorder(
max_reviews: u32,
) -> (
crate::runtime::config::AgentLoopConfig,
Arc<std::sync::Mutex<Vec<String>>>,
) {
let prompts = Arc::new(std::sync::Mutex::new(Vec::new()));
let spawner: Arc<dyn GuardianSpawner> = Arc::new(RecordingGuardianSpawner {
child_id: "guardian-child".to_string(),
prompts: prompts.clone(),
});
let config = crate::runtime::config::AgentLoopConfig {
gold_config: Some(crate::runtime::config::GoldConfig {
enabled: true,
auto_continue_enabled: true,
goal: Some("finish the task".to_string()),
max_auto_continuations: 3,
..crate::runtime::config::GoldConfig::default()
}),
guardian_config: Some(GuardianConfig {
enabled: true,
model_name: Some("guardian-test-model".to_string()),
max_reviews,
}),
guardian_spawner: Some(spawner),
..crate::runtime::config::AgentLoopConfig::default()
};
(config, prompts)
}
const GUARDIAN_FINAL_MESSAGE_HEADER: &str = "## Agent's final message";
#[test]
fn guardian_review_prompt_includes_final_assistant_content() {
let config = AgentLoopConfig::default();
let prompt = build_guardian_review_prompt(
&None,
&config,
Some("Final handoff: shipped the fix and ran the tests."),
);
assert!(prompt.contains(GUARDIAN_FINAL_MESSAGE_HEADER));
assert!(prompt.contains("Final handoff: shipped the fix and ran the tests."));
}
#[test]
fn guardian_review_prompt_omits_section_when_content_is_none() {
let config = AgentLoopConfig::default();
let prompt = build_guardian_review_prompt(&None, &config, None);
assert!(!prompt.contains(GUARDIAN_FINAL_MESSAGE_HEADER));
}
#[test]
fn guardian_review_prompt_omits_section_when_content_is_blank() {
let config = AgentLoopConfig::default();
let prompt = build_guardian_review_prompt(&None, &config, Some(" \n\t "));
assert!(!prompt.contains(GUARDIAN_FINAL_MESSAGE_HEADER));
}
#[tokio::test]
async fn guardian_only_review_context_includes_final_content_without_persisting_it() {
let mut session = Session::new("s400-guardian-only", "model");
let (config, prompts) = guardian_only_config_with_recorder(2);
let mut runtime_state = AgentRuntimeState::new("s400-guardian-only".to_string());
let (tx, _rx) = tokio::sync::mpsc::channel(16);
let final_text = "Final handoff: implemented the feature and verified with cargo test.";
let outcome = super::handle_no_tool_calls(
final_text.to_string(),
None,
None,
5,
5,
round_usage(),
&mut session,
&mut runtime_state,
&tx,
None,
"round-1",
"s400-guardian-only",
&config,
&None,
"model",
1,
Arc::new(StubProvider),
)
.await;
assert!(outcome.should_break);
assert!(!outcome.sent_complete);
assert!(runtime_state.waiting_for_children.is_some());
assert!(
session.messages.is_empty(),
"the deferred final message must not be persisted into the transcript \
at the guardian suspend point, got {:?}",
session.messages
);
let recorded = prompts.lock().unwrap();
assert_eq!(recorded.len(), 1, "exactly one review was spawned");
assert!(
recorded[0].contains(GUARDIAN_FINAL_MESSAGE_HEADER),
"guardian review prompt must include the final-message section:\n{}",
recorded[0]
);
assert!(
recorded[0].contains(final_text),
"guardian review prompt must include the agent's actual final content:\n{}",
recorded[0]
);
}
#[tokio::test]
async fn goal_loop_active_final_content_not_duplicated_in_guardian_prompt() {
let mut session = Session::new("s400-goal-loop", "model");
let (config, prompts) = guardian_and_gold_config_with_recorder(2);
let mut goal = ensure_goal_state(&session, "finish the task");
goal.declare(GoalDeclaredStatus::Complete, 1);
write_goal_state(&mut session, goal);
let mut runtime_state = AgentRuntimeState::new("s400-goal-loop".to_string());
let (tx, _rx) = tokio::sync::mpsc::channel(16);
let final_text = "Done — shipped and verified.";
let outcome = super::handle_no_tool_calls(
final_text.to_string(),
None,
None,
5,
5,
round_usage(),
&mut session,
&mut runtime_state,
&tx,
None,
"round-1",
"s400-goal-loop",
&config,
&None,
"model",
1,
Arc::new(ScriptedGoldProvider {
decision: "achieved",
confidence: "high",
}),
)
.await;
assert!(outcome.should_break);
assert!(!outcome.sent_complete);
assert!(runtime_state.waiting_for_children.is_some());
assert!(
session
.messages
.iter()
.any(|message| message.content == final_text),
"goal-loop path must add the final assistant message to the transcript"
);
let recorded = prompts.lock().unwrap();
assert_eq!(recorded.len(), 1, "exactly one review was spawned");
assert!(
!recorded[0].contains(GUARDIAN_FINAL_MESSAGE_HEADER),
"goal-loop case must not duplicate the final message into the guardian prompt \
(it is already in the forked transcript):\n{}",
recorded[0]
);
}
#[tokio::test]
async fn guardian_only_blank_final_content_adds_no_stray_context_block() {
let mut session = Session::new("s400-blank", "model");
let (config, prompts) = guardian_only_config_with_recorder(2);
let mut runtime_state = AgentRuntimeState::new("s400-blank".to_string());
let (tx, _rx) = tokio::sync::mpsc::channel(16);
let outcome = super::handle_no_tool_calls(
" \n ".to_string(),
None,
None,
5,
5,
round_usage(),
&mut session,
&mut runtime_state,
&tx,
None,
"round-1",
"s400-blank",
&config,
&None,
"model",
1,
Arc::new(StubProvider),
)
.await;
assert!(outcome.should_break);
assert!(!outcome.sent_complete);
assert!(session.messages.is_empty());
let recorded = prompts.lock().unwrap();
assert_eq!(recorded.len(), 1);
assert!(
!recorded[0].contains(GUARDIAN_FINAL_MESSAGE_HEADER),
"blank final content must not add a stray context block:\n{}",
recorded[0]
);
}
}