use car_ir::{Action, ActionProposal, ActionResult, ActionStatus, ActionType, ProposalResult};
use chrono::Utc;
use std::collections::HashMap;
use crate::types::{Artifact, DataPart, Message, Part, Task, TaskState, TaskStatus, TextPart};
#[derive(Debug, thiserror::Error)]
pub enum ProposalBuildError {
#[error("message contained no actionable content (no tool data part, no text)")]
Empty,
#[error("`data.parameters` must be a JSON object")]
BadParameters,
}
pub fn message_to_proposal(message: &Message) -> Result<ActionProposal, ProposalBuildError> {
let mut actions = Vec::new();
let mut context: HashMap<String, serde_json::Value> = HashMap::new();
let mut text_blobs: Vec<String> = Vec::new();
for part in &message.parts {
match part {
Part::Text(t) => text_blobs.push(t.text.clone()),
Part::Data(d) => {
if let Some(obj) = d.data.as_object() {
if let Some(tool) = obj.get("tool").and_then(|v| v.as_str()) {
let params_value = obj.get("parameters").cloned().unwrap_or_else(|| {
serde_json::Value::Object(serde_json::Map::new())
});
let params_obj = match params_value {
serde_json::Value::Object(map) => map,
_ => return Err(ProposalBuildError::BadParameters),
};
let parameters: HashMap<String, serde_json::Value> =
params_obj.into_iter().collect();
actions.push(Action {
id: short_id(),
action_type: ActionType::ToolCall,
tool: Some(tool.to_string()),
parameters,
preconditions: Vec::new(),
expected_effects: HashMap::new(),
state_dependencies: Vec::new(),
idempotent: false,
max_retries: 3,
failure_behavior: car_ir::FailureBehavior::Abort,
timeout_ms: None,
metadata: HashMap::new(),
});
} else {
context.insert("a2a_data".into(), d.data.clone());
}
}
}
Part::File(f) => {
context.insert(
"a2a_file".into(),
serde_json::to_value(&f.file).unwrap_or(serde_json::Value::Null),
);
}
}
}
if !text_blobs.is_empty() {
context.insert(
"a2a_text".into(),
serde_json::Value::String(text_blobs.join("\n")),
);
}
if actions.is_empty() && context.is_empty() {
return Err(ProposalBuildError::Empty);
}
let mut ctx_with_origin = context;
ctx_with_origin.insert(
"a2a_message_id".into(),
serde_json::Value::String(message.message_id.clone()),
);
Ok(ActionProposal {
id: short_id(),
source: "a2a".into(),
actions,
timestamp: Utc::now(),
context: ctx_with_origin,
})
}
pub fn action_results_to_artifacts(result: &ProposalResult) -> Vec<Artifact> {
result
.results
.iter()
.map(|r| action_result_to_artifact(r))
.collect()
}
fn action_result_to_artifact(result: &ActionResult) -> Artifact {
let summary = match result.status {
ActionStatus::Succeeded => "succeeded".to_string(),
ActionStatus::Failed => format!(
"failed: {}",
result.error.as_deref().unwrap_or("(no detail)")
),
ActionStatus::Rejected => format!(
"rejected: {}",
result.error.as_deref().unwrap_or("(no detail)")
),
ActionStatus::Skipped => "skipped".to_string(),
ActionStatus::Executing => "executing".to_string(),
ActionStatus::Validated => "validated".to_string(),
ActionStatus::Proposed => "proposed".to_string(),
};
let mut parts: Vec<Part> = vec![Part::Text(TextPart {
text: format!("action {}: {}", result.action_id, summary),
metadata: HashMap::new(),
})];
if let Some(out) = &result.output {
parts.push(Part::Data(DataPart {
data: out.clone(),
metadata: HashMap::new(),
}));
}
Artifact {
artifact_id: format!("art-{}", result.action_id),
name: format!("action.{}", result.action_id),
description: result.error.clone(),
parts,
metadata: HashMap::new(),
}
}
pub fn a2a_state_for(result: &ProposalResult) -> TaskState {
if result.results.is_empty() {
return TaskState::Completed;
}
let mut any_canceled = false;
let mut any_rejected = false;
let mut any_failed = false;
for r in &result.results {
match r.status {
ActionStatus::Rejected => any_rejected = true,
ActionStatus::Failed => any_failed = true,
ActionStatus::Skipped
if r.error
.as_deref()
.is_some_and(|e| e.starts_with(car_engine::CANCELED_PREFIX)) =>
{
any_canceled = true;
}
_ => {}
}
}
if any_canceled {
TaskState::Canceled
} else if any_rejected {
TaskState::Rejected
} else if any_failed {
TaskState::Failed
} else {
TaskState::Completed
}
}
pub fn task_with_status(
task_id: String,
context_id: String,
state: TaskState,
history: Vec<Message>,
artifacts: Vec<Artifact>,
) -> Task {
Task {
id: task_id,
context_id,
status: TaskStatus {
state,
message: None,
timestamp: Utc::now(),
},
history,
artifacts,
metadata: HashMap::new(),
}
}
pub fn short_id() -> String {
uuid::Uuid::new_v4().simple().to_string()[..12].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{DataPart, Message, MessageRole, Part, TextPart};
use serde_json::json;
#[test]
fn structured_data_part_compiles_to_tool_call() {
let msg = Message {
message_id: "m1".into(),
role: MessageRole::User,
parts: vec![Part::Data(DataPart {
data: json!({
"tool": "fs.read",
"parameters": { "path": "/tmp/x" }
}),
metadata: HashMap::new(),
})],
task_id: None,
context_id: None,
metadata: HashMap::new(),
};
let proposal = message_to_proposal(&msg).expect("compile");
assert_eq!(proposal.actions.len(), 1);
let action = &proposal.actions[0];
assert_eq!(action.action_type, ActionType::ToolCall);
assert_eq!(action.tool.as_deref(), Some("fs.read"));
assert_eq!(action.parameters.get("path"), Some(&json!("/tmp/x")));
assert_eq!(proposal.source, "a2a");
assert_eq!(
proposal.context.get("a2a_message_id"),
Some(&json!("m1"))
);
}
#[test]
fn text_only_message_yields_context_proposal() {
let msg = Message {
message_id: "m2".into(),
role: MessageRole::User,
parts: vec![Part::Text(TextPart {
text: "summarise this".into(),
metadata: HashMap::new(),
})],
task_id: None,
context_id: None,
metadata: HashMap::new(),
};
let proposal = message_to_proposal(&msg).expect("compile");
assert!(proposal.actions.is_empty());
assert_eq!(
proposal.context.get("a2a_text"),
Some(&json!("summarise this"))
);
}
#[test]
fn empty_message_errors() {
let msg = Message {
message_id: "m3".into(),
role: MessageRole::User,
parts: vec![],
task_id: None,
context_id: None,
metadata: HashMap::new(),
};
assert!(matches!(
message_to_proposal(&msg),
Err(ProposalBuildError::Empty)
));
}
#[test]
fn proposal_result_round_trips_to_artifacts() {
let pr = ProposalResult {
proposal_id: "p1".into(),
results: vec![
ActionResult {
action_id: "a1".into(),
status: ActionStatus::Succeeded,
output: Some(json!({"result": 42})),
error: None,
state_changes: HashMap::new(),
duration_ms: Some(12.0),
timestamp: Utc::now(),
},
ActionResult {
action_id: "a2".into(),
status: ActionStatus::Failed,
output: None,
error: Some("boom".into()),
state_changes: HashMap::new(),
duration_ms: Some(3.0),
timestamp: Utc::now(),
},
],
cost: car_ir::CostSummary::default(),
};
let arts = action_results_to_artifacts(&pr);
assert_eq!(arts.len(), 2);
assert_eq!(arts[0].name, "action.a1");
assert_eq!(arts[1].description.as_deref(), Some("boom"));
assert_eq!(a2a_state_for(&pr), TaskState::Failed);
}
#[test]
fn empty_proposal_result_is_completed() {
let pr = ProposalResult {
proposal_id: "p-empty".into(),
results: vec![],
cost: car_ir::CostSummary::default(),
};
assert_eq!(a2a_state_for(&pr), TaskState::Completed);
}
#[test]
fn skipped_with_canceled_prefix_maps_to_canceled_state() {
let pr = ProposalResult {
proposal_id: "p-cancel".into(),
results: vec![
ActionResult {
action_id: "a1".into(),
status: ActionStatus::Succeeded,
output: None,
error: None,
state_changes: HashMap::new(),
duration_ms: None,
timestamp: Utc::now(),
},
ActionResult {
action_id: "a2".into(),
status: ActionStatus::Skipped,
output: None,
error: Some(format!(
"{}{}",
car_engine::CANCELED_PREFIX,
"cancellation requested by caller"
)),
state_changes: HashMap::new(),
duration_ms: None,
timestamp: Utc::now(),
},
],
cost: car_ir::CostSummary::default(),
};
assert_eq!(a2a_state_for(&pr), TaskState::Canceled);
}
#[test]
fn rejected_action_yields_rejected_state() {
let pr = ProposalResult {
proposal_id: "p-rej".into(),
results: vec![ActionResult {
action_id: "a1".into(),
status: ActionStatus::Rejected,
output: None,
error: Some("policy denied".into()),
state_changes: HashMap::new(),
duration_ms: None,
timestamp: Utc::now(),
}],
cost: car_ir::CostSummary::default(),
};
assert_eq!(a2a_state_for(&pr), TaskState::Rejected);
}
}