use af_agent_session::{
ContentBlock, Event, InteractionResolution, SessionEvent, ToolAuthorizationStatus,
};
use af_llm::{ChatMessage, Role};
use serde_json::Value;
use crate::PlannedCall;
pub(super) fn content_text(content: &[ContentBlock]) -> String {
content
.iter()
.map(|block| match block {
ContentBlock::Text { text } => text.clone(),
ContentBlock::Resource {
resource_id,
media_type,
} => format!("[resource id={resource_id} media_type={media_type}]"),
ContentBlock::Data { slot, value } => format!("[data slot={slot}] {value}"),
ContentBlock::Citation {
resource_id,
label,
uri,
excerpt,
} => format!(
"[citation id={resource_id} label={label} uri={uri}] {}",
excerpt.as_deref().unwrap_or_default()
),
})
.collect::<Vec<_>>()
.join("\n")
}
pub(super) fn tool_message(call: &PlannedCall, value: Value) -> ChatMessage {
ChatMessage {
role: Role::Tool,
content: Some(value.to_string()),
tool_calls: None,
tool_call_id: Some(call.transcript_id.clone()),
name: Some(af_agent::model_tool_name(&call.name)),
}
}
pub(super) fn interaction_resolution_for_call(
events: &[SessionEvent],
run_id: &str,
call_id: &str,
source_event_seq: u64,
) -> Option<InteractionResolution> {
events.iter().rev().find_map(|event| match &event.event {
Event::InteractionResolved {
run_id: event_run_id,
interaction_id,
resolution,
..
} if event_run_id == run_id => {
let request = events.iter().find_map(|candidate| match &candidate.event {
Event::InteractionRequested {
run_id: request_run_id,
interaction_id: request_id,
payload,
..
} if request_run_id == run_id && request_id == interaction_id => Some(payload),
_ => None,
})?;
(request.get("call_id").and_then(Value::as_str) == Some(call_id)
&& request.get("source_event_seq").and_then(Value::as_u64)
== Some(source_event_seq))
.then_some(*resolution)
}
_ => None,
})
}
pub(super) fn tool_authorization_for_call(
events: &[SessionEvent],
run_id: &str,
call_id: &str,
) -> Option<ToolAuthorizationStatus> {
events.iter().rev().find_map(|event| match &event.event {
Event::ToolAuthorization {
run_id: event_run_id,
call_id: event_call_id,
status,
..
} if event_run_id == run_id && event_call_id == call_id => Some(*status),
_ => None,
})
}
pub(super) fn transcript_from_events(events: &[SessionEvent]) -> Vec<ChatMessage> {
let mut messages = Vec::new();
for envelope in events {
match &envelope.event {
Event::SummaryReplaced { summary, .. } => {
messages.clear();
messages.push(ChatMessage::system(format!(
"Conversation summary:\n{summary}"
)));
}
Event::UserMessage { content, .. } => {
messages.push(ChatMessage::user(content_text(content)))
}
Event::AssistantMessage { content, .. } => {
messages.push(ChatMessage::assistant(content_text(content)))
}
Event::AssistantToolCalls { content, calls, .. } => messages.push(ChatMessage {
role: Role::Assistant,
content: content.clone(),
tool_calls: Some(
calls
.iter()
.map(|call| af_llm::ToolCall {
id: call.call_id.clone(),
kind: "function".into(),
function: af_llm::FunctionCall {
name: af_agent::model_tool_name(&call.tool),
arguments: call.arguments.to_string(),
},
})
.collect(),
),
tool_call_id: None,
name: None,
}),
Event::ToolResult {
call_id, result, ..
} => messages.push(ChatMessage {
role: Role::Tool,
content: Some(result.to_string()),
tool_calls: None,
tool_call_id: Some(call_id.clone()),
name: None,
}),
Event::ToolResultsPruned { call_ids, .. } => {
for message in messages.iter_mut().filter(|message| {
message.role == Role::Tool
&& message
.tool_call_id
.as_ref()
.is_some_and(|id| call_ids.contains(id))
}) {
let content = message.content.as_deref().unwrap_or_default();
message.content = Some(format!(
"{}… [tool result pruned]",
content.chars().take(512).collect::<String>()
));
}
}
Event::InteractionResolved {
resolution: InteractionResolution::Answered,
payload,
..
} => messages.push(ChatMessage::user(format!(
"User answered the pending question: {payload}"
))),
_ => {}
}
}
messages
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use serde_json::json;
fn event(seq: u64, event: Event) -> SessionEvent {
SessionEvent {
session_id: "session".parse().unwrap(),
seq,
occurred_at: Utc::now(),
event,
}
}
#[test]
fn every_content_block_renders_to_prompt_text() {
let rendered = content_text(&[
ContentBlock::Text {
text: "hello".into(),
},
ContentBlock::Resource {
resource_id: "res-1".into(),
media_type: "image/png".into(),
},
ContentBlock::Data {
slot: "chart".into(),
value: json!({"points": 3}),
},
ContentBlock::Citation {
resource_id: "doc-1".into(),
label: "Guide".into(),
uri: "docs://guide".into(),
excerpt: Some("quoted".into()),
},
ContentBlock::Citation {
resource_id: "doc-2".into(),
label: "Bare".into(),
uri: "docs://bare".into(),
excerpt: None,
},
]);
assert_eq!(
rendered,
"hello\n[resource id=res-1 media_type=image/png]\n[data slot=chart] {\"points\":3}\n[citation id=doc-1 label=Guide uri=docs://guide] quoted\n[citation id=doc-2 label=Bare uri=docs://bare] "
);
}
#[test]
fn summary_replaces_history_and_pruned_tool_results_are_truncated_in_place() {
let long = "x".repeat(600);
let history = vec![
event(
1,
Event::UserMessage {
run_id: "run".parse().unwrap(),
content: vec![ContentBlock::Text {
text: "forgotten".into(),
}],
},
),
event(
2,
Event::SummaryReplaced {
run_id: "run".parse().unwrap(),
through_seq: 1,
summary: "the gist".into(),
compactor: "test".into(),
model: "m".into(),
},
),
event(
3,
Event::ToolResult {
run_id: "run".parse().unwrap(),
step: 1,
call_id: "call-long".parse().unwrap(),
result: json!(long),
is_error: false,
},
),
event(
4,
Event::ToolResult {
run_id: "run".parse().unwrap(),
step: 1,
call_id: "call-kept".parse().unwrap(),
result: json!("kept"),
is_error: false,
},
),
event(
5,
Event::ToolResultsPruned {
run_id: "run".parse().unwrap(),
call_ids: vec!["call-long".parse().unwrap()],
},
),
event(
6,
Event::InteractionResolved {
run_id: "run".parse().unwrap(),
interaction_id: "q".parse().unwrap(),
resolution: InteractionResolution::Answered,
payload: json!({"answer": 42}),
},
),
];
let transcript = transcript_from_events(&history);
assert_eq!(transcript.len(), 4);
assert_eq!(transcript[0].role, Role::System);
assert!(transcript[0]
.content
.as_deref()
.unwrap()
.contains("the gist"));
let pruned = transcript[1].content.as_deref().unwrap();
assert!(pruned.ends_with("… [tool result pruned]"));
assert_eq!(
pruned.chars().count(),
512 + "… [tool result pruned]".chars().count()
);
assert_eq!(transcript[2].content.as_deref(), Some("\"kept\""));
assert_eq!(transcript[3].role, Role::User);
assert!(transcript[3]
.content
.as_deref()
.unwrap()
.contains("{\"answer\":42}"));
}
}