use crate::a2a::tasks::{Link, State, Task};
use crate::a2a::{CallerIdentity, Principal, Resolver};
use crate::obs::log::Logger;
use crate::runtime::events::{Event, kinds};
use crate::runtime::reactor::{PendingKind, Runtime};
use serde_json::{Value, json};
use std::sync::mpsc::{Sender, SyncSender, sync_channel};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
pub const METHODS: &[&str] = &[
"SendMessage",
"SendStreamingMessage",
"GetTask",
"CancelTask",
"ListTasks",
"SubscribeToTask",
"SubscribeToEvents",
"CreateTaskPushNotificationConfig",
"GetTaskPushNotificationConfig",
"ListTaskPushNotificationConfigs",
"DeleteTaskPushNotificationConfig",
"GetExtendedAgentCard",
];
pub const TASK_NOT_FOUND: i64 = -32001;
pub const UNSUPPORTED_OPERATION: i64 = -32004;
pub const FEED_RING: usize = 1024;
#[derive(Debug, Clone, PartialEq)]
pub enum FeedVis {
All,
Operator,
Owner(Option<String>),
}
pub struct SharedFeed {
inner: Mutex<FeedInner>,
debug: std::sync::atomic::AtomicBool,
}
struct FeedInner {
seq: u64,
buf: std::collections::VecDeque<Value>,
dropped: u64,
}
impl SharedFeed {
pub fn new(debug: bool) -> SharedFeed {
SharedFeed {
inner: Mutex::new(FeedInner {
seq: 0,
buf: std::collections::VecDeque::with_capacity(FEED_RING),
dropped: 0,
}),
debug: std::sync::atomic::AtomicBool::new(debug),
}
}
pub fn debug(&self) -> bool {
self.debug.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn set_debug(&self, on: bool) {
self.debug.store(on, std::sync::atomic::Ordering::Relaxed);
}
pub fn push(&self, kind: &str, vis: FeedVis, data: Value) -> u64 {
let vis_tag = match vis {
FeedVis::All => json!("all"),
FeedVis::Operator => json!("op"),
FeedVis::Owner(None) => json!("op"),
FeedVis::Owner(Some(p)) => json!(p),
};
let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
g.seq += 1;
let seq = g.seq;
let ev = json!({"seq": seq, "ts": crate::state::now_ms(), "kind": kind, "data": data, "_vis": vis_tag});
if g.buf.len() == FEED_RING {
g.buf.pop_front();
g.dropped += 1;
}
g.buf.push_back(ev);
seq
}
pub fn since(
&self,
after: u64,
principal: &str,
is_operator: bool,
max: usize,
) -> (Vec<Value>, u64) {
let g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let mut out = Vec::new();
let mut cursor = after;
for ev in g.buf.iter() {
let seq = ev["seq"].as_u64().unwrap_or(0);
if seq <= after {
continue;
}
if out.len() >= max {
break;
}
cursor = seq;
let visible = match ev["_vis"].as_str() {
Some("all") => true,
Some("op") => is_operator,
Some(owner) => is_operator || owner == principal,
None => is_operator,
};
if visible {
let mut e = ev.clone();
if let Value::Object(o) = &mut e {
o.remove("_vis");
}
out.push(e);
}
}
(out, cursor)
}
pub fn bounds(&self) -> (u64, u64, u64) {
let g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let oldest = g
.buf
.front()
.and_then(|e| e["seq"].as_u64())
.unwrap_or(g.seq);
(g.seq, oldest, g.dropped)
}
}
const PAIR_WINDOW_SECS: u64 = 60;
const PAIR_MAX_FAILS: usize = 5;
pub struct PairingState {
seed: [u8; 32],
role: crate::config::v2::Role,
ttl_ms: u64,
sessions: Mutex<std::collections::HashMap<String, (crate::config::v2::Role, u64)>>,
fails: Mutex<Vec<u64>>,
}
impl PairingState {
pub fn new(role: crate::config::v2::Role, ttl: Duration) -> Result<PairingState, String> {
Ok(PairingState {
seed: os_random_32()?,
role,
ttl_ms: ttl.as_millis() as u64,
sessions: Mutex::new(std::collections::HashMap::new()),
fails: Mutex::new(Vec::new()),
})
}
fn code_for(&self, window: u64) -> String {
let mac = crate::sha::hmac_sha256(&self.seed, &window.to_be_bytes());
let n = u32::from_be_bytes([mac[0], mac[1], mac[2], mac[3]]) % 1_000_000;
format!("{n:06}")
}
pub fn current_code(&self) -> (String, u64) {
let now = crate::state::now_ms();
let window = now / 1000 / PAIR_WINDOW_SECS;
let expires_in = (window + 1) * PAIR_WINDOW_SECS * 1000 - now;
(self.code_for(window), expires_in)
}
pub fn pair(&self, code: &str) -> Result<(String, u64), String> {
let now = crate::state::now_ms();
{
let mut fails = self.fails.lock().unwrap_or_else(|e| e.into_inner());
fails.retain(|t| now.saturating_sub(*t) < PAIR_WINDOW_SECS * 1000);
if fails.len() >= PAIR_MAX_FAILS {
return Err("too many pairing attempts; wait a minute".into());
}
}
let window = now / 1000 / PAIR_WINDOW_SECS;
let code = code.trim().replace([' ', '-'], "");
let hit = crate::sha::ct_eq(self.code_for(window).as_bytes(), code.as_bytes())
| crate::sha::ct_eq(
self.code_for(window.saturating_sub(1)).as_bytes(),
code.as_bytes(),
);
if !hit {
self.fails
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(now);
return Err("wrong pairing code".into());
}
let token = format!("pat-{}", crate::sha::to_hex(&os_random_32()?));
let expires = now + self.ttl_ms;
let mut sessions = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
sessions.retain(|_, (_, exp)| *exp > now);
sessions.insert(token.clone(), (self.role, expires));
Ok((token, expires))
}
pub fn check_bearer(&self, bearer: &str) -> Option<crate::config::v2::Role> {
if !bearer.starts_with("pat-") {
return None;
}
let now = crate::state::now_ms();
let sessions = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
sessions
.get(bearer)
.filter(|(_, exp)| *exp > now)
.map(|(role, _)| *role)
}
pub fn role(&self) -> crate::config::v2::Role {
self.role
}
pub fn session_count(&self) -> usize {
let now = crate::state::now_ms();
self.sessions
.lock()
.unwrap_or_else(|e| e.into_inner())
.values()
.filter(|(_, exp)| *exp > now)
.count()
}
}
fn os_random_32() -> Result<[u8; 32], String> {
use std::io::Read;
let mut buf = [0u8; 32];
#[cfg(unix)]
{
std::fs::File::open("/dev/urandom")
.and_then(|mut f| f.read_exact(&mut buf))
.map_err(|e| format!("/dev/urandom: {e}"))?;
Ok(buf)
}
#[cfg(not(unix))]
{
let _ = &mut buf;
Err("pairing needs an OS entropy source (unix /dev/urandom)".into())
}
}
#[derive(Debug)]
pub struct A2aRequest {
pub method: String,
pub params: Value,
pub principal: Principal,
pub reply: SyncSender<Value>,
}
pub struct A2aBridge {
events_tx: Sender<Event>,
feed: Option<Arc<SharedFeed>>,
resolver: Arc<Resolver>,
pub request_timeout: Duration,
pub stream_deadline: Duration,
}
impl A2aBridge {
pub fn new(events_tx: Sender<Event>, resolver: Resolver) -> Arc<A2aBridge> {
Self::with_feed(events_tx, resolver, None)
}
pub fn with_feed(
events_tx: Sender<Event>,
resolver: Resolver,
feed: Option<Arc<SharedFeed>>,
) -> Arc<A2aBridge> {
Arc::new(A2aBridge {
events_tx,
feed,
resolver: Arc::new(resolver),
request_timeout: Duration::from_secs(120),
stream_deadline: Duration::from_secs(600),
})
}
pub fn principal_of(
&self,
mgmt: bool,
bearer: Option<&str>,
subject: Option<String>,
sans: Vec<String>,
) -> Principal {
let id = CallerIdentity {
management: mgmt,
loopback: mgmt,
subject,
sans,
..Default::default()
};
self.resolver.resolve(&id, bearer)
}
pub fn feed(&self) -> Option<Arc<SharedFeed>> {
self.feed.clone()
}
pub fn call(&self, method: &str, params: Value, principal: Principal) -> Value {
self.call_loop(method, params, principal)
}
fn call_loop(&self, method: &str, params: Value, principal: Principal) -> Value {
let (reply_tx, reply_rx) = sync_channel(1);
let req = A2aRequest {
method: method.to_string(),
params,
principal,
reply: reply_tx,
};
if self.events_tx.send(Event::A2a(Box::new(req))).is_err() {
return err_obj(rpc_internal(), "the runtime is shutting down");
}
reply_rx
.recv_timeout(self.request_timeout)
.unwrap_or_else(|_| err_obj(rpc_internal(), "the runtime did not answer in time"))
}
}
fn bare(m: &str) -> &str {
m.strip_prefix("a2a.").unwrap_or(m)
}
fn default_display_top() -> Vec<String> {
["name", "version", "instance", "debug"]
.map(String::from)
.to_vec()
}
fn default_display_bottom() -> Vec<String> {
[
"conn", "endpoint", "draining", "active", "turns", "tokens", "screen", "keys",
]
.map(String::from)
.to_vec()
}
pub fn paired_principal(role: crate::config::v2::Role) -> Principal {
use crate::config::v2::Role;
match role {
Role::Operator => Principal {
id: "operator".into(),
role: Role::Operator,
grants: vec!["*".into()],
rate: None,
budget: None,
labels: Default::default(),
},
other => Principal {
id: "user:paired".into(),
role: other,
grants: Vec::new(),
rate: None,
budget: None,
labels: Default::default(),
},
}
}
pub fn command_op(message: &Value) -> Option<String> {
message["parts"].as_array()?.iter().find_map(|p| {
p.get("data")
.and_then(|d| d.get("agentd"))
.and_then(|a| a.get("op"))
.and_then(Value::as_str)
.map(str::to_string)
})
}
pub(crate) fn command_data(message: &Value) -> Option<Value> {
message["parts"]
.as_array()?
.iter()
.find_map(|p| p.get("data").and_then(|d| d.get("agentd")).cloned())
}
fn message_text(message: &Value) -> String {
message["parts"]
.as_array()
.map(|parts| {
parts
.iter()
.filter_map(|p| p.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default()
}
fn fingerprint(v: &Value) -> u64 {
use std::hash::{Hash, Hasher};
fn walk<H: Hasher>(v: &Value, h: &mut H) {
match v {
Value::Object(o) => {
for (k, x) in o {
if k == "age_ms" || k == "uptime_ms" {
continue;
}
k.hash(h);
walk(x, h);
}
}
Value::Array(a) => {
for x in a {
walk(x, h);
}
}
Value::String(s) => s.hash(h),
Value::Number(n) => n.to_string().hash(h),
Value::Bool(b) => b.hash(h),
Value::Null => 0u8.hash(h),
}
}
let mut h = std::collections::hash_map::DefaultHasher::new();
walk(v, &mut h);
h.finish()
}
fn truncate_strings(v: Value, max: usize) -> Value {
match v {
Value::String(s) if s.len() > max => {
let mut cut = max;
while cut > 0 && !s.is_char_boundary(cut) {
cut -= 1;
}
Value::String(format!("{}…(+{} bytes)", &s[..cut], s.len() - cut))
}
Value::Array(a) => Value::Array(a.into_iter().map(|x| truncate_strings(x, max)).collect()),
Value::Object(o) => Value::Object(
o.into_iter()
.map(|(k, x)| (k, truncate_strings(x, max)))
.collect(),
),
other => other,
}
}
fn err_obj(code: i64, msg: &str) -> Value {
json!({"_error": {"code": code, "message": msg}})
}
fn rpc_internal() -> i64 {
::mcp::rpc::INTERNAL_ERROR
}
fn new_task_id() -> String {
format!("task-{}", crate::state::ulid::new())
}
const REDACTED: &str = "***";
fn redact_settings(doc: &Value) -> Value {
let schema = crate::config::v2::schema::schema();
let defs = schema.get("$defs").cloned().unwrap_or(Value::Null);
redact_by_schema(doc, &schema, &defs)
}
fn redact_by_schema(v: &Value, node: &Value, defs: &Value) -> Value {
let node = match node
.get("$ref")
.and_then(Value::as_str)
.and_then(|r| r.strip_prefix("#/$defs/"))
{
Some(name) => defs.get(name).unwrap_or(node),
None => node,
};
if let Some(alts) = node.get("oneOf").and_then(Value::as_array) {
let folded = alts
.iter()
.fold(v.clone(), |acc, alt| redact_by_schema(&acc, alt, defs));
let mut rest = node.clone();
if let Some(o) = rest.as_object_mut() {
o.remove("oneOf");
}
return redact_by_schema(&folded, &rest, defs);
}
if is_secret_node(node) {
return redact_value(v);
}
match v {
Value::Object(o) => {
let props = node.get("properties");
let extra = node.get("additionalProperties").filter(|a| a.is_object());
let headers = is_header_map_node(node);
Value::Object(
o.iter()
.map(|(k, x)| {
let out = match props.and_then(|p| p.get(k)).or(extra) {
_ if headers => redact_value(x),
Some(child) => redact_by_schema(x, child, defs),
None => x.clone(),
};
(k.clone(), out)
})
.collect(),
)
}
Value::Array(a) => match node.get("items") {
Some(items) => {
Value::Array(a.iter().map(|x| redact_by_schema(x, items, defs)).collect())
}
None => v.clone(),
},
other => other.clone(),
}
}
fn is_secret_node(node: &Value) -> bool {
node["type"] == "string"
&& node["description"]
.as_str()
.is_some_and(|d| d.starts_with("a secret"))
}
fn is_header_map_node(node: &Value) -> bool {
node.get("properties").is_none() && node["additionalProperties"]["type"] == "string"
}
fn redact_value(v: &Value) -> Value {
match v {
Value::String(s) if is_bare_secret_ref(s) => v.clone(),
Value::String(_) => json!(REDACTED),
other => other.clone(),
}
}
fn is_bare_secret_ref(s: &str) -> bool {
let Some(inner) = s
.strip_prefix("{{secret:")
.or_else(|| s.strip_prefix("{{secret-file:"))
else {
return false;
};
match inner.strip_suffix("}}") {
Some(name) => !name.is_empty() && !name.contains('{') && !name.contains('}'),
None => false,
}
}
impl Runtime {
pub(crate) fn on_a2a_request(&mut self, req: A2aRequest) {
let A2aRequest {
method,
params,
principal,
reply,
} = req;
self.note_principal(&principal);
if let Some(retry_after) = self.principal_rate_refusal(&principal) {
self.audit_a2a(
&method,
None,
&principal,
"rate_limited",
json!({"retry_after_s": retry_after}),
None,
);
let _ = reply.send(json!({"error": {
"code": -32029,
"message": format!("rate limit for {}: retry in about {retry_after}s", principal.id),
}}));
return;
}
self.reserved_task_id = params["message"]["taskId"]
.as_str()
.filter(|s| !s.is_empty() && !self.tasks.contains_key(*s))
.map(str::to_string);
let out = match bare(&method) {
"SendMessage" | "SendStreamingMessage" => self.a2a_send(&principal, ¶ms),
"NewTaskId" => json!({"id": new_task_id()}),
"GetTask" => self.a2a_get_task(&principal, ¶ms),
"ListTasks" => self.a2a_list_tasks(&principal),
"CancelTask" => self.a2a_cancel_task(&principal, ¶ms),
"PushConfigSet" => self.a2a_push_set(&principal, ¶ms),
"PushConfigGet" => self.a2a_push_get(&principal, ¶ms),
"PushConfigList" => self.a2a_push_list(&principal, ¶ms),
"PushConfigDelete" => self.a2a_push_delete(&principal, ¶ms),
"GetAgentCard" => self.a2a_agent_card(),
"GetExtendedAgentCard" => self.a2a_extended_card(&principal),
"Pair" => self.a2a_pair(¶ms),
m if crate::a2a::principals::is_admin(m) => {
self.a2a_admin(&principal, bare(&method), ¶ms)
}
other => err_obj(
UNSUPPORTED_OPERATION,
&format!("unsupported method: {other}"),
),
};
self.reserved_task_id = None;
let op = params.get("message").and_then(command_op);
let outcome = if out.get("_error").is_some() {
"error"
} else {
"ok"
};
let target = out["task"]["id"]
.as_str()
.map(|id| json!({"task": id}))
.unwrap_or(Value::Null);
let request_id = params["message"]["messageId"].as_str();
self.audit_a2a(
bare(&method),
op.as_deref(),
&principal,
outcome,
target,
request_id,
);
let _ = reply.send(out);
}
fn a2a_send(&mut self, principal: &Principal, params: &Value) -> Value {
if self.draining {
return err_obj(-32000, "the agent is draining");
}
let message = ¶ms["message"];
let internal_op = command_op(message).is_some_and(|op| op.starts_with("_instance."));
let declared = internal_op
|| command_op(message).is_some_and(|op| self.workflow_declares_a2a_command(&op));
if declared
&& let Some(op) = command_op(message)
&& let Some(schema) = self.a2a_command_schema(&op)
{
let mut payload = command_data(message).unwrap_or_else(|| json!({}));
if let Some(o) = payload.as_object_mut() {
o.remove("op");
}
if let Err(errs) = crate::jsonschema::validate(&schema, &payload) {
return err_obj(
::mcp::rpc::INVALID_PARAMS,
&format!(
"command {op:?} payload does not match its declared schema: {}",
errs.join("; ")
),
);
}
}
if !declared && let Some(op) = command_op(message) {
return self.a2a_command(principal, &op, message);
}
let text = message_text(message);
if text.trim().is_empty() && !declared {
return err_obj(
::mcp::rpc::INVALID_PARAMS,
"message has no text or command part",
);
}
let message_id = message["messageId"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| self.next_id("msg"));
let existing = message["taskId"].as_str().and_then(|tid| {
self.tasks
.get(tid)
.map(|t| (tid.to_string(), t.context_id.clone(), t.principal.clone()))
});
if let Some((tid, ctx, owner)) = &existing
&& (owner.as_deref() == Some(principal.id.as_str()) || principal.is_operator())
&& let Some(i) = self
.pending
.iter()
.position(|p| matches!(&p.kind, PendingKind::Human { task, .. } if task == tid))
{
let addressee = match &self.pending[i].kind {
PendingKind::Human { addressee, .. } => addressee.clone(),
_ => None,
};
let mut via = "human";
if let Some(a) = &addressee
&& !a.matches(principal)
{
if !principal.is_operator() {
let want = a.describe();
self.log.info(
"human.answer.not_addressed",
json!({"task": tid, "from": principal.id, "addressee": want}),
);
self.audit_a2a(
"SendMessage",
None,
principal,
"not_addressed",
json!({"task": tid, "addressee": want}),
None,
);
return err_obj(
::mcp::rpc::INVALID_PARAMS,
&format!(
"this decision is for {want}; your answer was not recorded and the gate is still open"
),
);
}
via = "operator_override";
self.log.warn(
"human.answer.override",
json!({"task": tid, "by": principal.id, "addressee": a.describe()}),
);
}
self.feed_push(
"message",
FeedVis::Owner(Some(principal.id.clone())),
json!({"contextId": ctx, "taskId": tid, "messageId": message_id, "principal": principal.id, "text": text}),
);
self.human_answer(i, &text, via, Some(&principal.id.clone()));
return json!({"task": self.tasks.get(tid).map(Task::to_a2a).unwrap_or(Value::Null)});
}
let (task_id, ctx_id) = match existing {
Some((tid, ctx, owner))
if owner.as_deref() == Some(principal.id.as_str()) || principal.is_operator() =>
{
if let Some(t) = self.tasks.get_mut(&tid) {
t.transition(State::Working, None);
}
(tid, ctx)
}
_ => {
let ctx = message["contextId"]
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(|| self.next_id("a2a"));
let tid = self.task_create(&ctx, principal, Link::Turn { ctx: ctx.clone() });
(tid, ctx)
}
};
let payload = json!({"context_id": ctx_id, "text": text, "parts": message["parts"],
"task": task_id, "message_id": message_id,
"role": match principal.role {
crate::config::v2::Role::Operator => "operator",
crate::config::v2::Role::User => "user",
crate::config::v2::Role::Agent => "agent",
crate::config::v2::Role::Anonymous => "anonymous",
}});
match self.accept_event(kinds::A2A_MESSAGE, Some(principal.id.clone()), payload) {
Ok(inbox_id) => {
self.event_to_task.insert(inbox_id, task_id.clone());
if let Some(t) = self.tasks.get_mut(&task_id) {
t.transition(State::Working, None);
}
self.task_sync(&task_id);
self.feed_push(
"message",
FeedVis::Owner(Some(principal.id.clone())),
json!({"contextId": ctx_id, "taskId": task_id, "messageId": message_id, "principal": principal.id, "text": text}),
);
json!({"task": self.tasks.get(&task_id).map(Task::to_a2a).unwrap_or(Value::Null)})
}
Err(e) => {
self.a2a_task_fail(&task_id, &e);
err_obj(rpc_internal(), &e)
}
}
}
fn workflow_declares_a2a_command(&self, op: &str) -> bool {
self.workflows.values().any(|w| {
w.start_steps().into_iter().any(|s| {
s.kind == "a2a" && s.spec.get("command").and_then(Value::as_str) == Some(op)
})
})
}
fn a2a_command_schema(&self, op: &str) -> Option<Value> {
self.workflows.values().find_map(|w| {
w.start_steps().into_iter().find_map(|s| {
(s.kind == "a2a" && s.spec.get("command").and_then(Value::as_str) == Some(op))
.then(|| s.spec.get("schema").cloned())
.flatten()
})
})
}
fn a2a_command(&mut self, principal: &Principal, op: &str, message: &Value) -> Value {
if !principal.may_command(op) {
return err_obj(
-32003,
&format!("command {op:?} not granted to {}", principal.id),
);
}
let data = command_data(message).unwrap_or_else(|| json!({}));
match op {
"interface.info" => return self.interface_info(),
"conversation.get" => return self.interface_conversation_get(principal, &data),
"run.get" => return self.interface_run_get(principal, &data),
"subagent.get" => return self.interface_subagent_get(&data),
"debug.events" => return self.interface_debug_events(&data),
"pairing.code" => return self.interface_pairing_code(),
"config.set" => return self.interface_config_set(&data),
_ => {}
}
let ctx = message["contextId"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| self.next_id("a2a"));
if matches!(
op,
"workflow.run"
| "workflow.cancel"
| "workflow.signal"
| "subagent.send"
| "subagent.kill"
) {
self.feed_push(
"command",
FeedVis::Owner(Some(principal.id.clone())),
json!({"op": op, "principal": principal.id, "contextId": ctx}),
);
}
match op {
"status" => {
let s = self.status_value();
let text = format!(
"{} runs, {} subagents, {} conversations; budget active: {}",
s["runs"].as_array().map(|a| a.len()).unwrap_or(0),
s["subagents"].as_array().map(|a| a.len()).unwrap_or(0),
s["conversations"].as_array().map(|a| a.len()).unwrap_or(0),
s["budget"]["active"]
);
self.task_complete_now(
&ctx,
principal,
Link::Turn { ctx: ctx.clone() },
State::Completed,
Some(text),
Some(s),
)
}
"config" => self.task_complete_now(
&ctx,
principal,
Link::Turn { ctx: ctx.clone() },
State::Completed,
Some("effective configuration".into()),
Some(json!({"config": redact_settings(&self.settings_doc)})),
),
"workflow.run" => {
let name = data["name"]
.as_str()
.or_else(|| data["workflow"].as_str())
.unwrap_or("")
.to_string();
let Some(wf) = self.workflows.get(&name) else {
return err_obj(
::mcp::rpc::INVALID_PARAMS,
&format!("no such workflow {name:?}"),
);
};
if let Some(cause) = self
.pressure
.refusal(wf.priority == crate::engine::model::Priority::Low)
{
return err_obj(rpc_internal(), &format!("shedding: {cause}"));
}
let run_id = format!("{}-{}", name, crate::state::ulid::new());
let task_id = self.task_create(&ctx, principal, Link::Run { id: run_id.clone() });
let payload = json!({
"workflow": name,
"run_id": run_id,
"inputs": data.get("inputs").cloned().unwrap_or_else(|| json!({})),
"payload": {"requested_by": principal.id},
"task": task_id,
"conversation": ctx,
});
match self.accept_event(kinds::WORKFLOW_RUN, Some(principal.id.clone()), payload) {
Ok(_) => {
if let Some(t) = self.tasks.get_mut(&task_id) {
t.transition(State::Working, None);
}
self.task_sync(&task_id);
json!({"task": self.tasks.get(&task_id).map(Task::to_a2a).unwrap_or(Value::Null)})
}
Err(e) => err_obj(rpc_internal(), &e),
}
}
"workflow.status" => {
let view: Vec<Value> = match data["run"].as_str() {
Some(id) => self
.runs
.get(id)
.map(|r| vec![run_view(id, r)])
.unwrap_or_default(),
None => self
.runs
.iter()
.filter(|(_, r)| {
principal.is_operator()
|| r.principal.as_deref() == Some(principal.id.as_str())
})
.map(|(id, r)| run_view(id, r))
.collect(),
};
self.task_complete_now(
&ctx,
principal,
Link::Turn { ctx: ctx.clone() },
State::Completed,
None,
Some(json!({"runs": view})),
)
}
"workflow.cancel" => match data["run"].as_str() {
Some(id) if self.runs.contains_key(id) => {
self.cancel_run(id, "cancelled over A2A");
self.task_complete_now(
&ctx,
principal,
Link::Run { id: id.to_string() },
State::Completed,
Some(format!("run {id} cancelled")),
None,
)
}
_ => err_obj(TASK_NOT_FOUND, "no such run"),
},
"workflow.signal" => {
let name = data["name"].as_str().unwrap_or("").to_string();
if name.is_empty() {
return err_obj(::mcp::rpc::INVALID_PARAMS, "workflow.signal needs a name");
}
let payload = data.get("payload").cloned().unwrap_or(Value::Null);
let target = data["run"].as_str().map(str::to_string);
let delivered =
self.deliver_signal(&name, payload, target.as_deref(), Some(&principal.id));
self.task_complete_now(
&ctx,
principal,
Link::Turn { ctx: ctx.clone() },
State::Completed,
Some(format!("signal {name:?} delivered to {delivered}")),
Some(json!({"signal": name, "delivered": delivered})),
)
}
"subagent.send" | "subagent.kill" | "subagent.status" => {
let mut args = data.clone();
if op == "subagent.send"
&& args.get("message").is_none()
&& let Some(t) = data["text"].as_str()
{
args["message"] = json!(t);
}
let tool_caller = crate::runtime::tools::ToolCaller {
principal: Some(principal.id.clone()),
..Default::default()
};
match self.subagent_tool(&tool_caller, op, args) {
crate::runtime::tools::ToolOutcome::Ready(v, false) => self.task_complete_now(
&ctx,
principal,
Link::Turn { ctx: ctx.clone() },
State::Completed,
None,
Some(v),
),
crate::runtime::tools::ToolOutcome::Ready(v, true) => err_obj(
::mcp::rpc::INVALID_PARAMS,
v.as_str().unwrap_or("subagent op failed"),
),
_ => err_obj(rpc_internal(), "unexpected deferred subagent op"),
}
}
"plan.get" => {
let id = data["id"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| crate::context::ROOT.to_string());
match self.contexts.get(&id) {
Some(c)
if principal.is_operator()
|| c.principal.as_deref() == Some(principal.id.as_str()) =>
{
self.task_complete_now(
&ctx,
principal,
Link::Turn { ctx: ctx.clone() },
State::Completed,
None,
Some(json!({"conversation": id, "plan": c.plan, "progress": c.plan.as_ref().map(|p| p.progress())})),
)
}
_ => err_obj(TASK_NOT_FOUND, "no such conversation"),
}
}
other => err_obj(
UNSUPPORTED_OPERATION,
&format!(
"command {other:?} is not available over A2A yet; send a natural-language message instead"
),
),
}
}
pub(crate) fn feed_push(&self, kind: &str, vis: FeedVis, data: Value) {
if let Some(feed) = &self.a2a_feed {
feed.push(kind, vis, data);
}
}
fn debug_gate(&self) -> Option<Value> {
if !self.settings.interface.enabled {
return Some(err_obj(
UNSUPPORTED_OPERATION,
"the interface surface is disabled (set interface.enabled: true)",
));
}
if !self.settings.interface.debug {
return Some(err_obj(
UNSUPPORTED_OPERATION,
"debug reads are disabled (set interface.debug: true)",
));
}
None
}
fn interface_info(&self) -> Value {
if !self.settings.interface.enabled {
return err_obj(
UNSUPPORTED_OPERATION,
"the interface surface is disabled (set interface.enabled: true)",
);
}
let debug = self.settings.interface.debug;
let mut ops = vec!["interface.info", "config.set"];
if debug {
ops.extend([
"conversation.get",
"run.get",
"subagent.get",
"debug.events",
]);
}
if self.a2a_pairing.is_some() {
ops.push("pairing.code");
}
let display = &self.settings.interface.display;
json!({"interface": {
"enabled": true,
"debug": debug,
"version": crate::VERSION,
"instance": self.instance,
"model": self.model,
"protocol": 1,
"feed": {"ring": FEED_RING, "method": "SubscribeToEvents"},
"ops": ops,
"display": {
"top": display.top.clone().unwrap_or_else(default_display_top),
"bottom": display.bottom.clone().unwrap_or_else(default_display_bottom),
"values": self.display_values(),
},
"pairing": {"enabled": self.a2a_pairing.is_some()},
}})
}
fn display_values(&self) -> Value {
let d = &self.settings.interface.display;
let mut out = serde_json::Map::new();
for item in d.top.iter().flatten().chain(d.bottom.iter().flatten()) {
let Some(key) = item.strip_prefix("memory:") else {
continue;
};
if let Ok(Some(env)) = self.durable.get(crate::state::Kind::Memory, key)
&& let Ok(rec) = serde_json::from_value::<crate::context::memory::Record>(env.state)
&& !rec.expired(crate::state::now_ms())
{
out.insert(item.clone(), rec.value);
}
}
Value::Object(out)
}
fn interface_pairing_code(&self) -> Value {
if !self.settings.interface.enabled {
return err_obj(
UNSUPPORTED_OPERATION,
"the interface surface is disabled (set interface.enabled: true)",
);
}
let Some(p) = &self.a2a_pairing else {
return err_obj(
UNSUPPORTED_OPERATION,
"pairing is disabled (set interface.pairing.enabled: true)",
);
};
let (code, expires_in) = p.current_code();
json!({"pairing": {
"code": code,
"expires_in_ms": expires_in,
"window_ms": PAIR_WINDOW_SECS * 1000,
"role": format!("{:?}", p.role()).to_lowercase(),
"sessions": p.session_count(),
"url": self.settings.a2a.listen,
}})
}
fn a2a_pair(&mut self, params: &Value) -> Value {
let Some(p) = &self.a2a_pairing else {
return err_obj(
UNSUPPORTED_OPERATION,
"pairing is disabled (set interface.pairing.enabled: true)",
);
};
let code = params
.get("code")
.and_then(Value::as_str)
.unwrap_or_default();
if code.is_empty() {
return err_obj(::mcp::rpc::INVALID_PARAMS, "Pair needs a code");
}
match p.pair(code) {
Ok((token, expires)) => {
self.log
.info("interface.paired", json!({"role": format!("{:?}", p.role()).to_lowercase(), "sessions": p.session_count()}));
self.feed_push(
"pairing",
FeedVis::Operator,
json!({"paired": true, "sessions": p.session_count()}),
);
json!({"token": token, "expiresAt": expires, "role": format!("{:?}", p.role()).to_lowercase(),
"agent": {"name": "agentd", "instance": self.instance, "version": crate::VERSION}})
}
Err(e) => err_obj(-32003, &e),
}
}
fn interface_config_set(&mut self, data: &Value) -> Value {
if !self.settings.interface.enabled {
return err_obj(
UNSUPPORTED_OPERATION,
"the interface surface is disabled (set interface.enabled: true)",
);
}
let path = data["path"].as_str().unwrap_or_default();
let value = data.get("value").cloned().unwrap_or(Value::Null);
let applied: Result<Value, String> = match path {
"interface.debug" => match value.as_bool() {
Some(on) => {
self.settings.interface.debug = on;
if let Some(feed) = &self.a2a_feed {
feed.set_debug(on);
}
if on {
let cap = self
.settings
.observability
.events_ring
.map(|n| n as usize)
.unwrap_or(crate::obs::log::EVENTS_RING_DEFAULT);
crate::obs::log::install_event_ring(cap);
}
Ok(json!(on))
}
None => Err("interface.debug takes true|false".into()),
},
"interface.display.top" | "interface.display.bottom" => {
let items: Option<Vec<String>> = value.as_array().map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
});
match items {
Some(list) => {
if path.ends_with(".top") {
self.settings.interface.display.top = Some(list.clone());
} else {
self.settings.interface.display.bottom = Some(list.clone());
}
Ok(json!(list))
}
None => Err("display lists take an array of item names".into()),
}
}
"agent.approval" => match value.as_str() {
Some("ask") | Some("await") | Some("human") => {
self.settings.agent.approval = crate::config::v2::Approval::Ask;
Ok(json!("ask"))
}
Some("auto") => {
self.settings.agent.approval = crate::config::v2::Approval::Auto;
Ok(json!("auto"))
}
Some("accept") | Some("accept_all") | Some("yes") => {
self.settings.agent.approval = crate::config::v2::Approval::Accept;
Ok(json!("accept"))
}
_ => Err("agent.approval takes ask | auto | accept".into()),
},
other => Err(format!(
"{other:?} is not runtime-settable; settable: interface.debug, interface.display.top, interface.display.bottom, agent.approval — everything else is the config file + SIGHUP (docs/configuration.md §11)"
)),
};
match applied {
Ok(v) => {
self.log
.info("interface.config_set", json!({"path": path, "value": v}));
self.feed_push(
"config",
FeedVis::Operator,
json!({"path": path, "value": v}),
);
json!({"set": {"path": path, "value": v}})
}
Err(e) => err_obj(::mcp::rpc::INVALID_PARAMS, &e),
}
}
fn interface_subagent_get(&self, data: &Value) -> Value {
if let Some(gate) = self.debug_gate() {
return gate;
}
let handle = data["handle"]
.as_str()
.or_else(|| data["id"].as_str())
.unwrap_or("");
let Some(s) = self.subagents.get(handle) else {
return err_obj(TASK_NOT_FOUND, "no such subagent");
};
json!({"subagent": {
"handle": s.handle,
"mode": s.mode,
"status": s.status,
"attempt": s.attempt,
"tokens": s.tokens,
"instruction": truncate_strings(json!(s.instruction), 4096),
"result": s.result.clone().map(|r| truncate_strings(r, 4096)),
"error": s.error,
"requested_by": s.requested_by,
"created": s.created,
"updated": s.updated,
"node": s.node.map(|n| n.0),
}})
}
fn interface_conversation_get(&self, principal: &Principal, data: &Value) -> Value {
if let Some(gate) = self.debug_gate() {
return gate;
}
let id = data["id"].as_str().unwrap_or("");
let limit = data["limit"].as_u64().unwrap_or(200).min(1000) as usize;
let Some(c) = self.contexts.get(id) else {
return err_obj(TASK_NOT_FOUND, "no such conversation");
};
let owner_ok =
principal.is_operator() || c.principal.as_deref() == Some(principal.id.as_str());
if !owner_ok {
return err_obj(TASK_NOT_FOUND, "no such conversation");
}
let skip = c.messages.len().saturating_sub(limit);
let messages: Vec<Value> = c.messages[skip..]
.iter()
.map(|m| truncate_strings(serde_json::to_value(m).unwrap_or(Value::Null), 4096))
.collect();
json!({"conversation": {
"id": id,
"kind": c.kind,
"version": c.version,
"turns": c.turns,
"est_tokens": c.est_tokens,
"principal": c.principal,
"task": c.task,
"skills": c.skills.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
"plan": c.plan,
"summary": if c.summary.is_empty() { Value::Null } else { serde_json::to_value(&c.summary).unwrap_or(Value::Null) },
"total_messages": c.messages.len(),
"messages": messages,
"updated": c.updated,
}})
}
fn interface_run_get(&self, principal: &Principal, data: &Value) -> Value {
if let Some(gate) = self.debug_gate() {
return gate;
}
let id = data["run"]
.as_str()
.or_else(|| data["id"].as_str())
.unwrap_or("");
let Some(r) = self.runs.get(id) else {
return err_obj(TASK_NOT_FOUND, "no such run");
};
let owner_ok =
principal.is_operator() || r.principal.as_deref() == Some(principal.id.as_str());
if !owner_ok {
return err_obj(TASK_NOT_FOUND, "no such run");
}
let steps: serde_json::Map<String, Value> = r
.steps
.iter()
.map(|(sid, st)| {
(
sid.clone(),
json!({
"status": st.status,
"attempt": st.attempt,
"started": st.started,
"finished": st.finished,
"error": st.error,
"wait": st.wait,
"output": st.output.clone().map(|o| truncate_strings(o, 2048)),
}),
)
})
.collect();
let mut run = r.summary();
run["steps"] = Value::Object(steps);
run["vars"] = truncate_strings(Value::Object(r.vars.clone()), 2048);
json!({"run": run})
}
fn interface_debug_events(&self, data: &Value) -> Value {
if let Some(gate) = self.debug_gate() {
return gate;
}
let after = data["after"].as_u64().unwrap_or(0);
let limit = data["limit"].as_u64().unwrap_or(200).min(500) as usize;
let level = data["level"].as_str();
let prefixes: Vec<&str> = data["prefix"].as_str().map(|p| vec![p]).unwrap_or_default();
match crate::obs::log::read_event_window(after, limit, level, &prefixes) {
Some(w) => {
json!({"events": w.events, "newest_seq": w.newest_seq, "oldest_seq": w.oldest_seq, "dropped": w.dropped})
}
None => err_obj(rpc_internal(), "the event ring is not installed"),
}
}
pub(crate) fn feed_tick(&mut self) {
if self.a2a_feed.is_none() {
return;
}
if self.feed_last.elapsed() < Duration::from_millis(250) {
return;
}
self.feed_last = Instant::now();
let mut fresh: Vec<(String, &'static str, FeedVis, Value)> = Vec::new();
for (id, r) in &self.runs {
fresh.push((
format!("run:{id}"),
"run",
FeedVis::Owner(r.principal.clone()),
r.summary(),
));
}
for c in self.contexts.status().as_array().into_iter().flatten() {
let id = c["id"].as_str().unwrap_or("").to_string();
let owner = c["principal"].as_str().map(str::to_string);
fresh.push((
format!("conv:{id}"),
"conversation",
FeedVis::Owner(owner),
c.clone(),
));
}
for (h, s) in &self.subagents {
fresh.push((
format!("sub:{h}"),
"subagent",
FeedVis::Operator,
json!({"handle": s.handle, "mode": s.mode, "status": s.status, "tokens": s.tokens, "error": s.error, "updated": s.updated}),
));
}
for c in self.children.status().as_array().into_iter().flatten() {
let node = c["node"].as_u64().unwrap_or(0);
fresh.push((
format!("child:{node}"),
"child",
FeedVis::Operator,
c.clone(),
));
}
fresh.push((
"status".into(),
"status",
FeedVis::Operator,
json!({
"instance": self.instance,
"model": self.model,
"draining": self.draining,
"inbox_pending": self.inbox_queue.len(),
"counters": {"turns": self.counters.turns, "tool_calls": self.counters.tool_calls, "runs_started": self.counters.runs_started, "runs_finished": self.counters.runs_finished, "tokens_in": self.counters.tokens_in, "tokens_out": self.counters.tokens_out},
"budget": self.governor.status(crate::state::now_ms()),
"store": {"kind": self.durable.store_kind(), "degraded": self.durable.is_degraded()},
}),
));
let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut pushes: Vec<(&'static str, FeedVis, Value)> = Vec::new();
for (key, kind, vis, data) in fresh {
let mark = fingerprint(&data);
seen.insert(key.clone());
if self.feed_marks.get(&key) != Some(&mark) {
self.feed_marks.insert(key, mark);
pushes.push((kind, vis, data));
}
}
let gone: Vec<String> = self
.feed_marks
.keys()
.filter(|k| !seen.contains(*k))
.cloned()
.collect();
for key in gone {
self.feed_marks.remove(&key);
if let Some((section, id)) = key.split_once(':') {
let kind: &'static str = match section {
"run" => "run.removed",
"conv" => "conversation.removed",
"sub" => "subagent.removed",
_ => "child.removed",
};
pushes.push((kind, FeedVis::Operator, json!({"id": id})));
}
}
for (kind, vis, data) in pushes {
self.feed_push(kind, vis, data);
}
}
fn a2a_get_task(&self, principal: &Principal, params: &Value) -> Value {
let id = params
.get("id")
.or_else(|| params.get("taskId"))
.and_then(Value::as_str)
.unwrap_or("");
match self.tasks.get(id) {
Some(t)
if principal.is_operator()
|| t.principal.as_deref() == Some(principal.id.as_str()) =>
{
t.to_a2a()
}
_ => err_obj(TASK_NOT_FOUND, "task not found"),
}
}
fn a2a_list_tasks(&self, principal: &Principal) -> Value {
let tasks: Vec<Value> = self
.tasks
.values()
.filter(|t| {
principal.is_operator() || t.principal.as_deref() == Some(principal.id.as_str())
})
.map(|t| t.summary())
.collect();
let n = tasks.len();
json!({"tasks": tasks, "totalSize": n, "pageSize": n, "nextPageToken": ""})
}
fn owned_task(&self, principal: &Principal, params: &Value) -> Result<String, Value> {
let id = params
.get("taskId")
.or_else(|| params.get("id"))
.and_then(Value::as_str)
.unwrap_or("");
match self.tasks.get(id) {
Some(t)
if principal.is_operator()
|| t.principal.as_deref() == Some(principal.id.as_str()) =>
{
Ok(id.to_string())
}
_ => Err(err_obj(TASK_NOT_FOUND, "task not found")),
}
}
fn push_enabled(&self) -> Result<(), Value> {
if self.settings.a2a.push.enabled {
Ok(())
} else {
Err(err_obj(
-32003,
"push notifications are not enabled (set a2a.push.enabled: true)",
))
}
}
fn a2a_push_set(&mut self, principal: &Principal, params: &Value) -> Value {
if let Err(e) = self.push_enabled() {
return e;
}
let cfg = params
.get("pushNotificationConfig")
.or_else(|| params.get("config"))
.cloned()
.unwrap_or_else(|| params.clone());
let task_id = match self.owned_task(principal, params) {
Ok(id) => id,
Err(e) => return e,
};
let id = cfg
.get("id")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(|| self.next_id("push"));
let target = match crate::a2a::push::from_wire(&cfg, id.clone()) {
Ok(t) => t,
Err(e) => return err_obj(::mcp::rpc::INVALID_PARAMS, &e),
};
let allow_private = self.settings.a2a.push.allow_private;
if let Err(e) = crate::a2a::push::check_url(&target.url, allow_private) {
return err_obj(
::mcp::rpc::INVALID_PARAMS,
&format!("push url refused: {e}"),
);
}
if let Err(e) = crate::config::v2::egress_allows(
&self.settings.services,
self.settings.security.egress,
crate::config::v2::ServiceKind::Http,
&target.url,
) {
return err_obj(
::mcp::rpc::INVALID_PARAMS,
&format!("push url refused: {e}"),
);
}
let wire = crate::a2a::push::to_wire(&task_id, &target);
if let Some(t) = self.tasks.get_mut(&task_id) {
t.push.retain(|p| p.id != id);
t.push.push(target);
t.dirty = true;
}
self.task_persist(&task_id);
self.log.info(
"a2a.push.registered",
json!({"task": task_id, "config": id, "principal": principal.id}),
);
wire
}
fn a2a_push_get(&mut self, principal: &Principal, params: &Value) -> Value {
if let Err(e) = self.push_enabled() {
return e;
}
let task_id = match self.owned_task(principal, params) {
Ok(id) => id,
Err(e) => return e,
};
let want = params
.get("pushNotificationConfigId")
.or_else(|| params.get("configId"))
.and_then(Value::as_str)
.unwrap_or("");
match self
.tasks
.get(&task_id)
.and_then(|t| t.push.iter().find(|p| want.is_empty() || p.id == want))
{
Some(p) => crate::a2a::push::to_wire(&task_id, p),
None => err_obj(TASK_NOT_FOUND, "no such push notification config"),
}
}
fn a2a_push_list(&mut self, principal: &Principal, params: &Value) -> Value {
if let Err(e) = self.push_enabled() {
return e;
}
let task_id = match self.owned_task(principal, params) {
Ok(id) => id,
Err(e) => return e,
};
let configs: Vec<Value> = self
.tasks
.get(&task_id)
.map(|t| {
t.push
.iter()
.map(|p| crate::a2a::push::to_wire(&task_id, p))
.collect()
})
.unwrap_or_default();
json!({ "configs": configs })
}
fn a2a_push_delete(&mut self, principal: &Principal, params: &Value) -> Value {
if let Err(e) = self.push_enabled() {
return e;
}
let task_id = match self.owned_task(principal, params) {
Ok(id) => id,
Err(e) => return e,
};
let want = params
.get("pushNotificationConfigId")
.or_else(|| params.get("configId"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if let Some(t) = self.tasks.get_mut(&task_id) {
t.push.retain(|p| !want.is_empty() && p.id != want);
t.dirty = true;
}
self.task_persist(&task_id);
json!({})
}
fn a2a_cancel_task(&mut self, principal: &Principal, params: &Value) -> Value {
let id = params
.get("id")
.or_else(|| params.get("taskId"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let owned = match self.tasks.get(&id) {
Some(t) => {
principal.is_operator() || t.principal.as_deref() == Some(principal.id.as_str())
}
None => false,
};
if !owned {
return err_obj(TASK_NOT_FOUND, "task not found");
}
if self.tasks.get(&id).is_some_and(|t| t.state.is_terminal()) {
return self.tasks.get(&id).map(Task::to_a2a).unwrap_or(Value::Null);
}
if let Some(i) = self
.pending
.iter()
.position(|p| matches!(&p.kind, PendingKind::Human { task, .. } if task == &id))
{
self.human_fail(i, "ask_human: the gate task was cancelled");
}
match self.tasks.get(&id).map(|t| t.link.clone()) {
Some(Link::Run { id: run }) if self.runs.contains_key(&run) => {
self.cancel_run(&run, "task cancelled over A2A")
}
Some(Link::Subagent { handle }) => {
if let Some(node) = self.subagents.get(&handle).and_then(|s| s.node) {
self.children.cancel(node, "task cancelled over A2A");
}
}
_ => {}
}
if let Some(t) = self.tasks.get_mut(&id) {
t.transition(State::Canceled, Some("cancelled".into()));
}
self.task_persist(&id);
self.task_sync(&id);
self.tasks.get(&id).map(Task::to_a2a).unwrap_or(Value::Null)
}
fn a2a_admin(&mut self, _principal: &Principal, method: &str, params: &Value) -> Value {
let reason = params
.get("reason")
.and_then(Value::as_str)
.unwrap_or("operator request")
.to_string();
match method.to_ascii_lowercase().as_str() {
"drain" | "a2a.drain" | "lameduck" | "a2a.lameduck" => {
self.begin_drain(&reason);
json!({"ok": true, "state": "draining", "reason": reason})
}
"cancel" | "a2a.cancel" => {
if let Some(run) = params.get("run").and_then(Value::as_str) {
self.cancel_run(run, &reason);
json!({"ok": true, "cancelled": run})
} else {
err_obj(::mcp::rpc::INVALID_PARAMS, "cancel needs a run id")
}
}
"pause" | "a2a.pause" => match params.get("run").and_then(Value::as_str) {
Some(run) => match self.runs.get_mut(run) {
Some(r) if r.status.is_terminal() => {
err_obj(::mcp::rpc::INVALID_PARAMS, "the run is already terminal")
}
Some(r) => {
r.status = crate::engine::RunStatus::Paused;
r.touch();
self.log
.info("run.paused", json!({"run": run, "reason": reason}));
json!({"ok": true, "paused": run})
}
None => err_obj(TASK_NOT_FOUND, "no such run"),
},
None => {
self.paused = true;
crate::obs::metrics::set_paused(true);
self.log.info("agent.paused", json!({"reason": reason}));
self.feed_push(
"lifecycle",
FeedVis::All,
json!({"paused": true, "reason": reason}),
);
json!({"ok": true, "state": "paused", "reason": reason})
}
},
"resume" | "a2a.resume" => match params.get("run").and_then(Value::as_str) {
Some(run) => match self.runs.get_mut(run) {
Some(r) if r.status == crate::engine::RunStatus::Paused => {
r.status = crate::engine::RunStatus::Running;
r.touch();
self.log.info("run.resumed", json!({"run": run}));
json!({"ok": true, "resumed": run})
}
Some(_) => err_obj(::mcp::rpc::INVALID_PARAMS, "the run is not paused"),
None => err_obj(TASK_NOT_FOUND, "no such run"),
},
None => {
self.paused = false;
crate::obs::metrics::set_paused(false);
self.log.info("agent.resumed", json!({}));
self.feed_push("lifecycle", FeedVis::All, json!({"paused": false}));
json!({"ok": true, "state": "running"})
}
},
other => err_obj(
UNSUPPORTED_OPERATION,
&format!("unknown admin op {other:?}"),
),
}
}
fn a2a_agent_card(&self) -> Value {
let skills: Vec<Value> = self
.workflows
.values()
.map(|w| json!({"id": w.name, "name": w.name, "description": w.description.clone().unwrap_or_default(), "tags": ["workflow"]}))
.collect();
let mut capabilities = json!({
"streaming": true,
"pushNotifications": self.settings.a2a.push.enabled,
"stateTransitionHistory": true,
});
if self.settings.interface.enabled {
capabilities["extensions"] =
json!([{"uri": "urn:agentd:interface", "params": {"enabled": true}}]);
}
let url = self.settings.a2a.listen.clone().unwrap_or_default();
json!({
"name": "agentd",
"description": "A durable agent (agentd) — conversations, workflows, and subagents over A2A.",
"version": crate::VERSION,
"supportedInterfaces": [
{"url": url, "protocolBinding": "JSONRPC", "protocolVersion": "0.3.0"}
],
"protocolVersion": "0.3.0",
"url": url,
"preferredTransport": "JSONRPC",
"capabilities": capabilities,
"defaultInputModes": ["text/plain", "application/json"],
"defaultOutputModes": ["text/plain", "application/json"],
"skills": skills,
})
}
fn a2a_extended_card(&self, principal: &Principal) -> Value {
if principal.is_anonymous() {
return err_obj(-32007, "the extended card requires an authenticated caller");
}
let mut card = self.a2a_agent_card();
let skills: Vec<Value> = self
.workflows
.values()
.filter(|w| principal.may_command(&format!("workflow.run:{}", w.name)))
.map(|w| json!({"id": w.name, "name": w.name, "description": w.description.clone().unwrap_or_default(), "tags": ["workflow"]}))
.collect();
card["skills"] = json!(skills);
card["supportsAuthenticatedExtendedCard"] = json!(true);
card
}
pub(crate) fn task_create(&mut self, ctx: &str, principal: &Principal, link: Link) -> String {
let id = match self.reserved_task_id.take() {
Some(id) => id,
None => new_task_id(),
};
let task = Task::new(&id, ctx, Some(&principal.id), link);
self.tasks.insert(id.clone(), task);
self.task_persist(&id);
self.task_sync(&id);
id
}
fn task_complete_now(
&mut self,
ctx: &str,
principal: &Principal,
link: Link,
state: State,
text: Option<String>,
result: Option<Value>,
) -> Value {
let id = self.task_create(ctx, principal, link);
if let Some(t) = self.tasks.get_mut(&id) {
if let Some(r) = result {
t.set_result(r);
}
t.transition(state, text);
}
self.task_persist(&id);
self.task_sync(&id);
json!({"task": self.tasks.get(&id).map(Task::to_a2a).unwrap_or(Value::Null)})
}
pub(crate) fn task_sync(&self, id: &str) {
let Some(sink) = &self.a2a_sink else {
return;
};
let allow_private = self.settings.a2a.push.allow_private;
match self.tasks.get(id) {
Some(t) => {
if t.state.is_terminal()
&& let Some(a) = crate::a2a::wire::result_artifact(t)
{
sink.artifact(&t.id, &t.context_id, a.clone());
}
sink.status(
&t.id,
&t.context_id,
t.state.to_wire(),
t.message.as_deref(),
t.updated,
);
if !t.push.is_empty() {
sink.push(t, allow_private);
}
self.feed_push(
"task",
FeedVis::Owner(t.principal.clone()),
json!({"task": t.to_a2a(), "link": t.link, "principal": t.principal}),
);
}
None => {
self.feed_push("task.removed", FeedVis::Operator, json!({"id": id}));
}
}
}
pub(crate) fn task_persist(&mut self, id: &str) {
if !self.tasks.get(id).is_some_and(|t| t.dirty) {
return;
}
let encoded = self.tasks.get(id).map(serde_json::to_value);
match encoded {
Some(Ok(v)) => {
if let Err(e) = self.durable.put(crate::state::Kind::Task, id, v, None) {
self.log.warn(
"a2a.task.persist.fail",
json!({"task": id, "err": e.to_string()}),
);
} else if let Some(t) = self.tasks.get_mut(id) {
t.dirty = false;
}
}
Some(Err(e)) => self.log.warn(
"a2a.task.encode.fail",
json!({"task": id, "err": e.to_string()}),
),
None => {}
}
}
pub(crate) fn a2a_task_for_event(
&mut self,
event: Option<&str>,
state: State,
text: Option<String>,
result: Option<Value>,
) {
let Some(ev) = event else { return };
let Some(task_id) = self.event_to_task.remove(ev) else {
return;
};
if let Some(t) = self.tasks.get_mut(&task_id) {
if let Some(r) = result {
t.set_result(r);
}
t.transition(state, text);
}
self.task_persist(&task_id);
self.task_sync(&task_id);
}
pub(crate) fn a2a_task_for_run(
&mut self,
task_id: &str,
status: &str,
output: Option<&Value>,
error: Option<&str>,
) {
if !self.tasks.contains_key(task_id) {
return;
}
let state = State::from_run(status);
if let Some(t) = self.tasks.get_mut(task_id) {
if let Some(o) = output {
t.set_result(o.clone());
}
t.transition(state, error.map(str::to_string));
}
self.task_persist(task_id);
self.task_sync(task_id);
}
fn a2a_task_fail(&mut self, id: &str, err: &str) {
if let Some(t) = self.tasks.get_mut(id) {
t.transition(State::Failed, Some(err.to_string()));
}
self.task_persist(id);
self.task_sync(id);
}
pub(crate) fn restore_a2a_tasks(&mut self, envs: &[crate::store::Envelope]) {
for env in envs {
match serde_json::from_value::<Task>(env.state.clone()) {
Ok(t) => {
let id = t.id.clone();
self.tasks.insert(id.clone(), t);
self.task_sync(&id);
}
Err(e) => self.log.warn(
"restore.task.corrupt",
json!({"id": env.id, "err": e.to_string()}),
),
}
}
self.rebuild_human_asks();
}
}
fn run_view(id: &str, r: &crate::engine::RunState) -> Value {
json!({"run": id, "workflow": r.workflow, "status": r.status.as_str(), "output": r.output, "error": r.error})
}
pub(crate) struct A2aServing {
pub feed: Option<Arc<SharedFeed>>,
pub pairing: Option<Arc<PairingState>>,
pub listener: crate::a2a::serve::Listener,
}
pub(crate) fn spawn_a2a_listener(
a2a: &crate::config::v2::A2a,
interface: &crate::config::v2::Interface,
events_tx: Sender<Event>,
resolver: Resolver,
env: &dyn Fn(&str) -> Option<String>,
_write_timeout: Duration,
log: Logger,
) -> Result<A2aServing, String> {
use std::path::Path;
let listen = a2a.listen.as_deref().ok_or("a2a.listen is not set")?;
let target =
crate::config::ServeTarget::parse(listen).map_err(|e| format!("a2a.listen: {e}"))?;
let (bind, tls_scheme) = match &target {
crate::config::ServeTarget::Http { bind, tls } => (bind.clone(), *tls),
crate::config::ServeTarget::Unix { path } => (path.clone(), false),
};
let unix_listener = matches!(&target, crate::config::ServeTarget::Unix { .. });
let server_bearer = match &a2a.bearer {
Some(b) => {
Some(crate::sec::secret::resolve(&b.0, env).map_err(|e| format!("a2a.bearer: {e}"))?)
}
None => None,
};
let pairing = if interface.enabled && interface.pairing.enabled {
let role = interface
.pairing
.role
.unwrap_or(crate::config::v2::Role::Operator);
let ttl = interface
.pairing
.ttl
.map(|d| d.0)
.unwrap_or(Duration::from_secs(12 * 3600));
Some(Arc::new(
PairingState::new(role, ttl).map_err(|e| format!("interface.pairing: {e}"))?,
))
} else {
None
};
let loopback_listener =
unix_listener || crate::net::http::is_loopback_host(crate::config::serve_host_of(&bind));
let require_auth = a2a.tls.client_ca.is_some()
|| server_bearer.is_some()
|| (pairing.is_some() && !loopback_listener);
let tls = if tls_scheme {
let cert = a2a
.tls
.cert
.as_deref()
.ok_or("a2a.tls.cert is required for https")?;
let key = a2a
.tls
.key
.as_deref()
.ok_or("a2a.tls.key is required for https")?;
let acceptor = crate::net::tls::TlsAcceptor::from_paths(
Path::new(cert),
Path::new(key),
a2a.tls.client_ca.as_deref().map(Path::new),
)
.map_err(|e| format!("a2a tls: {e}"))?;
Some(acceptor.server_config())
} else {
None
};
let feed = interface
.enabled
.then(|| Arc::new(SharedFeed::new(interface.debug)));
let bridge = A2aBridge::with_feed(events_tx, resolver, feed.clone());
let listener = crate::a2a::serve::spawn(
if unix_listener {
crate::a2a::serve::Bind::Unix(bind.clone())
} else {
crate::a2a::serve::Bind::Tcp(bind.clone())
},
crate::a2a::serve::Opts {
auth: crate::a2a::serve::Auth {
require_auth,
server_bearer,
pairing: pairing.clone(),
},
extra_origins: interface.origins.clone(),
tls,
request_timeout: bridge.request_timeout,
stream_deadline: bridge.stream_deadline,
},
Arc::clone(&bridge),
feed.clone(),
log.clone(),
)?;
log.info("a2a.listen", json!({"authority": listen, "bound": listener.bound, "tls": tls_scheme, "mtls": a2a.tls.client_ca.is_some(), "require_auth": require_auth, "interface": interface.enabled, "interface_debug": interface.enabled && interface.debug, "pairing": pairing.is_some()}));
Ok(A2aServing {
feed,
pairing,
listener,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn task_ids_are_ulids_so_two_lives_cannot_mint_the_same_one() {
let a = new_task_id();
let b = new_task_id();
assert_ne!(a, b);
assert!(a.starts_with("task-"), "the id keeps its prefix: {a}");
assert_eq!(a.len(), "task-".len() + 26, "a 26-char ULID: {a}");
assert!(a < b, "still time-sortable: {a} < {b}");
}
#[test]
fn the_config_view_redacts_every_credential_the_schema_declares() {
let doc = json!({
"intelligence": {
"model": "gpt-5",
"token": "sk-inline-from-env",
"headers": {"Authorization": "Bearer sk-inline-header", "X-Tenant": "acme"},
"auth": {"kind": "static", "token": "sk-inline-auth", "value": "sk-inline-value"}
},
"a2a": {"listen": "https://0.0.0.0:8443", "bearer": "{{secret:A2A_BEARER}}",
"peers": [{"name": "p", "endpoint": "https://p", "headers": {"X-Key": "k-inline"}}]},
"mcp": {"servers": [{"name": "s", "endpoint": "https://s",
"oauth": {"token_url": "u", "client_id": "c", "client_secret": "cs-inline"}}]},
"security": {"aauth": {"provider": "p", "enroll_token": "et-inline"}},
"webhooks": {"default_auth": {"hmac": {"secret": "hs-inline"}}}
});
let r = redact_settings(&doc);
assert!(
!r.to_string().contains("inline"),
"no credential survives the view: {r}"
);
assert_eq!(r["intelligence"]["token"], REDACTED);
assert_eq!(r["intelligence"]["auth"]["token"], REDACTED);
assert_eq!(r["intelligence"]["auth"]["value"], REDACTED);
assert_eq!(r["intelligence"]["headers"]["Authorization"], REDACTED);
assert_eq!(r["a2a"]["peers"][0]["headers"]["X-Key"], REDACTED);
assert_eq!(r["mcp"]["servers"][0]["oauth"]["client_secret"], REDACTED);
assert_eq!(r["security"]["aauth"]["enroll_token"], REDACTED);
assert_eq!(r["webhooks"]["default_auth"]["hmac"]["secret"], REDACTED);
assert_eq!(r["intelligence"]["model"], "gpt-5");
assert_eq!(r["a2a"]["listen"], "https://0.0.0.0:8443");
assert_eq!(r["mcp"]["servers"][0]["oauth"]["client_id"], "c");
assert_eq!(r["a2a"]["bearer"], "{{secret:A2A_BEARER}}");
assert!(r["intelligence"]["headers"].get("X-Tenant").is_some());
}
#[test]
fn only_a_lone_secret_reference_survives_redaction() {
assert!(is_bare_secret_ref("{{secret:TOKEN}}"));
assert!(is_bare_secret_ref("{{secret-file:/run/secrets/tok}}"));
assert!(!is_bare_secret_ref("Bearer {{secret:TOKEN}}"));
assert!(!is_bare_secret_ref("{{secret:TOKEN}}-sk-tail"));
assert!(!is_bare_secret_ref("{{secret:}}"));
assert!(!is_bare_secret_ref("sk-plain"));
let r = redact_settings(&json!({"intelligence": {"token": "Bearer {{secret:T}} sk-tail"}}));
assert_eq!(r["intelligence"]["token"], REDACTED);
}
#[test]
fn command_and_text_extraction() {
let m = json!({"parts": [{"text": "please"}, {"data": {"agentd": {"op": "workflow.run", "name": "x"}}}]});
assert_eq!(command_op(&m), Some("workflow.run".to_string()));
assert_eq!(command_data(&m).unwrap()["name"], "x");
assert_eq!(
message_text(&json!({"parts": [{"text": "a"}, {"text": "b"}]})),
"a\nb"
);
assert_eq!(command_op(&json!({"parts": [{"text": "hi"}]})), None);
}
#[cfg(feature = "a2a")]
#[test]
fn mtls_san_resolves_to_the_matched_principal_role() {
use crate::a2a::Resolver;
use crate::obs::log::{Comp, Level, LogCtx, Logger};
let resolver = Resolver::build(
&serde_json::from_value(json!({
"principals": [
{"match": {"san": "spiffe://corp/ops/*"}, "role": "operator"},
{"match": {"san": "spiffe://corp/team/*"}, "role": "user", "grants": ["knowledge.*"]},
]
}))
.unwrap(),
&|_| None,
)
.unwrap();
let log = Logger::new(
LogCtx {
run_id: "t".into(),
agent_id: "0".into(),
agent_path: "0".into(),
comp: Comp::Agent,
pid: 0,
trace_id: None,
},
Level::Warn,
);
let (tx, _rx) = std::sync::mpsc::channel();
let _ = log;
let bridge = A2aBridge::new(tx, resolver);
let p = bridge.principal_of(true, None, None, vec!["spiffe://corp/team/alice".into()]);
assert_eq!(p.role, crate::config::v2::Role::User);
assert_eq!(p.id, "user:spiffe://corp/team/alice");
let op = bridge.principal_of(true, None, None, vec!["spiffe://corp/ops/root".into()]);
assert!(op.is_operator());
let anon = bridge.principal_of(true, None, None, vec!["spiffe://other/x".into()]);
assert!(
anon.is_anonymous(),
"unmatched cert is denied, not operator"
);
}
#[test]
fn the_feed_scopes_replays_and_evicts() {
let f = SharedFeed::new(true);
f.push(
"task",
FeedVis::Owner(Some("user:a".into())),
json!({"n": 1}),
);
f.push("status", FeedVis::Operator, json!({"n": 2}));
f.push("lifecycle", FeedVis::All, json!({"n": 3}));
f.push("task", FeedVis::Owner(None), json!({"n": 4})); let (op, cursor) = f.since(0, "operator", true, 100);
assert_eq!(op.len(), 4, "operator sees all: {op:?}");
assert_eq!(cursor, 4);
assert!(op[0].get("_vis").is_none(), "the vis tag is stripped");
let (a, cursor_a) = f.since(0, "user:a", false, 100);
assert_eq!(a.len(), 2, "owner + all: {a:?}");
assert_eq!(cursor_a, 4, "the cursor advances past invisible events");
let (b, _) = f.since(0, "user:b", false, 100);
assert_eq!(b.len(), 1, "only the `all` event");
let (resumed, _) = f.since(2, "operator", true, 100);
assert_eq!(resumed.len(), 2);
assert_eq!(resumed[0]["seq"], 3);
for i in 0..(FEED_RING + 8) {
f.push("task", FeedVis::All, json!({"i": i}));
}
let (newest, oldest, dropped) = f.bounds();
assert_eq!(newest, 4 + (FEED_RING as u64) + 8);
assert_eq!(dropped, 12, "4 seed + 8 overflow evicted");
assert_eq!(oldest, newest - (FEED_RING as u64) + 1);
}
#[test]
fn pairing_codes_rotate_verify_rate_limit_and_mint_sessions() {
use crate::config::v2::Role;
let p = PairingState::new(Role::Operator, Duration::from_secs(60)).unwrap();
let w = crate::state::now_ms() / 1000 / PAIR_WINDOW_SECS;
let (code, expires_in) = p.current_code();
assert_eq!(code, p.code_for(w));
assert_eq!(code.len(), 6);
assert!(code.chars().all(|c| c.is_ascii_digit()));
assert!(expires_in <= PAIR_WINDOW_SECS * 1000);
assert_ne!(p.code_for(w), p.code_for(w + 1));
let q = PairingState::new(Role::Operator, Duration::from_secs(60)).unwrap();
assert_ne!(p.code_for(w), q.code_for(w), "seeded from OS randomness");
let prev = p.code_for(w.saturating_sub(1));
let spaced = format!("{} {}", &code[..3], &code[3..]);
let (tok, exp) = p.pair(&spaced).unwrap();
assert!(tok.starts_with("pat-") && tok.len() > 40, "{tok}");
assert!(exp > crate::state::now_ms());
let _ = p.pair(&prev).unwrap();
assert_eq!(p.session_count(), 2);
assert_eq!(p.check_bearer(&tok), Some(Role::Operator));
assert_eq!(p.check_bearer("pat-nope"), None);
assert_eq!(p.check_bearer("other"), None);
for _ in 0..PAIR_MAX_FAILS {
assert!(p.pair("000000").is_err() || p.pair("999999").is_err());
}
let locked = p.pair(&p.current_code().0);
assert!(
locked.is_err() && locked.unwrap_err().contains("too many"),
"even the right code is refused while locked out"
);
let short = PairingState::new(Role::User, Duration::from_millis(1)).unwrap();
let (t2, _) = short.pair(&short.current_code().0).unwrap();
std::thread::sleep(Duration::from_millis(5));
assert_eq!(short.check_bearer(&t2), None, "expired");
}
#[test]
fn paired_principals_and_display_defaults() {
use crate::config::v2::Role;
assert!(paired_principal(Role::Operator).is_operator());
let u = paired_principal(Role::User);
assert_eq!(u.role, Role::User);
assert_eq!(u.id, "user:paired");
assert!(u.may("SendMessage", None) && !u.may_command("config.set"));
assert!(default_display_top().contains(&"name".to_string()));
assert!(default_display_bottom().contains(&"conn".to_string()));
for item in default_display_top()
.iter()
.chain(default_display_bottom().iter())
{
assert!(
crate::config::v2::DISPLAY_ITEMS.contains(&item.as_str()),
"{item} is in the documented vocabulary"
);
}
}
#[test]
fn fingerprints_ignore_moving_fields_and_truncation_marks_cuts() {
let a = json!({"pid": 1, "age_ms": 100, "uptime_ms": 5});
let b = json!({"pid": 1, "age_ms": 999, "uptime_ms": 777});
assert_eq!(fingerprint(&a), fingerprint(&b), "age/uptime excluded");
let c = json!({"pid": 2, "age_ms": 100});
assert_ne!(fingerprint(&a), fingerprint(&c));
let big = "x".repeat(5000);
let t = truncate_strings(json!({"out": big, "list": ["ok", "y".repeat(9000)]}), 4096);
let out = t["out"].as_str().unwrap();
assert!(out.len() < 5000 && out.contains("…(+904 bytes)"), "{out}");
assert_eq!(t["list"][0], "ok");
assert!(t["list"][1].as_str().unwrap().contains("bytes)"));
}
#[test]
fn a_method_may_be_addressed_with_or_without_the_prefix() {
assert_eq!(bare("a2a.SendMessage"), "SendMessage");
assert_eq!(bare("GetTask"), "GetTask");
}
}