use std::sync::Arc;
use parking_lot::Mutex;
use theway_core::agent::session::session::Session;
use theway_core::types::AgentMessage;
use theway_core::{Agent, AgentRunError, AgentState};
use theway_llm_provider::Message as PiMessage;
use crate::trigger_engine::event::{TriggerEvent, TriggerListener};
use crate::trigger_engine::types::{Trigger, TriggerSource};
use super::types::PromoteAction;
use super::utils::emit_from_listeners;
fn build_template_context(
trace_id: &str,
trigger: &Trigger,
success: bool,
summary: &Option<String>,
message_count: usize,
) -> std::collections::HashMap<String, String> {
use std::collections::HashMap;
let mut ctx: HashMap<String, String> = HashMap::new();
ctx.insert("trace_id".into(), trace_id.to_string());
let (source_kind_str, source_server, source_method, source_subkind) = match &trigger.source {
TriggerSource::Mcp {
server_name,
method,
} => (
"mcp".to_string(),
Some(server_name.clone()),
Some(method.clone()),
None,
),
TriggerSource::Local { subkind } => {
("local".to_string(), None, None, Some(subkind.clone()))
}
TriggerSource::AgentDelegate { .. } => ("agent_delegate".to_string(), None, None, None),
};
ctx.insert("trigger.source.kind".into(), source_kind_str);
if let Some(v) = source_server {
ctx.insert("trigger.source.server_name".into(), v);
}
if let Some(v) = source_method {
ctx.insert("trigger.source.method".into(), v);
}
if let Some(v) = source_subkind {
ctx.insert("trigger.source.subkind".into(), v);
}
ctx.insert("trigger.source_label".into(), trigger.source_label.clone());
ctx.insert("trigger.event_label".into(), trigger.event_label.clone());
if let Some(s) = &trigger.payload_summary {
ctx.insert("trigger.payload_summary".into(), s.clone());
} else {
ctx.insert("trigger.payload_summary".into(), String::new());
}
ctx.insert(
"trigger.received_at".into(),
trigger.received_at.to_rfc3339(),
);
ctx.insert(
"trigger.idempotency_key".into(),
trigger.idempotency_key.clone(),
);
ctx.insert(
"trigger.authority.principal_id".into(),
trigger.authority.principal_id.clone(),
);
ctx.insert(
"trigger.authority.principal_label".into(),
trigger.authority.principal_label.clone(),
);
ctx.insert(
"trigger.authority.credential_scope".into(),
format!("{:?}", trigger.authority.credential_scope),
);
ctx.insert("result.summary".into(), summary.clone().unwrap_or_default());
ctx.insert(
"result.status".into(),
if success { "success" } else { "failed" }.into(),
);
ctx.insert("result.message_count".into(), message_count.to_string());
ctx.insert("result.cost_usd".into(), "null".into());
ctx.insert("result.branch_id".into(), "null".into());
ctx
}
const FORBIDDEN_TEMPLATE_FIELDS: &[&str] = &[
"trigger.payload",
"trigger.authority.allowed_source_actions",
];
#[derive(Debug, PartialEq, Eq)]
enum TemplateRenderError {
UnknownField(String),
ForbiddenField(String),
}
fn render_promotion_template(
body: &str,
ctx: &std::collections::HashMap<String, String>,
) -> Result<String, TemplateRenderError> {
let mut out = String::with_capacity(body.len());
let mut rest = body;
while let Some(open) = rest.find("{{") {
out.push_str(&rest[..open]);
let after_open = &rest[open + 2..];
let close = after_open.find("}}").ok_or_else(|| {
TemplateRenderError::UnknownField("unclosed `{{` placeholder".to_string())
})?;
let raw_name = &after_open[..close];
let name = raw_name.trim();
if FORBIDDEN_TEMPLATE_FIELDS.contains(&name) || name.starts_with("_meta") {
return Err(TemplateRenderError::ForbiddenField(name.to_string()));
}
let value = ctx
.get(name)
.ok_or_else(|| TemplateRenderError::UnknownField(name.to_string()))?;
out.push_str(value);
rest = &after_open[close + 2..];
}
out.push_str(rest);
Ok(out)
}
const DEFAULT_PROMOTE_SUMMARY_TEMPLATE: &str = "[Trigger {{trace_id}}] {{trigger.source_label}} fired {{trigger.event_label}}.\nResult: {{result.summary}}";
pub(super) const PROMOTION_BODY_CAP_BYTES: usize = 4096;
pub(super) fn sha256_hex(input: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
let out = hasher.finalize();
let mut s = String::with_capacity(out.len() * 2);
for byte in out.iter() {
use std::fmt::Write;
let _ = write!(&mut s, "{byte:02x}");
}
s
}
pub(super) fn ensure_trigger_prefix(body: String, trace_id: &str) -> (String, bool) {
let expected = format!("[Trigger {trace_id}] ");
if body.starts_with(&expected) {
(body, false)
} else {
(format!("{expected}{body}"), true)
}
}
const TRUNCATION_MARKER: &str = "…[truncated]";
pub(super) fn truncate_on_char_boundary(body: String, cap_bytes: usize) -> (String, bool) {
if body.len() <= cap_bytes {
return (body, false);
}
let budget = cap_bytes.saturating_sub(TRUNCATION_MARKER.len());
let mut cut = budget.min(body.len());
while cut > 0 && !body.is_char_boundary(cut) {
cut -= 1;
}
let mut truncated = body;
truncated.truncate(cut);
truncated.push_str(TRUNCATION_MARKER);
(truncated, true)
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn apply_promotion(
listeners: &Arc<Mutex<Vec<TriggerListener>>>,
parent_session: &Session,
parent_agent: &Arc<Agent>,
trace_id: &str,
trigger: &Trigger,
success: bool,
summary: &Option<String>,
message_count: usize,
_failure_reason: Option<&str>,
promote: &PromoteAction,
require_approval: bool,
details: &serde_json::Value,
) {
let (template_body_arg, promote_kind): (Option<String>, &'static str) = match promote {
PromoteAction::None => return, PromoteAction::PromoteSummaryNow { template_body } => {
(template_body.clone(), "promote_summary_now")
}
PromoteAction::PromoteSummaryWhenResultDetailsMatch {
template_body,
condition,
} => {
match condition.evaluate(details) {
Ok(_matched) => (
template_body.clone(),
"promote_summary_when_result_details_match",
),
Err(reason) => {
let audit_data = serde_json::json!({
"state": "skipped",
"trace_id": trace_id,
"promote_kind": "promote_summary_when_result_details_match",
"reason": reason.as_audit_str(),
"template_name": serde_json::Value::Null,
"template_hash": serde_json::Value::Null,
"inserted_entry_id": serde_json::Value::Null,
"rule_id": serde_json::Value::Null,
"redaction_status": "skipped",
"dedup_collapsed": false,
"prefix_injected": false,
});
let _ = parent_session
.append_custom("trigger_promotion", Some(audit_data))
.await;
return;
}
}
}
};
let ctx = build_template_context(trace_id, trigger, success, summary, message_count);
let body_template: &str = template_body_arg
.as_deref()
.unwrap_or(DEFAULT_PROMOTE_SUMMARY_TEMPLATE);
let template_hash = sha256_hex(body_template);
let template_name = match &template_body_arg {
None => "default".to_string(),
Some(_) => format!("inline:{}", &template_hash[..8]),
};
let template_name = Some(template_name);
let template_hash = Some(template_hash);
let rendered = match render_promotion_template(body_template, &ctx) {
Ok(s) => s,
Err(err) => {
let redaction_status = match &err {
TemplateRenderError::UnknownField(_) => "render_error",
TemplateRenderError::ForbiddenField(_) => "forbidden_field",
};
let err_msg = match &err {
TemplateRenderError::UnknownField(name) => {
format!("unknown template field: {name}")
}
TemplateRenderError::ForbiddenField(name) => {
format!("forbidden template field: {name}")
}
};
let audit_data = serde_json::json!({
"state": "failed",
"trace_id": trace_id,
"promote_kind": promote_kind,
"template_name": template_name,
"template_hash": template_hash,
"inserted_entry_id": serde_json::Value::Null,
"rule_id": serde_json::Value::Null,
"redaction_status": redaction_status,
"dedup_collapsed": false,
"prefix_injected": false,
});
if let Err(e) = parent_session
.append_custom("trigger_promotion", Some(audit_data))
.await
{
emit_from_listeners(
listeners,
TriggerEvent::PersistenceError {
context: "trigger_promotion".into(),
message: format!("trigger_promotion (failed) append failed: {:?}", e.code),
},
);
}
emit_from_listeners(
listeners,
TriggerEvent::PersistenceError {
context: "trigger_promotion".into(),
message: err_msg,
},
);
return;
}
};
let (rendered, prefix_injected) = ensure_trigger_prefix(rendered, trace_id);
if require_approval {
let (preview, truncated) =
truncate_on_char_boundary(rendered.clone(), PROMOTION_BODY_CAP_BYTES);
let redaction_status = if truncated { "truncated" } else { "clean" };
let audit_data = serde_json::json!({
"state": "pending",
"trace_id": trace_id,
"promote_kind": promote_kind,
"template_name": template_name,
"template_hash": template_hash,
"inserted_entry_id": serde_json::Value::Null,
"rule_id": serde_json::Value::Null,
"redaction_status": redaction_status,
"dedup_collapsed": false,
"prefix_injected": prefix_injected,
});
if let Err(e) = parent_session
.append_custom("trigger_promotion", Some(audit_data))
.await
{
emit_from_listeners(
listeners,
TriggerEvent::PersistenceError {
context: "trigger_promotion".into(),
message: format!("trigger_promotion (pending) append failed: {:?}", e.code),
},
);
}
emit_from_listeners(
listeners,
TriggerEvent::PromotionPending {
trace_id: trace_id.to_string(),
promote_kind: promote_kind.into(),
template_name,
preview: Some(preview),
},
);
return;
}
let (final_body, truncated) = truncate_on_char_boundary(rendered, PROMOTION_BODY_CAP_BYTES);
let redaction_status = if truncated { "truncated" } else { "clean" };
let user_message = AgentMessage::Llm(PiMessage::User(theway_llm_provider::UserMessage {
role: theway_llm_provider::UserRole::User,
content: theway_llm_provider::UserContent::Text(final_body),
timestamp: chrono::Utc::now().timestamp_millis(),
}));
let queued_for_followup = parent_agent.is_streaming();
let (audit_state, inserted_entry_id_value, inserted_entry_id_str) = if queued_for_followup {
parent_agent.enqueue_follow_up(user_message);
(
"queued",
serde_json::Value::Null,
String::new(), )
} else {
let id = match parent_session.append_message(user_message.clone()).await {
Ok(id) => id,
Err(e) => {
emit_from_listeners(
listeners,
TriggerEvent::PersistenceError {
context: "trigger_promotion".into(),
message: format!("promotion message append failed: {:?}", e.code),
},
);
let audit_data = serde_json::json!({
"state": "failed",
"trace_id": trace_id,
"promote_kind": promote_kind,
"template_name": template_name,
"template_hash": template_hash,
"inserted_entry_id": serde_json::Value::Null,
"rule_id": serde_json::Value::Null,
"redaction_status": "render_error",
"dedup_collapsed": false,
"prefix_injected": prefix_injected,
});
let _ = parent_session
.append_custom("trigger_promotion", Some(audit_data))
.await;
return;
}
};
parent_agent.state().messages.push(user_message);
("success", serde_json::Value::String(id.clone()), id)
};
let audit_data = serde_json::json!({
"state": audit_state,
"trace_id": trace_id,
"promote_kind": promote_kind,
"template_name": template_name,
"template_hash": template_hash,
"inserted_entry_id": inserted_entry_id_value,
"rule_id": serde_json::Value::Null,
"redaction_status": redaction_status,
"dedup_collapsed": false,
"prefix_injected": prefix_injected,
});
if let Err(e) = parent_session
.append_custom("trigger_promotion", Some(audit_data))
.await
{
emit_from_listeners(
listeners,
TriggerEvent::PersistenceError {
context: "trigger_promotion".into(),
message: format!(
"trigger_promotion ({audit_state}) append failed: {:?}",
e.code
),
},
);
}
emit_from_listeners(
listeners,
TriggerEvent::TriggerPromoted {
trace_id: trace_id.to_string(),
promote_kind: promote_kind.into(),
inserted_entry_id: inserted_entry_id_str,
template_name,
redaction_status: redaction_status.into(),
},
);
}
pub(super) fn compute_sub_agent_outcome(
sub_agent: &Agent,
run_outcome: &Result<(), AgentRunError>,
) -> (bool, Option<String>, usize) {
if let Err(_e) = run_outcome {
let state = sub_agent.state();
let last = last_assistant_text(&state);
return (false, last, state.messages.len());
}
let state = sub_agent.state();
let summary = last_assistant_text(&state);
(true, summary, state.messages.len())
}
fn last_assistant_text(state: &AgentState) -> Option<String> {
let last = state.messages.iter().rev().find_map(|m| match m {
AgentMessage::Llm(theway_llm_provider::Message::Assistant(a)) => Some(a),
_ => None,
})?;
let mut text = String::new();
for block in &last.content {
if let theway_llm_provider::ContentBlock::Text(t) = block {
if !text.is_empty() {
text.push('\n');
}
text.push_str(&t.text);
}
}
if text.is_empty() {
return None;
}
const SUMMARY_CAP_BYTES: usize = 4096;
let (capped, _truncated) = truncate_on_char_boundary(text, SUMMARY_CAP_BYTES);
Some(capped)
}
#[cfg(test)]
tests_bridge_macro::tests_bridge!("trigger_engine/execution/promotion");