use std::time::Duration;
use serde::Deserialize;
use serde_json::{Value, json};
use tracing::{debug, info, warn};
use crate::client::VtaClient;
use crate::error::VtaError;
const INBOUND_POLL_SECS: u64 = 20;
const DEFAULT_HEARTBEAT_SECS: u64 = 300;
#[derive(Debug, Clone)]
pub struct AgentConfig {
pub client_did: String,
pub private_key_multibase: String,
pub vta_did: String,
pub mediator_did: String,
pub rest_url: Option<String>,
pub display_name: String,
pub service_kind: String,
pub platform: Option<String>,
pub hpke_public_key: Option<String>,
pub heartbeat_secs: u64,
}
impl AgentConfig {
pub fn new(
client_did: impl Into<String>,
private_key_multibase: impl Into<String>,
vta_did: impl Into<String>,
mediator_did: impl Into<String>,
) -> Self {
Self {
client_did: client_did.into(),
private_key_multibase: private_key_multibase.into(),
vta_did: vta_did.into(),
mediator_did: mediator_did.into(),
rest_url: None,
display_name: "agent".to_string(),
service_kind: "ai-agent".to_string(),
platform: None,
hpke_public_key: None,
heartbeat_secs: DEFAULT_HEARTBEAT_SECS,
}
}
pub fn display_name(mut self, v: impl Into<String>) -> Self {
self.display_name = v.into();
self
}
pub fn service_kind(mut self, v: impl Into<String>) -> Self {
self.service_kind = v.into();
self
}
pub fn platform(mut self, v: impl Into<String>) -> Self {
self.platform = Some(v.into());
self
}
pub fn rest_url(mut self, v: impl Into<String>) -> Self {
self.rest_url = Some(v.into());
self
}
pub fn hpke_public_key(mut self, v: impl Into<String>) -> Self {
self.hpke_public_key = Some(v.into());
self
}
pub fn heartbeat_secs(mut self, secs: u64) -> Self {
self.heartbeat_secs = secs.max(1);
self
}
pub fn for_attach(display_name: impl Into<String>) -> Self {
Self::new("", "", "", "").display_name(display_name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentControl {
Continue,
Stop,
}
#[derive(Debug, Clone)]
pub struct InboundMessage {
pub id: String,
pub typ: String,
pub from: Option<String>,
pub body: Value,
}
impl InboundMessage {
pub fn parse(json: &str) -> Result<Self, VtaError> {
#[derive(Deserialize)]
struct Wire {
#[serde(default)]
id: String,
#[serde(rename = "type", default)]
typ: String,
#[serde(default)]
from: Option<String>,
#[serde(default)]
body: Value,
}
let w: Wire = serde_json::from_str(json)?;
Ok(Self {
id: w.id,
typ: w.typ,
from: w.from,
body: w.body,
})
}
}
pub struct AgentSession {
client: VtaClient,
config: AgentConfig,
}
impl AgentSession {
pub async fn enroll(config: AgentConfig) -> Result<Self, VtaError> {
let client = VtaClient::connect_didcomm(
&config.client_did,
&config.private_key_multibase,
&config.vta_did,
&config.mediator_did,
config.rest_url.clone(),
)
.await?;
let session = Self::from_client(client, config);
if let Err(e) = session.ensure_enrolled().await {
session.client.shutdown().await;
return Err(e);
}
Ok(session)
}
pub fn from_client(client: VtaClient, config: AgentConfig) -> Self {
Self { client, config }
}
pub async fn ensure_enrolled(&self) -> Result<(), VtaError> {
let consumer_kind = json!({ "kind": "service", "serviceKind": self.config.service_kind });
match self
.client
.device_register(
consumer_kind,
&self.config.display_name,
self.config.platform.as_deref(),
self.config.hpke_public_key.as_deref(),
)
.await
{
Ok(_) => info!(name = %self.config.display_name, "agent device registered"),
Err(e) if is_already_registered(&e) => {
debug!(name = %self.config.display_name, "agent already registered; reusing binding");
}
Err(e) => return Err(e),
}
Ok(())
}
pub fn client(&self) -> &VtaClient {
&self.client
}
pub async fn run<H, Fut>(&self, mut handler: H) -> Result<(), VtaError>
where
H: FnMut(InboundMessage) -> Fut,
Fut: std::future::Future<Output = AgentControl>,
{
let mut ticker = tokio::time::interval(Duration::from_secs(self.config.heartbeat_secs));
ticker.tick().await;
let result = loop {
tokio::select! {
_ = ticker.tick() => {
if let Err(e) = self.client.device_heartbeat(self.config.platform.as_deref()).await {
warn!(error = %e, "agent heartbeat failed; retrying next tick");
}
}
inbound = self.client.receive_next(INBOUND_POLL_SECS) => {
match inbound {
Ok(Some(json)) => match InboundMessage::parse(&json) {
Ok(msg) => {
if handler(msg).await == AgentControl::Stop {
break Ok(());
}
}
Err(e) => warn!(error = %e, "dropping unparseable inbound message"),
},
Ok(None) => {} Err(e) => break Err(e),
}
}
}
};
self.client.shutdown().await;
result
}
pub async fn shutdown(&self) {
self.client.shutdown().await;
}
}
fn is_already_registered(e: &VtaError) -> bool {
matches!(e, VtaError::Conflict(_)) || e.to_string().contains("already_registered")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_defaults_then_builders() {
let cfg = AgentConfig::new("did:key:zA", "zKey", "did:key:zVta", "did:key:zMed")
.display_name("nano-claw")
.platform("linux")
.heartbeat_secs(0); assert_eq!(cfg.service_kind, "ai-agent");
assert_eq!(cfg.display_name, "nano-claw");
assert_eq!(cfg.platform.as_deref(), Some("linux"));
assert_eq!(cfg.heartbeat_secs, 1, "heartbeat clamps to >= 1");
}
#[test]
fn inbound_message_parses_didcomm_envelope() {
let json = r#"{
"id": "urn:uuid:abc",
"type": "https://trusttasks.org/spec/push/wake/0.1",
"from": "did:key:zVta",
"body": { "reason": "work-available" }
}"#;
let msg = InboundMessage::parse(json).unwrap();
assert_eq!(msg.id, "urn:uuid:abc");
assert_eq!(msg.typ, "https://trusttasks.org/spec/push/wake/0.1");
assert_eq!(msg.from.as_deref(), Some("did:key:zVta"));
assert_eq!(msg.body["reason"], "work-available");
}
#[test]
fn inbound_message_is_lenient_about_missing_fields() {
let msg = InboundMessage::parse(r#"{"type":"x/1.0"}"#).unwrap();
assert_eq!(msg.typ, "x/1.0");
assert!(msg.id.is_empty());
assert!(msg.from.is_none());
assert!(msg.body.is_null());
}
#[test]
fn from_client_wraps_without_enrolling() {
let client = VtaClient::new("http://localhost:9999");
let session = AgentSession::from_client(
client,
AgentConfig::for_attach("vta-mcp").service_kind("ai-agent"),
);
assert_eq!(session.config.display_name, "vta-mcp");
assert_eq!(session.config.service_kind, "ai-agent");
let _ = session.client();
}
#[test]
fn already_registered_is_detected_across_transports() {
assert!(is_already_registered(&VtaError::Conflict("dup".into())));
assert!(is_already_registered(&VtaError::Protocol(
"trust task rejected: device/register:already_registered — ...".into()
)));
assert!(!is_already_registered(&VtaError::Protocol(
"something else".into()
)));
}
}