af-agent-runtime 0.5.0

Recoverable Turn/Step loop, tool pipeline, retry and context compaction.
Documentation
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}"));
    }
}