use crate::agent::{
AgentEnvelope, AgentId, AgentKind, METADATA_ROLE, OPERATION_CHAT, OPERATION_PROGRESS,
OPERATION_REASONING, OPERATION_SESSION, OPERATION_SESSION_PARKED, OPERATION_SESSION_UNPARKED,
OPERATION_STATE_DELTA, OPERATION_STATE_SNAPSHOT, OPERATION_TASK, TaskState,
};
use crate::filter::{ConsumerFilter, FilterExpr};
use crate::headers::TARGET_AGENT_ID;
use crate::memory::MemoryRecord;
use crate::query::{CmpOp, Value};
use crate::schema::TypedValue;
use crate::topics::{AGENT_CONTROL, AGENT_HEARTBEATS};
pub use crate::headers::BROADCAST;
pub const OPERATION_SESSION_PAUSE: &str = "session_pause";
pub const OPERATION_SESSION_RESUME: &str = "session_resume";
pub const OPERATION_SESSION_CANCEL: &str = "session_cancel";
pub const OPERATION_FORCE_CANCEL: &str = "force_cancel";
pub const OPERATION_TEXT_COMPLETION: &str = "text_completion";
pub const OPERATION_GENERATE_CONTENT: &str = "generate_content";
pub const OPERATION_EXECUTE_TOOL: &str = "execute_tool";
pub const OPERATION_INVOKE_AGENT: &str = "invoke_agent";
pub const OPERATION_CONTEXT_ASSEMBLED: &str = "context_assembled";
pub const OPERATION_CONTEXT_COMPACTED: &str = "context_compacted";
pub const OPERATION_CONTEXT_RETRIEVED: &str = "context_retrieved";
pub const OPERATION_POLICY_DECISION: &str = "policy_decision";
pub const CONTROL_OPERATIONS: [&str; 4] = [
OPERATION_SESSION_PAUSE,
OPERATION_SESSION_RESUME,
OPERATION_SESSION_CANCEL,
OPERATION_FORCE_CANCEL,
];
#[derive(Clone, Copy, Debug)]
pub enum SessionRecord<'a> {
Envelope(&'a AgentEnvelope),
Memory(&'a MemoryRecord),
DeadLetter,
Journal,
KvMutation,
GraphMutation,
Undecodable(Option<AgentKind>),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, strum::IntoStaticStr, strum::EnumString)]
pub enum DisplayType {
#[strum(serialize = "session.submitted")]
SessionSubmitted,
#[strum(serialize = "session.started")]
SessionStarted,
#[strum(serialize = "session.paused")]
SessionPaused,
#[strum(serialize = "session.resumed")]
SessionResumed,
#[strum(serialize = "session.completed")]
SessionCompleted,
#[strum(serialize = "session.failed")]
SessionFailed,
#[strum(serialize = "session.canceled")]
SessionCanceled,
#[strum(serialize = "session.heartbeat")]
SessionHeartbeat,
#[strum(serialize = "session.parked")]
SessionParked,
#[strum(serialize = "session.unparked")]
SessionUnparked,
#[strum(serialize = "session.control")]
SessionControl,
#[strum(serialize = "user.message")]
UserMessage,
#[strum(serialize = "model.request")]
ModelRequest,
#[strum(serialize = "model.response")]
ModelResponse,
#[strum(serialize = "model.stream")]
ModelStream,
#[strum(serialize = "tool.call")]
ToolCall,
#[strum(serialize = "tool.result")]
ToolResult,
#[strum(serialize = "agent.handoff")]
AgentHandoff,
#[strum(serialize = "agent.message")]
AgentMessage,
#[strum(serialize = "state.updated")]
StateUpdated,
#[strum(serialize = "context.assembled")]
ContextAssembled,
#[strum(serialize = "context.compacted")]
ContextCompacted,
#[strum(serialize = "context.retrieved")]
ContextRetrieved,
#[strum(serialize = "memory.created")]
MemoryCreated,
#[strum(serialize = "memory.forgotten")]
MemoryForgotten,
#[strum(serialize = "memory.feedback")]
MemoryFeedback,
#[strum(serialize = "task.status")]
TaskStatus,
#[strum(serialize = "workflow.step")]
WorkflowStep,
#[strum(serialize = "policy.decision")]
PolicyDecision,
#[strum(serialize = "error")]
Error,
#[strum(serialize = "dead_letter")]
DeadLetter,
#[strum(serialize = "undecodable")]
Undecodable,
#[strum(serialize = "invalid")]
Invalid,
#[strum(serialize = "kv.set")]
KvSet,
#[strum(serialize = "graph.upsert")]
GraphUpsert,
#[strum(serialize = "unauthorized_control")]
UnauthorizedControl,
}
impl DisplayType {
pub fn as_str(self) -> &'static str {
self.into()
}
}
impl std::fmt::Display for DisplayType {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
pub fn display_type(record: SessionRecord<'_>, topic: &str) -> DisplayType {
let envelope = match record {
SessionRecord::Envelope(envelope) => envelope,
SessionRecord::Memory(MemoryRecord::Item { .. }) => return DisplayType::MemoryCreated,
SessionRecord::Memory(MemoryRecord::Forget { .. }) => return DisplayType::MemoryForgotten,
SessionRecord::Memory(MemoryRecord::Feedback { .. }) => {
return DisplayType::MemoryFeedback;
}
SessionRecord::DeadLetter => return DisplayType::DeadLetter,
SessionRecord::Journal => return DisplayType::WorkflowStep,
SessionRecord::KvMutation => return DisplayType::KvSet,
SessionRecord::GraphMutation => return DisplayType::GraphUpsert,
SessionRecord::Undecodable(_) => return DisplayType::Undecodable,
};
let operation = envelope.operation.as_deref().unwrap_or_default();
match envelope.kind {
AgentKind::Status => status_display(envelope, operation, topic),
AgentKind::Command if is_control(operation) => {
if topic == AGENT_CONTROL {
DisplayType::SessionControl
} else {
DisplayType::UnauthorizedControl
}
}
AgentKind::Command if is_user(envelope) => DisplayType::UserMessage,
AgentKind::Command if is_model(operation) => DisplayType::ModelRequest,
AgentKind::Command if operation == OPERATION_EXECUTE_TOOL => DisplayType::ToolCall,
AgentKind::Command if operation == OPERATION_INVOKE_AGENT => DisplayType::AgentHandoff,
AgentKind::Event if is_user(envelope) => DisplayType::UserMessage,
AgentKind::Event => match operation {
OPERATION_STATE_DELTA | OPERATION_STATE_SNAPSHOT => DisplayType::StateUpdated,
OPERATION_CONTEXT_ASSEMBLED => DisplayType::ContextAssembled,
OPERATION_CONTEXT_COMPACTED => DisplayType::ContextCompacted,
OPERATION_CONTEXT_RETRIEVED => DisplayType::ContextRetrieved,
OPERATION_POLICY_DECISION => DisplayType::PolicyDecision,
OPERATION_SESSION_PARKED => DisplayType::SessionParked,
OPERATION_SESSION_UNPARKED => DisplayType::SessionUnparked,
_ => DisplayType::AgentMessage,
},
AgentKind::Response if is_model(operation) => DisplayType::ModelResponse,
AgentKind::Response | AgentKind::Error if operation == OPERATION_EXECUTE_TOOL => {
DisplayType::ToolResult
}
AgentKind::Error => DisplayType::Error,
AgentKind::Chunk if operation == OPERATION_CHAT || operation == OPERATION_REASONING => {
DisplayType::ModelStream
}
_ => DisplayType::AgentMessage,
}
}
fn status_display(envelope: &AgentEnvelope, operation: &str, topic: &str) -> DisplayType {
if operation == OPERATION_PROGRESS && topic == AGENT_HEARTBEATS {
return DisplayType::SessionHeartbeat;
}
if operation != OPERATION_SESSION {
return if operation == OPERATION_TASK {
DisplayType::TaskStatus
} else {
DisplayType::AgentMessage
};
}
match envelope.task_state {
Some(TaskState::Submitted) => DisplayType::SessionSubmitted,
Some(TaskState::Working) if is_session_start(&envelope.body) => DisplayType::SessionStarted,
Some(TaskState::Working) => DisplayType::SessionResumed,
Some(TaskState::Paused) => DisplayType::SessionPaused,
Some(TaskState::Completed) => DisplayType::SessionCompleted,
Some(TaskState::Failed | TaskState::Rejected) => DisplayType::SessionFailed,
Some(TaskState::Canceled) => DisplayType::SessionCanceled,
_ => DisplayType::TaskStatus,
}
}
fn is_session_start(body: &[u8]) -> bool {
crate::framing::decode_named::<ciborium::value::Value>(body).is_ok_and(|value| {
matches!(value, ciborium::value::Value::Map(entries)
if entries.iter().any(|(key, _)| {
matches!(key, ciborium::value::Value::Text(name) if name == "agent" || name == "sdk")
}))
})
}
fn is_control(operation: &str) -> bool {
CONTROL_OPERATIONS.contains(&operation)
}
fn is_model(operation: &str) -> bool {
matches!(
operation,
OPERATION_CHAT | OPERATION_TEXT_COMPLETION | OPERATION_GENERATE_CONTENT
)
}
fn is_user(envelope: &AgentEnvelope) -> bool {
envelope
.metadata
.as_ref()
.and_then(|metadata| metadata.get(METADATA_ROLE))
.is_some_and(|role| matches!(role, Value::Str(role) if role == "user"))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, strum::IntoStaticStr, strum::EnumString)]
#[strum(serialize_all = "snake_case")]
pub enum Dispatch {
Work,
Observational,
Reply,
Lifecycle,
Control,
Foreign,
}
impl Dispatch {
pub fn as_str(self) -> &'static str {
self.into()
}
}
#[derive(Clone, Copy, Debug)]
pub enum HandledOperations<'a> {
Any,
Only(&'a [&'a str]),
}
impl HandledOperations<'_> {
fn handles(self, operation: &str) -> bool {
match self {
Self::Any => true,
Self::Only(operations) => operations.contains(&operation),
}
}
}
pub fn classify(
envelope: &AgentEnvelope,
topic: &str,
me: &AgentId,
handled: HandledOperations<'_>,
) -> Dispatch {
let operation = envelope.operation.as_deref().unwrap_or_default();
match envelope.kind {
AgentKind::Status => Dispatch::Lifecycle,
AgentKind::Response | AgentKind::Error | AgentKind::Chunk => Dispatch::Reply,
AgentKind::Event => Dispatch::Observational,
AgentKind::Command => {
if envelope.target.as_ref().is_some_and(|target| target != me) {
return Dispatch::Foreign;
}
match (is_control(operation), topic == AGENT_CONTROL) {
(true, true) => Dispatch::Control,
(true, false) => Dispatch::Observational,
(false, true) => Dispatch::Foreign,
(false, false) if handled.handles(operation) => Dispatch::Work,
(false, false) => Dispatch::Observational,
}
}
}
}
pub fn addressee_filter(me: &AgentId) -> ConsumerFilter {
ConsumerFilter::headers_only(FilterExpr::header(
TARGET_AGENT_ID,
CmpOp::In,
TypedValue::List(vec![
TypedValue::String(me.as_str().to_owned()),
TypedValue::String(BROADCAST.to_owned()),
]),
))
}
pub fn broadcast_filter() -> ConsumerFilter {
ConsumerFilter::headers_only(FilterExpr::header(
TARGET_AGENT_ID,
CmpOp::Eq,
TypedValue::String(BROADCAST.to_owned()),
))
}
pub fn classify_generic(
addressee: Option<&str>,
has_cause: bool,
has_correlation: bool,
topic: &str,
me: &AgentId,
) -> Dispatch {
if addressee.is_some_and(|to| to != me.as_str() && to != BROADCAST) || topic == AGENT_CONTROL {
return Dispatch::Foreign;
}
if has_cause && has_correlation {
Dispatch::Reply
} else {
Dispatch::Work
}
}