use std::path::PathBuf;
use bamboo_agent_core::Session;
use bamboo_config::LifecycleHooksConfig;
use bamboo_domain::{AgentHookPoint, AgentRuntimeState, HookPayload, HookResult};
use bamboo_engine::HookRunner;
const USER_PROMPT_CONTEXT_START: &str = "<user_prompt_submit_context>";
const USER_PROMPT_CONTEXT_END: &str = "</user_prompt_submit_context>";
pub(crate) async fn apply_user_prompt_submit_hooks(
config: &LifecycleHooksConfig,
fallback_cwd: Option<PathBuf>,
session: &mut Session,
raw_prompt: &str,
) -> Result<String, String> {
let runner = HookRunner::new().with_lifecycle_config(config, fallback_cwd);
let mut runtime_state = session
.agent_runtime_state
.clone()
.unwrap_or_else(|| AgentRuntimeState::new(&session.id));
runtime_state.checkpoints.clear();
runtime_state.hook_contexts.clear();
runtime_state.stop_hook_forced_continuations = 0;
if !runner.has_hooks_for(AgentHookPoint::BeforeSessionSetup) {
session.agent_runtime_state = Some(runtime_state);
return Ok(raw_prompt.to_string());
}
let outcome = runner
.run_hooks(
AgentHookPoint::BeforeSessionSetup,
&HookPayload::Prompt {
prompt: raw_prompt.to_string(),
},
session,
&mut runtime_state,
None,
)
.await;
session.agent_runtime_state = Some(runtime_state);
let blocked_reason = match outcome.decision {
HookResult::Deny { reason } | HookResult::Abort { reason } => Some(reason),
HookResult::Suspend { reason } => Some(format!("hook suspended prompt submission: {reason}")),
HookResult::Ask => Some(
"UserPromptSubmit hook requested parent-agent review, but a user prompt has no owning parent agent"
.to_string(),
),
HookResult::Continue
| HookResult::Mutated
| HookResult::Allow
| HookResult::InjectContext { .. } => None,
HookResult::WithContext { .. } => unreachable!("hook runner unwraps context results"),
};
if let Some(reason) = blocked_reason {
return Err(reason);
}
let contexts = outcome
.injected_contexts
.into_iter()
.map(|context| context.trim().to_string())
.filter(|context| !context.is_empty())
.collect::<Vec<_>>();
if contexts.is_empty() {
return Ok(raw_prompt.to_string());
}
Ok(format!(
"{raw_prompt}\n\n{USER_PROMPT_CONTEXT_START}\n{}\n{USER_PROMPT_CONTEXT_END}",
contexts.join("\n\n---\n\n")
))
}
#[cfg(test)]
mod tests {
use super::*;
use bamboo_config::{
LifecycleHookCommand, LifecycleHookGroup, LifecycleHookType,
DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS,
};
fn config(command: &str) -> LifecycleHooksConfig {
LifecycleHooksConfig {
enabled: true,
user_prompt_submit: vec![LifecycleHookGroup {
enabled: true,
matcher: None,
hooks: vec![LifecycleHookCommand {
hook_type: LifecycleHookType::Command,
command: command.to_string(),
timeout_ms: DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS,
}],
}],
..Default::default()
}
}
#[tokio::test]
async fn prompt_block_is_fail_closed_and_never_appends_a_message() {
let mut session = Session::new("blocked-prompt", "model");
let result = apply_user_prompt_submit_hooks(
&config("payload=$(cat); case \"$payload\" in *'\"prompt\":\"raw prompt\"'*) printf 'policy says no' >&2; exit 2 ;; *) exit 1 ;; esac"),
None,
&mut session,
"raw prompt",
)
.await;
assert_eq!(result, Err("policy says no".to_string()));
assert!(session.messages.is_empty());
assert_eq!(
session
.agent_runtime_state
.as_ref()
.map(|state| state.checkpoints.len()),
Some(1)
);
}
#[tokio::test]
async fn prompt_context_is_delimited_and_raw_prompt_is_unchanged_on_stdin() {
let mut session = Session::new("context-prompt", "model");
let prompt = apply_user_prompt_submit_hooks(
&config(
"payload=$(cat); case \"$payload\" in *'\"prompt\":\"raw prompt\"'*) printf '%s' '{\"additional_context\":\"workspace policy\"}' ;; *) exit 2 ;; esac",
),
None,
&mut session,
"raw prompt",
)
.await
.expect("context hook should pass");
assert_eq!(
prompt,
"raw prompt\n\n<user_prompt_submit_context>\nworkspace policy\n</user_prompt_submit_context>"
);
assert!(session.messages.is_empty());
}
#[tokio::test]
async fn prompt_ask_has_no_manual_fallback_and_fails_closed_without_parent() {
let mut session = Session::new("ask-prompt", "model");
let result = apply_user_prompt_submit_hooks(
&config("printf '%s' '{\"decision\":\"ask\"}'"),
None,
&mut session,
"raw prompt",
)
.await;
assert!(result.is_err_and(|reason| reason.contains("no owning parent agent")));
assert!(session.messages.is_empty());
}
}