use super::*;
pub(in crate::agent) fn emit_hook_lifecycle_records(
session_persistence: &mut SessionPersistence<'_>,
output_sink: &mut Option<&mut dyn AgentOutputSink>,
records: &[HookLifecycleRecord],
) -> anyhow::Result<()> {
for record in records {
session_persistence.try_record(
SessionEventKind::HookLifecycle,
record.to_session_payload(),
output_sink,
)?;
}
Ok(())
}
pub(in crate::agent) fn emit_hook_context_injection_records(
session_persistence: &mut SessionPersistence<'_>,
output_sink: &mut Option<&mut dyn AgentOutputSink>,
records: &[HookContextInjectionRecord],
) -> anyhow::Result<()> {
for record in records {
session_persistence.try_record(
SessionEventKind::HookContextInjection,
record.to_session_payload(),
output_sink,
)?;
}
Ok(())
}
pub(in crate::agent) fn emit_provider_context_injection_display(
output_sink: &mut Option<&mut dyn AgentOutputSink>,
records: &[HookContextInjectionRecord],
context_items: &[ProviderConversationItem],
settings: &InjectedContentSettings,
parent_activity_id: Option<&ActivityId>,
activity_sender: Option<&ActivitySender>,
) -> anyhow::Result<()> {
if !settings.show_in_transcript && !settings.show_in_activity_tree {
return Ok(());
}
let mut provider_messages = context_items.iter().filter_map(|item| match item {
ProviderConversationItem::Message(message) => Some(message.content.as_str()),
_ => None,
});
for record in records {
let content = if settings.style == InjectedContentStyle::Content
&& record.status == HookContextInjectionStatus::Success
{
let joined = provider_messages
.by_ref()
.take(record.item_count)
.collect::<Vec<_>>()
.join("\n");
(!joined.is_empty()).then(|| bounded_injected_content(&joined))
} else {
None
};
let display = provider_context_injection_display(record, content);
if settings.show_in_transcript
&& let Some(sink) = output_sink.as_deref_mut()
{
sink.output_event(OutputEvent::ProviderContextInjection {
metadata: display.clone(),
})?;
}
if settings.show_in_activity_tree
&& let Some(sender) = activity_sender
{
emit_provider_context_injection_activity(sender, parent_activity_id, record, &display);
}
}
Ok(())
}
fn provider_context_injection_display(
record: &HookContextInjectionRecord,
content: Option<String>,
) -> ProviderContextInjectionDisplay {
ProviderContextInjectionDisplay::new(
record.phase,
record.label.clone(),
record.target_tool.clone(),
record.status.as_str(),
record.item_count,
record.byte_count,
content,
)
}
fn emit_provider_context_injection_activity(
sender: &ActivitySender,
parent_activity_id: Option<&ActivityId>,
record: &HookContextInjectionRecord,
display: &ProviderContextInjectionDisplay,
) {
let id = provider_context_injection_activity_id(parent_activity_id, record);
let metadata = provider_context_injection_activity_metadata(display);
let status = if record.status == HookContextInjectionStatus::Success {
ActivityStatus::Success
} else {
ActivityStatus::Failed
};
sender(ActivityEvent::Started {
id: id.clone(),
parent_id: parent_activity_id.cloned(),
kind: ActivityKind::ProviderContextInjection,
status: ActivityStatus::Running,
metadata: metadata.clone(),
});
sender(ActivityEvent::Finished {
id,
status,
metadata: Some(metadata),
});
}
fn provider_context_injection_activity_id(
parent_activity_id: Option<&ActivityId>,
record: &HookContextInjectionRecord,
) -> ActivityId {
let phase = record.phase.as_str();
if let Some(parent_activity_id) = parent_activity_id {
parent_activity_id.child(format!(
"{phase}-provider-context-injection-{}",
record.hook_index
))
} else {
ActivityId::new(format!(
"provider-context-injection/{}/{}/{}",
record.target_tool, phase, record.hook_index
))
}
}
fn provider_context_injection_activity_metadata(
display: &ProviderContextInjectionDisplay,
) -> ActivityMetadata {
let label = format!("provider context injection {}", display.label);
ActivityMetadata {
label,
detail: Some(format!(
"provider context injection '{}' for {} {}: {} {} {} bytes",
display.label,
display.target,
display.phase.as_str(),
display.status,
item_count_text(display.item_count),
display.byte_count
)),
fields: vec![
("phase".to_string(), display.phase.as_str().to_string()),
("hook_label".to_string(), display.label.clone()),
("target".to_string(), display.target.clone()),
("status".to_string(), display.status.clone()),
("item_count".to_string(), display.item_count.to_string()),
("bytes".to_string(), display.byte_count.to_string()),
],
}
}
fn item_count_text(count: usize) -> String {
if count == 1 {
"1 item".to_string()
} else {
format!("{count} items")
}
}
fn bounded_injected_content(text: &str) -> String {
const MAX_INJECTED_CONTENT_CHARS: usize = 2000;
let redacted = crate::output::redact_sensitive_text(text);
let mut chars = redacted.chars();
let truncated = chars
.by_ref()
.take(MAX_INJECTED_CONTENT_CHARS)
.collect::<String>();
if chars.next().is_some() {
format!("{truncated}…")
} else {
truncated
}
}
pub(in crate::agent) fn emit_hook_diagnostics(
session_persistence: &mut SessionPersistence<'_>,
output_sink: &mut Option<&mut dyn AgentOutputSink>,
diagnostics: &[HookDiagnostic],
) -> anyhow::Result<()> {
for diagnostic in diagnostics {
session_persistence.try_record(
SessionEventKind::HookDiagnostic,
json!({
"phase": diagnostic.phase.as_str(),
"tool": diagnostic.tool_name,
"label": diagnostic.label,
"category": diagnostic.category.as_str(),
"policy": diagnostic.policy.as_str(),
"target_ran": diagnostic.target_ran,
"message": diagnostic.sanitized_message(),
}),
output_sink,
)?;
if let Some(sink) = output_sink.as_deref_mut() {
sink.output_event(OutputEvent::HookDiagnostic {
diagnostic: Box::new(diagnostic.clone()),
})?;
}
}
Ok(())
}
pub(in crate::agent) struct MessageHookOutcome {
pub(in crate::agent) context_items: Vec<ProviderConversationItem>,
pub(in crate::agent) failure: Option<HookDiagnostic>,
}
#[expect(clippy::too_many_arguments)]
pub(in crate::agent) fn run_message_phase_hooks(
hooks: Option<&HookRuntime>,
phase: HookPhase,
message_id: &str,
text: &str,
hook_context: &HookContextMetadata,
cancellation: &AgentCancellation,
session_persistence: &mut SessionPersistence<'_>,
output_sink: &mut Option<&mut dyn AgentOutputSink>,
) -> anyhow::Result<MessageHookOutcome> {
let Some(hooks) = hooks else {
return Ok(MessageHookOutcome {
context_items: Vec::new(),
failure: None,
});
};
let dispatch_context = ToolDispatchContext::new_with_hook_context_and_cancellation(
Some(ActivityId::new(message_id)),
output_sink
.as_deref()
.and_then(AgentOutputSink::activity_sender),
hook_context.clone(),
cancellation.clone(),
);
let outcome = match phase {
HookPhase::AfterAssistant => hooks.run_after_assistant(message_id, text, &dispatch_context),
HookPhase::AfterReasoning => hooks.run_after_reasoning(message_id, text, &dispatch_context),
_ => unreachable!("run_message_phase_hooks called with non-message phase"),
};
emit_hook_lifecycle_records(session_persistence, output_sink, &outcome.lifecycle_records)?;
emit_hook_context_injection_records(
session_persistence,
output_sink,
&outcome.context_injection_records,
)?;
emit_provider_context_injection_display(
output_sink,
&outcome.context_injection_records,
&outcome.context_items,
&hooks.injected_content_settings(),
dispatch_context.parent_activity_id.as_ref(),
dispatch_context.activity_sender.as_ref(),
)?;
let mut failure = None;
let mut diagnostics = outcome.diagnostics;
match outcome.action {
HookAction::Continue => {}
HookAction::Block(diagnostic) | HookAction::Fail(diagnostic) => {
diagnostics.push(diagnostic.clone());
failure = Some(diagnostic);
}
}
emit_hook_diagnostics(session_persistence, output_sink, &diagnostics)?;
if outcome.canceled {
return Err(AgentRunCanceled.into());
}
Ok(MessageHookOutcome {
context_items: outcome.context_items,
failure,
})
}