Add Maestro local A2A bridge#399
Conversation
PR SummaryMedium Risk Overview Updates request protection to cover these new A2A routes (CSRF applicability, per-subject task visibility, cancel signaling via Adds a new smoke test script Reviewed by Cursor Bugbot for commit 6794aff. 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:
|
|
Warning Review the following alerts detected in dependencies. According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.
|
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".
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c23db5fcec
ℹ️ 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.
- ✅ Resolved by another fix: Agent card URL derived from unvalidated Host header
- The current branch already ignores request Host/forwarded-proto headers and derives the agent card URL from explicit config instead.
- ✅ Resolved by another fix: Python cancel handler mutates shared task dict in-place
- The current branch already rebuilds canceled tasks with deep-copied nested state before storing and returning them.
Preview (6e2346f3da)
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,1020 @@
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>,
+}
+
+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) = 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()))
+ } else {
+ (
+ generate_a2a_id("maestro-task"),
+ explicit_context_id.unwrap_or_else(|| a2a_context_id(request, head)),
+ Vec::new(),
+ None,
+ )
+ };
+ 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,
+ previous_task,
+ })
+}
+
+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.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 previous_task = target.previous_task;
+
+ 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 config.listen_host == "0.0.0.0" || config.listen_host == "::" {
+ "127.0.0.1"
+ } else {
+ config.listen_host.as_str()
+ };
+ 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)
+}
+
+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()),
+ );
... diff truncated: showing 800 of 2783 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: 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".

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.