Add Maestro local A2A bridge#399
Conversation
PR SummaryMedium Risk Overview Updates the Rust control-plane HTTP layer to accept additional A2A/trace/evalops headers in CORS preflight, and extends CSRF checks to cover A2A routes (including Reviewed by Cursor Bugbot for commit 65e50af. Bugbot is set up for automated code reviews on this repo. Configure here. |
|
This PR changes mirrored Maestro source files in the public repo, but it does not link the matching private source-of-truth PR. Add one of these to the PR body, then re-run the check:
Mirrored files touched:
|
|
No dependency changes detected. Learn more about Socket for GitHub. 👍 No dependency changes detected in pull request |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 53624feb17
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Background task overwrites canceled task status unconditionally
- A2A task completion now skips writing back to the task map when the stored task is already canceled, and a regression test covers the race.
Preview (8ee0590c6d)
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -104,6 +104,7 @@
"smoke": "node scripts/smoke-cli.js",
"smoke:local-e2e": "node scripts/smoke-cli.js",
"smoke:event-bus": "tsx scripts/smoke-maestro-event-bus.ts",
+ "smoke:a2a-local": "tsx scripts/smoke-maestro-a2a-local.ts",
"smoke:headless": "node scripts/smoke-headless.js",
"headless:responsiveness": "node scripts/headless-responsiveness-harness.js",
"smoke:pack": "node scripts/smoke-packed-cli.js",
diff --git a/packages/control-plane-rs/src/http.rs b/packages/control-plane-rs/src/http.rs
--- a/packages/control-plane-rs/src/http.rs
+++ b/packages/control-plane-rs/src/http.rs
@@ -11,7 +11,7 @@
const MAX_HEADER_BYTES: usize = 64 * 1024;
pub(crate) const MAX_JSON_BODY_BYTES: usize = 32 * 1024 * 1024;
-const CORS_ALLOW_HEADERS: &str = "authorization,content-type,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
+const CORS_ALLOW_HEADERS: &str = "authorization,content-type,a2a-version,a2a-extensions,traceparent,tracestate,x-evalops-agent-id,x-evalops-actor-id,x-evalops-session-id,x-evalops-workspace-id,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
#[derive(Debug)]
pub(crate) struct RequestHead {
diff --git a/packages/control-plane-rs/src/main.rs b/packages/control-plane-rs/src/main.rs
--- a/packages/control-plane-rs/src/main.rs
+++ b/packages/control-plane-rs/src/main.rs
@@ -49,6 +49,8 @@
const DEFAULT_EXTRACT_MAX_CHARS: usize = 200_000;
const MAX_EXTRACT_INPUT_BYTES: usize = 50 * 1024 * 1024;
const MAX_PROJECT_ONBOARDING_IMPRESSIONS: u8 = 4;
+const A2A_PROTOCOL_VERSION: &str = "1.0";
+const A2A_DEFAULT_TURN_TIMEOUT_MS: u64 = 180_000;
static ATTACHMENT_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
type PendingToolResponseSender = mpsc::UnboundedSender<(String, bool, Option<ToolResult>)>;
@@ -151,6 +153,7 @@
shared_sessions: Arc<Mutex<HashMap<String, SharedSessionGrant>>>,
approval_modes: Arc<Mutex<HashMap<String, String>>>,
pending_tool_responses: Arc<Mutex<HashMap<String, PendingToolResponseSender>>>,
+ a2a_tasks: Arc<Mutex<HashMap<String, Value>>>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -295,6 +298,7 @@
shared_sessions: Arc::new(Mutex::new(shared_sessions)),
approval_modes: Arc::new(Mutex::new(HashMap::new())),
pending_tool_responses: Arc::new(Mutex::new(HashMap::new())),
+ a2a_tasks: Arc::new(Mutex::new(HashMap::new())),
};
loop {
@@ -329,6 +333,16 @@
return handle_chat_endpoint(stream, initial, head, state).await;
}
+ if is_a2a_endpoint(&head) {
+ let response = handle_a2a_endpoint(&mut stream, &mut initial, head, &state).await;
+ stream
+ .write_all(&response)
+ .await
+ .map_err(|error| error.to_string())?;
+ let _ = stream.shutdown().await;
+ return Ok(());
+ }
+
if is_local_endpoint(&head) {
let response = handle_local_endpoint(&mut stream, &mut initial, head, &state).await;
stream
@@ -508,6 +522,642 @@
Some(request_id)
}
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2APartBody {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ text: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ url: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ data: Option<Value>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ metadata: Option<Value>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ filename: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ media_type: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2AMessageBody {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ message_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ context_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ task_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ role: Option<String>,
+ #[serde(default)]
+ parts: Vec<A2APartBody>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ metadata: Option<Value>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ extensions: Option<Vec<String>>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ reference_task_ids: Option<Vec<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct A2ASendMessageRequest {
+ message: A2AMessageBody,
+ #[serde(default)]
+ configuration: Option<Value>,
+ #[serde(default)]
+ metadata: Option<Value>,
+}
+
+#[derive(Debug, Default)]
+struct A2ATurnOutput {
+ assistant_text: String,
+ thinking_text: String,
+ usage: Option<TokenUsage>,
+ tools: Vec<Value>,
+}
+
+fn is_a2a_endpoint(head: &RequestHead) -> bool {
+ if head.method == "OPTIONS" {
+ return head.path == "/.well-known/agent-card.json"
+ || head.path == "/message:send"
+ || head.path == "/tasks"
+ || head.path.starts_with("/tasks/");
+ }
+ matches!(
+ (head.method.as_str(), head.path.as_str()),
+ ("GET", "/.well-known/agent-card.json") | ("POST", "/message:send") | ("GET", "/tasks")
+ ) || (head.method == "GET" && a2a_task_id_from_get_path(&head.path).is_some())
+ || (head.method == "POST" && a2a_task_id_from_cancel_path(&head.path).is_some())
+}
+
+fn a2a_task_id_from_get_path(path: &str) -> Option<&str> {
+ let id = path.strip_prefix("/tasks/")?;
+ (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+fn a2a_task_id_from_cancel_path(path: &str) -> Option<&str> {
+ let id = path.strip_prefix("/tasks/")?.strip_suffix(":cancel")?;
+ (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+async fn handle_a2a_endpoint(
+ stream: &mut TcpStream,
+ initial: &mut Vec<u8>,
+ head: RequestHead,
+ state: &AppState,
+) -> Vec<u8> {
+ if head.method == "OPTIONS" {
+ return response(204, "text/plain; charset=utf-8", &[]);
+ }
+
+ if head.method == "GET" && head.path == "/.well-known/agent-card.json" {
+ return json_response(200, &a2a_agent_card(&head, &state.config));
+ }
+
+ if let Err(response) = authorize(&head, &state.config) {
+ return response;
+ }
+
+ if head.method == "GET" && head.path == "/tasks" {
+ let tasks = state
+ .a2a_tasks
+ .lock()
+ .await
+ .values()
+ .cloned()
+ .collect::<Vec<_>>();
+ return json_response(200, &serde_json::json!({ "tasks": tasks }));
+ }
+
+ if head.method == "GET" {
+ if let Some(task_id) = a2a_task_id_from_get_path(&head.path) {
+ let tasks = state.a2a_tasks.lock().await;
+ return tasks.get(task_id).map_or_else(
+ || a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found"),
+ |task| json_response(200, task),
+ );
+ }
+ }
+
+ if head.method == "POST" {
+ if let Some(task_id) = a2a_task_id_from_cancel_path(&head.path) {
+ let mut tasks = state.a2a_tasks.lock().await;
+ let Some(task) = tasks.get_mut(task_id) else {
+ return a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found");
+ };
+ let context_id = task
+ .get("contextId")
+ .and_then(Value::as_str)
+ .unwrap_or("a2a")
+ .to_string();
+ task["status"] = serde_json::json!({
+ "state": "TASK_STATE_CANCELED",
+ "message": a2a_agent_message(&context_id, "Task canceled"),
+ "timestamp": now_rfc3339()
+ });
+ return json_response(200, task);
+ }
+ }
+
+ if head.method == "POST" && head.path == "/message:send" {
+ return handle_a2a_message_send(stream, initial, &head, state).await;
+ }
+
+ a2a_error_response(404, "NOT_FOUND", "A2A endpoint not found")
+}
+
+async fn handle_a2a_message_send(
+ stream: &mut TcpStream,
+ initial: &mut Vec<u8>,
+ head: &RequestHead,
+ state: &AppState,
+) -> Vec<u8> {
+ let body = match read_request_body(stream, initial, head).await {
+ Ok(body) => body,
+ Err(error) => return a2a_error_response(400, "INVALID_REQUEST", &error),
+ };
+ let request: A2ASendMessageRequest = match serde_json::from_slice(&body) {
+ Ok(request) => request,
+ Err(error) => {
+ return a2a_error_response(
+ 400,
+ "INVALID_REQUEST",
+ &format!("invalid A2A message request: {error}"),
+ );
+ }
+ };
+
+ let Some(prompt) = a2a_message_text(&request.message) else {
+ return a2a_error_response(
+ 400,
+ "INVALID_REQUEST",
+ "A2A message must contain at least one text part",
+ );
+ };
+
+ let context_id = a2a_context_id(&request, head);
+ let task_id = generate_a2a_id("maestro-task");
+ let user_message = a2a_user_message_value(&request.message, &context_id);
+
+ let metadata = a2a_task_metadata(head, &request);
+ if a2a_return_immediately(&request) {
+ let accepted_message = a2a_agent_message(&context_id, "Maestro accepted the A2A task.");
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_WORKING",
+ accepted_message.clone(),
+ vec![user_message.clone(), accepted_message],
+ Vec::new(),
+ metadata.clone(),
+ );
+ state
+ .a2a_tasks
+ .lock()
+ .await
+ .insert(task_id.clone(), task.clone());
+ let state = state.clone();
+ tokio::spawn(async move {
+ let _ = complete_a2a_task(&state, prompt, task_id, context_id, user_message, metadata)
+ .await;
+ });
+ return json_response(200, &serde_json::json!({ "task": task }));
+ }
+
+ let task = complete_a2a_task(state, prompt, task_id, context_id, user_message, metadata).await;
+ json_response(200, &serde_json::json!({ "task": task }))
+}
+
+async fn complete_a2a_task(
+ state: &AppState,
+ prompt: String,
+ task_id: String,
+ context_id: String,
+ user_message: Value,
+ mut metadata: Value,
+) -> Value {
+ let turn = match run_a2a_native_turn(state, prompt).await {
+ Ok(turn) => turn,
+ Err(error) => {
+ let message = a2a_agent_message(&context_id, &error);
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_FAILED",
+ message.clone(),
+ vec![user_message, message],
+ Vec::new(),
+ metadata,
+ );
+ insert_a2a_task_if_not_canceled(state, &task_id, task.clone()).await;
+ return task;
+ }
+ };
+
+ let assistant_text = if turn.assistant_text.trim().is_empty() {
+ "Maestro completed the A2A task without a text response.".to_string()
+ } else {
+ turn.assistant_text
+ };
+ let agent_message = a2a_agent_message(&context_id, &assistant_text);
+ if !turn.thinking_text.trim().is_empty() {
+ metadata["thinking"] = Value::String(turn.thinking_text);
+ }
+ if !turn.tools.is_empty() {
+ metadata["tools"] = Value::Array(turn.tools);
+ }
+ if let Some(usage) = turn.usage {
+ metadata["usage"] = serde_json::json!({
+ "input": usage.input_tokens,
+ "output": usage.output_tokens,
+ "cacheRead": usage.cache_read_tokens,
+ "cacheWrite": usage.cache_write_tokens,
+ "cost": usage.cost.unwrap_or(0.0)
+ });
+ }
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_COMPLETED",
+ agent_message.clone(),
+ vec![user_message, agent_message],
+ vec![serde_json::json!({
+ "artifactId": format!("{task_id}-assistant-response"),
+ "name": "assistant-response",
+ "parts": [{ "text": assistant_text, "mediaType": "text/plain" }]
+ })],
+ metadata,
+ );
+ insert_a2a_task_if_not_canceled(state, &task_id, task.clone()).await;
+ task
+}
+
+async fn insert_a2a_task_if_not_canceled(state: &AppState, task_id: &str, task: Value) {
+ let mut tasks = state.a2a_tasks.lock().await;
+ let is_canceled = tasks
+ .get(task_id)
+ .and_then(|existing| existing.get("status"))
+ .and_then(|status| status.get("state"))
+ .and_then(Value::as_str)
+ == Some("TASK_STATE_CANCELED");
+ if !is_canceled {
+ tasks.insert(task_id.to_string(), task);
+ }
+}
+
+fn a2a_agent_card(head: &RequestHead, config: &Config) -> Value {
+ let base_url = a2a_public_base_url(head, config);
+ serde_json::json!({
+ "protocolVersion": A2A_PROTOCOL_VERSION,
+ "name": trimmed_env("MAESTRO_A2A_AGENT_NAME")
+ .unwrap_or_else(|| "Maestro Desktop Agent".to_string()),
+ "description": "Local Maestro Rust/TS TUI agent endpoint for A2A task delegation.",
+ "url": base_url,
+ "preferredTransport": "HTTP+JSON",
+ "supportedInterfaces": [{
+ "url": base_url,
+ "protocolBinding": "HTTP+JSON",
+ "protocolVersion": A2A_PROTOCOL_VERSION
+ }],
+ "provider": {
+ "organization": "EvalOps",
+ "url": "https://evalops.com"
+ },
+ "version": env!("CARGO_PKG_VERSION"),
+ "capabilities": {
+ "streaming": false,
+ "pushNotifications": false,
+ "extendedAgentCard": false
+ },
+ "defaultInputModes": ["text/plain"],
+ "defaultOutputModes": ["text/plain", "application/json"],
+ "skills": [{
+ "id": "maestro-tui-turn",
+ "name": "Maestro TUI turn",
+ "description": "Run a prompt through the local Maestro native TUI agent runner.",
+ "tags": ["maestro", "tui", "codex", "a2a"],
+ "examples": [
+ "Review the current workspace and summarize the next highest leverage action."
+ ],
+ "inputModes": ["text/plain"],
+ "outputModes": ["text/plain", "application/json"]
+ }]
+ })
+}
+
+fn a2a_public_base_url(head: &RequestHead, config: &Config) -> String {
+ if let Some(url) =
+ trimmed_env("MAESTRO_A2A_PUBLIC_URL").or_else(|| trimmed_env("MAESTRO_CONTROL_PUBLIC_URL"))
+ {
+ return url.trim_end_matches('/').to_string();
+ }
+ let proto = head
+ .headers
+ .get("x-forwarded-proto")
+ .and_then(|value| value.split(',').next())
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .unwrap_or("http");
+ let host = head
+ .headers
+ .get("host")
+ .map(String::as_str)
+ .filter(|host| !host.trim().is_empty())
+ .map(str::trim)
+ .map(str::to_string)
+ .unwrap_or_else(|| {
+ let host = if config.listen_host == "0.0.0.0" || config.listen_host == "::" {
+ "127.0.0.1"
+ } else {
+ config.listen_host.as_str()
+ };
+ format!("{host}:{}", config.listen_port)
+ });
+ format!("{proto}://{host}")
+}
+
+fn a2a_message_text(message: &A2AMessageBody) -> Option<String> {
+ let text = message
+ .parts
+ .iter()
+ .filter_map(|part| part.text.as_deref())
+ .map(str::trim)
+ .filter(|part| !part.is_empty())
+ .collect::<Vec<_>>()
+ .join("\n\n");
+ (!text.is_empty()).then_some(text)
+}
+
+fn a2a_context_id(request: &A2ASendMessageRequest, head: &RequestHead) -> String {
+ request
+ .message
+ .context_id
+ .as_deref()
+ .or_else(|| {
+ request
+ .message
+ .metadata
+ .as_ref()
+ .and_then(|metadata| metadata.get("sessionId").and_then(Value::as_str))
+ })
+ .or_else(|| head.headers.get("x-evalops-session-id").map(String::as_str))
+ .or_else(|| head.headers.get("x-maestro-session-id").map(String::as_str))
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(str::to_string)
+ .unwrap_or_else(|| generate_a2a_id("maestro-context"))
+}
+
+fn a2a_user_message_value(message: &A2AMessageBody, context_id: &str) -> Value {
+ let mut value = serde_json::to_value(message).unwrap_or_else(|_| serde_json::json!({}));
+ if let Value::Object(object) = &mut value {
+ object
+ .entry("messageId")
+ .or_insert_with(|| Value::String(generate_a2a_id("maestro-message")));
+ object
+ .entry("contextId")
+ .or_insert_with(|| Value::String(context_id.to_string()));
+ object
+ .entry("role")
+ .or_insert_with(|| Value::String("ROLE_USER".to_string()));
+ }
+ value
+}
+
+fn a2a_agent_message(context_id: &str, text: &str) -> Value {
+ serde_json::json!({
+ "messageId": generate_a2a_id("maestro-message"),
+ "contextId": context_id,
+ "role": "ROLE_AGENT",
+ "parts": [{ "text": text, "mediaType": "text/plain" }],
+ "metadata": {
+ "runtime": "maestro-rust-control-plane",
+ "surface": "rust-tui"
+ }
+ })
+}
+
+fn a2a_task_value(
+ task_id: &str,
+ context_id: &str,
+ state: &str,
+ status_message: Value,
+ history: Vec<Value>,
+ artifacts: Vec<Value>,
+ metadata: Value,
+) -> Value {
+ serde_json::json!({
+ "id": task_id,
+ "contextId": context_id,
+ "status": {
+ "state": state,
+ "message": status_message,
+ "timestamp": now_rfc3339()
+ },
+ "history": history,
+ "artifacts": artifacts,
+ "metadata": metadata
+ })
+}
+
+fn a2a_task_metadata(head: &RequestHead, request: &A2ASendMessageRequest) -> Value {
+ let mut metadata = Map::new();
+ metadata.insert(
+ "runtime".to_string(),
+ Value::String("maestro-rust-control-plane".to_string()),
+ );
+ metadata.insert("surface".to_string(), Value::String("rust-tui".to_string()));
+ metadata.insert(
+ "a2aProtocolVersion".to_string(),
+ Value::String(A2A_PROTOCOL_VERSION.to_string()),
+ );
+ for (field, header) in [
+ ("workspaceId", "x-evalops-workspace-id"),
+ ("agentId", "x-evalops-agent-id"),
+ ("sessionId", "x-evalops-session-id"),
+ ("actorId", "x-evalops-actor-id"),
+ ("traceparent", "traceparent"),
+ ("tracestate", "tracestate"),
+ ] {
+ if let Some(value) = head.headers.get(header).map(String::as_str) {
+ if !value.trim().is_empty() {
+ metadata.insert(field.to_string(), Value::String(value.trim().to_string()));
+ }
+ }
+ }
+ if let Some(Value::Object(request_metadata)) = request.metadata.as_ref() {
+ for (key, value) in request_metadata {
+ metadata.entry(key.clone()).or_insert_with(|| value.clone());
+ }
+ }
+ if let Some(configuration) = request.configuration.as_ref() {
+ metadata
+ .entry("configuration".to_string())
+ .or_insert_with(|| configuration.clone());
+ }
+ if let Some(Value::Object(message_metadata)) = request.message.metadata.as_ref() {
+ for (key, value) in message_metadata {
+ metadata.entry(key.clone()).or_insert_with(|| value.clone());
+ }
+ }
+ Value::Object(metadata)
+}
+
+fn a2a_return_immediately(request: &A2ASendMessageRequest) -> bool {
+ request
+ .configuration
+ .as_ref()
+ .and_then(|configuration| configuration.get("returnImmediately"))
+ .and_then(Value::as_bool)
+ .unwrap_or(false)
+}
+
+async fn run_a2a_native_turn(state: &AppState, prompt: String) -> Result<A2ATurnOutput, String> {
+ if let Some(response) = trimmed_env("MAESTRO_A2A_FAKE_RESPONSE") {
+ return Ok(A2ATurnOutput {
+ assistant_text: response,
+ ..Default::default()
+ });
+ }
+
+ let model = if let Some(model) = trimmed_env("MAESTRO_A2A_MODEL") {
+ model
+ } else {
+ let selected = state.selected_model.lock().await;
+ format!("{}/{}", selected.provider, selected.id)
+ };
+ let config = NativeAgentConfig {
+ model,
+ cwd: state.config.cwd.to_string_lossy().to_string(),
+ system_prompt: Some(
+ trimmed_env("MAESTRO_A2A_SYSTEM_PROMPT").unwrap_or_else(|| {
+ "You are the local Maestro Desktop A2A agent. Complete delegated work from peer agents clearly and concisely.".to_string()
+ }),
+ ),
+ thinking_enabled: truthy_env("MAESTRO_A2A_THINKING"),
+ thinking_budget: env::var("MAESTRO_A2A_THINKING_BUDGET")
+ .ok()
+ .and_then(|value| value.parse().ok())
+ .unwrap_or(10_000),
+ ..NativeAgentConfig::default()
+ };
+ let (agent, mut events) = NativeAgent::new(config).map_err(|error| error.to_string())?;
+ agent
+ .prompt(prompt, Vec::new())
+ .await
+ .map_err(|error| error.to_string())?;
+
+ let timeout = Duration::from_millis(env_u64(
+ "MAESTRO_A2A_TURN_TIMEOUT_MS",
+ A2A_DEFAULT_TURN_TIMEOUT_MS,
+ ));
+ let approval_mode = trimmed_env("MAESTRO_A2A_TOOL_APPROVAL")
+ .unwrap_or_else(|| "fail".to_string())
+ .to_ascii_lowercase();
+ let auto_approve_tools = matches!(approval_mode.as_str(), "auto" | "approve" | "approved");
+ let mut output = A2ATurnOutput::default();
+ let mut last_error: Option<String> = None;
+ let mut response_ended = false;
+
+ loop {
+ let event = match tokio::time::timeout(timeout, events.recv()).await {
+ Ok(Some(event)) => event,
+ Ok(None) => break,
+ Err(_) => {
+ agent.cancel();
+ return Err("A2A native TUI turn timed out".to_string());
+ }
+ };
+ match event {
+ FromAgent::ResponseChunk {
+ content,
+ is_thinking,
+ ..
+ } => {
+ if is_thinking {
+ output.thinking_text.push_str(&content);
+ } else {
+ output.assistant_text.push_str(&content);
+ }
+ }
+ FromAgent::ResponseEnd { usage, .. } => {
+ output.usage = usage;
+ response_ended = true;
+ break;
+ }
+ FromAgent::ToolCall {
+ call_id,
+ tool,
+ args,
+ requires_approval,
+ } => {
+ record_tool_call_metadata(&mut output.tools, &call_id, &tool, args);
+ if requires_approval {
+ let _ = agent.tool_response_sender().send((
+ call_id.clone(),
+ auto_approve_tools,
+ None,
+ ));
+ if !auto_approve_tools {
+ finish_tool_metadata(&mut output.tools, &call_id, false);
+ }
+ }
+ }
+ FromAgent::ToolEnd {
+ call_id, success, ..
+ } => {
+ finish_tool_metadata(&mut output.tools, &call_id, success);
+ }
+ FromAgent::HookBlocked {
+ call_id,
+ tool,
+ reason,
+ } => {
+ if !output
+ .tools
+ .iter()
+ .any(|entry| entry.get("id").and_then(Value::as_str) == Some(&call_id))
+ {
+ record_tool_call_metadata(&mut output.tools, &call_id, &tool, Value::Null);
+ }
+ finish_tool_metadata(&mut output.tools, &call_id, false);
+ last_error = Some(reason);
+ }
+ FromAgent::Error { message, fatal } => {
+ last_error = Some(message);
+ if fatal {
+ break;
+ }
+ }
+ _ => {}
+ }
+ }
+
+ if response_ended {
+ Ok(output)
+ } else {
+ Err(last_error
+ .unwrap_or_else(|| "A2A native TUI turn ended before response_end".to_string()))
+ }
+}
+
+fn generate_a2a_id(prefix: &str) -> String {
+ let mut bytes = [0_u8; 16];
+ if getrandom::fill(&mut bytes).is_ok() {
+ return format!("{prefix}-{}", URL_SAFE_NO_PAD.encode(bytes));
+ }
+ format!("{prefix}-{}-{}", now_millis(), process::id())
+}
+
+fn a2a_error_response(status: u16, code: &str, message: &str) -> Vec<u8> {
+ json_response(
+ status,
+ &serde_json::json!({ "error": { "code": code, "message": message } }),
+ )
+}
+
async fn handle_local_endpoint(
stream: &mut TcpStream,
initial: &mut Vec<u8>,
@@ -5655,6 +6305,13 @@
.unwrap_or(default)
}
+fn env_u64(name: &str, default: u64) -> u64 {
+ env::var(name)
+ .ok()
+ .and_then(|value| value.parse::<u64>().ok())
+ .unwrap_or(default)
+}
+
fn trimmed_env(name: &str) -> Option<String> {
env::var(name)
.ok()
@@ -5779,6 +6436,7 @@
shared_sessions: Arc::new(Mutex::new(HashMap::new())),
approval_modes: Arc::new(Mutex::new(HashMap::new())),
pending_tool_responses: Arc::new(Mutex::new(HashMap::new())),
+ a2a_tasks: Arc::new(Mutex::new(HashMap::new())),
}
}
@@ -6028,6 +6686,146 @@
}
#[test]
+ fn detects_a2a_control_plane_routes() {
+ for request in [
+ "GET /.well-known/agent-card.json HTTP/1.1\r\nHost: localhost\r\n\r\n",
+ "POST /message:send HTTP/1.1\r\nHost: localhost\r\n\r\n",
+ "GET /tasks/maestro-task-1 HTTP/1.1\r\nHost: localhost\r\n\r\n",
+ "GET /tasks HTTP/1.1\r\nHost: localhost\r\n\r\n",
+ "POST /tasks/maestro-task-1:cancel HTTP/1.1\r\nHost: localhost\r\n\r\n",
+ "OPTIONS /message:send HTTP/1.1\r\nHost: localhost\r\n\r\n",
+ ] {
+ let head = parse_request_head(request.as_bytes()).expect("request should parse");
+ assert!(is_a2a_endpoint(&head), "{request} should be A2A");
+ }
+ }
+
+ #[test]
+ fn a2a_agent_card_advertises_http_json_interface() {
+ let head = parse_request_head(
+ b"GET /.well-known/agent-card.json HTTP/1.1\r\nHost: mini.local:8080\r\n\r\n",
+ )
+ .expect("request should parse");
+ let card = a2a_agent_card(&head, &auth_test_config());
+
+ assert_eq!(card["protocolVersion"], A2A_PROTOCOL_VERSION);
+ assert_eq!(card["url"], "http://mini.local:8080");
+ assert_eq!(
+ card["supportedInterfaces"][0]["protocolBinding"],
+ "HTTP+JSON"
+ );
+ assert_eq!(card["skills"][0]["id"], "maestro-tui-turn");
+ }
+
+ #[test]
+ fn a2a_send_message_honors_return_immediately_configuration() {
+ let request = A2ASendMessageRequest {
+ message: A2AMessageBody {
+ message_id: Some("msg-1".to_string()),
+ context_id: Some("ctx-1".to_string()),
+ task_id: None,
+ role: Some("ROLE_USER".to_string()),
+ parts: vec![A2APartBody {
+ text: Some("hello".to_string()),
+ url: None,
+ data: None,
+ metadata: None,
+ filename: None,
+ media_type: Some("text/plain".to_string()),
+ }],
+ metadata: None,
+ extensions: None,
+ reference_task_ids: None,
+ },
+ configuration: Some(serde_json::json!({ "returnImmediately": true })),
+ metadata: None,
+ };
+
+ assert!(a2a_return_immediately(&request));
+ }
+
+ #[tokio::test(flavor = "current_thread")]
+ async fn a2a_message_send_runs_fake_turn_and_records_task() {
+ let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
... diff truncated: showing 800 of 1060 linesYou can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8ee0590c6d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Turn timeout resets per event, not total
- I replaced the per-receive timeout with one pinned turn-wide sleep so the configured deadline now bounds the entire A2A turn.
Preview (fae5207f8f)
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -104,6 +104,7 @@
"smoke": "node scripts/smoke-cli.js",
"smoke:local-e2e": "node scripts/smoke-cli.js",
"smoke:event-bus": "tsx scripts/smoke-maestro-event-bus.ts",
+ "smoke:a2a-local": "tsx scripts/smoke-maestro-a2a-local.ts",
"smoke:headless": "node scripts/smoke-headless.js",
"headless:responsiveness": "node scripts/headless-responsiveness-harness.js",
"smoke:pack": "node scripts/smoke-packed-cli.js",
diff --git a/packages/control-plane-rs/src/http.rs b/packages/control-plane-rs/src/http.rs
--- a/packages/control-plane-rs/src/http.rs
+++ b/packages/control-plane-rs/src/http.rs
@@ -11,7 +11,7 @@
const MAX_HEADER_BYTES: usize = 64 * 1024;
pub(crate) const MAX_JSON_BODY_BYTES: usize = 32 * 1024 * 1024;
-const CORS_ALLOW_HEADERS: &str = "authorization,content-type,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
+const CORS_ALLOW_HEADERS: &str = "authorization,content-type,a2a-version,a2a-extensions,traceparent,tracestate,x-organization-id,x-evalops-agent-id,x-evalops-actor-id,x-evalops-session-id,x-evalops-workspace-id,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
#[derive(Debug)]
pub(crate) struct RequestHead {
diff --git a/packages/control-plane-rs/src/main.rs b/packages/control-plane-rs/src/main.rs
--- a/packages/control-plane-rs/src/main.rs
+++ b/packages/control-plane-rs/src/main.rs
@@ -17,7 +17,7 @@
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::process::Command;
-use tokio::sync::{mpsc, Mutex};
+use tokio::sync::{mpsc, watch, Mutex};
mod auth;
mod http;
@@ -49,9 +49,13 @@
const DEFAULT_EXTRACT_MAX_CHARS: usize = 200_000;
const MAX_EXTRACT_INPUT_BYTES: usize = 50 * 1024 * 1024;
const MAX_PROJECT_ONBOARDING_IMPRESSIONS: u8 = 4;
+const A2A_PROTOCOL_VERSION: &str = "1.0";
+const A2A_DEFAULT_TURN_TIMEOUT_MS: u64 = 180_000;
static ATTACHMENT_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
type PendingToolResponseSender = mpsc::UnboundedSender<(String, bool, Option<ToolResult>)>;
+type A2ACancelSender = watch::Sender<bool>;
+type A2ACancelReceiver = watch::Receiver<bool>;
#[derive(Debug, Clone)]
struct Config {
listen_host: String,
@@ -151,6 +155,8 @@
shared_sessions: Arc<Mutex<HashMap<String, SharedSessionGrant>>>,
approval_modes: Arc<Mutex<HashMap<String, String>>>,
pending_tool_responses: Arc<Mutex<HashMap<String, PendingToolResponseSender>>>,
+ a2a_tasks: Arc<Mutex<HashMap<String, Value>>>,
+ a2a_cancel_senders: Arc<Mutex<HashMap<String, A2ACancelSender>>>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -295,6 +301,8 @@
shared_sessions: Arc::new(Mutex::new(shared_sessions)),
approval_modes: Arc::new(Mutex::new(HashMap::new())),
pending_tool_responses: Arc::new(Mutex::new(HashMap::new())),
+ a2a_tasks: Arc::new(Mutex::new(HashMap::new())),
+ a2a_cancel_senders: Arc::new(Mutex::new(HashMap::new())),
};
loop {
@@ -329,6 +337,16 @@
return handle_chat_endpoint(stream, initial, head, state).await;
}
+ if is_a2a_endpoint(&head) {
+ let response = handle_a2a_endpoint(&mut stream, &mut initial, head, &state).await;
+ stream
+ .write_all(&response)
+ .await
+ .map_err(|error| error.to_string())?;
+ let _ = stream.shutdown().await;
+ return Ok(());
+ }
+
if is_local_endpoint(&head) {
let response = handle_local_endpoint(&mut stream, &mut initial, head, &state).await;
stream
@@ -508,6 +526,832 @@
Some(request_id)
}
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2APartBody {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ text: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ url: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ data: Option<Value>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ metadata: Option<Value>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ filename: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ media_type: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2AMessageBody {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ message_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ context_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ task_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ role: Option<String>,
+ #[serde(default)]
+ parts: Vec<A2APartBody>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ metadata: Option<Value>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ extensions: Option<Vec<String>>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ reference_task_ids: Option<Vec<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct A2ASendMessageRequest {
+ message: A2AMessageBody,
+ #[serde(default)]
+ configuration: Option<Value>,
+ #[serde(default)]
+ metadata: Option<Value>,
+}
+
+#[derive(Debug, Default)]
+struct A2ATurnOutput {
+ assistant_text: String,
+ thinking_text: String,
+ usage: Option<TokenUsage>,
+ tools: Vec<Value>,
+}
+
+enum A2ATurnResult {
+ Completed(A2ATurnOutput),
+ Canceled,
+}
+
+fn is_a2a_endpoint(head: &RequestHead) -> bool {
+ if head.method == "OPTIONS" {
+ return head.path == "/.well-known/agent-card.json"
+ || head.path == "/message:send"
+ || head.path == "/tasks"
+ || head.path.starts_with("/tasks/");
+ }
+ matches!(
+ (head.method.as_str(), head.path.as_str()),
+ ("GET", "/.well-known/agent-card.json") | ("POST", "/message:send") | ("GET", "/tasks")
+ ) || (head.method == "GET" && a2a_task_id_from_get_path(&head.path).is_some())
+ || (head.method == "POST" && a2a_task_id_from_cancel_path(&head.path).is_some())
+}
+
+fn a2a_task_id_from_get_path(path: &str) -> Option<&str> {
+ let id = path.strip_prefix("/tasks/")?;
+ (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+fn a2a_task_id_from_cancel_path(path: &str) -> Option<&str> {
+ let id = path.strip_prefix("/tasks/")?.strip_suffix(":cancel")?;
+ (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+async fn handle_a2a_endpoint(
+ stream: &mut TcpStream,
+ initial: &mut Vec<u8>,
+ head: RequestHead,
+ state: &AppState,
+) -> Vec<u8> {
+ if head.method == "OPTIONS" {
+ return response(204, "text/plain; charset=utf-8", &[]);
+ }
+
+ if head.method == "GET" && head.path == "/.well-known/agent-card.json" {
+ return json_response(200, &a2a_agent_card(&head, &state.config));
+ }
+
+ if let Err(response) = authorize(&head, &state.config) {
+ return response;
+ }
+
+ if head.method == "GET" && head.path == "/tasks" {
+ let tasks = state
+ .a2a_tasks
+ .lock()
+ .await
+ .values()
+ .cloned()
+ .collect::<Vec<_>>();
+ return json_response(200, &serde_json::json!({ "tasks": tasks }));
+ }
+
+ if head.method == "GET" {
+ if let Some(task_id) = a2a_task_id_from_get_path(&head.path) {
+ let tasks = state.a2a_tasks.lock().await;
+ return tasks.get(task_id).map_or_else(
+ || a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found"),
+ |task| json_response(200, task),
+ );
+ }
+ }
+
+ if head.method == "POST" {
+ if let Some(task_id) = a2a_task_id_from_cancel_path(&head.path) {
+ return match cancel_a2a_task(state, task_id).await {
+ Ok(task) => json_response(200, &task),
+ Err(response) => response,
+ };
+ }
+ }
+
+ if head.method == "POST" && head.path == "/message:send" {
+ return handle_a2a_message_send(stream, initial, &head, state).await;
+ }
+
+ a2a_error_response(404, "NOT_FOUND", "A2A endpoint not found")
+}
+
+async fn cancel_a2a_task(state: &AppState, task_id: &str) -> Result<Value, Vec<u8>> {
+ let mut tasks = state.a2a_tasks.lock().await;
+ let Some(task) = tasks.get_mut(task_id) else {
+ return Err(a2a_error_response(
+ 404,
+ "TASK_NOT_FOUND",
+ "A2A task not found",
+ ));
+ };
+ if a2a_task_is_terminal(task) {
+ return Err(a2a_error_response(
+ 400,
+ "TASK_NOT_CANCELABLE",
+ "A2A task cannot be canceled from its current state",
+ ));
+ }
+ let context_id = task
+ .get("contextId")
+ .and_then(Value::as_str)
+ .unwrap_or("a2a")
+ .to_string();
+ task["status"] = serde_json::json!({
+ "state": "TASK_STATE_CANCELED",
+ "message": a2a_agent_message(&context_id, "Task canceled"),
+ "timestamp": now_rfc3339()
+ });
+ let task = task.clone();
+ drop(tasks);
+
+ if let Some(sender) = state.a2a_cancel_senders.lock().await.remove(task_id) {
+ let _ = sender.send(true);
+ }
+
+ Ok(task)
+}
+
+fn a2a_task_status_state(task: &Value) -> Option<&str> {
+ task.get("status")
+ .and_then(|status| status.get("state"))
+ .and_then(Value::as_str)
+}
+
+fn a2a_task_is_terminal(task: &Value) -> bool {
+ matches!(
+ a2a_task_status_state(task),
+ Some(
+ "TASK_STATE_COMPLETED"
+ | "TASK_STATE_FAILED"
+ | "TASK_STATE_CANCELED"
+ | "TASK_STATE_REJECTED"
+ )
+ )
+}
+
+async fn a2a_resolve_send_task_id(
+ state: &AppState,
+ request: &A2ASendMessageRequest,
+) -> Result<String, Vec<u8>> {
+ let Some(task_id) = request
+ .message
+ .task_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|task_id| !task_id.is_empty())
+ else {
+ return Ok(generate_a2a_id("maestro-task"));
+ };
+
+ let tasks = state.a2a_tasks.lock().await;
+ let Some(task) = tasks.get(task_id) else {
+ return Err(a2a_error_response(
+ 404,
+ "TASK_NOT_FOUND",
+ "A2A task not found",
+ ));
+ };
+ if a2a_task_is_terminal(task) {
+ return Err(a2a_error_response(
+ 400,
+ "UNSUPPORTED_OPERATION",
+ "A2A terminal tasks cannot accept more messages",
+ ));
+ }
+ if let Some(message_context_id) = request
+ .message
+ .context_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|context_id| !context_id.is_empty())
+ {
+ if let Some(task_context_id) = task.get("contextId").and_then(Value::as_str) {
+ if message_context_id != task_context_id {
+ return Err(a2a_error_response(
+ 400,
+ "INVALID_REQUEST",
+ "A2A message contextId must match the referenced task",
+ ));
+ }
+ }
+ }
+ Ok(task_id.to_string())
+}
+
+async fn a2a_existing_task_history(state: &AppState, task_id: &str) -> Vec<Value> {
+ state
+ .a2a_tasks
+ .lock()
+ .await
+ .get(task_id)
+ .and_then(|task| task.get("history"))
+ .and_then(Value::as_array)
+ .cloned()
+ .unwrap_or_default()
+}
+
+async fn store_a2a_task_unless_canceled(state: &AppState, task_id: &str, task: Value) -> Value {
+ let mut tasks = state.a2a_tasks.lock().await;
+ if let Some(existing) = tasks.get(task_id) {
+ if a2a_task_status_state(existing) == Some("TASK_STATE_CANCELED") {
+ return existing.clone();
+ }
+ }
+ tasks.insert(task_id.to_string(), task.clone());
+ task
+}
+
+async fn handle_a2a_message_send(
+ stream: &mut TcpStream,
+ initial: &mut Vec<u8>,
+ head: &RequestHead,
+ state: &AppState,
+) -> Vec<u8> {
+ let body = match read_request_body(stream, initial, head).await {
+ Ok(body) => body,
+ Err(error) => return a2a_error_response(400, "INVALID_REQUEST", &error),
+ };
+ let request: A2ASendMessageRequest = match serde_json::from_slice(&body) {
+ Ok(request) => request,
+ Err(error) => {
+ return a2a_error_response(
+ 400,
+ "INVALID_REQUEST",
+ &format!("invalid A2A message request: {error}"),
+ );
+ }
+ };
+
+ let Some(prompt) = a2a_message_text(&request.message) else {
+ return a2a_error_response(
+ 400,
+ "INVALID_REQUEST",
+ "A2A message must contain at least one text part",
+ );
+ };
+
+ let context_id = a2a_context_id(&request, head);
+ let task_id = match a2a_resolve_send_task_id(state, &request).await {
+ Ok(task_id) => task_id,
+ Err(response) => return response,
+ };
+ let user_message = a2a_user_message_value(&request.message, &context_id);
+ let mut history = a2a_existing_task_history(state, &task_id).await;
+ history.push(user_message.clone());
+
+ let metadata = a2a_task_metadata(head, &request);
+ let (cancel_tx, cancel_rx) = watch::channel(false);
+ state
+ .a2a_cancel_senders
+ .lock()
+ .await
+ .insert(task_id.clone(), cancel_tx);
+ if a2a_return_immediately(&request) {
+ let accepted_message = a2a_agent_message(&context_id, "Maestro accepted the A2A task.");
+ let mut accepted_history = history.clone();
+ accepted_history.push(accepted_message.clone());
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_WORKING",
+ accepted_message.clone(),
+ accepted_history.clone(),
+ Vec::new(),
+ metadata.clone(),
+ );
+ state
+ .a2a_tasks
+ .lock()
+ .await
+ .insert(task_id.clone(), task.clone());
+ let state = state.clone();
+ tokio::spawn(async move {
+ let _ = complete_a2a_task(
+ &state,
+ prompt,
+ task_id,
+ context_id,
+ accepted_history,
+ metadata,
+ cancel_rx,
+ )
+ .await;
+ });
+ return json_response(200, &serde_json::json!({ "task": task }));
+ }
+
+ let task = complete_a2a_task(
+ state, prompt, task_id, context_id, history, metadata, cancel_rx,
+ )
+ .await;
+ json_response(200, &serde_json::json!({ "task": task }))
+}
+
+async fn complete_a2a_task(
+ state: &AppState,
+ prompt: String,
+ task_id: String,
+ context_id: String,
+ mut history: Vec<Value>,
+ mut metadata: Value,
+ cancel_rx: A2ACancelReceiver,
+) -> Value {
+ let turn = match run_a2a_native_turn(state, prompt, cancel_rx).await {
+ Ok(A2ATurnResult::Completed(turn)) => turn,
+ Ok(A2ATurnResult::Canceled) => {
+ let message = a2a_agent_message(&context_id, "Task canceled");
+ history.push(message.clone());
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_CANCELED",
+ message,
+ history,
+ Vec::new(),
+ metadata,
+ );
+ let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+ state.a2a_cancel_senders.lock().await.remove(&task_id);
+ return task;
+ }
+ Err(error) => {
+ let message = a2a_agent_message(&context_id, &error);
+ history.push(message.clone());
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_FAILED",
+ message.clone(),
+ history,
+ Vec::new(),
+ metadata,
+ );
+ let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+ state.a2a_cancel_senders.lock().await.remove(&task_id);
+ return task;
+ }
+ };
+
+ let assistant_text = if turn.assistant_text.trim().is_empty() {
+ "Maestro completed the A2A task without a text response.".to_string()
+ } else {
+ turn.assistant_text
+ };
+ let agent_message = a2a_agent_message(&context_id, &assistant_text);
+ if !turn.thinking_text.trim().is_empty() {
+ metadata["thinking"] = Value::String(turn.thinking_text);
+ }
+ if !turn.tools.is_empty() {
+ metadata["tools"] = Value::Array(turn.tools);
+ }
+ if let Some(usage) = turn.usage {
+ metadata["usage"] = serde_json::json!({
+ "input": usage.input_tokens,
+ "output": usage.output_tokens,
+ "cacheRead": usage.cache_read_tokens,
+ "cacheWrite": usage.cache_write_tokens,
+ "cost": usage.cost.unwrap_or(0.0)
+ });
+ }
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_COMPLETED",
+ agent_message.clone(),
+ {
+ history.push(agent_message);
+ history
+ },
+ vec![serde_json::json!({
+ "artifactId": format!("{task_id}-assistant-response"),
+ "name": "assistant-response",
+ "parts": [{ "text": assistant_text, "mediaType": "text/plain" }]
+ })],
+ metadata,
+ );
+ let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+ state.a2a_cancel_senders.lock().await.remove(&task_id);
+ task
+}
+
+fn a2a_agent_card(head: &RequestHead, config: &Config) -> Value {
+ let base_url = a2a_public_base_url(head, config);
+ serde_json::json!({
+ "protocolVersion": A2A_PROTOCOL_VERSION,
+ "name": trimmed_env("MAESTRO_A2A_AGENT_NAME")
+ .unwrap_or_else(|| "Maestro Desktop Agent".to_string()),
+ "description": "Local Maestro Rust/TS TUI agent endpoint for A2A task delegation.",
+ "url": base_url,
+ "preferredTransport": "HTTP+JSON",
+ "supportedInterfaces": [{
+ "url": base_url,
+ "protocolBinding": "HTTP+JSON",
+ "protocolVersion": A2A_PROTOCOL_VERSION
+ }],
+ "provider": {
+ "organization": "EvalOps",
+ "url": "https://evalops.com"
+ },
+ "version": env!("CARGO_PKG_VERSION"),
+ "capabilities": {
+ "streaming": false,
+ "pushNotifications": false,
+ "extendedAgentCard": false
+ },
+ "defaultInputModes": ["text/plain"],
+ "defaultOutputModes": ["text/plain", "application/json"],
+ "skills": [{
+ "id": "maestro-tui-turn",
+ "name": "Maestro TUI turn",
+ "description": "Run a prompt through the local Maestro native TUI agent runner.",
+ "tags": ["maestro", "tui", "codex", "a2a"],
+ "examples": [
+ "Review the current workspace and summarize the next highest leverage action."
+ ],
+ "inputModes": ["text/plain"],
+ "outputModes": ["text/plain", "application/json"]
+ }]
+ })
+}
+
+fn a2a_public_base_url(head: &RequestHead, config: &Config) -> String {
+ if let Some(url) =
+ trimmed_env("MAESTRO_A2A_PUBLIC_URL").or_else(|| trimmed_env("MAESTRO_CONTROL_PUBLIC_URL"))
+ {
+ return url.trim_end_matches('/').to_string();
+ }
+ let proto = head
+ .headers
+ .get("x-forwarded-proto")
+ .and_then(|value| value.split(',').next())
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .unwrap_or("http");
+ let host = head
+ .headers
+ .get("host")
+ .map(String::as_str)
+ .filter(|host| !host.trim().is_empty())
+ .map(str::trim)
+ .map(str::to_string)
+ .unwrap_or_else(|| {
+ let host = if config.listen_host == "0.0.0.0" || config.listen_host == "::" {
+ "127.0.0.1"
+ } else {
+ config.listen_host.as_str()
+ };
+ format!("{host}:{}", config.listen_port)
+ });
+ format!("{proto}://{host}")
+}
+
+fn a2a_message_text(message: &A2AMessageBody) -> Option<String> {
+ let text = message
+ .parts
+ .iter()
+ .filter_map(|part| part.text.as_deref())
+ .map(str::trim)
+ .filter(|part| !part.is_empty())
+ .collect::<Vec<_>>()
+ .join("\n\n");
+ (!text.is_empty()).then_some(text)
+}
+
+fn a2a_context_id(request: &A2ASendMessageRequest, head: &RequestHead) -> String {
+ request
+ .message
+ .context_id
+ .as_deref()
+ .or_else(|| {
+ request
+ .message
+ .metadata
+ .as_ref()
+ .and_then(|metadata| metadata.get("sessionId").and_then(Value::as_str))
+ })
+ .or_else(|| head.headers.get("x-evalops-session-id").map(String::as_str))
+ .or_else(|| head.headers.get("x-maestro-session-id").map(String::as_str))
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(str::to_string)
+ .unwrap_or_else(|| generate_a2a_id("maestro-context"))
+}
+
+fn a2a_user_message_value(message: &A2AMessageBody, context_id: &str) -> Value {
+ let mut value = serde_json::to_value(message).unwrap_or_else(|_| serde_json::json!({}));
+ if let Value::Object(object) = &mut value {
+ object
+ .entry("messageId")
+ .or_insert_with(|| Value::String(generate_a2a_id("maestro-message")));
+ object
+ .entry("contextId")
+ .or_insert_with(|| Value::String(context_id.to_string()));
+ object
+ .entry("role")
+ .or_insert_with(|| Value::String("ROLE_USER".to_string()));
+ }
+ value
+}
+
+fn a2a_agent_message(context_id: &str, text: &str) -> Value {
+ serde_json::json!({
+ "messageId": generate_a2a_id("maestro-message"),
+ "contextId": context_id,
+ "role": "ROLE_AGENT",
+ "parts": [{ "text": text, "mediaType": "text/plain" }],
+ "metadata": {
+ "runtime": "maestro-rust-control-plane",
+ "surface": "rust-tui"
+ }
+ })
+}
+
+fn a2a_task_value(
+ task_id: &str,
+ context_id: &str,
+ state: &str,
+ status_message: Value,
+ history: Vec<Value>,
+ artifacts: Vec<Value>,
+ metadata: Value,
+) -> Value {
+ serde_json::json!({
+ "id": task_id,
+ "contextId": context_id,
+ "status": {
+ "state": state,
+ "message": status_message,
+ "timestamp": now_rfc3339()
+ },
+ "history": history,
+ "artifacts": artifacts,
+ "metadata": metadata
+ })
+}
+
+fn a2a_task_metadata(head: &RequestHead, request: &A2ASendMessageRequest) -> Value {
+ let mut metadata = Map::new();
+ metadata.insert(
+ "runtime".to_string(),
+ Value::String("maestro-rust-control-plane".to_string()),
+ );
+ metadata.insert("surface".to_string(), Value::String("rust-tui".to_string()));
+ metadata.insert(
+ "a2aProtocolVersion".to_string(),
+ Value::String(A2A_PROTOCOL_VERSION.to_string()),
+ );
+ for (field, header) in [
+ ("workspaceId", "x-evalops-workspace-id"),
+ ("agentId", "x-evalops-agent-id"),
+ ("sessionId", "x-evalops-session-id"),
+ ("actorId", "x-evalops-actor-id"),
+ ("traceparent", "traceparent"),
+ ("tracestate", "tracestate"),
+ ] {
+ if let Some(value) = head.headers.get(header).map(String::as_str) {
+ if !value.trim().is_empty() {
+ metadata.insert(field.to_string(), Value::String(value.trim().to_string()));
+ }
+ }
+ }
+ if let Some(Value::Object(request_metadata)) = request.metadata.as_ref() {
+ for (key, value) in request_metadata {
+ metadata.entry(key.clone()).or_insert_with(|| value.clone());
+ }
+ }
+ if let Some(configuration) = request.configuration.as_ref() {
+ metadata
+ .entry("configuration".to_string())
+ .or_insert_with(|| configuration.clone());
+ }
+ if let Some(Value::Object(message_metadata)) = request.message.metadata.as_ref() {
+ for (key, value) in message_metadata {
+ metadata.entry(key.clone()).or_insert_with(|| value.clone());
+ }
+ }
+ Value::Object(metadata)
+}
+
+fn a2a_return_immediately(request: &A2ASendMessageRequest) -> bool {
+ request
+ .configuration
+ .as_ref()
+ .and_then(|configuration| configuration.get("returnImmediately"))
+ .and_then(Value::as_bool)
+ .unwrap_or(false)
+}
+
+async fn run_a2a_native_turn(
+ state: &AppState,
+ prompt: String,
+ mut cancel_rx: A2ACancelReceiver,
+) -> Result<A2ATurnResult, String> {
+ if *cancel_rx.borrow() {
+ return Ok(A2ATurnResult::Canceled);
+ }
+
+ if let Some(response) = trimmed_env("MAESTRO_A2A_FAKE_RESPONSE") {
+ if a2a_wait_for_fake_response_delay(&mut cancel_rx).await {
+ return Ok(A2ATurnResult::Canceled);
+ }
+ return Ok(A2ATurnResult::Completed(A2ATurnOutput {
+ assistant_text: response,
+ ..Default::default()
+ }));
+ }
+
+ let model = if let Some(model) = trimmed_env("MAESTRO_A2A_MODEL") {
+ model
+ } else {
+ let selected = state.selected_model.lock().await;
+ format!("{}/{}", selected.provider, selected.id)
+ };
+ let config = NativeAgentConfig {
+ model,
+ cwd: state.config.cwd.to_string_lossy().to_string(),
+ system_prompt: Some(
+ trimmed_env("MAESTRO_A2A_SYSTEM_PROMPT").unwrap_or_else(|| {
+ "You are the local Maestro Desktop A2A agent. Complete delegated work from peer agents clearly and concisely.".to_string()
+ }),
+ ),
+ thinking_enabled: truthy_env("MAESTRO_A2A_THINKING"),
+ thinking_budget: env::var("MAESTRO_A2A_THINKING_BUDGET")
+ .ok()
+ .and_then(|value| value.parse().ok())
+ .unwrap_or(10_000),
+ ..NativeAgentConfig::default()
+ };
+ let (agent, mut events) = NativeAgent::new(config).map_err(|error| error.to_string())?;
+ agent
+ .prompt(prompt, Vec::new())
+ .await
+ .map_err(|error| error.to_string())?;
+
+ let timeout = Duration::from_millis(env_u64(
+ "MAESTRO_A2A_TURN_TIMEOUT_MS",
+ A2A_DEFAULT_TURN_TIMEOUT_MS,
+ ));
+ let approval_mode = trimmed_env("MAESTRO_A2A_TOOL_APPROVAL")
+ .unwrap_or_else(|| "fail".to_string())
+ .to_ascii_lowercase();
+ let auto_approve_tools = matches!(approval_mode.as_str(), "auto" | "approve" | "approved");
+ let mut output = A2ATurnOutput::default();
+ let mut last_error: Option<String> = None;
+ let mut response_ended = false;
+ let turn_timeout = tokio::time::sleep(timeout);
+ tokio::pin!(turn_timeout);
+
+ loop {
+ let event = tokio::select! {
+ _ = &mut turn_timeout => {
+ agent.cancel();
... diff truncated: showing 800 of 1510 linesYou can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fae5207f8f
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4a23e05aa5
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e38158558a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 44a69d1a68
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 18400f7e43
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Cancel task omits artifact clearing unlike Python bridge
- The Rust A2A cancel path now clears existing task artifacts and includes a regression test for canceling an INPUT_REQUIRED task with stale artifacts.
Preview (081c27c74e)
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -104,6 +104,8 @@
"smoke": "node scripts/smoke-cli.js",
"smoke:local-e2e": "node scripts/smoke-cli.js",
"smoke:event-bus": "tsx scripts/smoke-maestro-event-bus.ts",
+ "smoke:a2a-local": "tsx scripts/smoke-maestro-a2a-local.ts",
+ "a2a:codex-bridge": "python3 scripts/codex-a2a-bridge.py",
"smoke:headless": "node scripts/smoke-headless.js",
"headless:responsiveness": "node scripts/headless-responsiveness-harness.js",
"smoke:pack": "node scripts/smoke-packed-cli.js",
diff --git a/packages/control-plane-rs/src/http.rs b/packages/control-plane-rs/src/http.rs
--- a/packages/control-plane-rs/src/http.rs
+++ b/packages/control-plane-rs/src/http.rs
@@ -11,7 +11,7 @@
const MAX_HEADER_BYTES: usize = 64 * 1024;
pub(crate) const MAX_JSON_BODY_BYTES: usize = 32 * 1024 * 1024;
-const CORS_ALLOW_HEADERS: &str = "authorization,content-type,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
+const CORS_ALLOW_HEADERS: &str = "authorization,content-type,a2a-version,a2a-extensions,traceparent,tracestate,x-organization-id,x-evalops-agent-id,x-evalops-actor-id,x-evalops-session-id,x-evalops-workspace-id,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
#[derive(Debug)]
pub(crate) struct RequestHead {
diff --git a/packages/control-plane-rs/src/main.rs b/packages/control-plane-rs/src/main.rs
--- a/packages/control-plane-rs/src/main.rs
+++ b/packages/control-plane-rs/src/main.rs
@@ -17,7 +17,7 @@
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::process::Command;
-use tokio::sync::{mpsc, Mutex};
+use tokio::sync::{mpsc, watch, Mutex};
mod auth;
mod http;
@@ -49,9 +49,15 @@
const DEFAULT_EXTRACT_MAX_CHARS: usize = 200_000;
const MAX_EXTRACT_INPUT_BYTES: usize = 50 * 1024 * 1024;
const MAX_PROJECT_ONBOARDING_IMPRESSIONS: u8 = 4;
+const A2A_PROTOCOL_VERSION: &str = "1.0";
+const A2A_DEFAULT_TURN_TIMEOUT_MS: u64 = 180_000;
+const A2A_DEFAULT_RESPONSE_END_SETTLE_MS: u64 = 250;
+const A2A_TERMINAL_TASK_STORE_LIMIT: usize = 128;
static ATTACHMENT_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
type PendingToolResponseSender = mpsc::UnboundedSender<(String, bool, Option<ToolResult>)>;
+type A2ACancelSender = watch::Sender<bool>;
+type A2ACancelReceiver = watch::Receiver<bool>;
#[derive(Debug, Clone)]
struct Config {
listen_host: String,
@@ -151,6 +157,8 @@
shared_sessions: Arc<Mutex<HashMap<String, SharedSessionGrant>>>,
approval_modes: Arc<Mutex<HashMap<String, String>>>,
pending_tool_responses: Arc<Mutex<HashMap<String, PendingToolResponseSender>>>,
+ a2a_tasks: Arc<Mutex<HashMap<String, Value>>>,
+ a2a_cancel_senders: Arc<Mutex<HashMap<String, A2ACancelSender>>>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -295,6 +303,8 @@
shared_sessions: Arc::new(Mutex::new(shared_sessions)),
approval_modes: Arc::new(Mutex::new(HashMap::new())),
pending_tool_responses: Arc::new(Mutex::new(HashMap::new())),
+ a2a_tasks: Arc::new(Mutex::new(HashMap::new())),
+ a2a_cancel_senders: Arc::new(Mutex::new(HashMap::new())),
};
loop {
@@ -329,6 +339,16 @@
return handle_chat_endpoint(stream, initial, head, state).await;
}
+ if is_a2a_endpoint(&head) {
+ let response = handle_a2a_endpoint(&mut stream, &mut initial, head, &state).await;
+ stream
+ .write_all(&response)
+ .await
+ .map_err(|error| error.to_string())?;
+ let _ = stream.shutdown().await;
+ return Ok(());
+ }
+
if is_local_endpoint(&head) {
let response = handle_local_endpoint(&mut stream, &mut initial, head, &state).await;
stream
@@ -508,6 +528,970 @@
Some(request_id)
}
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2APartBody {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ text: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ url: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ data: Option<Value>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ metadata: Option<Value>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ filename: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ media_type: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2AMessageBody {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ message_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ context_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ task_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ role: Option<String>,
+ #[serde(default)]
+ parts: Vec<A2APartBody>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ metadata: Option<Value>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ extensions: Option<Vec<String>>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ reference_task_ids: Option<Vec<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct A2ASendMessageRequest {
+ message: A2AMessageBody,
+ #[serde(default)]
+ configuration: Option<Value>,
+ #[serde(default)]
+ metadata: Option<Value>,
+}
+
+#[derive(Debug, Default)]
+struct A2ATurnOutput {
+ assistant_text: String,
+ thinking_text: String,
+ usage: Option<TokenUsage>,
+ tools: Vec<Value>,
+}
+
+enum A2ATurnResult {
+ Completed(A2ATurnOutput),
+ Canceled,
+}
+
+#[derive(Debug)]
+struct A2ASendTarget {
+ task_id: String,
+ context_id: String,
+ history: Vec<Value>,
+}
+
+fn is_a2a_endpoint(head: &RequestHead) -> bool {
+ if head.method == "OPTIONS" {
+ return head.path == "/.well-known/agent-card.json"
+ || head.path == "/message:send"
+ || head.path == "/tasks"
+ || head.path.starts_with("/tasks/");
+ }
+ matches!(
+ (head.method.as_str(), head.path.as_str()),
+ ("GET", "/.well-known/agent-card.json") | ("POST", "/message:send") | ("GET", "/tasks")
+ ) || (head.method == "GET" && a2a_task_id_from_get_path(&head.path).is_some())
+ || (head.method == "POST" && a2a_task_id_from_cancel_path(&head.path).is_some())
+}
+
+fn a2a_task_id_from_get_path(path: &str) -> Option<&str> {
+ let id = path.strip_prefix("/tasks/")?;
+ (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+fn a2a_task_id_from_cancel_path(path: &str) -> Option<&str> {
+ let id = path.strip_prefix("/tasks/")?.strip_suffix(":cancel")?;
+ (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+async fn handle_a2a_endpoint(
+ stream: &mut TcpStream,
+ initial: &mut Vec<u8>,
+ head: RequestHead,
+ state: &AppState,
+) -> Vec<u8> {
+ if head.method == "OPTIONS" {
+ return response(204, "text/plain; charset=utf-8", &[]);
+ }
+
+ if head.method == "GET" && head.path == "/.well-known/agent-card.json" {
+ return json_response(200, &a2a_agent_card(&head, &state.config));
+ }
+
+ if let Err(response) = authorize(&head, &state.config) {
+ return response;
+ }
+
+ if head.method == "GET" && head.path == "/tasks" {
+ let tasks = state
+ .a2a_tasks
+ .lock()
+ .await
+ .values()
+ .cloned()
+ .collect::<Vec<_>>();
+ return json_response(200, &serde_json::json!({ "tasks": tasks }));
+ }
+
+ if head.method == "GET" {
+ if let Some(task_id) = a2a_task_id_from_get_path(&head.path) {
+ let tasks = state.a2a_tasks.lock().await;
+ return tasks.get(task_id).map_or_else(
+ || a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found"),
+ |task| json_response(200, task),
+ );
+ }
+ }
+
+ if head.method == "POST" {
+ if let Some(task_id) = a2a_task_id_from_cancel_path(&head.path) {
+ return match cancel_a2a_task(state, task_id).await {
+ Ok(task) => json_response(200, &task),
+ Err(response) => response,
+ };
+ }
+ }
+
+ if head.method == "POST" && head.path == "/message:send" {
+ return handle_a2a_message_send(stream, initial, &head, state).await;
+ }
+
+ a2a_error_response(404, "NOT_FOUND", "A2A endpoint not found")
+}
+
+async fn cancel_a2a_task(state: &AppState, task_id: &str) -> Result<Value, Vec<u8>> {
+ let mut tasks = state.a2a_tasks.lock().await;
+ let Some(task) = tasks.get_mut(task_id) else {
+ return Err(a2a_error_response(
+ 404,
+ "TASK_NOT_FOUND",
+ "A2A task not found",
+ ));
+ };
+ if a2a_task_is_terminal(task) {
+ return Err(a2a_error_response(
+ 400,
+ "TASK_NOT_CANCELABLE",
+ "A2A task cannot be canceled from its current state",
+ ));
+ }
+ let context_id = task
+ .get("contextId")
+ .and_then(Value::as_str)
+ .unwrap_or("a2a")
+ .to_string();
+ task["status"] = serde_json::json!({
+ "state": "TASK_STATE_CANCELED",
+ "message": a2a_agent_message(&context_id, "Task canceled"),
+ "timestamp": now_rfc3339()
+ });
+ task["artifacts"] = serde_json::json!([]);
+ let task = task.clone();
+ prune_a2a_terminal_tasks(&mut tasks);
+ drop(tasks);
+
+ if let Some(sender) = state.a2a_cancel_senders.lock().await.remove(task_id) {
+ let _ = sender.send(true);
+ }
+
+ Ok(task)
+}
+
+fn a2a_task_status_state(task: &Value) -> Option<&str> {
+ task.get("status")
+ .and_then(|status| status.get("state"))
+ .and_then(Value::as_str)
+}
+
+fn a2a_task_status_timestamp(task: &Value) -> Option<&str> {
+ task.get("status")
+ .and_then(|status| status.get("timestamp"))
+ .and_then(Value::as_str)
+}
+
+fn a2a_task_is_terminal(task: &Value) -> bool {
+ matches!(
+ a2a_task_status_state(task),
+ Some(
+ "TASK_STATE_COMPLETED"
+ | "TASK_STATE_FAILED"
+ | "TASK_STATE_CANCELED"
+ | "TASK_STATE_REJECTED"
+ )
+ )
+}
+
+fn a2a_task_accepts_message(task: &Value) -> bool {
+ a2a_task_status_state(task) == Some("TASK_STATE_INPUT_REQUIRED")
+}
+
+async fn claim_a2a_send_task(
+ state: &AppState,
+ request: &A2ASendMessageRequest,
+ head: &RequestHead,
+ metadata: Value,
+) -> Result<A2ASendTarget, Vec<u8>> {
+ let requested_task_id = request
+ .message
+ .task_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|task_id| !task_id.is_empty());
+ let explicit_context_id = request
+ .message
+ .context_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|context_id| !context_id.is_empty())
+ .map(str::to_string);
+
+ let mut tasks = state.a2a_tasks.lock().await;
+ let (task_id, context_id, mut history) = if let Some(task_id) = requested_task_id {
+ let Some(task) = tasks.get(task_id) else {
+ return Err(a2a_error_response(
+ 404,
+ "TASK_NOT_FOUND",
+ "A2A task not found",
+ ));
+ };
+ if a2a_task_is_terminal(task) {
+ return Err(a2a_error_response(
+ 400,
+ "UNSUPPORTED_OPERATION",
+ "A2A terminal tasks cannot accept more messages",
+ ));
+ }
+ if !a2a_task_accepts_message(task) {
+ return Err(a2a_error_response(
+ 409,
+ "UNSUPPORTED_OPERATION",
+ "A2A task is not ready to accept another message",
+ ));
+ }
+
+ let task_context_id = task
+ .get("contextId")
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .filter(|context_id| !context_id.is_empty())
+ .map(str::to_string);
+ if let (Some(message_context_id), Some(task_context_id)) =
+ (explicit_context_id.as_deref(), task_context_id.as_deref())
+ {
+ if message_context_id != task_context_id {
+ return Err(a2a_error_response(
+ 400,
+ "INVALID_REQUEST",
+ "A2A message contextId must match the referenced task",
+ ));
+ }
+ }
+ let context_id = explicit_context_id
+ .or(task_context_id)
+ .unwrap_or_else(|| a2a_context_id(request, head));
+ let history = task
+ .get("history")
+ .and_then(Value::as_array)
+ .cloned()
+ .unwrap_or_default();
+ (task_id.to_string(), context_id, history)
+ } else {
+ (
+ generate_a2a_id("maestro-task"),
+ explicit_context_id.unwrap_or_else(|| a2a_context_id(request, head)),
+ Vec::new(),
+ )
+ };
+ history.push(a2a_user_message_value(&request.message, &context_id));
+ let working_message = a2a_agent_message(&context_id, "Maestro is working on the A2A task.");
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_WORKING",
+ working_message,
+ history.clone(),
+ Vec::new(),
+ metadata,
+ );
+ tasks.insert(task_id.clone(), task);
+ prune_a2a_terminal_tasks(&mut tasks);
+ Ok(A2ASendTarget {
+ task_id,
+ context_id,
+ history,
+ })
+}
+
+async fn a2a_canceled_task(state: &AppState, task_id: &str) -> Option<Value> {
+ state.a2a_tasks.lock().await.get(task_id).and_then(|task| {
+ (a2a_task_status_state(task) == Some("TASK_STATE_CANCELED")).then(|| task.clone())
+ })
+}
+
+async fn store_a2a_task_unless_canceled(state: &AppState, task_id: &str, task: Value) -> Value {
+ let mut tasks = state.a2a_tasks.lock().await;
+ if let Some(existing) = tasks.get(task_id) {
+ if a2a_task_status_state(existing) == Some("TASK_STATE_CANCELED") {
+ return existing.clone();
+ }
+ }
+ tasks.insert(task_id.to_string(), task.clone());
+ prune_a2a_terminal_tasks(&mut tasks);
+ task
+}
+
+fn prune_a2a_terminal_tasks(tasks: &mut HashMap<String, Value>) {
+ let mut terminal_tasks = tasks
+ .iter()
+ .filter(|(_, task)| a2a_task_is_terminal(task))
+ .map(|(task_id, task)| {
+ (
+ task_id.clone(),
+ a2a_task_status_timestamp(task)
+ .unwrap_or_default()
+ .to_string(),
+ )
+ })
+ .collect::<Vec<_>>();
+ if terminal_tasks.len() <= A2A_TERMINAL_TASK_STORE_LIMIT {
+ return;
+ }
+ terminal_tasks.sort_by(|(left_id, left_timestamp), (right_id, right_timestamp)| {
+ left_timestamp
+ .cmp(right_timestamp)
+ .then_with(|| left_id.cmp(right_id))
+ });
+ let overflow = terminal_tasks.len() - A2A_TERMINAL_TASK_STORE_LIMIT;
+ for (task_id, _) in terminal_tasks.into_iter().take(overflow) {
+ tasks.remove(&task_id);
+ }
+}
+
+async fn register_a2a_cancel_sender(
+ state: &AppState,
+ task_id: &str,
+ cancel_tx: A2ACancelSender,
+) -> Result<(), Vec<u8>> {
+ let mut senders = state.a2a_cancel_senders.lock().await;
+ if senders.contains_key(task_id) {
+ return Err(a2a_error_response(
+ 409,
+ "UNSUPPORTED_OPERATION",
+ "A2A task is already running",
+ ));
+ }
+ senders.insert(task_id.to_string(), cancel_tx);
+ Ok(())
+}
+
+async fn handle_a2a_message_send(
+ stream: &mut TcpStream,
+ initial: &mut Vec<u8>,
+ head: &RequestHead,
+ state: &AppState,
+) -> Vec<u8> {
+ let body = match read_request_body(stream, initial, head).await {
+ Ok(body) => body,
+ Err(error) => return a2a_error_response(400, "INVALID_REQUEST", &error),
+ };
+ let request: A2ASendMessageRequest = match serde_json::from_slice(&body) {
+ Ok(request) => request,
+ Err(error) => {
+ return a2a_error_response(
+ 400,
+ "INVALID_REQUEST",
+ &format!("invalid A2A message request: {error}"),
+ );
+ }
+ };
+
+ let Some(prompt) = a2a_message_text(&request.message) else {
+ return a2a_error_response(
+ 400,
+ "INVALID_REQUEST",
+ "A2A message must contain at least one text part",
+ );
+ };
+
+ let metadata = a2a_task_metadata(head, &request);
+ let target = match claim_a2a_send_task(state, &request, head, metadata.clone()).await {
+ Ok(target) => target,
+ Err(response) => return response,
+ };
+ let task_id = target.task_id;
+ let context_id = target.context_id;
+ let history = target.history;
+
+ let (cancel_tx, cancel_rx) = watch::channel(false);
+ if let Err(response) = register_a2a_cancel_sender(state, &task_id, cancel_tx).await {
+ return response;
+ }
+ if let Some(task) = a2a_canceled_task(state, &task_id).await {
+ state.a2a_cancel_senders.lock().await.remove(&task_id);
+ return json_response(200, &serde_json::json!({ "task": task }));
+ }
+ if a2a_return_immediately(&request) {
+ let accepted_message = a2a_agent_message(&context_id, "Maestro accepted the A2A task.");
+ let mut accepted_history = history.clone();
+ accepted_history.push(accepted_message.clone());
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_WORKING",
+ accepted_message.clone(),
+ accepted_history.clone(),
+ Vec::new(),
+ metadata.clone(),
+ );
+ let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+ let state = state.clone();
+ tokio::spawn(async move {
+ let _ = complete_a2a_task(
+ &state,
+ prompt,
+ task_id,
+ context_id,
+ accepted_history,
+ metadata,
+ cancel_rx,
+ )
+ .await;
+ });
+ return json_response(200, &serde_json::json!({ "task": task }));
+ }
+
+ let task = complete_a2a_task(
+ state, prompt, task_id, context_id, history, metadata, cancel_rx,
+ )
+ .await;
+ json_response(200, &serde_json::json!({ "task": task }))
+}
+
+async fn complete_a2a_task(
+ state: &AppState,
+ prompt: String,
+ task_id: String,
+ context_id: String,
+ mut history: Vec<Value>,
+ mut metadata: Value,
+ cancel_rx: A2ACancelReceiver,
+) -> Value {
+ let turn = match run_a2a_native_turn(state, prompt, cancel_rx).await {
+ Ok(A2ATurnResult::Completed(turn)) => turn,
+ Ok(A2ATurnResult::Canceled) => {
+ let message = a2a_agent_message(&context_id, "Task canceled");
+ history.push(message.clone());
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_CANCELED",
+ message,
+ history,
+ Vec::new(),
+ metadata,
+ );
+ let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+ state.a2a_cancel_senders.lock().await.remove(&task_id);
+ return task;
+ }
+ Err(error) => {
+ let message = a2a_agent_message(&context_id, &error);
+ history.push(message.clone());
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_FAILED",
+ message.clone(),
+ history,
+ Vec::new(),
+ metadata,
+ );
+ let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+ state.a2a_cancel_senders.lock().await.remove(&task_id);
+ return task;
+ }
+ };
+
+ let assistant_text = if turn.assistant_text.trim().is_empty() {
+ "Maestro completed the A2A task without a text response.".to_string()
+ } else {
+ turn.assistant_text
+ };
+ let agent_message = a2a_agent_message(&context_id, &assistant_text);
+ if !turn.thinking_text.trim().is_empty() {
+ metadata["thinking"] = Value::String(turn.thinking_text);
+ }
+ if !turn.tools.is_empty() {
+ metadata["tools"] = Value::Array(turn.tools);
+ }
+ if let Some(usage) = turn.usage {
+ metadata["usage"] = serde_json::json!({
+ "input": usage.input_tokens,
+ "output": usage.output_tokens,
+ "cacheRead": usage.cache_read_tokens,
+ "cacheWrite": usage.cache_write_tokens,
+ "cost": usage.cost.unwrap_or(0.0)
+ });
+ }
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_COMPLETED",
+ agent_message.clone(),
+ {
+ history.push(agent_message);
+ history
+ },
+ vec![serde_json::json!({
+ "artifactId": format!("{task_id}-assistant-response"),
+ "name": "assistant-response",
+ "parts": [{ "text": assistant_text, "mediaType": "text/plain" }]
+ })],
+ metadata,
+ );
+ let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+ state.a2a_cancel_senders.lock().await.remove(&task_id);
+ task
+}
+
+fn a2a_agent_card(head: &RequestHead, config: &Config) -> Value {
+ let base_url = a2a_public_base_url(head, config);
+ serde_json::json!({
+ "protocolVersion": A2A_PROTOCOL_VERSION,
+ "name": trimmed_env("MAESTRO_A2A_AGENT_NAME")
+ .unwrap_or_else(|| "Maestro Desktop Agent".to_string()),
+ "description": "Local Maestro Rust/TS TUI agent endpoint for A2A task delegation.",
+ "url": base_url,
+ "preferredTransport": "HTTP+JSON",
+ "supportedInterfaces": [{
+ "url": base_url,
+ "protocolBinding": "HTTP+JSON",
+ "protocolVersion": A2A_PROTOCOL_VERSION
+ }],
+ "provider": {
+ "organization": "EvalOps",
+ "url": "https://evalops.com"
+ },
+ "version": env!("CARGO_PKG_VERSION"),
+ "capabilities": {
+ "streaming": false,
+ "pushNotifications": false,
+ "extendedAgentCard": false
+ },
+ "defaultInputModes": ["text/plain"],
+ "defaultOutputModes": ["text/plain", "application/json"],
+ "skills": [{
+ "id": "maestro-tui-turn",
+ "name": "Maestro TUI turn",
+ "description": "Run a prompt through the local Maestro native TUI agent runner.",
+ "tags": ["maestro", "tui", "codex", "a2a"],
+ "examples": [
+ "Review the current workspace and summarize the next highest leverage action."
+ ],
+ "inputModes": ["text/plain"],
+ "outputModes": ["text/plain", "application/json"]
+ }]
+ })
+}
+
+fn a2a_public_base_url(head: &RequestHead, config: &Config) -> String {
+ if let Some(url) =
+ trimmed_env("MAESTRO_A2A_PUBLIC_URL").or_else(|| trimmed_env("MAESTRO_CONTROL_PUBLIC_URL"))
+ {
+ return url.trim_end_matches('/').to_string();
+ }
+ let proto = head
+ .headers
+ .get("x-forwarded-proto")
+ .and_then(|value| value.split(',').next())
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .unwrap_or("http");
+ let host = head
+ .headers
+ .get("host")
+ .map(String::as_str)
+ .filter(|host| !host.trim().is_empty())
+ .map(str::trim)
+ .map(str::to_string)
+ .unwrap_or_else(|| {
+ let host = if config.listen_host == "0.0.0.0" || config.listen_host == "::" {
+ "127.0.0.1"
+ } else {
+ config.listen_host.as_str()
+ };
+ format!("{host}:{}", config.listen_port)
+ });
+ format!("{proto}://{host}")
+}
+
+fn a2a_message_text(message: &A2AMessageBody) -> Option<String> {
+ let text = message
+ .parts
+ .iter()
+ .filter_map(|part| part.text.as_deref())
+ .map(str::trim)
+ .filter(|part| !part.is_empty())
+ .collect::<Vec<_>>()
+ .join("\n\n");
+ (!text.is_empty()).then_some(text)
+}
+
+fn trimmed_non_empty_string(value: &str) -> Option<String> {
+ let value = value.trim();
+ (!value.is_empty()).then(|| value.to_string())
+}
+
+fn a2a_context_id(request: &A2ASendMessageRequest, head: &RequestHead) -> String {
+ request
+ .message
+ .context_id
+ .as_deref()
+ .and_then(trimmed_non_empty_string)
+ .or_else(|| {
+ request
+ .message
+ .metadata
+ .as_ref()
+ .and_then(|metadata| metadata.get("sessionId").and_then(Value::as_str))
+ .and_then(trimmed_non_empty_string)
+ })
+ .or_else(|| {
+ head.headers
+ .get("x-evalops-session-id")
+ .and_then(|value| trimmed_non_empty_string(value))
+ })
+ .or_else(|| {
+ head.headers
+ .get("x-maestro-session-id")
+ .and_then(|value| trimmed_non_empty_string(value))
+ })
+ .unwrap_or_else(|| generate_a2a_id("maestro-context"))
+}
+
+fn a2a_user_message_value(message: &A2AMessageBody, context_id: &str) -> Value {
+ let mut value = serde_json::to_value(message).unwrap_or_else(|_| serde_json::json!({}));
+ if let Value::Object(object) = &mut value {
+ object
+ .entry("messageId")
+ .or_insert_with(|| Value::String(generate_a2a_id("maestro-message")));
+ object.insert(
+ "contextId".to_string(),
+ Value::String(context_id.to_string()),
+ );
+ object
+ .entry("role")
+ .or_insert_with(|| Value::String("ROLE_USER".to_string()));
+ }
+ value
+}
+
+fn a2a_agent_message(context_id: &str, text: &str) -> Value {
+ serde_json::json!({
+ "messageId": generate_a2a_id("maestro-message"),
+ "contextId": context_id,
+ "role": "ROLE_AGENT",
+ "parts": [{ "text": text, "mediaType": "text/plain" }],
+ "metadata": {
+ "runtime": "maestro-rust-control-plane",
+ "surface": "rust-tui"
+ }
+ })
+}
+
+fn a2a_task_value(
+ task_id: &str,
+ context_id: &str,
+ state: &str,
+ status_message: Value,
+ history: Vec<Value>,
+ artifacts: Vec<Value>,
+ metadata: Value,
+) -> Value {
+ serde_json::json!({
+ "id": task_id,
+ "contextId": context_id,
+ "status": {
+ "state": state,
+ "message": status_message,
+ "timestamp": now_rfc3339()
+ },
+ "history": history,
+ "artifacts": artifacts,
+ "metadata": metadata
... diff truncated: showing 800 of 2439 linesYou can send follow-ups to the cloud agent here.
|
Bugbot Autofix prepared a fix for the issue found in the latest run.
You can send follow-ups to the cloud agent here. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 11b85c7c93
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 801e843f55
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: deca429596
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 970196e4bf
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6e2346f3da
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: IPv6 host produces malformed URL without port
- IPv6 public hosts are now bracketed and still receive the configured port unless an explicit bracketed port is already present.
Preview (f0be74879f)
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -104,6 +104,8 @@
"smoke": "node scripts/smoke-cli.js",
"smoke:local-e2e": "node scripts/smoke-cli.js",
"smoke:event-bus": "tsx scripts/smoke-maestro-event-bus.ts",
+ "smoke:a2a-local": "tsx scripts/smoke-maestro-a2a-local.ts",
+ "a2a:codex-bridge": "python3 scripts/codex-a2a-bridge.py",
"smoke:headless": "node scripts/smoke-headless.js",
"headless:responsiveness": "node scripts/headless-responsiveness-harness.js",
"smoke:pack": "node scripts/smoke-packed-cli.js",
diff --git a/packages/control-plane-rs/src/http.rs b/packages/control-plane-rs/src/http.rs
--- a/packages/control-plane-rs/src/http.rs
+++ b/packages/control-plane-rs/src/http.rs
@@ -11,7 +11,7 @@
const MAX_HEADER_BYTES: usize = 64 * 1024;
pub(crate) const MAX_JSON_BODY_BYTES: usize = 32 * 1024 * 1024;
-const CORS_ALLOW_HEADERS: &str = "authorization,content-type,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
+const CORS_ALLOW_HEADERS: &str = "authorization,content-type,a2a-version,a2a-extensions,traceparent,tracestate,x-organization-id,x-evalops-agent-id,x-evalops-actor-id,x-evalops-session-id,x-evalops-workspace-id,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
#[derive(Debug)]
pub(crate) struct RequestHead {
diff --git a/packages/control-plane-rs/src/main.rs b/packages/control-plane-rs/src/main.rs
--- a/packages/control-plane-rs/src/main.rs
+++ b/packages/control-plane-rs/src/main.rs
@@ -17,7 +17,7 @@
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::process::Command;
-use tokio::sync::{mpsc, Mutex};
+use tokio::sync::{mpsc, watch, Mutex};
mod auth;
mod http;
@@ -49,9 +49,15 @@
const DEFAULT_EXTRACT_MAX_CHARS: usize = 200_000;
const MAX_EXTRACT_INPUT_BYTES: usize = 50 * 1024 * 1024;
const MAX_PROJECT_ONBOARDING_IMPRESSIONS: u8 = 4;
+const A2A_PROTOCOL_VERSION: &str = "1.0";
+const A2A_DEFAULT_TURN_TIMEOUT_MS: u64 = 180_000;
+const A2A_DEFAULT_RESPONSE_END_SETTLE_MS: u64 = 250;
+const A2A_TERMINAL_TASK_STORE_LIMIT: usize = 128;
static ATTACHMENT_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
type PendingToolResponseSender = mpsc::UnboundedSender<(String, bool, Option<ToolResult>)>;
+type A2ACancelSender = watch::Sender<bool>;
+type A2ACancelReceiver = watch::Receiver<bool>;
#[derive(Debug, Clone)]
struct Config {
listen_host: String,
@@ -151,6 +157,8 @@
shared_sessions: Arc<Mutex<HashMap<String, SharedSessionGrant>>>,
approval_modes: Arc<Mutex<HashMap<String, String>>>,
pending_tool_responses: Arc<Mutex<HashMap<String, PendingToolResponseSender>>>,
+ a2a_tasks: Arc<Mutex<HashMap<String, Value>>>,
+ a2a_cancel_senders: Arc<Mutex<HashMap<String, A2ACancelSender>>>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -295,6 +303,8 @@
shared_sessions: Arc::new(Mutex::new(shared_sessions)),
approval_modes: Arc::new(Mutex::new(HashMap::new())),
pending_tool_responses: Arc::new(Mutex::new(HashMap::new())),
+ a2a_tasks: Arc::new(Mutex::new(HashMap::new())),
+ a2a_cancel_senders: Arc::new(Mutex::new(HashMap::new())),
};
loop {
@@ -329,6 +339,16 @@
return handle_chat_endpoint(stream, initial, head, state).await;
}
+ if is_a2a_endpoint(&head) {
+ let response = handle_a2a_endpoint(&mut stream, &mut initial, head, &state).await;
+ stream
+ .write_all(&response)
+ .await
+ .map_err(|error| error.to_string())?;
+ let _ = stream.shutdown().await;
+ return Ok(());
+ }
+
if is_local_endpoint(&head) {
let response = handle_local_endpoint(&mut stream, &mut initial, head, &state).await;
stream
@@ -508,6 +528,1062 @@
Some(request_id)
}
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2APartBody {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ text: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ url: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ data: Option<Value>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ metadata: Option<Value>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ filename: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ media_type: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2AMessageBody {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ message_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ context_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ task_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ role: Option<String>,
+ #[serde(default)]
+ parts: Vec<A2APartBody>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ metadata: Option<Value>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ extensions: Option<Vec<String>>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ reference_task_ids: Option<Vec<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct A2ASendMessageRequest {
+ message: A2AMessageBody,
+ #[serde(default)]
+ configuration: Option<Value>,
+ #[serde(default)]
+ metadata: Option<Value>,
+}
+
+#[derive(Debug, Default)]
+struct A2ATurnOutput {
+ assistant_text: String,
+ thinking_text: String,
+ usage: Option<TokenUsage>,
+ tools: Vec<Value>,
+}
+
+enum A2ATurnResult {
+ Completed(A2ATurnOutput),
+ Canceled,
+}
+
+#[derive(Debug)]
+struct A2ASendTarget {
+ task_id: String,
+ context_id: String,
+ history: Vec<Value>,
+ previous_task: Option<Value>,
+ metadata: Value,
+}
+
+fn is_a2a_endpoint(head: &RequestHead) -> bool {
+ if head.method == "OPTIONS" {
+ return head.path == "/.well-known/agent-card.json"
+ || head.path == "/message:send"
+ || head.path == "/tasks"
+ || head.path.starts_with("/tasks/");
+ }
+ matches!(
+ (head.method.as_str(), head.path.as_str()),
+ ("GET", "/.well-known/agent-card.json") | ("POST", "/message:send") | ("GET", "/tasks")
+ ) || (head.method == "GET" && a2a_task_id_from_get_path(&head.path).is_some())
+ || (head.method == "POST" && a2a_task_id_from_cancel_path(&head.path).is_some())
+}
+
+fn a2a_task_id_from_get_path(path: &str) -> Option<&str> {
+ let id = path.strip_prefix("/tasks/")?;
+ (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+fn a2a_task_id_from_cancel_path(path: &str) -> Option<&str> {
+ let id = path.strip_prefix("/tasks/")?.strip_suffix(":cancel")?;
+ (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+async fn handle_a2a_endpoint(
+ stream: &mut TcpStream,
+ initial: &mut Vec<u8>,
+ head: RequestHead,
+ state: &AppState,
+) -> Vec<u8> {
+ if head.method == "OPTIONS" {
+ return response(204, "text/plain; charset=utf-8", &[]);
+ }
+
+ if head.method == "GET" && head.path == "/.well-known/agent-card.json" {
+ return json_response(200, &a2a_agent_card(&head, &state.config));
+ }
+
+ let Some(auth) = auth_context(&head, &state.config) else {
+ return json_response(401, &serde_json::json!({ "error": "Unauthorized" }));
+ };
+
+ if head.method == "GET" && head.path == "/tasks" {
+ let tasks = state
+ .a2a_tasks
+ .lock()
+ .await
+ .values()
+ .filter(|task| a2a_task_visible_to_auth(task, &auth))
+ .cloned()
+ .collect::<Vec<_>>();
+ return json_response(200, &serde_json::json!({ "tasks": tasks }));
+ }
+
+ if head.method == "GET" {
+ if let Some(task_id) = a2a_task_id_from_get_path(&head.path) {
+ let tasks = state.a2a_tasks.lock().await;
+ return tasks.get(task_id).map_or_else(
+ || a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found"),
+ |task| {
+ if a2a_task_visible_to_auth(task, &auth) {
+ json_response(200, task)
+ } else {
+ a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found")
+ }
+ },
+ );
+ }
+ }
+
+ if head.method == "POST" {
+ if let Some(task_id) = a2a_task_id_from_cancel_path(&head.path) {
+ return match cancel_a2a_task(state, task_id, &auth).await {
+ Ok(task) => json_response(200, &task),
+ Err(response) => response,
+ };
+ }
+ }
+
+ if head.method == "POST" && head.path == "/message:send" {
+ return handle_a2a_message_send(stream, initial, &head, state, &auth).await;
+ }
+
+ a2a_error_response(404, "NOT_FOUND", "A2A endpoint not found")
+}
+
+async fn cancel_a2a_task(
+ state: &AppState,
+ task_id: &str,
+ auth: &AuthContext,
+) -> Result<Value, Vec<u8>> {
+ let mut tasks = state.a2a_tasks.lock().await;
+ let Some(task) = tasks.get_mut(task_id) else {
+ return Err(a2a_error_response(
+ 404,
+ "TASK_NOT_FOUND",
+ "A2A task not found",
+ ));
+ };
+ if !a2a_task_visible_to_auth(task, auth) {
+ return Err(a2a_error_response(
+ 404,
+ "TASK_NOT_FOUND",
+ "A2A task not found",
+ ));
+ }
+ if a2a_task_is_terminal(task) {
+ return Err(a2a_error_response(
+ 400,
+ "TASK_NOT_CANCELABLE",
+ "A2A task cannot be canceled from its current state",
+ ));
+ }
+ let context_id = task
+ .get("contextId")
+ .and_then(Value::as_str)
+ .unwrap_or("a2a")
+ .to_string();
+ task["status"] = serde_json::json!({
+ "state": "TASK_STATE_CANCELED",
+ "message": a2a_agent_message(&context_id, "Task canceled"),
+ "timestamp": now_rfc3339()
+ });
+ task["artifacts"] = serde_json::json!([]);
+ let task = task.clone();
+ prune_a2a_terminal_tasks(&mut tasks);
+ drop(tasks);
+
+ if let Some(sender) = state.a2a_cancel_senders.lock().await.remove(task_id) {
+ let _ = sender.send(true);
+ }
+
+ Ok(task)
+}
+
+fn a2a_task_status_state(task: &Value) -> Option<&str> {
+ task.get("status")
+ .and_then(|status| status.get("state"))
+ .and_then(Value::as_str)
+}
+
+fn a2a_task_status_timestamp(task: &Value) -> Option<&str> {
+ task.get("status")
+ .and_then(|status| status.get("timestamp"))
+ .and_then(Value::as_str)
+}
+
+fn a2a_task_is_terminal(task: &Value) -> bool {
+ matches!(
+ a2a_task_status_state(task),
+ Some(
+ "TASK_STATE_COMPLETED"
+ | "TASK_STATE_FAILED"
+ | "TASK_STATE_CANCELED"
+ | "TASK_STATE_REJECTED"
+ )
+ )
+}
+
+fn a2a_task_accepts_message(task: &Value) -> bool {
+ a2a_task_status_state(task) == Some("TASK_STATE_INPUT_REQUIRED")
+}
+
+fn a2a_task_owner_subject(task: &Value) -> Option<&str> {
+ task.get("metadata")
+ .and_then(|metadata| metadata.get("ownerSubject"))
+ .and_then(Value::as_str)
+}
+
+fn a2a_task_visible_to_auth(task: &Value, auth: &AuthContext) -> bool {
+ if auth.unrestricted {
+ return true;
+ }
+ auth.subject
+ .as_deref()
+ .is_some_and(|subject| a2a_task_owner_subject(task) == Some(subject))
+}
+
+async fn claim_a2a_send_task(
+ state: &AppState,
+ request: &A2ASendMessageRequest,
+ head: &RequestHead,
+ auth: &AuthContext,
+ metadata: Value,
+) -> Result<A2ASendTarget, Vec<u8>> {
+ let requested_task_id = request
+ .message
+ .task_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|task_id| !task_id.is_empty());
+ let explicit_context_id = request
+ .message
+ .context_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|context_id| !context_id.is_empty())
+ .map(str::to_string);
+
+ let mut tasks = state.a2a_tasks.lock().await;
+ let (task_id, context_id, mut history, previous_task, task_metadata) =
+ if let Some(task_id) = requested_task_id {
+ let Some(task) = tasks.get(task_id) else {
+ return Err(a2a_error_response(
+ 404,
+ "TASK_NOT_FOUND",
+ "A2A task not found",
+ ));
+ };
+ if !a2a_task_visible_to_auth(task, auth) {
+ return Err(a2a_error_response(
+ 404,
+ "TASK_NOT_FOUND",
+ "A2A task not found",
+ ));
+ }
+ if a2a_task_is_terminal(task) {
+ return Err(a2a_error_response(
+ 400,
+ "UNSUPPORTED_OPERATION",
+ "A2A terminal tasks cannot accept more messages",
+ ));
+ }
+ if !a2a_task_accepts_message(task) {
+ return Err(a2a_error_response(
+ 409,
+ "UNSUPPORTED_OPERATION",
+ "A2A task is not ready to accept another message",
+ ));
+ }
+
+ let task_context_id = task
+ .get("contextId")
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .filter(|context_id| !context_id.is_empty())
+ .map(str::to_string);
+ if let (Some(message_context_id), Some(task_context_id)) =
+ (explicit_context_id.as_deref(), task_context_id.as_deref())
+ {
+ if message_context_id != task_context_id {
+ return Err(a2a_error_response(
+ 400,
+ "INVALID_REQUEST",
+ "A2A message contextId must match the referenced task",
+ ));
+ }
+ }
+ let context_id = explicit_context_id
+ .or(task_context_id)
+ .unwrap_or_else(|| a2a_context_id(request, head));
+ let history = task
+ .get("history")
+ .and_then(Value::as_array)
+ .cloned()
+ .unwrap_or_default();
+ (
+ task_id.to_string(),
+ context_id,
+ history,
+ Some(task.clone()),
+ a2a_merge_task_metadata(task, metadata),
+ )
+ } else {
+ (
+ generate_a2a_id("maestro-task"),
+ explicit_context_id.unwrap_or_else(|| a2a_context_id(request, head)),
+ Vec::new(),
+ None,
+ metadata,
+ )
+ };
+ history.push(a2a_user_message_value(&request.message, &context_id));
+ let working_message = a2a_agent_message(&context_id, "Maestro is working on the A2A task.");
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_WORKING",
+ working_message,
+ history.clone(),
+ Vec::new(),
+ task_metadata.clone(),
+ );
+ tasks.insert(task_id.clone(), task);
+ prune_a2a_terminal_tasks(&mut tasks);
+ Ok(A2ASendTarget {
+ task_id,
+ context_id,
+ history,
+ previous_task,
+ metadata: task_metadata,
+ })
+}
+
+fn a2a_merge_task_metadata(existing_task: &Value, metadata: Value) -> Value {
+ let mut merged = existing_task
+ .get("metadata")
+ .and_then(Value::as_object)
+ .cloned()
+ .unwrap_or_default();
+ if let Value::Object(metadata) = metadata {
+ for (key, value) in metadata {
+ merged.insert(key, value);
+ }
+ }
+ Value::Object(merged)
+}
+
+async fn rollback_a2a_send_claim(state: &AppState, task_id: &str, previous_task: Option<Value>) {
+ let mut tasks = state.a2a_tasks.lock().await;
+ if let Some(previous_task) = previous_task {
+ tasks.insert(task_id.to_string(), previous_task);
+ } else {
+ tasks.remove(task_id);
+ }
+}
+
+async fn a2a_canceled_task(state: &AppState, task_id: &str) -> Option<Value> {
+ state.a2a_tasks.lock().await.get(task_id).and_then(|task| {
+ (a2a_task_status_state(task) == Some("TASK_STATE_CANCELED")).then(|| task.clone())
+ })
+}
+
+async fn store_a2a_task_unless_canceled(state: &AppState, task_id: &str, task: Value) -> Value {
+ let mut tasks = state.a2a_tasks.lock().await;
+ if let Some(existing) = tasks.get(task_id) {
+ if a2a_task_status_state(existing) == Some("TASK_STATE_CANCELED") {
+ return existing.clone();
+ }
+ }
+ tasks.insert(task_id.to_string(), task.clone());
+ prune_a2a_terminal_tasks(&mut tasks);
+ task
+}
+
+fn prune_a2a_terminal_tasks(tasks: &mut HashMap<String, Value>) {
+ let mut terminal_tasks = tasks
+ .iter()
+ .filter(|(_, task)| a2a_task_is_terminal(task))
+ .map(|(task_id, task)| {
+ (
+ task_id.clone(),
+ a2a_task_status_timestamp(task)
+ .unwrap_or_default()
+ .to_string(),
+ )
+ })
+ .collect::<Vec<_>>();
+ if terminal_tasks.len() <= A2A_TERMINAL_TASK_STORE_LIMIT {
+ return;
+ }
+ terminal_tasks.sort_by(|(left_id, left_timestamp), (right_id, right_timestamp)| {
+ left_timestamp
+ .cmp(right_timestamp)
+ .then_with(|| left_id.cmp(right_id))
+ });
+ let overflow = terminal_tasks.len() - A2A_TERMINAL_TASK_STORE_LIMIT;
+ for (task_id, _) in terminal_tasks.into_iter().take(overflow) {
+ tasks.remove(&task_id);
+ }
+}
+
+async fn register_a2a_cancel_sender(
+ state: &AppState,
+ task_id: &str,
+ cancel_tx: A2ACancelSender,
+) -> Result<(), Vec<u8>> {
+ let mut senders = state.a2a_cancel_senders.lock().await;
+ if senders.contains_key(task_id) {
+ return Err(a2a_error_response(
+ 409,
+ "UNSUPPORTED_OPERATION",
+ "A2A task is already running",
+ ));
+ }
+ senders.insert(task_id.to_string(), cancel_tx);
+ Ok(())
+}
+
+async fn handle_a2a_message_send(
+ stream: &mut TcpStream,
+ initial: &mut Vec<u8>,
+ head: &RequestHead,
+ state: &AppState,
+ auth: &AuthContext,
+) -> Vec<u8> {
+ let body = match read_request_body(stream, initial, head).await {
+ Ok(body) => body,
+ Err(error) => return a2a_error_response(400, "INVALID_REQUEST", &error),
+ };
+ let request: A2ASendMessageRequest = match serde_json::from_slice(&body) {
+ Ok(request) => request,
+ Err(error) => {
+ return a2a_error_response(
+ 400,
+ "INVALID_REQUEST",
+ &format!("invalid A2A message request: {error}"),
+ );
+ }
+ };
+
+ let Some(prompt) = a2a_message_text(&request.message) else {
+ return a2a_error_response(
+ 400,
+ "INVALID_REQUEST",
+ "A2A message must contain at least one text part",
+ );
+ };
+
+ let metadata = a2a_task_metadata(head, &request, auth);
+ let target = match claim_a2a_send_task(state, &request, head, auth, metadata).await {
+ Ok(target) => target,
+ Err(response) => return response,
+ };
+ let task_id = target.task_id;
+ let context_id = target.context_id;
+ let history = target.history;
+ let previous_task = target.previous_task;
+ let metadata = target.metadata;
+
+ let (cancel_tx, cancel_rx) = watch::channel(false);
+ if let Err(response) = register_a2a_cancel_sender(state, &task_id, cancel_tx).await {
+ rollback_a2a_send_claim(state, &task_id, previous_task).await;
+ return response;
+ }
+ if let Some(task) = a2a_canceled_task(state, &task_id).await {
+ state.a2a_cancel_senders.lock().await.remove(&task_id);
+ return json_response(200, &serde_json::json!({ "task": task }));
+ }
+ if a2a_return_immediately(&request) {
+ let accepted_message = a2a_agent_message(&context_id, "Maestro accepted the A2A task.");
+ let mut accepted_history = history.clone();
+ accepted_history.push(accepted_message.clone());
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_WORKING",
+ accepted_message.clone(),
+ accepted_history.clone(),
+ Vec::new(),
+ metadata.clone(),
+ );
+ let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+ let state = state.clone();
+ tokio::spawn(async move {
+ let _ = complete_a2a_task(
+ &state,
+ prompt,
+ task_id,
+ context_id,
+ accepted_history,
+ metadata,
+ cancel_rx,
+ )
+ .await;
+ });
+ return json_response(200, &serde_json::json!({ "task": task }));
+ }
+
+ let task = complete_a2a_task(
+ state, prompt, task_id, context_id, history, metadata, cancel_rx,
+ )
+ .await;
+ json_response(200, &serde_json::json!({ "task": task }))
+}
+
+async fn complete_a2a_task(
+ state: &AppState,
+ prompt: String,
+ task_id: String,
+ context_id: String,
+ mut history: Vec<Value>,
+ mut metadata: Value,
+ cancel_rx: A2ACancelReceiver,
+) -> Value {
+ let turn = match run_a2a_native_turn(state, prompt, cancel_rx).await {
+ Ok(A2ATurnResult::Completed(turn)) => turn,
+ Ok(A2ATurnResult::Canceled) => {
+ let message = a2a_agent_message(&context_id, "Task canceled");
+ history.push(message.clone());
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_CANCELED",
+ message,
+ history,
+ Vec::new(),
+ metadata,
+ );
+ let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+ state.a2a_cancel_senders.lock().await.remove(&task_id);
+ return task;
+ }
+ Err(error) => {
+ let message = a2a_agent_message(&context_id, &error);
+ history.push(message.clone());
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_FAILED",
+ message.clone(),
+ history,
+ Vec::new(),
+ metadata,
+ );
+ let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+ state.a2a_cancel_senders.lock().await.remove(&task_id);
+ return task;
+ }
+ };
+
+ let assistant_text = if turn.assistant_text.trim().is_empty() {
+ "Maestro completed the A2A task without a text response.".to_string()
+ } else {
+ turn.assistant_text
+ };
+ let agent_message = a2a_agent_message(&context_id, &assistant_text);
+ if !turn.thinking_text.trim().is_empty() {
+ metadata["thinking"] = Value::String(turn.thinking_text);
+ }
+ if !turn.tools.is_empty() {
+ metadata["tools"] = Value::Array(turn.tools);
+ }
+ if let Some(usage) = turn.usage {
+ metadata["usage"] = serde_json::json!({
+ "input": usage.input_tokens,
+ "output": usage.output_tokens,
+ "cacheRead": usage.cache_read_tokens,
+ "cacheWrite": usage.cache_write_tokens,
+ "cost": usage.cost.unwrap_or(0.0)
+ });
+ }
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_COMPLETED",
+ agent_message.clone(),
+ {
+ history.push(agent_message);
+ history
+ },
+ vec![serde_json::json!({
+ "artifactId": format!("{task_id}-assistant-response"),
+ "name": "assistant-response",
+ "parts": [{ "text": assistant_text, "mediaType": "text/plain" }]
+ })],
+ metadata,
+ );
+ let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+ state.a2a_cancel_senders.lock().await.remove(&task_id);
+ task
+}
+
+fn a2a_agent_card(head: &RequestHead, config: &Config) -> Value {
+ let base_url = a2a_public_base_url(head, config);
+ serde_json::json!({
+ "protocolVersion": A2A_PROTOCOL_VERSION,
+ "name": trimmed_env("MAESTRO_A2A_AGENT_NAME")
+ .unwrap_or_else(|| "Maestro Desktop Agent".to_string()),
+ "description": "Local Maestro Rust/TS TUI agent endpoint for A2A task delegation.",
+ "url": base_url,
+ "preferredTransport": "HTTP+JSON",
+ "supportedInterfaces": [{
+ "url": base_url,
+ "protocolBinding": "HTTP+JSON",
+ "protocolVersion": A2A_PROTOCOL_VERSION
+ }],
+ "provider": {
+ "organization": "EvalOps",
+ "url": "https://evalops.com"
+ },
+ "version": env!("CARGO_PKG_VERSION"),
+ "capabilities": {
+ "streaming": false,
+ "pushNotifications": false,
+ "extendedAgentCard": false
+ },
+ "defaultInputModes": ["text/plain"],
+ "defaultOutputModes": ["text/plain", "application/json"],
+ "skills": [{
+ "id": "maestro-tui-turn",
+ "name": "Maestro TUI turn",
+ "description": "Run a prompt through the local Maestro native TUI agent runner.",
+ "tags": ["maestro", "tui", "codex", "a2a"],
+ "examples": [
+ "Review the current workspace and summarize the next highest leverage action."
+ ],
+ "inputModes": ["text/plain"],
+ "outputModes": ["text/plain", "application/json"]
+ }]
+ })
+}
+
+fn a2a_public_base_url(_head: &RequestHead, config: &Config) -> String {
+ if let Some(url) =
+ trimmed_env("MAESTRO_A2A_PUBLIC_URL").or_else(|| trimmed_env("MAESTRO_CONTROL_PUBLIC_URL"))
+ {
+ return url.trim_end_matches('/').to_string();
+ }
+ let host = if let Some(host) = trimmed_env("MAESTRO_A2A_PUBLIC_HOST")
+ .or_else(|| trimmed_env("MAESTRO_CONTROL_PUBLIC_HOST"))
+ {
+ host
+ } else if config.listen_host == "0.0.0.0" || config.listen_host == "::" {
+ trimmed_env("HOSTNAME")
+ .or_else(|| trimmed_env("COMPUTERNAME"))
+ .unwrap_or_else(|| "127.0.0.1".to_string())
+ } else {
+ config.listen_host.clone()
+ };
+ if host.starts_with('[') {
+ if host.contains("]:") {
+ format!("http://{host}")
+ } else {
+ format!("http://{host}:{}", config.listen_port)
+ }
+ } else if host.matches(':').count() > 1 {
+ format!("http://[{host}]:{}", config.listen_port)
+ } else if host.contains(':') {
+ format!("http://{host}")
+ } else {
+ format!("http://{host}:{}", config.listen_port)
+ }
+}
+
+fn a2a_message_text(message: &A2AMessageBody) -> Option<String> {
+ let text = message
+ .parts
+ .iter()
+ .filter_map(|part| part.text.as_deref())
+ .map(str::trim)
+ .filter(|part| !part.is_empty())
+ .collect::<Vec<_>>()
+ .join("\n\n");
+ (!text.is_empty()).then_some(text)
+}
+
... diff truncated: showing 800 of 2976 linesYou can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f0be74879f
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: A2A endpoints bypass CSRF validation for state-changing operations
- A2A POST handlers now invoke CSRF validation and the CSRF matcher covers
/message:sendplus task-cancel routes with regression tests.
- A2A POST handlers now invoke CSRF validation and the CSRF matcher covers
- ✅ Fixed: Unused function
update_tool_metadata_statusduplicatesfinish_tool_metadatapattern- A2A native turn handling now marks tool calls as
runningonToolStartbeforefinish_tool_metadatarecords completion or error.
- A2A native turn handling now marks tool calls as
Preview (7b34f77157)
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -104,6 +104,8 @@
"smoke": "node scripts/smoke-cli.js",
"smoke:local-e2e": "node scripts/smoke-cli.js",
"smoke:event-bus": "tsx scripts/smoke-maestro-event-bus.ts",
+ "smoke:a2a-local": "tsx scripts/smoke-maestro-a2a-local.ts",
+ "a2a:codex-bridge": "python3 scripts/codex-a2a-bridge.py",
"smoke:headless": "node scripts/smoke-headless.js",
"headless:responsiveness": "node scripts/headless-responsiveness-harness.js",
"smoke:pack": "node scripts/smoke-packed-cli.js",
diff --git a/packages/control-plane-rs/src/auth.rs b/packages/control-plane-rs/src/auth.rs
--- a/packages/control-plane-rs/src/auth.rs
+++ b/packages/control-plane-rs/src/auth.rs
@@ -185,9 +185,23 @@
}
pub(crate) fn csrf_applies(head: &RequestHead) -> bool {
- head.path.starts_with("/api/") && !matches!(head.method.as_str(), "GET" | "HEAD" | "OPTIONS")
+ if matches!(head.method.as_str(), "GET" | "HEAD" | "OPTIONS") {
+ return false;
+ }
+
+ head.path.starts_with("/api/") || csrf_applies_to_a2a_path(&head.path)
}
+fn csrf_applies_to_a2a_path(path: &str) -> bool {
+ path == "/message:send"
+ || path
+ .strip_prefix("/tasks/")
+ .and_then(|value| value.strip_suffix(":cancel"))
+ .is_some_and(|task_id| {
+ !task_id.is_empty() && !task_id.contains('/') && !task_id.contains(':')
+ })
+}
+
pub(crate) fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
if left.len() != right.len() {
return false;
diff --git a/packages/control-plane-rs/src/http.rs b/packages/control-plane-rs/src/http.rs
--- a/packages/control-plane-rs/src/http.rs
+++ b/packages/control-plane-rs/src/http.rs
@@ -11,7 +11,7 @@
const MAX_HEADER_BYTES: usize = 64 * 1024;
pub(crate) const MAX_JSON_BODY_BYTES: usize = 32 * 1024 * 1024;
-const CORS_ALLOW_HEADERS: &str = "authorization,content-type,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
+const CORS_ALLOW_HEADERS: &str = "authorization,content-type,a2a-version,a2a-extensions,traceparent,tracestate,x-organization-id,x-evalops-agent-id,x-evalops-actor-id,x-evalops-session-id,x-evalops-workspace-id,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
#[derive(Debug)]
pub(crate) struct RequestHead {
diff --git a/packages/control-plane-rs/src/main.rs b/packages/control-plane-rs/src/main.rs
--- a/packages/control-plane-rs/src/main.rs
+++ b/packages/control-plane-rs/src/main.rs
@@ -17,7 +17,7 @@
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::process::Command;
-use tokio::sync::{mpsc, Mutex};
+use tokio::sync::{mpsc, watch, Mutex};
mod auth;
mod http;
@@ -49,9 +49,15 @@
const DEFAULT_EXTRACT_MAX_CHARS: usize = 200_000;
const MAX_EXTRACT_INPUT_BYTES: usize = 50 * 1024 * 1024;
const MAX_PROJECT_ONBOARDING_IMPRESSIONS: u8 = 4;
+const A2A_PROTOCOL_VERSION: &str = "1.0";
+const A2A_DEFAULT_TURN_TIMEOUT_MS: u64 = 180_000;
+const A2A_DEFAULT_RESPONSE_END_SETTLE_MS: u64 = 250;
+const A2A_TERMINAL_TASK_STORE_LIMIT: usize = 128;
static ATTACHMENT_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
type PendingToolResponseSender = mpsc::UnboundedSender<(String, bool, Option<ToolResult>)>;
+type A2ACancelSender = watch::Sender<bool>;
+type A2ACancelReceiver = watch::Receiver<bool>;
#[derive(Debug, Clone)]
struct Config {
listen_host: String,
@@ -151,6 +157,8 @@
shared_sessions: Arc<Mutex<HashMap<String, SharedSessionGrant>>>,
approval_modes: Arc<Mutex<HashMap<String, String>>>,
pending_tool_responses: Arc<Mutex<HashMap<String, PendingToolResponseSender>>>,
+ a2a_tasks: Arc<Mutex<HashMap<String, Value>>>,
+ a2a_cancel_senders: Arc<Mutex<HashMap<String, A2ACancelSender>>>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -295,6 +303,8 @@
shared_sessions: Arc::new(Mutex::new(shared_sessions)),
approval_modes: Arc::new(Mutex::new(HashMap::new())),
pending_tool_responses: Arc::new(Mutex::new(HashMap::new())),
+ a2a_tasks: Arc::new(Mutex::new(HashMap::new())),
+ a2a_cancel_senders: Arc::new(Mutex::new(HashMap::new())),
};
loop {
@@ -329,6 +339,16 @@
return handle_chat_endpoint(stream, initial, head, state).await;
}
+ if is_a2a_endpoint(&head) {
+ let response = handle_a2a_endpoint(&mut stream, &mut initial, head, &state).await;
+ stream
+ .write_all(&response)
+ .await
+ .map_err(|error| error.to_string())?;
+ let _ = stream.shutdown().await;
+ return Ok(());
+ }
+
if is_local_endpoint(&head) {
let response = handle_local_endpoint(&mut stream, &mut initial, head, &state).await;
stream
@@ -508,6 +528,1078 @@
Some(request_id)
}
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2APartBody {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ text: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ url: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ data: Option<Value>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ metadata: Option<Value>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ filename: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ media_type: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2AMessageBody {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ message_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ context_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ task_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ role: Option<String>,
+ #[serde(default)]
+ parts: Vec<A2APartBody>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ metadata: Option<Value>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ extensions: Option<Vec<String>>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ reference_task_ids: Option<Vec<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct A2ASendMessageRequest {
+ message: A2AMessageBody,
+ #[serde(default)]
+ configuration: Option<Value>,
+ #[serde(default)]
+ metadata: Option<Value>,
+}
+
+#[derive(Debug, Default)]
+struct A2ATurnOutput {
+ assistant_text: String,
+ thinking_text: String,
+ usage: Option<TokenUsage>,
+ tools: Vec<Value>,
+}
+
+enum A2ATurnResult {
+ Completed(A2ATurnOutput),
+ Canceled,
+}
+
+#[derive(Debug)]
+struct A2ASendTarget {
+ task_id: String,
+ context_id: String,
+ history: Vec<Value>,
+ previous_task: Option<Value>,
+ metadata: Value,
+}
+
+fn is_a2a_endpoint(head: &RequestHead) -> bool {
+ if head.method == "OPTIONS" {
+ return head.path == "/.well-known/agent-card.json"
+ || head.path == "/message:send"
+ || head.path == "/tasks"
+ || head.path.starts_with("/tasks/");
+ }
+ matches!(
+ (head.method.as_str(), head.path.as_str()),
+ ("GET", "/.well-known/agent-card.json") | ("POST", "/message:send") | ("GET", "/tasks")
+ ) || (head.method == "GET" && a2a_task_id_from_get_path(&head.path).is_some())
+ || (head.method == "POST" && a2a_task_id_from_cancel_path(&head.path).is_some())
+}
+
+fn a2a_task_id_from_get_path(path: &str) -> Option<&str> {
+ let id = path.strip_prefix("/tasks/")?;
+ (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+fn a2a_task_id_from_cancel_path(path: &str) -> Option<&str> {
+ let id = path.strip_prefix("/tasks/")?.strip_suffix(":cancel")?;
+ (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+async fn handle_a2a_endpoint(
+ stream: &mut TcpStream,
+ initial: &mut Vec<u8>,
+ head: RequestHead,
+ state: &AppState,
+) -> Vec<u8> {
+ if head.method == "OPTIONS" {
+ return response(204, "text/plain; charset=utf-8", &[]);
+ }
+
+ if let Err(response) = validate_csrf(&head, &state.config) {
+ return response;
+ }
+
+ if head.method == "GET" && head.path == "/.well-known/agent-card.json" {
+ return json_response(200, &a2a_agent_card(&head, &state.config));
+ }
+
+ let Some(auth) = auth_context(&head, &state.config) else {
+ return json_response(401, &serde_json::json!({ "error": "Unauthorized" }));
+ };
+
+ if head.method == "GET" && head.path == "/tasks" {
+ let tasks = state
+ .a2a_tasks
+ .lock()
+ .await
+ .values()
+ .filter(|task| a2a_task_visible_to_auth(task, &auth))
+ .cloned()
+ .collect::<Vec<_>>();
+ return json_response(200, &serde_json::json!({ "tasks": tasks }));
+ }
+
+ if head.method == "GET" {
+ if let Some(task_id) = a2a_task_id_from_get_path(&head.path) {
+ let tasks = state.a2a_tasks.lock().await;
+ return tasks.get(task_id).map_or_else(
+ || a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found"),
+ |task| {
+ if a2a_task_visible_to_auth(task, &auth) {
+ json_response(200, task)
+ } else {
+ a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found")
+ }
+ },
+ );
+ }
+ }
+
+ if head.method == "POST" {
+ if let Some(task_id) = a2a_task_id_from_cancel_path(&head.path) {
+ return match cancel_a2a_task(state, task_id, &auth).await {
+ Ok(task) => json_response(200, &task),
+ Err(response) => response,
+ };
+ }
+ }
+
+ if head.method == "POST" && head.path == "/message:send" {
+ return handle_a2a_message_send(stream, initial, &head, state, &auth).await;
+ }
+
+ a2a_error_response(404, "NOT_FOUND", "A2A endpoint not found")
+}
+
+async fn cancel_a2a_task(
+ state: &AppState,
+ task_id: &str,
+ auth: &AuthContext,
+) -> Result<Value, Vec<u8>> {
+ let mut tasks = state.a2a_tasks.lock().await;
+ let Some(task) = tasks.get_mut(task_id) else {
+ return Err(a2a_error_response(
+ 404,
+ "TASK_NOT_FOUND",
+ "A2A task not found",
+ ));
+ };
+ if !a2a_task_visible_to_auth(task, auth) {
+ return Err(a2a_error_response(
+ 404,
+ "TASK_NOT_FOUND",
+ "A2A task not found",
+ ));
+ }
+ if a2a_task_is_terminal(task) {
+ return Err(a2a_error_response(
+ 400,
+ "TASK_NOT_CANCELABLE",
+ "A2A task cannot be canceled from its current state",
+ ));
+ }
+ let context_id = task
+ .get("contextId")
+ .and_then(Value::as_str)
+ .unwrap_or("a2a")
+ .to_string();
+ task["status"] = serde_json::json!({
+ "state": "TASK_STATE_CANCELED",
+ "message": a2a_agent_message(&context_id, "Task canceled"),
+ "timestamp": now_rfc3339()
+ });
+ task["artifacts"] = serde_json::json!([]);
+ let task = task.clone();
+ prune_a2a_terminal_tasks(&mut tasks);
+ drop(tasks);
+
+ if let Some(sender) = state.a2a_cancel_senders.lock().await.remove(task_id) {
+ let _ = sender.send(true);
+ }
+
+ Ok(task)
+}
+
+fn a2a_task_status_state(task: &Value) -> Option<&str> {
+ task.get("status")
+ .and_then(|status| status.get("state"))
+ .and_then(Value::as_str)
+}
+
+fn a2a_task_status_timestamp(task: &Value) -> Option<&str> {
+ task.get("status")
+ .and_then(|status| status.get("timestamp"))
+ .and_then(Value::as_str)
+}
+
+fn a2a_task_is_terminal(task: &Value) -> bool {
+ matches!(
+ a2a_task_status_state(task),
+ Some(
+ "TASK_STATE_COMPLETED"
+ | "TASK_STATE_FAILED"
+ | "TASK_STATE_CANCELED"
+ | "TASK_STATE_REJECTED"
+ )
+ )
+}
+
+fn a2a_task_accepts_message(task: &Value) -> bool {
+ a2a_task_status_state(task) == Some("TASK_STATE_INPUT_REQUIRED")
+}
+
+fn a2a_task_owner_subject(task: &Value) -> Option<&str> {
+ task.get("metadata")
+ .and_then(|metadata| metadata.get("ownerSubject"))
+ .and_then(Value::as_str)
+}
+
+fn a2a_task_visible_to_auth(task: &Value, auth: &AuthContext) -> bool {
+ if auth.unrestricted {
+ return true;
+ }
+ auth.subject
+ .as_deref()
+ .is_some_and(|subject| a2a_task_owner_subject(task) == Some(subject))
+}
+
+async fn claim_a2a_send_task(
+ state: &AppState,
+ request: &A2ASendMessageRequest,
+ head: &RequestHead,
+ auth: &AuthContext,
+ metadata: Value,
+) -> Result<A2ASendTarget, Vec<u8>> {
+ let requested_task_id = request
+ .message
+ .task_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|task_id| !task_id.is_empty());
+ let explicit_context_id = request
+ .message
+ .context_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|context_id| !context_id.is_empty())
+ .map(str::to_string);
+
+ let mut tasks = state.a2a_tasks.lock().await;
+ let (task_id, context_id, mut history, previous_task, task_metadata) =
+ if let Some(task_id) = requested_task_id {
+ let Some(task) = tasks.get(task_id) else {
+ return Err(a2a_error_response(
+ 404,
+ "TASK_NOT_FOUND",
+ "A2A task not found",
+ ));
+ };
+ if !a2a_task_visible_to_auth(task, auth) {
+ return Err(a2a_error_response(
+ 404,
+ "TASK_NOT_FOUND",
+ "A2A task not found",
+ ));
+ }
+ if a2a_task_is_terminal(task) {
+ return Err(a2a_error_response(
+ 400,
+ "UNSUPPORTED_OPERATION",
+ "A2A terminal tasks cannot accept more messages",
+ ));
+ }
+ if !a2a_task_accepts_message(task) {
+ return Err(a2a_error_response(
+ 409,
+ "UNSUPPORTED_OPERATION",
+ "A2A task is not ready to accept another message",
+ ));
+ }
+
+ let task_context_id = task
+ .get("contextId")
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .filter(|context_id| !context_id.is_empty())
+ .map(str::to_string);
+ if let (Some(message_context_id), Some(task_context_id)) =
+ (explicit_context_id.as_deref(), task_context_id.as_deref())
+ {
+ if message_context_id != task_context_id {
+ return Err(a2a_error_response(
+ 400,
+ "INVALID_REQUEST",
+ "A2A message contextId must match the referenced task",
+ ));
+ }
+ }
+ let context_id = explicit_context_id
+ .or(task_context_id)
+ .unwrap_or_else(|| a2a_context_id(request, head));
+ let history = task
+ .get("history")
+ .and_then(Value::as_array)
+ .cloned()
+ .unwrap_or_default();
+ (
+ task_id.to_string(),
+ context_id,
+ history,
+ Some(task.clone()),
+ a2a_merge_task_metadata(task, metadata),
+ )
+ } else {
+ (
+ generate_a2a_id("maestro-task"),
+ explicit_context_id.unwrap_or_else(|| a2a_context_id(request, head)),
+ Vec::new(),
+ None,
+ metadata,
+ )
+ };
+ history.push(a2a_user_message_value(&request.message, &context_id));
+ let working_message = a2a_agent_message(&context_id, "Maestro is working on the A2A task.");
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_WORKING",
+ working_message,
+ history.clone(),
+ Vec::new(),
+ task_metadata.clone(),
+ );
+ tasks.insert(task_id.clone(), task);
+ prune_a2a_terminal_tasks(&mut tasks);
+ Ok(A2ASendTarget {
+ task_id,
+ context_id,
+ history,
+ previous_task,
+ metadata: task_metadata,
+ })
+}
+
+fn a2a_merge_task_metadata(existing_task: &Value, metadata: Value) -> Value {
+ let mut merged = existing_task
+ .get("metadata")
+ .and_then(Value::as_object)
+ .cloned()
+ .unwrap_or_default();
+ if let Value::Object(metadata) = metadata {
+ for (key, value) in metadata {
+ merged.insert(key, value);
+ }
+ }
+ Value::Object(merged)
+}
+
+async fn rollback_a2a_send_claim(state: &AppState, task_id: &str, previous_task: Option<Value>) {
+ let mut tasks = state.a2a_tasks.lock().await;
+ if let Some(previous_task) = previous_task {
+ tasks.insert(task_id.to_string(), previous_task);
+ } else {
+ tasks.remove(task_id);
+ }
+}
+
+async fn a2a_canceled_task(state: &AppState, task_id: &str) -> Option<Value> {
+ state.a2a_tasks.lock().await.get(task_id).and_then(|task| {
+ (a2a_task_status_state(task) == Some("TASK_STATE_CANCELED")).then(|| task.clone())
+ })
+}
+
+async fn store_a2a_task_unless_canceled(state: &AppState, task_id: &str, task: Value) -> Value {
+ let mut tasks = state.a2a_tasks.lock().await;
+ if let Some(existing) = tasks.get(task_id) {
+ if a2a_task_status_state(existing) == Some("TASK_STATE_CANCELED") {
+ return existing.clone();
+ }
+ }
+ tasks.insert(task_id.to_string(), task.clone());
+ prune_a2a_terminal_tasks(&mut tasks);
+ task
+}
+
+fn prune_a2a_terminal_tasks(tasks: &mut HashMap<String, Value>) {
+ let mut terminal_tasks = tasks
+ .iter()
+ .filter(|(_, task)| a2a_task_is_terminal(task))
+ .map(|(task_id, task)| {
+ (
+ task_id.clone(),
+ a2a_task_status_timestamp(task)
+ .unwrap_or_default()
+ .to_string(),
+ )
+ })
+ .collect::<Vec<_>>();
+ if terminal_tasks.len() <= A2A_TERMINAL_TASK_STORE_LIMIT {
+ return;
+ }
+ terminal_tasks.sort_by(|(left_id, left_timestamp), (right_id, right_timestamp)| {
+ left_timestamp
+ .cmp(right_timestamp)
+ .then_with(|| left_id.cmp(right_id))
+ });
+ let overflow = terminal_tasks.len() - A2A_TERMINAL_TASK_STORE_LIMIT;
+ for (task_id, _) in terminal_tasks.into_iter().take(overflow) {
+ tasks.remove(&task_id);
+ }
+}
+
+async fn register_a2a_cancel_sender(
+ state: &AppState,
+ task_id: &str,
+ cancel_tx: A2ACancelSender,
+) -> Result<(), Vec<u8>> {
+ let mut senders = state.a2a_cancel_senders.lock().await;
+ if senders.contains_key(task_id) {
+ return Err(a2a_error_response(
+ 409,
+ "UNSUPPORTED_OPERATION",
+ "A2A task is already running",
+ ));
+ }
+ senders.insert(task_id.to_string(), cancel_tx);
+ Ok(())
+}
+
+async fn handle_a2a_message_send(
+ stream: &mut TcpStream,
+ initial: &mut Vec<u8>,
+ head: &RequestHead,
+ state: &AppState,
+ auth: &AuthContext,
+) -> Vec<u8> {
+ let body = match read_request_body(stream, initial, head).await {
+ Ok(body) => body,
+ Err(error) => return a2a_error_response(400, "INVALID_REQUEST", &error),
+ };
+ let request: A2ASendMessageRequest = match serde_json::from_slice(&body) {
+ Ok(request) => request,
+ Err(error) => {
+ return a2a_error_response(
+ 400,
+ "INVALID_REQUEST",
+ &format!("invalid A2A message request: {error}"),
+ );
+ }
+ };
+
+ let Some(prompt) = a2a_message_text(&request.message) else {
+ return a2a_error_response(
+ 400,
+ "INVALID_REQUEST",
+ "A2A message must contain at least one text part",
+ );
+ };
+ let return_immediately = match a2a_return_immediately(&request) {
+ Ok(value) => value,
+ Err(error) => return a2a_error_response(400, "INVALID_REQUEST", error),
+ };
+
+ let metadata = a2a_task_metadata(head, &request, auth);
+ let target = match claim_a2a_send_task(state, &request, head, auth, metadata).await {
+ Ok(target) => target,
+ Err(response) => return response,
+ };
+ let task_id = target.task_id;
+ let context_id = target.context_id;
+ let history = target.history;
+ let previous_task = target.previous_task;
+ let metadata = target.metadata;
+
+ let (cancel_tx, cancel_rx) = watch::channel(false);
+ if let Err(response) = register_a2a_cancel_sender(state, &task_id, cancel_tx).await {
+ rollback_a2a_send_claim(state, &task_id, previous_task).await;
+ return response;
+ }
+ if let Some(task) = a2a_canceled_task(state, &task_id).await {
+ state.a2a_cancel_senders.lock().await.remove(&task_id);
+ return json_response(200, &serde_json::json!({ "task": task }));
+ }
+ if return_immediately {
+ let accepted_message = a2a_agent_message(&context_id, "Maestro accepted the A2A task.");
+ let mut accepted_history = history.clone();
+ accepted_history.push(accepted_message.clone());
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_WORKING",
+ accepted_message.clone(),
+ accepted_history.clone(),
+ Vec::new(),
+ metadata.clone(),
+ );
+ let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+ let state = state.clone();
+ tokio::spawn(async move {
+ let _ = complete_a2a_task(
+ &state,
+ prompt,
+ task_id,
+ context_id,
+ accepted_history,
+ metadata,
+ cancel_rx,
+ )
+ .await;
+ });
+ return json_response(200, &serde_json::json!({ "task": task }));
+ }
+
+ let task = complete_a2a_task(
+ state, prompt, task_id, context_id, history, metadata, cancel_rx,
+ )
+ .await;
+ json_response(200, &serde_json::json!({ "task": task }))
+}
+
+async fn complete_a2a_task(
+ state: &AppState,
+ prompt: String,
+ task_id: String,
+ context_id: String,
+ mut history: Vec<Value>,
+ mut metadata: Value,
+ cancel_rx: A2ACancelReceiver,
+) -> Value {
+ let turn = match run_a2a_native_turn(state, prompt, cancel_rx).await {
+ Ok(A2ATurnResult::Completed(turn)) => turn,
+ Ok(A2ATurnResult::Canceled) => {
+ let message = a2a_agent_message(&context_id, "Task canceled");
+ history.push(message.clone());
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_CANCELED",
+ message,
+ history,
+ Vec::new(),
+ metadata,
+ );
+ let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+ state.a2a_cancel_senders.lock().await.remove(&task_id);
+ return task;
+ }
+ Err(error) => {
+ let message = a2a_agent_message(&context_id, &error);
+ history.push(message.clone());
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_FAILED",
+ message.clone(),
+ history,
+ Vec::new(),
+ metadata,
+ );
+ let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+ state.a2a_cancel_senders.lock().await.remove(&task_id);
+ return task;
+ }
+ };
+
+ let assistant_text = if turn.assistant_text.trim().is_empty() {
+ "Maestro completed the A2A task without a text response.".to_string()
+ } else {
+ turn.assistant_text
+ };
+ let agent_message = a2a_agent_message(&context_id, &assistant_text);
+ if !turn.thinking_text.trim().is_empty() {
+ metadata["thinking"] = Value::String(turn.thinking_text);
+ }
+ if !turn.tools.is_empty() {
+ metadata["tools"] = Value::Array(turn.tools);
+ }
+ if let Some(usage) = turn.usage {
+ metadata["usage"] = serde_json::json!({
+ "input": usage.input_tokens,
+ "output": usage.output_tokens,
+ "cacheRead": usage.cache_read_tokens,
+ "cacheWrite": usage.cache_write_tokens,
+ "cost": usage.cost.unwrap_or(0.0)
+ });
+ }
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_COMPLETED",
+ agent_message.clone(),
+ {
+ history.push(agent_message);
+ history
+ },
+ vec![serde_json::json!({
+ "artifactId": format!("{task_id}-assistant-response"),
+ "name": "assistant-response",
+ "parts": [{ "text": assistant_text, "mediaType": "text/plain" }]
+ })],
+ metadata,
+ );
+ let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+ state.a2a_cancel_senders.lock().await.remove(&task_id);
+ task
+}
+
+fn a2a_agent_card(head: &RequestHead, config: &Config) -> Value {
+ let base_url = a2a_public_base_url(head, config);
+ serde_json::json!({
+ "protocolVersion": A2A_PROTOCOL_VERSION,
+ "name": trimmed_env("MAESTRO_A2A_AGENT_NAME")
+ .unwrap_or_else(|| "Maestro Desktop Agent".to_string()),
+ "description": "Local Maestro Rust/TS TUI agent endpoint for A2A task delegation.",
+ "url": base_url,
+ "preferredTransport": "HTTP+JSON",
+ "supportedInterfaces": [{
+ "url": base_url,
+ "protocolBinding": "HTTP+JSON",
+ "protocolVersion": A2A_PROTOCOL_VERSION
+ }],
+ "provider": {
+ "organization": "EvalOps",
+ "url": "https://evalops.com"
+ },
+ "version": env!("CARGO_PKG_VERSION"),
+ "capabilities": {
+ "streaming": false,
+ "pushNotifications": false,
+ "extendedAgentCard": false
+ },
+ "defaultInputModes": ["text/plain"],
+ "defaultOutputModes": ["text/plain", "application/json"],
+ "skills": [{
+ "id": "maestro-tui-turn",
+ "name": "Maestro TUI turn",
+ "description": "Run a prompt through the local Maestro native TUI agent runner.",
+ "tags": ["maestro", "tui", "codex", "a2a"],
+ "examples": [
+ "Review the current workspace and summarize the next highest leverage action."
+ ],
+ "inputModes": ["text/plain"],
+ "outputModes": ["text/plain", "application/json"]
+ }]
+ })
+}
+
+fn a2a_public_base_url(_head: &RequestHead, config: &Config) -> String {
+ if let Some(url) =
+ trimmed_env("MAESTRO_A2A_PUBLIC_URL").or_else(|| trimmed_env("MAESTRO_CONTROL_PUBLIC_URL"))
+ {
+ return url.trim_end_matches('/').to_string();
+ }
+ let host = if let Some(host) = trimmed_env("MAESTRO_A2A_PUBLIC_HOST")
... diff truncated: showing 800 of 3152 linesYou can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2f5a7d1f8b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Duplicated cancel-path validation logic across modules
- I extracted the A2A cancel-path parser into
auth.rsand reused it from both the CSRF guard and router so the validation now lives in one place.
- I extracted the A2A cancel-path parser into
Preview (1e10692d6d)
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -104,6 +104,8 @@
"smoke": "node scripts/smoke-cli.js",
"smoke:local-e2e": "node scripts/smoke-cli.js",
"smoke:event-bus": "tsx scripts/smoke-maestro-event-bus.ts",
+ "smoke:a2a-local": "tsx scripts/smoke-maestro-a2a-local.ts",
+ "a2a:codex-bridge": "python3 scripts/codex-a2a-bridge.py",
"smoke:headless": "node scripts/smoke-headless.js",
"headless:responsiveness": "node scripts/headless-responsiveness-harness.js",
"smoke:pack": "node scripts/smoke-packed-cli.js",
diff --git a/packages/control-plane-rs/src/auth.rs b/packages/control-plane-rs/src/auth.rs
--- a/packages/control-plane-rs/src/auth.rs
+++ b/packages/control-plane-rs/src/auth.rs
@@ -185,9 +185,22 @@
}
pub(crate) fn csrf_applies(head: &RequestHead) -> bool {
- head.path.starts_with("/api/") && !matches!(head.method.as_str(), "GET" | "HEAD" | "OPTIONS")
+ if matches!(head.method.as_str(), "GET" | "HEAD" | "OPTIONS") {
+ return false;
+ }
+
+ head.path.starts_with("/api/") || csrf_applies_to_a2a_path(&head.path)
}
+pub(crate) fn a2a_task_id_from_cancel_path(path: &str) -> Option<&str> {
+ let task_id = path.strip_prefix("/tasks/")?.strip_suffix(":cancel")?;
+ (!task_id.is_empty() && !task_id.contains('/') && !task_id.contains(':')).then_some(task_id)
+}
+
+fn csrf_applies_to_a2a_path(path: &str) -> bool {
+ path == "/message:send" || a2a_task_id_from_cancel_path(path).is_some()
+}
+
pub(crate) fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
if left.len() != right.len() {
return false;
diff --git a/packages/control-plane-rs/src/http.rs b/packages/control-plane-rs/src/http.rs
--- a/packages/control-plane-rs/src/http.rs
+++ b/packages/control-plane-rs/src/http.rs
@@ -11,7 +11,7 @@
const MAX_HEADER_BYTES: usize = 64 * 1024;
pub(crate) const MAX_JSON_BODY_BYTES: usize = 32 * 1024 * 1024;
-const CORS_ALLOW_HEADERS: &str = "authorization,content-type,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
+const CORS_ALLOW_HEADERS: &str = "authorization,content-type,a2a-version,a2a-extensions,traceparent,tracestate,x-organization-id,x-evalops-agent-id,x-evalops-actor-id,x-evalops-session-id,x-evalops-workspace-id,x-composer-artifact-access,x-composer-api-key,x-composer-approval-mode,x-composer-client,x-composer-client-tools,x-composer-csrf,x-composer-agent-id,x-composer-slim-events,x-composer-workspace,x-composer-workspace-id,x-maestro-artifact-access,x-maestro-api-key,x-maestro-approval-mode,x-maestro-agent-id,x-maestro-client,x-maestro-client-tools,x-maestro-csrf,x-maestro-slim-events,x-maestro-workspace,x-maestro-workspace-id,x-csrf-token,x-xsrf-token";
#[derive(Debug)]
pub(crate) struct RequestHead {
diff --git a/packages/control-plane-rs/src/main.rs b/packages/control-plane-rs/src/main.rs
--- a/packages/control-plane-rs/src/main.rs
+++ b/packages/control-plane-rs/src/main.rs
@@ -17,7 +17,7 @@
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::process::Command;
-use tokio::sync::{mpsc, Mutex};
+use tokio::sync::{mpsc, watch, Mutex};
mod auth;
mod http;
@@ -49,9 +49,15 @@
const DEFAULT_EXTRACT_MAX_CHARS: usize = 200_000;
const MAX_EXTRACT_INPUT_BYTES: usize = 50 * 1024 * 1024;
const MAX_PROJECT_ONBOARDING_IMPRESSIONS: u8 = 4;
+const A2A_PROTOCOL_VERSION: &str = "1.0";
+const A2A_DEFAULT_TURN_TIMEOUT_MS: u64 = 180_000;
+const A2A_DEFAULT_RESPONSE_END_SETTLE_MS: u64 = 250;
+const A2A_TERMINAL_TASK_STORE_LIMIT: usize = 128;
static ATTACHMENT_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
type PendingToolResponseSender = mpsc::UnboundedSender<(String, bool, Option<ToolResult>)>;
+type A2ACancelSender = watch::Sender<bool>;
+type A2ACancelReceiver = watch::Receiver<bool>;
#[derive(Debug, Clone)]
struct Config {
listen_host: String,
@@ -151,6 +157,8 @@
shared_sessions: Arc<Mutex<HashMap<String, SharedSessionGrant>>>,
approval_modes: Arc<Mutex<HashMap<String, String>>>,
pending_tool_responses: Arc<Mutex<HashMap<String, PendingToolResponseSender>>>,
+ a2a_tasks: Arc<Mutex<HashMap<String, Value>>>,
+ a2a_cancel_senders: Arc<Mutex<HashMap<String, A2ACancelSender>>>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -295,6 +303,8 @@
shared_sessions: Arc::new(Mutex::new(shared_sessions)),
approval_modes: Arc::new(Mutex::new(HashMap::new())),
pending_tool_responses: Arc::new(Mutex::new(HashMap::new())),
+ a2a_tasks: Arc::new(Mutex::new(HashMap::new())),
+ a2a_cancel_senders: Arc::new(Mutex::new(HashMap::new())),
};
loop {
@@ -329,6 +339,16 @@
return handle_chat_endpoint(stream, initial, head, state).await;
}
+ if is_a2a_endpoint(&head) {
+ let response = handle_a2a_endpoint(&mut stream, &mut initial, head, &state).await;
+ stream
+ .write_all(&response)
+ .await
+ .map_err(|error| error.to_string())?;
+ let _ = stream.shutdown().await;
+ return Ok(());
+ }
+
if is_local_endpoint(&head) {
let response = handle_local_endpoint(&mut stream, &mut initial, head, &state).await;
stream
@@ -508,6 +528,1073 @@
Some(request_id)
}
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2APartBody {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ text: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ url: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ data: Option<Value>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ metadata: Option<Value>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ filename: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ media_type: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct A2AMessageBody {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ message_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ context_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ task_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ role: Option<String>,
+ #[serde(default)]
+ parts: Vec<A2APartBody>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ metadata: Option<Value>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ extensions: Option<Vec<String>>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ reference_task_ids: Option<Vec<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct A2ASendMessageRequest {
+ message: A2AMessageBody,
+ #[serde(default)]
+ configuration: Option<Value>,
+ #[serde(default)]
+ metadata: Option<Value>,
+}
+
+#[derive(Debug, Default)]
+struct A2ATurnOutput {
+ assistant_text: String,
+ thinking_text: String,
+ usage: Option<TokenUsage>,
+ tools: Vec<Value>,
+}
+
+enum A2ATurnResult {
+ Completed(A2ATurnOutput),
+ Canceled,
+}
+
+#[derive(Debug)]
+struct A2ASendTarget {
+ task_id: String,
+ context_id: String,
+ history: Vec<Value>,
+ previous_task: Option<Value>,
+ metadata: Value,
+}
+
+fn is_a2a_endpoint(head: &RequestHead) -> bool {
+ if head.method == "OPTIONS" {
+ return head.path == "/.well-known/agent-card.json"
+ || head.path == "/message:send"
+ || head.path == "/tasks"
+ || head.path.starts_with("/tasks/");
+ }
+ matches!(
+ (head.method.as_str(), head.path.as_str()),
+ ("GET", "/.well-known/agent-card.json") | ("POST", "/message:send") | ("GET", "/tasks")
+ ) || (head.method == "GET" && a2a_task_id_from_get_path(&head.path).is_some())
+ || (head.method == "POST" && a2a_task_id_from_cancel_path(&head.path).is_some())
+}
+
+fn a2a_task_id_from_get_path(path: &str) -> Option<&str> {
+ let id = path.strip_prefix("/tasks/")?;
+ (!id.is_empty() && !id.contains('/') && !id.contains(':')).then_some(id)
+}
+
+async fn handle_a2a_endpoint(
+ stream: &mut TcpStream,
+ initial: &mut Vec<u8>,
+ head: RequestHead,
+ state: &AppState,
+) -> Vec<u8> {
+ if head.method == "OPTIONS" {
+ return response(204, "text/plain; charset=utf-8", &[]);
+ }
+
+ if let Err(response) = validate_csrf(&head, &state.config) {
+ return response;
+ }
+
+ if head.method == "GET" && head.path == "/.well-known/agent-card.json" {
+ return json_response(200, &a2a_agent_card(&head, &state.config));
+ }
+
+ let Some(auth) = auth_context(&head, &state.config) else {
+ return json_response(401, &serde_json::json!({ "error": "Unauthorized" }));
+ };
+
+ if head.method == "GET" && head.path == "/tasks" {
+ let tasks = state
+ .a2a_tasks
+ .lock()
+ .await
+ .values()
+ .filter(|task| a2a_task_visible_to_auth(task, &auth))
+ .cloned()
+ .collect::<Vec<_>>();
+ return json_response(200, &serde_json::json!({ "tasks": tasks }));
+ }
+
+ if head.method == "GET" {
+ if let Some(task_id) = a2a_task_id_from_get_path(&head.path) {
+ let tasks = state.a2a_tasks.lock().await;
+ return tasks.get(task_id).map_or_else(
+ || a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found"),
+ |task| {
+ if a2a_task_visible_to_auth(task, &auth) {
+ json_response(200, task)
+ } else {
+ a2a_error_response(404, "TASK_NOT_FOUND", "A2A task not found")
+ }
+ },
+ );
+ }
+ }
+
+ if head.method == "POST" {
+ if let Some(task_id) = a2a_task_id_from_cancel_path(&head.path) {
+ return match cancel_a2a_task(state, task_id, &auth).await {
+ Ok(task) => json_response(200, &task),
+ Err(response) => response,
+ };
+ }
+ }
+
+ if head.method == "POST" && head.path == "/message:send" {
+ return handle_a2a_message_send(stream, initial, &head, state, &auth).await;
+ }
+
+ a2a_error_response(404, "NOT_FOUND", "A2A endpoint not found")
+}
+
+async fn cancel_a2a_task(
+ state: &AppState,
+ task_id: &str,
+ auth: &AuthContext,
+) -> Result<Value, Vec<u8>> {
+ let mut tasks = state.a2a_tasks.lock().await;
+ let Some(task) = tasks.get_mut(task_id) else {
+ return Err(a2a_error_response(
+ 404,
+ "TASK_NOT_FOUND",
+ "A2A task not found",
+ ));
+ };
+ if !a2a_task_visible_to_auth(task, auth) {
+ return Err(a2a_error_response(
+ 404,
+ "TASK_NOT_FOUND",
+ "A2A task not found",
+ ));
+ }
+ if a2a_task_is_terminal(task) {
+ return Err(a2a_error_response(
+ 400,
+ "TASK_NOT_CANCELABLE",
+ "A2A task cannot be canceled from its current state",
+ ));
+ }
+ let context_id = task
+ .get("contextId")
+ .and_then(Value::as_str)
+ .unwrap_or("a2a")
+ .to_string();
+ task["status"] = serde_json::json!({
+ "state": "TASK_STATE_CANCELED",
+ "message": a2a_agent_message(&context_id, "Task canceled"),
+ "timestamp": now_rfc3339()
+ });
+ task["artifacts"] = serde_json::json!([]);
+ let task = task.clone();
+ prune_a2a_terminal_tasks(&mut tasks);
+ drop(tasks);
+
+ if let Some(sender) = state.a2a_cancel_senders.lock().await.remove(task_id) {
+ let _ = sender.send(true);
+ }
+
+ Ok(task)
+}
+
+fn a2a_task_status_state(task: &Value) -> Option<&str> {
+ task.get("status")
+ .and_then(|status| status.get("state"))
+ .and_then(Value::as_str)
+}
+
+fn a2a_task_status_timestamp(task: &Value) -> Option<&str> {
+ task.get("status")
+ .and_then(|status| status.get("timestamp"))
+ .and_then(Value::as_str)
+}
+
+fn a2a_task_is_terminal(task: &Value) -> bool {
+ matches!(
+ a2a_task_status_state(task),
+ Some(
+ "TASK_STATE_COMPLETED"
+ | "TASK_STATE_FAILED"
+ | "TASK_STATE_CANCELED"
+ | "TASK_STATE_REJECTED"
+ )
+ )
+}
+
+fn a2a_task_accepts_message(task: &Value) -> bool {
+ a2a_task_status_state(task) == Some("TASK_STATE_INPUT_REQUIRED")
+}
+
+fn a2a_task_owner_subject(task: &Value) -> Option<&str> {
+ task.get("metadata")
+ .and_then(|metadata| metadata.get("ownerSubject"))
+ .and_then(Value::as_str)
+}
+
+fn a2a_task_visible_to_auth(task: &Value, auth: &AuthContext) -> bool {
+ if auth.unrestricted {
+ return true;
+ }
+ auth.subject
+ .as_deref()
+ .is_some_and(|subject| a2a_task_owner_subject(task) == Some(subject))
+}
+
+async fn claim_a2a_send_task(
+ state: &AppState,
+ request: &A2ASendMessageRequest,
+ head: &RequestHead,
+ auth: &AuthContext,
+ metadata: Value,
+) -> Result<A2ASendTarget, Vec<u8>> {
+ let requested_task_id = request
+ .message
+ .task_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|task_id| !task_id.is_empty());
+ let explicit_context_id = request
+ .message
+ .context_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|context_id| !context_id.is_empty())
+ .map(str::to_string);
+
+ let mut tasks = state.a2a_tasks.lock().await;
+ let (task_id, context_id, mut history, previous_task, task_metadata) =
+ if let Some(task_id) = requested_task_id {
+ let Some(task) = tasks.get(task_id) else {
+ return Err(a2a_error_response(
+ 404,
+ "TASK_NOT_FOUND",
+ "A2A task not found",
+ ));
+ };
+ if !a2a_task_visible_to_auth(task, auth) {
+ return Err(a2a_error_response(
+ 404,
+ "TASK_NOT_FOUND",
+ "A2A task not found",
+ ));
+ }
+ if a2a_task_is_terminal(task) {
+ return Err(a2a_error_response(
+ 400,
+ "UNSUPPORTED_OPERATION",
+ "A2A terminal tasks cannot accept more messages",
+ ));
+ }
+ if !a2a_task_accepts_message(task) {
+ return Err(a2a_error_response(
+ 409,
+ "UNSUPPORTED_OPERATION",
+ "A2A task is not ready to accept another message",
+ ));
+ }
+
+ let task_context_id = task
+ .get("contextId")
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .filter(|context_id| !context_id.is_empty())
+ .map(str::to_string);
+ if let (Some(message_context_id), Some(task_context_id)) =
+ (explicit_context_id.as_deref(), task_context_id.as_deref())
+ {
+ if message_context_id != task_context_id {
+ return Err(a2a_error_response(
+ 400,
+ "INVALID_REQUEST",
+ "A2A message contextId must match the referenced task",
+ ));
+ }
+ }
+ let context_id = explicit_context_id
+ .or(task_context_id)
+ .unwrap_or_else(|| a2a_context_id(request, head));
+ let history = task
+ .get("history")
+ .and_then(Value::as_array)
+ .cloned()
+ .unwrap_or_default();
+ (
+ task_id.to_string(),
+ context_id,
+ history,
+ Some(task.clone()),
+ a2a_merge_task_metadata(task, metadata),
+ )
+ } else {
+ (
+ generate_a2a_id("maestro-task"),
+ explicit_context_id.unwrap_or_else(|| a2a_context_id(request, head)),
+ Vec::new(),
+ None,
+ metadata,
+ )
+ };
+ history.push(a2a_user_message_value(&request.message, &context_id));
+ let working_message = a2a_agent_message(&context_id, "Maestro is working on the A2A task.");
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_WORKING",
+ working_message,
+ history.clone(),
+ Vec::new(),
+ task_metadata.clone(),
+ );
+ tasks.insert(task_id.clone(), task);
+ prune_a2a_terminal_tasks(&mut tasks);
+ Ok(A2ASendTarget {
+ task_id,
+ context_id,
+ history,
+ previous_task,
+ metadata: task_metadata,
+ })
+}
+
+fn a2a_merge_task_metadata(existing_task: &Value, metadata: Value) -> Value {
+ let mut merged = existing_task
+ .get("metadata")
+ .and_then(Value::as_object)
+ .cloned()
+ .unwrap_or_default();
+ if let Value::Object(metadata) = metadata {
+ for (key, value) in metadata {
+ merged.insert(key, value);
+ }
+ }
+ Value::Object(merged)
+}
+
+async fn rollback_a2a_send_claim(state: &AppState, task_id: &str, previous_task: Option<Value>) {
+ let mut tasks = state.a2a_tasks.lock().await;
+ if let Some(previous_task) = previous_task {
+ tasks.insert(task_id.to_string(), previous_task);
+ } else {
+ tasks.remove(task_id);
+ }
+}
+
+async fn a2a_canceled_task(state: &AppState, task_id: &str) -> Option<Value> {
+ state.a2a_tasks.lock().await.get(task_id).and_then(|task| {
+ (a2a_task_status_state(task) == Some("TASK_STATE_CANCELED")).then(|| task.clone())
+ })
+}
+
+async fn store_a2a_task_unless_canceled(state: &AppState, task_id: &str, task: Value) -> Value {
+ let mut tasks = state.a2a_tasks.lock().await;
+ if let Some(existing) = tasks.get(task_id) {
+ if a2a_task_status_state(existing) == Some("TASK_STATE_CANCELED") {
+ return existing.clone();
+ }
+ }
+ tasks.insert(task_id.to_string(), task.clone());
+ prune_a2a_terminal_tasks(&mut tasks);
+ task
+}
+
+fn prune_a2a_terminal_tasks(tasks: &mut HashMap<String, Value>) {
+ let mut terminal_tasks = tasks
+ .iter()
+ .filter(|(_, task)| a2a_task_is_terminal(task))
+ .map(|(task_id, task)| {
+ (
+ task_id.clone(),
+ a2a_task_status_timestamp(task)
+ .unwrap_or_default()
+ .to_string(),
+ )
+ })
+ .collect::<Vec<_>>();
+ if terminal_tasks.len() <= A2A_TERMINAL_TASK_STORE_LIMIT {
+ return;
+ }
+ terminal_tasks.sort_by(|(left_id, left_timestamp), (right_id, right_timestamp)| {
+ left_timestamp
+ .cmp(right_timestamp)
+ .then_with(|| left_id.cmp(right_id))
+ });
+ let overflow = terminal_tasks.len() - A2A_TERMINAL_TASK_STORE_LIMIT;
+ for (task_id, _) in terminal_tasks.into_iter().take(overflow) {
+ tasks.remove(&task_id);
+ }
+}
+
+async fn register_a2a_cancel_sender(
+ state: &AppState,
+ task_id: &str,
+ cancel_tx: A2ACancelSender,
+) -> Result<(), Vec<u8>> {
+ let mut senders = state.a2a_cancel_senders.lock().await;
+ if senders.contains_key(task_id) {
+ return Err(a2a_error_response(
+ 409,
+ "UNSUPPORTED_OPERATION",
+ "A2A task is already running",
+ ));
+ }
+ senders.insert(task_id.to_string(), cancel_tx);
+ Ok(())
+}
+
+async fn handle_a2a_message_send(
+ stream: &mut TcpStream,
+ initial: &mut Vec<u8>,
+ head: &RequestHead,
+ state: &AppState,
+ auth: &AuthContext,
+) -> Vec<u8> {
+ let body = match read_request_body(stream, initial, head).await {
+ Ok(body) => body,
+ Err(error) => return a2a_error_response(400, "INVALID_REQUEST", &error),
+ };
+ let request: A2ASendMessageRequest = match serde_json::from_slice(&body) {
+ Ok(request) => request,
+ Err(error) => {
+ return a2a_error_response(
+ 400,
+ "INVALID_REQUEST",
+ &format!("invalid A2A message request: {error}"),
+ );
+ }
+ };
+
+ let Some(prompt) = a2a_message_text(&request.message) else {
+ return a2a_error_response(
+ 400,
+ "INVALID_REQUEST",
+ "A2A message must contain at least one text part",
+ );
+ };
+ let return_immediately = match a2a_return_immediately(&request) {
+ Ok(value) => value,
+ Err(error) => return a2a_error_response(400, "INVALID_REQUEST", error),
+ };
+
+ let metadata = a2a_task_metadata(head, &request, auth);
+ let target = match claim_a2a_send_task(state, &request, head, auth, metadata).await {
+ Ok(target) => target,
+ Err(response) => return response,
+ };
+ let task_id = target.task_id;
+ let context_id = target.context_id;
+ let history = target.history;
+ let previous_task = target.previous_task;
+ let metadata = target.metadata;
+
+ let (cancel_tx, cancel_rx) = watch::channel(false);
+ if let Err(response) = register_a2a_cancel_sender(state, &task_id, cancel_tx).await {
+ rollback_a2a_send_claim(state, &task_id, previous_task).await;
+ return response;
+ }
+ if let Some(task) = a2a_canceled_task(state, &task_id).await {
+ state.a2a_cancel_senders.lock().await.remove(&task_id);
+ return json_response(200, &serde_json::json!({ "task": task }));
+ }
+ if return_immediately {
+ let accepted_message = a2a_agent_message(&context_id, "Maestro accepted the A2A task.");
+ let mut accepted_history = history.clone();
+ accepted_history.push(accepted_message.clone());
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_WORKING",
+ accepted_message.clone(),
+ accepted_history.clone(),
+ Vec::new(),
+ metadata.clone(),
+ );
+ let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+ let state = state.clone();
+ tokio::spawn(async move {
+ let _ = complete_a2a_task(
+ &state,
+ prompt,
+ task_id,
+ context_id,
+ accepted_history,
+ metadata,
+ cancel_rx,
+ )
+ .await;
+ });
+ return json_response(200, &serde_json::json!({ "task": task }));
+ }
+
+ let task = complete_a2a_task(
+ state, prompt, task_id, context_id, history, metadata, cancel_rx,
+ )
+ .await;
+ json_response(200, &serde_json::json!({ "task": task }))
+}
+
+async fn complete_a2a_task(
+ state: &AppState,
+ prompt: String,
+ task_id: String,
+ context_id: String,
+ mut history: Vec<Value>,
+ mut metadata: Value,
+ cancel_rx: A2ACancelReceiver,
+) -> Value {
+ let turn = match run_a2a_native_turn(state, prompt, cancel_rx).await {
+ Ok(A2ATurnResult::Completed(turn)) => turn,
+ Ok(A2ATurnResult::Canceled) => {
+ let message = a2a_agent_message(&context_id, "Task canceled");
+ history.push(message.clone());
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_CANCELED",
+ message,
+ history,
+ Vec::new(),
+ metadata,
+ );
+ let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+ state.a2a_cancel_senders.lock().await.remove(&task_id);
+ return task;
+ }
+ Err(error) => {
+ let message = a2a_agent_message(&context_id, &error);
+ history.push(message.clone());
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_FAILED",
+ message.clone(),
+ history,
+ Vec::new(),
+ metadata,
+ );
+ let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+ state.a2a_cancel_senders.lock().await.remove(&task_id);
+ return task;
+ }
+ };
+
+ let assistant_text = if turn.assistant_text.trim().is_empty() {
+ "Maestro completed the A2A task without a text response.".to_string()
+ } else {
+ turn.assistant_text
+ };
+ let agent_message = a2a_agent_message(&context_id, &assistant_text);
+ if !turn.thinking_text.trim().is_empty() {
+ metadata["thinking"] = Value::String(turn.thinking_text);
+ }
+ if !turn.tools.is_empty() {
+ metadata["tools"] = Value::Array(turn.tools);
+ }
+ if let Some(usage) = turn.usage {
+ metadata["usage"] = serde_json::json!({
+ "input": usage.input_tokens,
+ "output": usage.output_tokens,
+ "cacheRead": usage.cache_read_tokens,
+ "cacheWrite": usage.cache_write_tokens,
+ "cost": usage.cost.unwrap_or(0.0)
+ });
+ }
+ let task = a2a_task_value(
+ &task_id,
+ &context_id,
+ "TASK_STATE_COMPLETED",
+ agent_message.clone(),
+ {
+ history.push(agent_message);
+ history
+ },
+ vec![serde_json::json!({
+ "artifactId": format!("{task_id}-assistant-response"),
+ "name": "assistant-response",
+ "parts": [{ "text": assistant_text, "mediaType": "text/plain" }]
+ })],
+ metadata,
+ );
+ let task = store_a2a_task_unless_canceled(state, &task_id, task).await;
+ state.a2a_cancel_senders.lock().await.remove(&task_id);
+ task
+}
+
+fn a2a_agent_card(head: &RequestHead, config: &Config) -> Value {
+ let base_url = a2a_public_base_url(head, config);
+ serde_json::json!({
+ "protocolVersion": A2A_PROTOCOL_VERSION,
+ "name": trimmed_env("MAESTRO_A2A_AGENT_NAME")
+ .unwrap_or_else(|| "Maestro Desktop Agent".to_string()),
+ "description": "Local Maestro Rust/TS TUI agent endpoint for A2A task delegation.",
+ "url": base_url,
+ "preferredTransport": "HTTP+JSON",
+ "supportedInterfaces": [{
+ "url": base_url,
+ "protocolBinding": "HTTP+JSON",
+ "protocolVersion": A2A_PROTOCOL_VERSION
+ }],
+ "provider": {
+ "organization": "EvalOps",
+ "url": "https://evalops.com"
+ },
+ "version": env!("CARGO_PKG_VERSION"),
+ "capabilities": {
+ "streaming": false,
+ "pushNotifications": false,
+ "extendedAgentCard": false
+ },
+ "defaultInputModes": ["text/plain"],
+ "defaultOutputModes": ["text/plain", "application/json"],
+ "skills": [{
+ "id": "maestro-tui-turn",
+ "name": "Maestro TUI turn",
+ "description": "Run a prompt through the local Maestro native TUI agent runner.",
+ "tags": ["maestro", "tui", "codex", "a2a"],
+ "examples": [
+ "Review the current workspace and summarize the next highest leverage action."
+ ],
+ "inputModes": ["text/plain"],
+ "outputModes": ["text/plain", "application/json"]
+ }]
+ })
+}
+
+fn a2a_public_base_url(_head: &RequestHead, config: &Config) -> String {
+ if let Some(url) =
+ trimmed_env("MAESTRO_A2A_PUBLIC_URL").or_else(|| trimmed_env("MAESTRO_CONTROL_PUBLIC_URL"))
+ {
+ return url.trim_end_matches('/').to_string();
+ }
+ let host = if let Some(host) = trimmed_env("MAESTRO_A2A_PUBLIC_HOST")
+ .or_else(|| trimmed_env("MAESTRO_CONTROL_PUBLIC_HOST"))
+ {
+ host
+ } else if config.listen_host == "0.0.0.0" || config.listen_host == "::" {
+ trimmed_env("HOSTNAME")
+ .or_else(|| trimmed_env("COMPUTERNAME"))
... diff truncated: showing 800 of 3193 linesYou can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 7ded0bc. Configure here.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5c25f8f18f
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1e10692d6d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 75bffa225e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Summary
Source of truth
Test Plan
cd packages/control-plane-rs && cargo check --bin maestro-control-planecd packages/control-plane-rs && cargo test --bin maestro-control-plane a2abun run smoke:a2a-localbunx biome check . && bun run lint:evals+ build/binary compileNotes
MAESTRO_A2A_FAKE_RESPONSEso CI/local checks prove the A2A wire path without spending model tokens. Without that env var,/message:sendinvokes the native Maestro agent runner.