systemprompt-agent 0.2.2

Agent-to-Agent (A2A) protocol for systemprompt.io AI governance: streaming, JSON-RPC models, task lifecycle, .well-known discovery, and governed agent orchestration.
Documentation
pub mod metadata_builder;
pub mod parts_builder;
mod type_inference;

use crate::error::{ArtifactError, RowParseError};
use crate::models::a2a::Artifact;
use rmcp::model::CallToolResult;
use serde::Deserialize;
use serde_json::{Value as JsonValue, json};
use systemprompt_identifiers::{ArtifactId, McpExecutionId, SkillId};
use systemprompt_models::artifacts::types::ArtifactType;

pub use metadata_builder::{BuildMetadataParams, build_metadata};
pub use parts_builder::build_parts;

pub use type_inference::infer_type;

#[derive(Debug, Deserialize)]
pub struct ParsedMetadata {
    pub skill_id: Option<SkillId>,
    pub skill_name: Option<String>,
    pub execution_id: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct ParsedToolResponse {
    pub artifact_id: ArtifactId,
    pub mcp_execution_id: McpExecutionId,
    pub artifact: JsonValue,
    #[serde(rename = "_metadata")]
    pub metadata: ParsedMetadata,
}

pub fn parse_tool_response(
    structured_content: &JsonValue,
) -> Result<ParsedToolResponse, ArtifactError> {
    if structured_content.is_null() {
        return Err(RowParseError::MissingField {
            field: "structured_content (received null)".to_string(),
        }
        .into());
    }

    if let Some(obj) = structured_content.as_object() {
        if obj.is_empty() {
            return Err(RowParseError::MissingField {
                field: "structured_content (received empty object {})".to_string(),
            }
            .into());
        }
    }

    serde_json::from_value(structured_content.clone()).map_err(|e| {
        let actual_keys = structured_content
            .as_object()
            .map_or_else(Vec::new, |o| o.keys().cloned().collect());
        ArtifactError::InvalidSchema {
            expected: "ToolResponse {artifact_id, mcp_execution_id, artifact, _metadata}",
            actual_keys,
            source: e,
        }
    })
}

pub fn artifact_type_to_string(artifact_type: &ArtifactType) -> String {
    match artifact_type {
        ArtifactType::Text => "text".to_string(),
        ArtifactType::Table => "table".to_string(),
        ArtifactType::Chart => "chart".to_string(),
        ArtifactType::Form => "form".to_string(),
        ArtifactType::Dashboard => "dashboard".to_string(),
        ArtifactType::PresentationCard => "presentation_card".to_string(),
        ArtifactType::List => "list".to_string(),
        ArtifactType::CopyPasteText => "copy_paste_text".to_string(),
        ArtifactType::Image => "image".to_string(),
        ArtifactType::Video => "video".to_string(),
        ArtifactType::Audio => "audio".to_string(),
        ArtifactType::Custom(name) => name.clone(),
    }
}

pub fn calculate_fingerprint(tool_name: &str, tool_arguments: Option<&JsonValue>) -> String {
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher};

    #[allow(clippy::collection_is_never_read)]
    let args_str = tool_arguments
        .and_then(|args| {
            serde_json::to_string(args)
                .map_err(|e| {
                    tracing::debug!(error = %e, "Failed to serialize tool arguments for fingerprint");
                    e
                })
                .ok()
        })
        .unwrap_or_else(String::new);

    let mut hasher = DefaultHasher::new();
    args_str.hash(&mut hasher);
    let hash = hasher.finish();

    format!("{}-{:x}", tool_name, hash)
}

struct TransformParsedParams<'a> {
    tool_name: &'a str,
    parsed: ParsedToolResponse,
    output_schema: Option<&'a JsonValue>,
    context_id: &'a str,
    task_id: &'a str,
    tool_arguments: Option<&'a JsonValue>,
}

fn transform_parsed(params: TransformParsedParams<'_>) -> Result<Artifact, ArtifactError> {
    let TransformParsedParams {
        tool_name,
        parsed,
        output_schema,
        context_id,
        task_id,
        tool_arguments,
    } = params;
    let artifact_type = infer_type(&parsed.artifact, output_schema, tool_name)?;
    let fingerprint = calculate_fingerprint(tool_name, tool_arguments);
    let parts = build_parts(&parsed.artifact)?;

    let mcp_execution_id = Some(parsed.mcp_execution_id.to_string())
        .filter(|s| !s.is_empty())
        .or_else(|| parsed.metadata.execution_id.clone());

    let mut metadata = build_metadata(BuildMetadataParams {
        artifact_type: &artifact_type,
        schema: output_schema,
        mcp_execution_id,
        context_id,
        task_id,
        tool_name,
    })?;

    metadata = metadata.with_fingerprint(fingerprint);

    if let Some(sid) = &parsed.metadata.skill_id {
        metadata = metadata.with_skill_id(sid.clone());
    }

    Ok(Artifact {
        id: parsed.artifact_id,
        title: Some(tool_name.to_string()),
        description: None,
        parts,
        metadata,
        extensions: vec![json!(
            "https://systemprompt.io/extensions/artifact-rendering/v1"
        )],
    })
}

#[derive(Debug)]
pub struct TransformParams<'a> {
    pub tool_name: &'a str,
    pub tool_result: &'a CallToolResult,
    pub output_schema: Option<&'a JsonValue>,
    pub context_id: &'a str,
    pub task_id: &'a str,
    pub tool_arguments: Option<&'a JsonValue>,
}

#[derive(Debug)]
pub struct TransformFromJsonParams<'a> {
    pub tool_name: &'a str,
    pub tool_result_json: &'a JsonValue,
    pub output_schema: Option<&'a JsonValue>,
    pub context_id: &'a str,
    pub task_id: &'a str,
    pub tool_arguments: Option<&'a JsonValue>,
}

#[derive(Debug, Copy, Clone)]
pub struct McpToA2aTransformer;

impl McpToA2aTransformer {
    pub fn transform(params: &TransformParams<'_>) -> Result<Artifact, ArtifactError> {
        let TransformParams {
            tool_name,
            tool_result,
            output_schema,
            context_id,
            task_id,
            tool_arguments,
        } = params;
        let structured_content =
            tool_result
                .structured_content
                .as_ref()
                .ok_or_else(|| RowParseError::MissingField {
                    field: "structured_content".to_string(),
                })?;

        let parsed = parse_tool_response(structured_content)?;
        transform_parsed(TransformParsedParams {
            tool_name,
            parsed,
            output_schema: *output_schema,
            context_id,
            task_id,
            tool_arguments: *tool_arguments,
        })
    }

    pub fn transform_from_json(
        params: &TransformFromJsonParams<'_>,
    ) -> Result<Artifact, ArtifactError> {
        let TransformFromJsonParams {
            tool_name,
            tool_result_json,
            output_schema,
            context_id,
            task_id,
            tool_arguments,
        } = params;
        let parsed = parse_tool_response(tool_result_json)?;
        transform_parsed(TransformParsedParams {
            tool_name,
            parsed,
            output_schema: *output_schema,
            context_id,
            task_id,
            tool_arguments: *tool_arguments,
        })
    }
}