af-agent-runtime 0.9.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 user_message(content: &[ContentBlock]) -> Result<ChatMessage, crate::RuntimeError> {
    let mut message = ChatMessage::user(content_text(content));
    for block in content {
        if let ContentBlock::Resource {
            resource_id,
            media_type,
        } = block
        {
            if media_type.starts_with("image/") {
                let asset_id = resource_id.parse().map_err(|_| {
                    crate::RuntimeError::InvalidInput("image asset id is required".into())
                })?;
                let image = af_llm::InputImage {
                    asset_id,
                    media_type: media_type.clone(),
                };
                image
                    .validate()
                    .map_err(|e| crate::RuntimeError::InvalidInput(e.to_string()))?;
                message.images.push(image);
            }
        }
    }
    Ok(message)
}

pub(super) fn tool_message(call: &PlannedCall, value: Value) -> ChatMessage {
    ChatMessage {
        images: Vec::new(),
        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,
    })
}

// Compaction summarizes text, but the current input's stable image references
// still belong to the turn. Replay and live compaction must preserve them alike.
pub(super) fn replace_with_summary(messages: &mut Vec<ChatMessage>, summary: &str) {
    let images = messages
        .iter()
        .rfind(|message| message.role == Role::User)
        .map(|message| message.images.clone())
        .unwrap_or_default();
    messages.clear();
    messages.push(ChatMessage::system(format!(
        "Conversation summary:\n{summary}"
    )));
    if !images.is_empty() {
        let mut attached = ChatMessage::user("Images attached to the current input:");
        attached.images = images;
        messages.push(attached);
    }
}

#[cfg(test)]
pub(super) fn transcript_from_events(
    events: &[SessionEvent],
) -> Result<Vec<ChatMessage>, crate::RuntimeError> {
    let mut messages = Vec::new();
    for event in events {
        apply_transcript_event(&mut messages, event)?;
    }
    Ok(messages)
}

pub(super) fn apply_transcript_event(
    messages: &mut Vec<ChatMessage>,
    envelope: &SessionEvent,
) -> Result<(), crate::RuntimeError> {
    match &envelope.event {
        Event::SummaryReplaced { summary, .. } => {
            replace_with_summary(messages, summary);
        }
        Event::UserMessage { content, .. } => messages.push(user_message(content)?),
        Event::AssistantMessage { content, .. } => {
            messages.push(ChatMessage::assistant(content_text(content)))
        }
        Event::AssistantToolCalls { content, calls, .. } => messages.push(ChatMessage {
            images: Vec::new(),
            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 {
            images: Vec::new(),
            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}"
        ))),
        _ => {}
    }
    Ok(())
}

#[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 a5_compaction_replay_keeps_current_images_and_bounded_recent_history() {
        let history = (1..=12)
            .map(|seq| {
                event(
                    seq,
                    Event::UserMessage {
                        run_id: "run".parse().unwrap(),
                        content: vec![ContentBlock::Resource {
                            resource_id: format!("asset-{seq}"),
                            media_type: "image/png".into(),
                        }],
                    },
                )
            })
            .collect::<Vec<_>>();
        let mut live = transcript_from_events(&history).unwrap();
        af_llm::images::select_images(&mut live, 4).unwrap();
        assert_eq!(live.iter().flat_map(|m| &m.images).count(), 4);
        replace_with_summary(&mut live, "summary");
        assert_eq!(live.last().unwrap().images[0].asset_id.as_str(), "asset-12");
        let mut replay = history;
        replay.push(event(
            13,
            Event::SummaryReplaced {
                run_id: "run".parse().unwrap(),
                through_seq: 12,
                summary: "summary".into(),
                compactor: "test".into(),
                model: "vision".into(),
            },
        ));
        assert_eq!(
            serde_json::to_value(transcript_from_events(&replay).unwrap()).unwrap(),
            serde_json::to_value(live).unwrap()
        );
    }

    #[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).unwrap();
        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}"));
    }
}