From ccf4d552b886a86d4548227fe2ecd95a76cd4bf2 Mon Sep 17 00:00:00 2001 From: Vadim Briliantov Date: Thu, 20 Aug 2026 02:07:53 +0200 Subject: [PATCH 01/10] Add subagents RFD draft --- docs/docs.json | 3 +- docs/rfds/subagents.mdx | 350 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 docs/rfds/subagents.mdx diff --git a/docs/docs.json b/docs/docs.json index e968f4eec..6e912abca 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -197,7 +197,8 @@ "rfds/tool-call-name", "rfds/get-auth-state", "rfds/session-compaction", - "rfds/session-notices" + "rfds/session-notices", + "rfds/subagents" ] }, { diff --git a/docs/rfds/subagents.mdx b/docs/rfds/subagents.mdx new file mode 100644 index 000000000..95f7bd6e3 --- /dev/null +++ b/docs/rfds/subagents.mdx @@ -0,0 +1,350 @@ +--- +title: "Subagent Sessions" +description: "RFD for exposing agent-created subagents as restricted ACP sessions" +--- + +Author(s): Vadim Briliantov + +## Elevator pitch + +> What are you proposing to change? + +Allow an Agent to expose subagents that it creates while handling a prompt. Each +subagent is represented by its own ACP session ID, so its messages, thoughts, +plans, tool calls, and lifecycle can be displayed independently from the parent +session. + +The parent Agent announces a subagent with a `subagent_spawned` session update. +Subsequent `session/update` notifications use the subagent's session ID. A +subagent session is observational by default: the Client cannot prompt, queue a +message for, or steer it. An Agent may separately advertise that a particular +subagent can be cancelled or closed by the Client. + +## Status quo + +> How do things work today and what problems does this cause? Why would we change things? + +ACP models a conversation as a single session update stream. Agents can run +work concurrently internally, but there is no portable way to tell the Client +that an update came from a subagent or to describe the relationship between the +two sessions. + +Agents therefore have to flatten subagent activity into the parent session, +hide it, or encode it in custom tool calls. Clients cannot reliably: + +- render concurrent work as separate activity; +- associate plans, messages, and tool calls with the worker that produced them; +- show a subagent's name and assigned task; +- track nested subagents; +- cancel one subagent without cancelling the parent turn; or +- distinguish a completed subagent from one that is still running. + +Treating a subagent as an ordinary user-facing session is also inaccurate. It +would imply that Clients can call methods such as `session/prompt`, or future +queueing and steering methods, even when the underlying runtime has no way to +accept user input for that worker. + +## What we propose to do about it + +> What are you proposing to improve the situation? + +### Capability negotiation + +Add an optional `subagents` object to `ClientCapabilities`. Its presence means +the Client understands the session updates and restricted-session semantics in +this RFD. The field is optional and non-nullable. An omitted field means the +capability is not supported; `{}` means it is supported. + +Add an optional `subagents` object to `AgentCapabilities.sessionCapabilities`. +Its presence means the Agent may expose agent-created subagents. The field is +optional and non-nullable. An omitted field means the capability is not +supported; `{}` means it is supported. + +```json +{ + "clientCapabilities": { + "subagents": {} + } +} +``` + +```json +{ + "agentCapabilities": { + "sessionCapabilities": { + "subagents": {} + } + } +} +``` + +An Agent **MUST NOT** send the updates defined by this RFD unless both parties +advertised `subagents`. It may still use subagents internally and present their +results through the parent session. + +### Announcing a subagent + +When an Agent creates a subagent that it wants to expose, it **MUST** send a +`subagent_spawned` update on the immediate parent session before sending any +updates for the child session: + +```json +{ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "sess_parent", + "update": { + "sessionUpdate": "subagent_spawned", + "subagentSessionId": "sess_child_1", + "name": "test-investigator", + "task": "Find the cause of the failing integration tests", + "capabilities": { + "cancel": true, + "close": true + } + } + } +} +``` + +`subagentSessionId`, `name`, `task`, and `capabilities` are required and +non-nullable: + +- `subagentSessionId` is an opaque `SessionId` unique within the ACP + connection. It identifies the child in all subsequent ACP messages. +- `name` is a short, human-readable label. It need not be unique. +- `task` is a human-readable summary of the work delegated to the child. It is + descriptive, not a prompt that the Client can edit or resubmit. +- `capabilities` describes the Client-to-Agent operations permitted for this + specific child session. `cancel` and `close` are optional, non-nullable + booleans whose omission is equivalent to `false`. + +The outer `sessionId` establishes the immediate parent. This supports arbitrary +nesting without adding a second parent identifier. If a child spawns another +subagent, the Agent sends `subagent_spawned` with the child's session ID as the +outer `sessionId`. + +The Agent **MUST** send the spawn update even if it expects the child to finish +very quickly. The Client **MUST** create the child session before processing +later updates bearing that session ID. + +The child inherits the parent's effective working directory, additional +directories, MCP servers, and Client capabilities. The Agent may apply stricter +runtime or tool restrictions to the child, but it **MUST NOT** grant access that +was not available to the parent. A future RFD may add explicit per-child +execution-context fields if Clients need to display or negotiate them. + +### Independent update streams + +After the spawn update, the Agent sends existing ACP updates for the child in +the normal form, using the child's session ID: + +```json +{ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "sess_child_1", + "update": { + "sessionUpdate": "plan", + "entries": [ + { + "content": "Reproduce the failing test", + "priority": "high", + "status": "in_progress" + } + ] + } + } +} +``` + +Updates from the parent and any number of children may be interleaved. Ordering +is defined by the transport order within each session; no ordering between +different sessions is implied. + +Subagents may use Agent-to-Client methods such as filesystem, terminal, and +permission requests when the Client advertised those capabilities. A subagent +session is therefore not "read-only" with respect to the workspace. It is +restricted only in the direction of user interaction: the Client observes it +and may use explicitly advertised lifecycle controls, but cannot submit new +work to it. Existing permission and security boundaries apply equally to parent +and child activity. + +### Lifecycle updates + +The Agent **MUST** report the terminal state of every announced subagent by +sending a `subagent_state_update` on its immediate parent session: + +```json +{ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "sess_parent", + "update": { + "sessionUpdate": "subagent_state_update", + "subagentSessionId": "sess_child_1", + "state": "completed" + } + } +} +``` + +`subagentSessionId` and `state` are required and non-nullable. `state` is one +of `completed`, `failed`, or `cancelled`. The initial `subagent_spawned` update +implicitly places the child in the `running` state, so a separate running +update is unnecessary. + +The terminal lifecycle update **MUST** be sent after all child +`session/update` notifications. A Client may then retain the child for display +or discard its transient state. + +### Restricted session methods + +The Client **MUST NOT** call `session/new`, `session/load`, `session/resume`, +`session/fork`, `session/prompt`, or any queueing or steering method with a +subagent session ID. Subagents are created and assigned work by their parent, +not by the Client. + +If `capabilities.cancel` is `true`, the Client may send the existing +`session/cancel` notification with the subagent session ID. This cancels only +that subagent and its descendants. It does not cancel its parent or siblings. +After cancellation finishes, the Agent sends `subagent_state_update` with +`state: "cancelled"`. + +Unlike cancellation of a user-facing session, there is no child +`session/prompt` request to complete with a `cancelled` stop reason. The +terminal `subagent_state_update` is the Client's confirmation that cancellation +finished. + +If `capabilities.close` is `true`, and the Agent also advertised the existing +`sessionCapabilities.close` capability, the Client may call `session/close` on +the subagent session. Closing first applies the cancellation behavior above and +then releases the child's resources. `close: true` **MUST NOT** be advertised +for a child unless the connection-level `sessionCapabilities.close` capability +is also present. + +The Client **MUST NOT** infer individual cancellation or close support merely +because the Agent can spawn subagents. The per-child flags are authoritative. + +### Parent cancellation and failure + +Cancelling or closing a parent session **MUST** cascade to all running +descendants. The Agent **MUST** emit a terminal lifecycle update for each +announced descendant before completing the parent cancellation or close. + +A child failure does not automatically fail or cancel its parent. The parent +Agent decides whether to recover, delegate the work again, or report the +failure in its own output. + +### Scope in ACP v1 + +ACP v1 ties `session/update` closely to an active prompt turn. To avoid adding a +second prompt-lifecycle change to this RFD, all announced subagents and their +terminal lifecycle updates **MUST** complete before the parent +`session/prompt` request returns. Detached background subagents that outlive the +parent turn are out of scope for this initial proposal. + +## Shiny future + +> How will things play out once this feature exists? + +A Client can render a parent session with a live tree of concurrent workers. +Selecting a worker shows its own plan, messages, tool calls, and status without +mixing them into the main transcript. Where supported, the user can stop a +runaway worker without discarding useful work from its siblings or parent. + +Agents that do not implement subagents remain unchanged. Agents that use +subagents internally but do not want to expose them can continue flattening or +summarizing their output in the parent session. + +## Implementation details and plan + +> Tell me more about your implementation. What is your detailed implementation plan? + +1. Add `SubagentCapabilities` to client and agent initialization capabilities. +2. Add `SubagentSessionCapabilities`, `SubagentSpawned`, and + `SubagentStateUpdate` schema types. +3. Add the two variants to `SessionUpdate` and regenerate all SDK schemas. +4. Update example Clients to keep a session tree and route updates by session + ID. +5. Update example Agents to announce a child before its first update and to + emit exactly one terminal lifecycle update. +6. Validate the design in at least one Agent with native subagents and one + Client with concurrent-session UI before stabilization. + +## Frequently asked questions + +> What questions have arisen over the course of authoring this document or during subsequent discussions? + +### Is a subagent session read-only? + +Only from the Client's conversational perspective. The Client cannot prompt, +queue, or steer it. The subagent may still read and write files, run terminal +commands, and request permissions using the capabilities already negotiated on +the ACP connection. Making all subagents workspace-read-only would prevent many +coding use cases and should instead be an agent policy or a future sandbox +capability. + +### Do Claude Code and Codex support stopping individual subagents? + +Current Claude Code and Codex orchestration surfaces both have concepts for +stopping or closing an individual worker. That does not mean every version, +runtime mode, or ACP adapter can implement it, and other Agents may only support +coarse parent-turn cancellation. Individual cancellation must therefore be a +capability, not a protocol requirement implied by subagent support. + +The proposal uses a per-child capability because support may even vary within +one Agent: a local worker might be interruptible while a delegated remote job +is not. + +### Why reuse `session/cancel` instead of adding `subagent/stop`? + +Once a child has a real ACP session ID, the existing session-scoped lifecycle +methods already identify it unambiguously. Reusing them avoids two ways to +perform the same operation. The subagent capability narrows where those methods +are valid and defines the required cascade behavior. + +### Why not model a subagent as a tool call? + +A tool call is useful for a compact parent-level summary, but it cannot contain +an independent stream of plans, messages, nested tool calls, or child +subagents. A session is the existing ACP abstraction that already provides +those streams. + +### Why not allow prompting or steering a subagent? + +Some runtimes can message a running worker, but others create a worker with a +fixed task and only allow waiting or cancellation. Making conversational input +part of the baseline would exclude those implementations and blur ownership of +the delegated task. A future RFD can add an explicit per-child `prompt` or +`steer` capability if interoperable semantics emerge. + +### Is `session/fork` sufficient for subagents? + +No. Forking is initiated by the Client and creates a normal session derived +from existing context. This RFD covers sessions initiated by an Agent during a +turn, with a parent relationship and restricted Client controls. An Agent may +use a fork internally to implement a subagent, but the wire semantics are +different. + +### What alternative approaches did you consider, and why did you settle on this one? + +- Put a `subagentId` on every existing update. This would duplicate routing + information and require changing every update type. +- Use only tool-call parent/child relationships. This cannot represent nested, + independent session streams cleanly. +- Treat children as unrestricted ACP sessions. This advertises methods that + many agent-created workers cannot accept. +- Add dedicated stop and close methods. Existing session methods already have + the desired addressing and lifecycle semantics. + +Representing each child with a session ID reuses the most protocol machinery +while the spawn notification and positive capability allowlist capture the +important difference from a user-facing session. + +## Revision history + +- 2026-08-19: Initial draft. From 223d9174b635a941c200c86ee47324067b6a81f6 Mon Sep 17 00:00:00 2001 From: Vadim Briliantov Date: Thu, 20 Aug 2026 02:38:01 +0200 Subject: [PATCH 02/10] Add to schema.unstable.json and to Rust client --- agent-client-protocol-schema/Cargo.toml | 2 + agent-client-protocol-schema/src/v1/agent.rs | 46 +++ agent-client-protocol-schema/src/v1/client.rs | 341 ++++++++++++++++++ docs/protocol/v1/draft/schema.mdx | 225 ++++++++++++ schema/v1/schema.unstable.json | 170 +++++++++ 5 files changed, 784 insertions(+) diff --git a/agent-client-protocol-schema/Cargo.toml b/agent-client-protocol-schema/Cargo.toml index c900c0624..cd0f533df 100644 --- a/agent-client-protocol-schema/Cargo.toml +++ b/agent-client-protocol-schema/Cargo.toml @@ -33,6 +33,7 @@ unstable = [ "unstable_nes", "unstable_plan_operations", "unstable_session_fork", + "unstable_subagents", "unstable_session_compaction", "unstable_session_notices", "unstable_end_turn_token_usage", @@ -49,6 +50,7 @@ unstable_plan_operations = [] unstable_session_fork = [] unstable_session_compaction = [] unstable_session_notices = [] +unstable_subagents = [] unstable_end_turn_token_usage = [] unstable_tool_call_name = [] diff --git a/agent-client-protocol-schema/src/v1/agent.rs b/agent-client-protocol-schema/src/v1/agent.rs index 187f119dd..8a827ef11 100644 --- a/agent-client-protocol-schema/src/v1/agent.rs +++ b/agent-client-protocol-schema/src/v1/agent.rs @@ -17,6 +17,9 @@ use super::{ ClientCapabilities, ContentBlock, ExtNotification, ExtRequest, ExtResponse, Meta, SessionId, }; +#[cfg(feature = "unstable_subagents")] +use super::SubagentCapabilities; + #[cfg(feature = "unstable_mcp_over_acp")] use super::mcp::{ MCP_MESSAGE_METHOD_NAME, MessageMcpNotification, MessageMcpRequest, MessageMcpResponse, @@ -4071,6 +4074,20 @@ pub struct SessionCapabilities { #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] #[serde(default)] pub close: Option, + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// Whether the agent may expose agent-created subagents. + /// + /// Optional and non-nullable. Omission means the agent does not advertise support. + /// Supplying `{}` means the agent may send subagent session updates when the client also + /// advertises support. + #[cfg(feature = "unstable_subagents")] + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(with = "SubagentCapabilities", extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub subagents: Option, /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at /// these keys. @@ -4159,6 +4176,18 @@ impl SessionCapabilities { self } + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// Whether the agent may expose agent-created subagents. + #[cfg(feature = "unstable_subagents")] + #[must_use] + pub fn subagents(mut self, subagents: impl IntoOption) -> Self { + self.subagents = subagents.into_option(); + self + } + /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at /// these keys. @@ -5212,6 +5241,23 @@ mod test_serialization { use super::*; use serde_json::json; + #[cfg(feature = "unstable_subagents")] + #[test] + fn test_subagent_capability_serialization() { + let capabilities = SessionCapabilities::new().subagents(SubagentCapabilities::new()); + assert_eq!( + serde_json::to_value(capabilities).unwrap(), + json!({ "subagents": {} }) + ); + + let omitted: SessionCapabilities = serde_json::from_value(json!({})).unwrap(); + assert!(omitted.subagents.is_none()); + + let null: SessionCapabilities = + serde_json::from_value(json!({ "subagents": null })).unwrap(); + assert!(null.subagents.is_none()); + } + fn test_meta() -> Meta { json!({ "source": "test" }).as_object().unwrap().clone() } diff --git a/agent-client-protocol-schema/src/v1/client.rs b/agent-client-protocol-schema/src/v1/client.rs index 486902327..5c3518b97 100644 --- a/agent-client-protocol-schema/src/v1/client.rs +++ b/agent-client-protocol-schema/src/v1/client.rs @@ -437,6 +437,211 @@ impl CompactionSummaryChunk { self.meta = meta.into_option(); self } + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// Notification that the session spawned a subagent. + #[cfg(feature = "unstable_subagents")] + SubagentSpawned(SubagentSpawnedUpdate), + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// Notification that a subagent reached a terminal state. + #[cfg(feature = "unstable_subagents")] + SubagentStateUpdate(SubagentStateUpdate), +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// Notification that a session spawned a subagent. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SubagentSpawnedUpdate { + /// The opaque session ID used by all subsequent updates for the child. + pub subagent_session_id: SessionId, + /// A short, human-readable label for the subagent. + pub name: String, + /// A human-readable summary of the work delegated to the subagent. + pub task: String, + /// Client-to-agent operations permitted for this subagent session. + pub capabilities: SubagentSessionCapabilities, + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + /// + /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + #[serde(rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl SubagentSpawnedUpdate { + /// Builds a subagent spawn update with all required fields set. + #[must_use] + pub fn new( + subagent_session_id: impl Into, + name: impl Into, + task: impl Into, + capabilities: SubagentSessionCapabilities, + ) -> Self { + Self { + subagent_session_id: subagent_session_id.into(), + name: name.into(), + task: task.into(), + capabilities, + meta: None, + } + } + + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// Client-to-agent operations permitted for a specific subagent session. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SubagentSessionCapabilities { + /// Whether the client may cancel this subagent. Omission is equivalent to `false`. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub cancel: bool, + /// Whether the client may close this subagent. Omission is equivalent to `false`. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub close: bool, + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + #[serde(rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl SubagentSessionCapabilities { + /// Builds an empty capability set; cancellation and close are disabled. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Whether the client may cancel this subagent. + #[must_use] + pub fn cancel(mut self, cancel: bool) -> Self { + self.cancel = cancel; + self + } + + /// Whether the client may close this subagent. + #[must_use] + pub fn close(mut self, close: bool) -> Self { + self.close = close; + self + } + + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// Notification that a subagent reached a terminal state. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SubagentStateUpdate { + /// The session ID of the subagent whose state changed. + pub subagent_session_id: SessionId, + /// The terminal state reached by the subagent. + pub state: SubagentState, + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + #[serde(rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl SubagentStateUpdate { + /// Builds a terminal state update with all required fields set. + #[must_use] + pub fn new(subagent_session_id: impl Into, state: SubagentState) -> Self { + Self { + subagent_session_id: subagent_session_id.into(), + state, + meta: None, + } + } + + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// Terminal state of an announced subagent. +#[cfg(feature = "unstable_subagents")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum SubagentState { + /// The subagent completed its task successfully. + Completed, + /// The subagent failed to complete its task. + Failed, + /// The subagent was cancelled. + Cancelled, } /// The current mode of the session has changed @@ -2087,6 +2292,20 @@ pub struct ClientCapabilities { /// /// This capability is not part of the spec yet, and may be removed or changed at any point. /// + /// Whether the client understands exposed subagent sessions. + /// + /// Optional and non-nullable. Omission means the client does not advertise support. + /// Supplying `{}` means the client understands subagent lifecycle updates and restricted + /// session semantics. + #[cfg(feature = "unstable_subagents")] + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(with = "SubagentCapabilities", extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub subagents: Option, + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// /// Whether the client supports `plan_update` and `plan_removed` session updates. /// /// Optional. Omitted or `null` both mean the client does not advertise support. @@ -2177,6 +2396,18 @@ impl ClientCapabilities { self } + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// Whether the client understands exposed subagent sessions. + #[cfg(feature = "unstable_subagents")] + #[must_use] + pub fn subagents(mut self, subagents: impl IntoOption) -> Self { + self.subagents = subagents.into_option(); + self + } + /// **UNSTABLE** /// /// This capability is not part of the spec yet, and may be removed or changed at any point. @@ -2241,6 +2472,50 @@ impl ClientCapabilities { } } +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// Capability marker for exposing subagents as restricted ACP sessions. +/// +/// Supplying `{}` advertises support for subagent lifecycle updates and restricted-session +/// semantics. Both the client and agent must advertise this capability before the agent sends +/// subagent updates. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[non_exhaustive] +pub struct SubagentCapabilities { + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + #[serde(rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl SubagentCapabilities { + /// Builds an empty capability marker. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + /// Session-related capabilities supported by the client. #[serde_as] #[skip_serializing_none] @@ -3087,6 +3362,72 @@ mod tests { assert!(null.compaction.is_none()); } + #[cfg(feature = "unstable_subagents")] + #[test] + fn test_subagent_updates_serialization() { + use serde_json::json; + + let spawned = SessionUpdate::SubagentSpawned(SubagentSpawnedUpdate::new( + "sess_child_1", + "test-investigator", + "Find the cause of the failing integration tests", + SubagentSessionCapabilities::new().cancel(true).close(true), + )); + assert_eq!( + serde_json::to_value(spawned).unwrap(), + json!({ + "sessionUpdate": "subagent_spawned", + "subagentSessionId": "sess_child_1", + "name": "test-investigator", + "task": "Find the cause of the failing integration tests", + "capabilities": { + "cancel": true, + "close": true + } + }) + ); + + let state = SessionUpdate::SubagentStateUpdate(SubagentStateUpdate::new( + "sess_child_1", + SubagentState::Completed, + )); + assert_eq!( + serde_json::to_value(state).unwrap(), + json!({ + "sessionUpdate": "subagent_state_update", + "subagentSessionId": "sess_child_1", + "state": "completed" + }) + ); + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn test_subagent_capability_semantics() { + use serde_json::json; + + let capabilities = + serde_json::to_value(ClientCapabilities::new().subagents(SubagentCapabilities::new())) + .unwrap(); + assert_eq!(capabilities["subagents"], json!({})); + + let omitted: ClientCapabilities = serde_json::from_value(json!({})).unwrap(); + assert!(omitted.subagents.is_none()); + + let null: ClientCapabilities = + serde_json::from_value(json!({ "subagents": null })).unwrap(); + assert!(null.subagents.is_none()); + + let child_capabilities: SubagentSessionCapabilities = serde_json::from_value(json!({ + "cancel": null, + "close": "yes" + })) + .unwrap(); + assert!(!child_capabilities.cancel); + assert!(!child_capabilities.close); + assert_eq!(serde_json::to_value(child_capabilities).unwrap(), json!({})); + } + #[test] fn test_elicitation_capability_semantics() { use serde_json::json; diff --git a/docs/protocol/v1/draft/schema.mdx b/docs/protocol/v1/draft/schema.mdx index 1af228117..0e574118e 100644 --- a/docs/protocol/v1/draft/schema.mdx +++ b/docs/protocol/v1/draft/schema.mdx @@ -3210,6 +3210,18 @@ The position encodings supported by the client, in order of preference. Optional. Omitted or `null` both mean the client does not advertise any session-related extensions. + +SubagentCapabilities} > + **UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Whether the client understands exposed subagent sessions. + +Optional and non-nullable. Omission means the client does not advertise support. +Supplying `\{\}` means the client understands subagent lifecycle updates and restricted +session semantics. + Whether the Client support all `terminal/*` methods. @@ -7281,6 +7293,18 @@ Supplying `\{\}` means the agent supports listing sessions. Optional. Omitted or `null` both mean the agent does not advertise support. Supplying `\{\}` means the agent supports resuming sessions. + +SubagentCapabilities} > + **UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Whether the agent may expose agent-created subagents. + +Optional and non-nullable. Omission means the agent does not advertise support. +Supplying `\{\}` means the agent may send subagent session updates when the client also +advertises support. + ## SessionCloseCapabilities @@ -8318,6 +8342,77 @@ Agents MUST only send this update when the Client advertised + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Notification that the session spawned a subagent. + + + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/draft/extensibility) + + +SubagentSessionCapabilities} required> + Client-to-agent operations permitted for this subagent session. + + + A short, human-readable label for the subagent. + + + The discriminator value. Must be `"subagent_spawned"`. + +SessionId} required> + The opaque session ID used by all subsequent updates for the child. + + + A human-readable summary of the work delegated to the subagent. + + + + + + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Notification that a subagent reached a terminal state. + + + + + The _meta property is reserved by ACP to allow clients and agents to attach + additional metadata to their interactions. Implementations MUST NOT make + assumptions about values at these keys. + + + The discriminator value. Must be `"subagent_state_update"`. + +SubagentState} + required +> + The terminal state reached by the subagent. + +SessionId} + required +> + The session ID of the subagent whose state changed. + + + + + ## StopReason Reasons why an agent stops processing a prompt turn. @@ -8477,6 +8572,136 @@ Optional. Omitted and `null` are equivalent and mean no title is provided. +## SubagentCapabilities + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Capability marker for exposing subagents as restricted ACP sessions. + +Supplying `\{\}` advertises support for subagent lifecycle updates and restricted-session +semantics. Both the client and agent must advertise this capability before the agent sends +subagent updates. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach + additional metadata to their interactions. Implementations MUST NOT make + assumptions about values at these keys. + + +## SubagentSessionCapabilities + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Client-to-agent operations permitted for a specific subagent session. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach + additional metadata to their interactions. Implementations MUST NOT make + assumptions about values at these keys. + + + Whether the client may cancel this subagent. Omission is equivalent to + `false`. + + + Whether the client may close this subagent. Omission is equivalent to `false`. + + +## SubagentSpawnedUpdate + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Notification that a session spawned a subagent. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/draft/extensibility) + + +SubagentSessionCapabilities} required> + Client-to-agent operations permitted for this subagent session. + + + A short, human-readable label for the subagent. + +SessionId} required> + The opaque session ID used by all subsequent updates for the child. + + + A human-readable summary of the work delegated to the subagent. + + +## SubagentState + +Terminal state of an announced subagent. + +**Type:** Union + + + The subagent completed its task successfully. + + + + The subagent failed to complete its task. + + + + The subagent was cancelled. + + +## SubagentStateUpdate + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Notification that a subagent reached a terminal state. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach + additional metadata to their interactions. Implementations MUST NOT make + assumptions about values at these keys. + +SubagentState} + required +> + The terminal state reached by the subagent. + +SessionId} + required +> + The session ID of the subagent whose state changed. + + ## Terminal Embed a terminal created with `terminal/create` by its id. diff --git a/schema/v1/schema.unstable.json b/schema/v1/schema.unstable.json index 136230e16..45f7e422a 100644 --- a/schema/v1/schema.unstable.json +++ b/schema/v1/schema.unstable.json @@ -2836,6 +2836,15 @@ ], "x-deserialize-default-on-error": true }, + "subagents": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent may expose agent-created subagents.\n\nOptional and non-nullable. Omission means the agent does not advertise support.\nSupplying `{}` means the agent may send subagent session updates when the client also\nadvertises support.", + "x-deserialize-default-on-error": true, + "allOf": [ + { + "$ref": "#/$defs/SubagentCapabilities" + } + ] + }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", "type": ["object", "null"], @@ -2916,6 +2925,18 @@ } } }, + "SubagentCapabilities": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapability marker for exposing subagents as restricted ACP sessions.\n\nSupplying `{}` advertises support for subagent lifecycle updates and restricted-session\nsemantics. Both the client and agent must advertise this capability before the agent sends\nsubagent updates.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, "AgentAuthCapabilities": { "description": "Authentication-related capabilities supported by the agent.", "type": "object", @@ -5232,6 +5253,38 @@ "$ref": "#/$defs/CompactionSummaryChunk" } ] + }, + { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification that the session spawned a subagent.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "subagent_spawned" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/SubagentSpawnedUpdate" + } + ] + }, + { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification that a subagent reached a terminal state.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "subagent_state_update" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/SubagentStateUpdate" + } + ] } ], "discriminator": { @@ -5998,6 +6051,114 @@ }, "required": ["compactionId", "content"] }, + "SubagentSessionCapabilities": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nClient-to-agent operations permitted for a specific subagent session.", + "type": "object", + "properties": { + "cancel": { + "description": "Whether the client may cancel this subagent. Omission is equivalent to `false`.", + "type": "boolean", + "x-deserialize-default-on-error": true + }, + "close": { + "description": "Whether the client may close this subagent. Omission is equivalent to `false`.", + "type": "boolean", + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "SubagentSpawnedUpdate": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification that a session spawned a subagent.", + "type": "object", + "properties": { + "subagentSessionId": { + "description": "The opaque session ID used by all subsequent updates for the child.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "name": { + "description": "A short, human-readable label for the subagent.", + "type": "string" + }, + "task": { + "description": "A human-readable summary of the work delegated to the subagent.", + "type": "string" + }, + "capabilities": { + "description": "Client-to-agent operations permitted for this subagent session.", + "allOf": [ + { + "$ref": "#/$defs/SubagentSessionCapabilities" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["subagentSessionId", "name", "task", "capabilities"] + }, + "SubagentState": { + "description": "Terminal state of an announced subagent.", + "oneOf": [ + { + "description": "The subagent completed its task successfully.", + "type": "string", + "const": "completed" + }, + { + "description": "The subagent failed to complete its task.", + "type": "string", + "const": "failed" + }, + { + "description": "The subagent was cancelled.", + "type": "string", + "const": "cancelled" + } + ] + }, + "SubagentStateUpdate": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification that a subagent reached a terminal state.", + "type": "object", + "properties": { + "subagentSessionId": { + "description": "The session ID of the subagent whose state changed.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "state": { + "description": "The terminal state reached by the subagent.", + "allOf": [ + { + "$ref": "#/$defs/SubagentState" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["subagentSessionId", "state"] + }, "CompleteElicitationNotification": { "description": "Notification sent by the agent when a URL-based elicitation is complete.", "type": "object", @@ -6368,6 +6529,15 @@ ], "x-deserialize-default-on-error": true }, + "subagents": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the client understands exposed subagent sessions.\n\nOptional and non-nullable. Omission means the client does not advertise support.\nSupplying `{}` means the client understands subagent lifecycle updates and restricted\nsession semantics.", + "x-deserialize-default-on-error": true, + "allOf": [ + { + "$ref": "#/$defs/SubagentCapabilities" + } + ] + }, "plan": { "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the client supports `plan_update` and `plan_removed` session updates.\n\nOptional. Omitted or `null` both mean the client does not advertise support.\nSupplying `{}` means the client can receive both update types.", "anyOf": [ From b978a273b8ddfd1ce22bf643c8a74e11ca048617 Mon Sep 17 00:00:00 2001 From: Vadim Briliantov Date: Tue, 25 Aug 2026 12:19:18 +0200 Subject: [PATCH 03/10] Fixup --- agent-client-protocol-schema/src/v1/client.rs | 43 ++++++++++----- docs/protocol/v1/draft/schema.mdx | 4 ++ docs/rfds/subagents.mdx | 53 +++++++++++++++++-- schema/v1/schema.unstable.json | 5 ++ 4 files changed, 87 insertions(+), 18 deletions(-) diff --git a/agent-client-protocol-schema/src/v1/client.rs b/agent-client-protocol-schema/src/v1/client.rs index 5c3518b97..7c00da2ed 100644 --- a/agent-client-protocol-schema/src/v1/client.rs +++ b/agent-client-protocol-schema/src/v1/client.rs @@ -166,6 +166,20 @@ pub enum SessionUpdate { /// [`ClientSessionCapabilities::compaction`]. #[cfg(feature = "unstable_session_compaction")] CompactionSummaryChunk(CompactionSummaryChunk), + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// Notification that the session spawned a subagent. + #[cfg(feature = "unstable_subagents")] + SubagentSpawned(SubagentSpawnedUpdate), + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// Notification that a subagent reached a terminal state. + #[cfg(feature = "unstable_subagents")] + SubagentStateUpdate(SubagentStateUpdate), } /// **UNSTABLE** @@ -437,20 +451,6 @@ impl CompactionSummaryChunk { self.meta = meta.into_option(); self } - /// **UNSTABLE** - /// - /// This capability is not part of the spec yet, and may be removed or changed at any point. - /// - /// Notification that the session spawned a subagent. - #[cfg(feature = "unstable_subagents")] - SubagentSpawned(SubagentSpawnedUpdate), - /// **UNSTABLE** - /// - /// This capability is not part of the spec yet, and may be removed or changed at any point. - /// - /// Notification that a subagent reached a terminal state. - #[cfg(feature = "unstable_subagents")] - SubagentStateUpdate(SubagentStateUpdate), } /// **UNSTABLE** @@ -642,6 +642,8 @@ pub enum SubagentState { Failed, /// The subagent was cancelled. Cancelled, + /// The Agent lost the child runtime and cannot determine its task outcome. + Disconnected, } /// The current mode of the session has changed @@ -3399,6 +3401,19 @@ mod tests { "state": "completed" }) ); + + let disconnected = SessionUpdate::SubagentStateUpdate(SubagentStateUpdate::new( + "sess_child_2", + SubagentState::Disconnected, + )); + assert_eq!( + serde_json::to_value(disconnected).unwrap(), + json!({ + "sessionUpdate": "subagent_state_update", + "subagentSessionId": "sess_child_2", + "state": "disconnected" + }) + ); } #[cfg(feature = "unstable_subagents")] diff --git a/docs/protocol/v1/draft/schema.mdx b/docs/protocol/v1/draft/schema.mdx index 0e574118e..d09b13fee 100644 --- a/docs/protocol/v1/draft/schema.mdx +++ b/docs/protocol/v1/draft/schema.mdx @@ -8670,6 +8670,10 @@ Terminal state of an announced subagent. The subagent was cancelled. + + The Agent lost the child runtime and cannot determine its task outcome. + + ## SubagentStateUpdate **UNSTABLE** diff --git a/docs/rfds/subagents.mdx b/docs/rfds/subagents.mdx index 95f7bd6e3..509ca0c05 100644 --- a/docs/rfds/subagents.mdx +++ b/docs/rfds/subagents.mdx @@ -193,14 +193,56 @@ sending a `subagent_state_update` on its immediate parent session: ``` `subagentSessionId` and `state` are required and non-nullable. `state` is one -of `completed`, `failed`, or `cancelled`. The initial `subagent_spawned` update -implicitly places the child in the `running` state, so a separate running -update is unnecessary. +of `completed`, `failed`, `cancelled`, or `disconnected`. The initial +`subagent_spawned` update implicitly places the child in the `running` state, +so a separate running update is unnecessary. + +`disconnected` means the Agent can no longer associate the announced child +with a live runtime and therefore does not know its task outcome. It is a +terminal state for the exposed ACP child, not a claim that the delegated task +failed or was cancelled. An Agent **MUST NOT** report `failed` or `cancelled` +solely because a connection ended or a child runtime could not be restored. The terminal lifecycle update **MUST** be sent after all child `session/update` notifications. A Client may then retain the child for display or discard its transient state. +### Reconnection and replay + +The Client reconnects only the parent session. It **MUST NOT** call +`session/load` or `session/resume` with a subagent session ID. This RFD does not +define restoration of child runtimes: loading or resuming a parent restores +only the parent runtime. + +The authoritative flow for reconstructing the child tree is: + +1. The Client calls `session/load` with the parent session ID. +2. The Agent examines persisted child history. Every `subagent_spawned` update + without a corresponding terminal lifecycle update identifies an orphaned + child. +3. The Agent terminates each orphan with a `subagent_state_update` whose state + is `disconnected`. For nested orphans, descendants are terminated before + their parents, and every terminal update is sent on the immediate parent + session. +4. The Agent replays the parent and child history in its original order, + preserving the child session IDs. Each synthesized `disconnected` update is + sent after all persisted updates for that child. +5. The Client reconstructs the session tree from `subagent_spawned` updates and + treats `disconnected` as terminal. Replayed child IDs are historical display + identifiers and cannot be loaded or resumed independently. + +An implementation may persist synthesized terminal updates. Repeated loads +**MUST** produce the same terminal result, **MUST NOT** revive an orphan, and +**MUST NOT** report it as `failed` or `cancelled` merely because its runtime was +not restored. + +`session/resume` does not replay or reconstruct child history. The Agent +**MUST NOT** send a standalone child terminal update during resume because the +Client may not have received the corresponding `subagent_spawned` update. A +Client that retained UI state from the interrupted connection may locally show +unterminated children as connection-incomplete. If it needs the authoritative +child tree and terminal states, it uses `session/load` instead. + ### Restricted session methods The Client **MUST NOT** call `session/new`, `session/load`, `session/resume`, @@ -271,7 +313,8 @@ summarizing their output in the parent session. 4. Update example Clients to keep a session tree and route updates by session ID. 5. Update example Agents to announce a child before its first update and to - emit exactly one terminal lifecycle update. + emit exactly one terminal lifecycle update, including `disconnected` for + orphaned children that cannot be restored after reconnection. 6. Validate the design in at least one Agent with native subagents and one Client with concurrent-session UI before stabilization. @@ -347,4 +390,6 @@ important difference from a user-facing session. ## Revision history +- 2026-08-25: Defined child reconnection, replay, orphan, and disconnected + lifecycle semantics. - 2026-08-19: Initial draft. diff --git a/schema/v1/schema.unstable.json b/schema/v1/schema.unstable.json index 45f7e422a..11457834c 100644 --- a/schema/v1/schema.unstable.json +++ b/schema/v1/schema.unstable.json @@ -6127,6 +6127,11 @@ "description": "The subagent was cancelled.", "type": "string", "const": "cancelled" + }, + { + "description": "The Agent lost the child runtime and cannot determine its task outcome.", + "type": "string", + "const": "disconnected" } ] }, From 4315189be14287a69dedc6378f6b0b2307c375ea Mon Sep 17 00:00:00 2001 From: Vadim Briliantov Date: Mon, 31 Aug 2026 16:23:30 +0200 Subject: [PATCH 04/10] fixes after review --- agent-client-protocol-schema/src/v1/client.rs | 4 + docs/protocol/v1/draft/schema.mdx | 4 + docs/rfds/subagents.mdx | 108 ++++++++++++++++-- schema/v1/schema.unstable.json | 2 +- 4 files changed, 109 insertions(+), 9 deletions(-) diff --git a/agent-client-protocol-schema/src/v1/client.rs b/agent-client-protocol-schema/src/v1/client.rs index 7c00da2ed..b0a985923 100644 --- a/agent-client-protocol-schema/src/v1/client.rs +++ b/agent-client-protocol-schema/src/v1/client.rs @@ -585,6 +585,10 @@ impl SubagentSessionCapabilities { /// This capability is not part of the spec yet, and may be removed or changed at any point. /// /// Notification that a subagent reached a terminal state. +/// +/// Sent on the immediate parent session after all child session updates and +/// after every pending permission or elicitation request issued for the child +/// has resolved. #[cfg(feature = "unstable_subagents")] #[serde_as] #[skip_serializing_none] diff --git a/docs/protocol/v1/draft/schema.mdx b/docs/protocol/v1/draft/schema.mdx index d09b13fee..450846eeb 100644 --- a/docs/protocol/v1/draft/schema.mdx +++ b/docs/protocol/v1/draft/schema.mdx @@ -8682,6 +8682,10 @@ This capability is not part of the spec yet, and may be removed or changed at an Notification that a subagent reached a terminal state. +Sent on the immediate parent session after all child session updates and +after every pending permission or elicitation request issued for the child +has resolved. + **Type:** Object **Properties:** diff --git a/docs/rfds/subagents.mdx b/docs/rfds/subagents.mdx index 509ca0c05..6cd8b35d3 100644 --- a/docs/rfds/subagents.mdx +++ b/docs/rfds/subagents.mdx @@ -204,8 +204,39 @@ failed or was cancelled. An Agent **MUST NOT** report `failed` or `cancelled` solely because a connection ended or a child runtime could not be restored. The terminal lifecycle update **MUST** be sent after all child -`session/update` notifications. A Client may then retain the child for display -or discard its transient state. +`session/update` notifications and after every pending Agent-to-Client request +for the child has resolved, as defined in +[Pending permission and elicitation requests](#pending-permission-and-elicitation-requests). +A Client may then retain the child for display or discard its transient state. + +### Unknown outcomes on a live connection + +Two failures can leave an announced child without a terminal lifecycle update +outside of the `session/load` flow below: + +- the ACP connection ends while the child is still running; or +- the parent `session/prompt` request returns — with any stop reason or with an + error — before the child's terminal update was received. Under the + [v1 scope rule](#scope-in-acp-v1) this is an Agent bug, but Clients still + need a defined outcome. + +In both cases the Client **MUST** immediately place the child and its announced +descendants in a local `disconnected` state: the task outcome is unknown. The +Client **MUST NOT** present such a child as `failed` or `cancelled`, **MUST +NOT** continue presenting it as running, and **MUST NOT** wait indefinitely for +a terminal update. + +This local state is presentational only. The Client **MUST NOT** emit a wire +message to report it: `session/update` flows only from Agent to Client, and no +Client-to-Agent notification exists for subagent state. + +Because the Client synthesized this state rather than receiving it, it is +provisional. History replayed by a subsequent `session/load` of the parent, +including `disconnected` updates synthesized by the orphan-recovery flow below, +is authoritative and replaces it. If a late `subagent_state_update` for the +child nevertheless arrives on a still-live connection, the Client **MAY** +accept it in place of the local state, but **MUST NOT** return the child to a +running presentation. ### Reconnection and replay @@ -239,9 +270,11 @@ not restored. `session/resume` does not replay or reconstruct child history. The Agent **MUST NOT** send a standalone child terminal update during resume because the Client may not have received the corresponding `subagent_spawned` update. A -Client that retained UI state from the interrupted connection may locally show -unterminated children as connection-incomplete. If it needs the authoritative -child tree and terminal states, it uses `session/load` instead. +Client that retained UI state from the interrupted connection keeps showing +unterminated children in the local `disconnected` state defined in +[Unknown outcomes on a live connection](#unknown-outcomes-on-a-live-connection). +If it needs the authoritative child tree and terminal states, it uses +`session/load` instead. ### Restricted session methods @@ -281,6 +314,43 @@ A child failure does not automatically fail or cancel its parent. The parent Agent decides whether to recover, delegate the work again, or report the failure in its own output. +### Pending permission and elicitation requests + +A child, or one of its descendants, can own pending Agent-to-Client requests +such as `session/request_permission` and `elicitation/create` when the child or +an ancestor is cancelled or closed. + +Every such request **MUST** resolve before the child's terminal +`subagent_state_update`. The Agent **MUST NOT** send the terminal update while +a request it issued for that child or its descendants is still outstanding. +Resolution is either a Client response — including the `cancelled` permission +outcome or the `cancel` elicitation action — or +[request cancellation](/protocol/v1/cancellation) by the Agent, which still +completes the request with a response or a `-32800` error. + +Mirroring [prompt-turn cancellation](/protocol/v1/prompt-turn#cancellation), +when the Client sends `session/cancel` or calls `session/close` for a child, or +for a parent whose cancellation cascades to the child, it **MUST** respond to +pending `session/request_permission` requests for the child and its descendants +with the `cancelled` outcome and **SHOULD** respond to pending +`elicitation/create` requests with the `cancel` action. + +Responses travel from Client to Agent while the terminal update travels from +Agent to Client, so the two can cross on the wire: + +- If the Client receives the terminal `subagent_state_update` while it still + holds an unanswered permission or elicitation request for that child or its + descendants, it **MUST** resolve the request immediately with the `cancelled` + outcome or the `cancel` action and release the interactive control. The + request remains attributed to the terminated child's history: a terminal + child **MUST NOT** retain a live control, and the control **MUST NOT** move + to the parent or any other session. +- If the Agent receives a permission or elicitation response for a child that + it has started terminating, the response still resolves the request, but the + Agent **MUST** treat it as if it were `cancelled`: it **MUST NOT** start new + child activity based on it and **MUST NOT** send child `session/update` + notifications after the terminal update. + ### Scope in ACP v1 ACP v1 ties `session/update` closely to an active prompt turn. To avoid adding a @@ -310,12 +380,19 @@ summarizing their output in the parent session. 2. Add `SubagentSessionCapabilities`, `SubagentSpawned`, and `SubagentStateUpdate` schema types. 3. Add the two variants to `SessionUpdate` and regenerate all SDK schemas. -4. Update example Clients to keep a session tree and route updates by session +4. Ship SDK releases that carry the draft `subagents` capability and update + types — or at minimum preserve them when deserializing and re-serializing — + before adapter rollout. SDKs that strip the draft fields force temporary + out-of-band negotiation bridges such as `_meta`-scoped capability flags. + Such bridges are compatibility shims, not an alternative protocol, and are + retired once SDK support ships. SDK preservation of the draft fields is an + explicit prerequisite for the validation step below. +5. Update example Clients to keep a session tree and route updates by session ID. -5. Update example Agents to announce a child before its first update and to +6. Update example Agents to announce a child before its first update and to emit exactly one terminal lifecycle update, including `disconnected` for orphaned children that cannot be restored after reconnection. -6. Validate the design in at least one Agent with native subagents and one +7. Validate the design in at least one Agent with native subagents and one Client with concurrent-session UI before stabilization. ## Frequently asked questions @@ -365,6 +442,16 @@ part of the baseline would exclude those implementations and blur ownership of the delegated task. A future RFD can add an explicit per-child `prompt` or `steer` capability if interoperable semantics emerge. +### Why is the field named `task` rather than `description`? + +Early draft implementations of this proposal used a `description` field. +Adapters have since converged on `task`: it names the work delegated to the +child rather than describing the child itself, which keeps it distinct from +`name`. `task` is the only canonical field in this RFD. An implementation that +shipped against an early draft may temporarily accept `description` as an +input fallback while migrating, but it emits `task`, and Clients are not +required to understand `description`. + ### Is `session/fork` sufficient for subagents? No. Forking is initiated by the Client and creates a normal session derived @@ -390,6 +477,11 @@ important difference from a user-facing session. ## Revision history +- 2026-08-31: Defined the Client-local `disconnected` state for unknown + outcomes on a live connection, required pending permission and elicitation + requests to resolve before the terminal lifecycle update with defined race + handling, and made SDK preservation of the draft fields an explicit rollout + prerequisite. - 2026-08-25: Defined child reconnection, replay, orphan, and disconnected lifecycle semantics. - 2026-08-19: Initial draft. diff --git a/schema/v1/schema.unstable.json b/schema/v1/schema.unstable.json index 11457834c..db124211d 100644 --- a/schema/v1/schema.unstable.json +++ b/schema/v1/schema.unstable.json @@ -6136,7 +6136,7 @@ ] }, "SubagentStateUpdate": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification that a subagent reached a terminal state.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification that a subagent reached a terminal state.\n\nSent on the immediate parent session after all child session updates and\nafter every pending permission or elicitation request issued for the child\nhas resolved.", "type": "object", "properties": { "subagentSessionId": { From 182be7511ed3cc788bc3760c7c867d03ce11d0d3 Mon Sep 17 00:00:00 2001 From: Vadim Briliantov Date: Wed, 16 Sep 2026 01:26:46 +0200 Subject: [PATCH 05/10] docs(rfd): rework subagents RFD to a single upsert-style subagent_update Address review feedback from #1992: - Merge subagent_spawned and subagent_state_update into one upsert-style subagent_update following the v2 entity pattern; add explicit running state - Make name, task, and capabilities optional; only subagentSessionId required - Drop the Agent-side capability: the Client capability alone gates the updates - Drop the per-child close capability; session/close is not valid for child IDs - Stop implying children inherit the parent's execution context - Allow SDKs to buffer updates for unannounced child sessions - Explain why the local disconnected state cannot wait for late updates - Add FAQ entries for naming, capability, and disconnected-state choices - Define v2 support: capability-free, v2 patch semantics, open state enum Co-Authored-By: Claude Fable 5 --- docs/rfds/subagents.mdx | 313 +++++++++++++++++++++++++++------------- 1 file changed, 209 insertions(+), 104 deletions(-) diff --git a/docs/rfds/subagents.mdx b/docs/rfds/subagents.mdx index 6cd8b35d3..d28cb8978 100644 --- a/docs/rfds/subagents.mdx +++ b/docs/rfds/subagents.mdx @@ -14,11 +14,12 @@ subagent is represented by its own ACP session ID, so its messages, thoughts, plans, tool calls, and lifecycle can be displayed independently from the parent session. -The parent Agent announces a subagent with a `subagent_spawned` session update. -Subsequent `session/update` notifications use the subagent's session ID. A -subagent session is observational by default: the Client cannot prompt, queue a -message for, or steer it. An Agent may separately advertise that a particular -subagent can be cancelled or closed by the Client. +The parent Agent announces and tracks each subagent with upsert-style +`subagent_update` session updates. Subsequent `session/update` notifications +use the subagent's session ID. A subagent session is observational by default: +the Client cannot prompt, queue a message for, or steer it. An Agent may +separately advertise that a particular subagent can be cancelled by the +Client. ## Status quo @@ -55,11 +56,6 @@ the Client understands the session updates and restricted-session semantics in this RFD. The field is optional and non-nullable. An omitted field means the capability is not supported; `{}` means it is supported. -Add an optional `subagents` object to `AgentCapabilities.sessionCapabilities`. -Its presence means the Agent may expose agent-created subagents. The field is -optional and non-nullable. An omitted field means the capability is not -supported; `{}` means it is supported. - ```json { "clientCapabilities": { @@ -68,25 +64,29 @@ supported; `{}` means it is supported. } ``` -```json -{ - "agentCapabilities": { - "sessionCapabilities": { - "subagents": {} - } - } -} -``` - -An Agent **MUST NOT** send the updates defined by this RFD unless both parties +An Agent **MUST NOT** send the updates defined by this RFD unless the Client advertised `subagents`. It may still use subagents internally and present their results through the parent session. -### Announcing a subagent +There is no Agent-side capability. Subagent visibility flows only from Agent to +Client, so the Client capability alone is enough to prevent sending updates the +other side cannot understand; a Client learns that a particular Agent exposes +subagents by receiving the first update. The Client capability itself exists +only because v1 Clients predate this RFD. In ACP v2, subagent updates are part +of the baseline session model and support is assumed rather than negotiated; +see [Subagents in ACP v2](#subagents-in-acp-v2). + +### Announcing and updating a subagent -When an Agent creates a subagent that it wants to expose, it **MUST** send a -`subagent_spawned` update on the immediate parent session before sending any -updates for the child session: +All subagent information travels in a single `subagent_update` session update +with upsert semantics, following the entity-update pattern ACP v2 uses for tool +calls, messages, and terminals. It is sent on the immediate parent session: the +first update for an unknown `subagentSessionId` announces the child, and later +updates for the same ID modify it. + +When an Agent creates a subagent that it wants to expose, it **MUST** send the +announcing `subagent_update` before sending any updates bearing the child's +session ID: ```json { @@ -95,50 +95,66 @@ updates for the child session: "params": { "sessionId": "sess_parent", "update": { - "sessionUpdate": "subagent_spawned", + "sessionUpdate": "subagent_update", "subagentSessionId": "sess_child_1", "name": "test-investigator", "task": "Find the cause of the failing integration tests", "capabilities": { - "cancel": true, - "close": true + "cancel": true } } } } ``` -`subagentSessionId`, `name`, `task`, and `capabilities` are required and -non-nullable: +Only `subagentSessionId` is required. For every other field, omission and +`null` are equivalent and mean the field is unchanged; a concrete value +replaces the previous one. Fields can therefore be revised mid-run — a task +summary that becomes more specific as work proceeds, for example — but not +cleared: - `subagentSessionId` is an opaque `SessionId` unique within the ACP connection. It identifies the child in all subsequent ACP messages. -- `name` is a short, human-readable label. It need not be unique. -- `task` is a human-readable summary of the work delegated to the child. It is - descriptive, not a prompt that the Client can edit or resubmit. -- `capabilities` describes the Client-to-Agent operations permitted for this - specific child session. `cancel` and `close` are optional, non-nullable - booleans whose omission is equivalent to `false`. +- `name` is an optional short, human-readable label. It need not be unique. If + it was never supplied, the Client chooses its own fallback presentation. +- `task` is an optional human-readable summary of the work delegated to the + child. It is descriptive, not a prompt that the Client can edit or resubmit. +- `capabilities` optionally describes the Client-to-Agent operations permitted + for this specific child session. `cancel` is an optional, non-nullable + boolean whose omission is equivalent to `false`. If `capabilities` was never + supplied, no operations are permitted. +- `state` is the optional lifecycle state defined in [Lifecycle](#lifecycle). + If it was never supplied, the child is `running`, so the announcing update + usually omits it. The outer `sessionId` establishes the immediate parent. This supports arbitrary nesting without adding a second parent identifier. If a child spawns another -subagent, the Agent sends `subagent_spawned` with the child's session ID as the +subagent, the Agent sends `subagent_update` with the child's session ID as the outer `sessionId`. -The Agent **MUST** send the spawn update even if it expects the child to finish -very quickly. The Client **MUST** create the child session before processing -later updates bearing that session ID. - -The child inherits the parent's effective working directory, additional -directories, MCP servers, and Client capabilities. The Agent may apply stricter -runtime or tool restrictions to the child, but it **MUST NOT** grant access that -was not available to the parent. A future RFD may add explicit per-child -execution-context fields if Clients need to display or negotiate them. +The Agent **MUST** send the announcing update even if it expects the child to +finish very quickly, so that the Client never receives child updates it cannot +attribute to an announced session. How a Client tracks or renders announced +children is its own concern; the ordering guarantee is the protocol contract. +SDKs **MAY** additionally tolerate non-conforming Agents by buffering updates +for unknown session IDs until the announcing update arrives, but Agents +**MUST NOT** rely on such tolerance. + +The child's execution context — its working directory, MCP servers, and +available tools — is chosen by the Agent and is not guaranteed to match the +parent's. An Agent may, for example, run a child in a scratch directory or +without the parent's MCP servers. Whatever the context, all child activity +flows through the same ACP connection: Agent-to-Client requests made on behalf +of a child are subject to the same Client capabilities and permission checks as +the parent's, and the Agent **MUST NOT** use a child to circumvent restrictions +the Client imposed on the parent session. A future RFD may add explicit +per-child execution-context fields if Clients need to display or negotiate +them. ### Independent update streams -After the spawn update, the Agent sends existing ACP updates for the child in -the normal form, using the child's session ID: +After the announcing update, the Agent sends existing ACP updates for the child +in the normal form, using the child's session ID: ```json { @@ -172,10 +188,14 @@ and may use explicitly advertised lifecycle controls, but cannot submit new work to it. Existing permission and security boundaries apply equally to parent and child activity. -### Lifecycle updates +### Lifecycle + +`state` is one of `running`, `completed`, `failed`, `cancelled`, or +`disconnected`. A child whose state was never reported is `running`; every +other state is terminal. -The Agent **MUST** report the terminal state of every announced subagent by -sending a `subagent_state_update` on its immediate parent session: +The Agent **MUST** report a terminal state for every announced subagent by +sending a `subagent_update` on its immediate parent session: ```json { @@ -184,7 +204,7 @@ sending a `subagent_state_update` on its immediate parent session: "params": { "sessionId": "sess_parent", "update": { - "sessionUpdate": "subagent_state_update", + "sessionUpdate": "subagent_update", "subagentSessionId": "sess_child_1", "state": "completed" } @@ -192,22 +212,19 @@ sending a `subagent_state_update` on its immediate parent session: } ``` -`subagentSessionId` and `state` are required and non-nullable. `state` is one -of `completed`, `failed`, `cancelled`, or `disconnected`. The initial -`subagent_spawned` update implicitly places the child in the `running` state, -so a separate running update is unnecessary. - `disconnected` means the Agent can no longer associate the announced child with a live runtime and therefore does not know its task outcome. It is a terminal state for the exposed ACP child, not a claim that the delegated task failed or was cancelled. An Agent **MUST NOT** report `failed` or `cancelled` solely because a connection ended or a child runtime could not be restored. -The terminal lifecycle update **MUST** be sent after all child +The update that carries the terminal state **MUST** be sent after all child `session/update` notifications and after every pending Agent-to-Client request for the child has resolved, as defined in [Pending permission and elicitation requests](#pending-permission-and-elicitation-requests). -A Client may then retain the child for display or discard its transient state. +The Agent **MUST NOT** send further `subagent_update`s for that child +afterwards. A Client may then retain the child for display or discard its +transient state. ### Unknown outcomes on a live connection @@ -226,6 +243,11 @@ Client **MUST NOT** present such a child as `failed` or `cancelled`, **MUST NOT** continue presenting it as running, and **MUST NOT** wait indefinitely for a terminal update. +Waiting for further updates is not a real alternative: after the connection +ends nothing more can arrive, and after `session/prompt` returns, v1 ties +`session/update` to the active prompt turn, so there is no defined channel +through which the child's actual outcome could still be delivered. + This local state is presentational only. The Client **MUST NOT** emit a wire message to report it: `session/update` flows only from Agent to Client, and no Client-to-Agent notification exists for subagent state. @@ -233,7 +255,7 @@ Client-to-Agent notification exists for subagent state. Because the Client synthesized this state rather than receiving it, it is provisional. History replayed by a subsequent `session/load` of the parent, including `disconnected` updates synthesized by the orphan-recovery flow below, -is authoritative and replaces it. If a late `subagent_state_update` for the +is authoritative and replaces it. If a late terminal `subagent_update` for the child nevertheless arrives on a still-live connection, the Client **MAY** accept it in place of the local state, but **MUST NOT** return the child to a running presentation. @@ -248,19 +270,18 @@ only the parent runtime. The authoritative flow for reconstructing the child tree is: 1. The Client calls `session/load` with the parent session ID. -2. The Agent examines persisted child history. Every `subagent_spawned` update - without a corresponding terminal lifecycle update identifies an orphaned - child. -3. The Agent terminates each orphan with a `subagent_state_update` whose state - is `disconnected`. For nested orphans, descendants are terminated before +2. The Agent examines persisted child history. Every announced child without a + corresponding terminal `subagent_update` identifies an orphan. +3. The Agent terminates each orphan with a `subagent_update` whose state is + `disconnected`. For nested orphans, descendants are terminated before their parents, and every terminal update is sent on the immediate parent session. 4. The Agent replays the parent and child history in its original order, preserving the child session IDs. Each synthesized `disconnected` update is sent after all persisted updates for that child. -5. The Client reconstructs the session tree from `subagent_spawned` updates and - treats `disconnected` as terminal. Replayed child IDs are historical display - identifiers and cannot be loaded or resumed independently. +5. The Client reconstructs the session tree from announcing `subagent_update`s + and treats `disconnected` as terminal. Replayed child IDs are historical + display identifiers and cannot be loaded or resumed independently. An implementation may persist synthesized terminal updates. Repeated loads **MUST** produce the same terminal result, **MUST NOT** revive an orphan, and @@ -269,7 +290,7 @@ not restored. `session/resume` does not replay or reconstruct child history. The Agent **MUST NOT** send a standalone child terminal update during resume because the -Client may not have received the corresponding `subagent_spawned` update. A +Client may not have received the corresponding announcing update. A Client that retained UI state from the interrupted connection keeps showing unterminated children in the local `disconnected` state defined in [Unknown outcomes on a live connection](#unknown-outcomes-on-a-live-connection). @@ -279,36 +300,32 @@ If it needs the authoritative child tree and terminal states, it uses ### Restricted session methods The Client **MUST NOT** call `session/new`, `session/load`, `session/resume`, -`session/fork`, `session/prompt`, or any queueing or steering method with a -subagent session ID. Subagents are created and assigned work by their parent, -not by the Client. +`session/fork`, `session/prompt`, `session/close`, or any queueing or steering +method with a subagent session ID. Subagents are created, assigned work, and +released by their parent, not by the Client. If `capabilities.cancel` is `true`, the Client may send the existing `session/cancel` notification with the subagent session ID. This cancels only that subagent and its descendants. It does not cancel its parent or siblings. -After cancellation finishes, the Agent sends `subagent_state_update` with +After cancellation finishes, the Agent sends a `subagent_update` with `state: "cancelled"`. Unlike cancellation of a user-facing session, there is no child `session/prompt` request to complete with a `cancelled` stop reason. The -terminal `subagent_state_update` is the Client's confirmation that cancellation +terminal `subagent_update` is the Client's confirmation that cancellation finished. -If `capabilities.close` is `true`, and the Agent also advertised the existing -`sessionCapabilities.close` capability, the Client may call `session/close` on -the subagent session. Closing first applies the cancellation behavior above and -then releases the child's resources. `close: true` **MUST NOT** be advertised -for a child unless the connection-level `sessionCapabilities.close` capability -is also present. - -The Client **MUST NOT** infer individual cancellation or close support merely -because the Agent can spawn subagents. The per-child flags are authoritative. +The Client **MUST NOT** infer individual cancellation support merely because +the Agent can spawn subagents. The per-child flag is authoritative. ### Parent cancellation and failure Cancelling or closing a parent session **MUST** cascade to all running -descendants. The Agent **MUST** emit a terminal lifecycle update for each +descendants. The Agent **MUST** emit a terminal `subagent_update` for each announced descendant before completing the parent cancellation or close. +Closing a subagent's parent — via the existing `sessionCapabilities.close` +capability — is also how a Client releases child resources; there is no +child-level close. A child failure does not automatically fail or cancel its parent. The parent Agent decides whether to recover, delegate the work again, or report the @@ -320,17 +337,17 @@ A child, or one of its descendants, can own pending Agent-to-Client requests such as `session/request_permission` and `elicitation/create` when the child or an ancestor is cancelled or closed. -Every such request **MUST** resolve before the child's terminal -`subagent_state_update`. The Agent **MUST NOT** send the terminal update while -a request it issued for that child or its descendants is still outstanding. -Resolution is either a Client response — including the `cancelled` permission -outcome or the `cancel` elicitation action — or +Every such request **MUST** resolve before the `subagent_update` that carries +the child's terminal state. The Agent **MUST NOT** send the terminal update +while a request it issued for that child or its descendants is still +outstanding. Resolution is either a Client response — including the `cancelled` +permission outcome or the `cancel` elicitation action — or [request cancellation](/protocol/v1/cancellation) by the Agent, which still completes the request with a response or a `-32800` error. Mirroring [prompt-turn cancellation](/protocol/v1/prompt-turn#cancellation), -when the Client sends `session/cancel` or calls `session/close` for a child, or -for a parent whose cancellation cascades to the child, it **MUST** respond to +when the Client sends `session/cancel` for a child, or cancels or closes a +parent whose cancellation cascades to the child, it **MUST** respond to pending `session/request_permission` requests for the child and its descendants with the `cancelled` outcome and **SHOULD** respond to pending `elicitation/create` requests with the `cancel` action. @@ -338,7 +355,7 @@ with the `cancelled` outcome and **SHOULD** respond to pending Responses travel from Client to Agent while the terminal update travels from Agent to Client, so the two can cross on the wire: -- If the Client receives the terminal `subagent_state_update` while it still +- If the Client receives the terminal `subagent_update` while it still holds an unanswered permission or elicitation request for that child or its descendants, it **MUST** resolve the request immediately with the `cancelled` outcome or the `cancel` action and release the interactive control. The @@ -355,10 +372,40 @@ Agent to Client, so the two can cross on the wire: ACP v1 ties `session/update` closely to an active prompt turn. To avoid adding a second prompt-lifecycle change to this RFD, all announced subagents and their -terminal lifecycle updates **MUST** complete before the parent +terminal `subagent_update`s **MUST** complete before the parent `session/prompt` request returns. Detached background subagents that outlive the parent turn are out of scope for this initial proposal. +### Subagents in ACP v2 + +The v2 schema carries the same `subagent_update`, adapted to v2 conventions: + +- **No capability.** v2 Clients preserve and otherwise ignore unknown + `sessionUpdate` types, so an Agent may send `subagent_update` without prior + negotiation. The `subagents` Client capability is v1-only. +- **v2 patch semantics.** As with tool calls and terminals, only + `subagentSessionId` is required; for other fields, omission leaves the stored + value unchanged while `null` clears or unsets it (in v1, `null` is instead + equivalent to omission). A child whose `state` is unset is `running`; a child + whose `capabilities` are unset permits no operations. +- **Open state enum.** Unknown `state` values are preserved rather than + dropped, matching other v2 status enums. Values beginning with `_` are + reserved for implementation-specific extensions; other unknown values are + reserved for future ACP states. + +The restricted-session rules, cancellation flow, pending-request resolution, +and orphan recovery defined above apply to v2 unchanged. The v1 scope rule +above does not carry over as-is: the proposed v2 prompt lifecycle decouples +session updates from the prompt turn and resolves `session/prompt` at +acceptance, which is expected to make detached subagents that outlive the +parent turn expressible — and it removes the "prompt returned without a +terminal update" trigger, leaving connection loss as the only cause for the +Client-local `disconnected` state in +[Unknown outcomes on a live connection](#unknown-outcomes-on-a-live-connection). +Defining detached-subagent semantics stays out of scope for this RFD until the +v2 prompt lifecycle stabilizes; until then, v2 Agents **SHOULD** still +terminate announced children within the turn. + ## Shiny future > How will things play out once this feature exists? @@ -376,23 +423,28 @@ summarizing their output in the parent session. > Tell me more about your implementation. What is your detailed implementation plan? -1. Add `SubagentCapabilities` to client and agent initialization capabilities. -2. Add `SubagentSessionCapabilities`, `SubagentSpawned`, and - `SubagentStateUpdate` schema types. -3. Add the two variants to `SessionUpdate` and regenerate all SDK schemas. -4. Ship SDK releases that carry the draft `subagents` capability and update +1. Add `SubagentCapabilities` to the client initialization capabilities. +2. Add `SubagentSessionCapabilities`, `SubagentUpdate`, and `SubagentState` + schema types. +3. Add the `subagent_update` variant to `SessionUpdate` and regenerate all SDK + schemas. +4. Mirror the `subagent_update` types into the v2 schema behind the same + unstable flag — capability-free, with v2 patch semantics and an open state + enum — so the v1 and v2 wire shapes stay aligned. +5. Ship SDK releases that carry the draft `subagents` capability and update types — or at minimum preserve them when deserializing and re-serializing — before adapter rollout. SDKs that strip the draft fields force temporary out-of-band negotiation bridges such as `_meta`-scoped capability flags. Such bridges are compatibility shims, not an alternative protocol, and are retired once SDK support ships. SDK preservation of the draft fields is an explicit prerequisite for the validation step below. -5. Update example Clients to keep a session tree and route updates by session - ID. -6. Update example Agents to announce a child before its first update and to - emit exactly one terminal lifecycle update, including `disconnected` for +6. Update example Clients to keep a session tree and route updates by session + ID. SDKs may buffer updates for unknown child session IDs until the + announcing update arrives, as tolerance for non-conforming Agents. +7. Update example Agents to announce a child before its first update and to + emit exactly one terminal `subagent_update`, including `disconnected` for orphaned children that cannot be restored after reconnection. -7. Validate the design in at least one Agent with native subagents and one +8. Validate the design in at least one Agent with native subagents and one Client with concurrent-session UI before stabilization. ## Frequently asked questions @@ -427,6 +479,48 @@ methods already identify it unambiguously. Reusing them avoids two ways to perform the same operation. The subagent capability narrows where those methods are valid and defines the required cascade behavior. +### Why one `subagent_update` type instead of separate spawn and state notifications? + +An earlier draft used a `subagent_spawned` notification plus a +`subagent_state_update` for the terminal state. ACP v2 instead treats tool +calls, messages, and terminals as entities maintained by a single upsert-style +update, and subagents now follow that pattern so the design carries into v2 +unchanged: the first update announces the entity, later updates patch +individual fields, and one of them reports the terminal state. A single type +also lets descriptive fields such as `task` be revised mid-run without adding +further notification types. + +### Why is the child identified by `subagentSessionId` rather than `sessionId`? + +A `subagent_update` travels on the parent's stream, where the enclosing +`params.sessionId` already denotes the parent. Reusing the key name `sessionId` +for a different session inside the same message would invite routing bugs in +code that dispatches by field name. The longer name states which of the two +sessions it identifies. + +### Why is `cancel` the only per-child capability? + +It is the smallest control validated by the reference integrations: stopping a +runaway worker is the lifecycle action users reliably need, and it maps +directly onto the existing `session/cancel` notification. An earlier draft also +advertised a per-child `close`, but the parent Agent owns the child's runtime +and releases its resources when the child terminates or when the parent session +is closed, so a Client-initiated close had no clear job. `capabilities` is an +object precisely so that future per-child controls — `close`, `prompt`, +`steer`, or configuration changes — can be added without a breaking change once +interoperable semantics emerge. + +### Why does the `disconnected` state exist? + +`completed`, `failed`, and `cancelled` are claims about the delegated task's +outcome. After a crash or reconnection, an Agent can find an announced child it +can no longer associate with a live runtime, and the parent session's own state +cannot express which of its children ended how. Without `disconnected`, the +Agent would have to guess (`failed`), lie (`cancelled`), or leave the child +running forever in the Client's presentation. The parent being able to +re-prompt or recover on its own does not remove the need to close out the +child's announced history honestly. + ### Why not model a subagent as a tool call? A tool call is useful for a compact parent-level summary, but it cannot contain @@ -470,13 +564,24 @@ different. many agent-created workers cannot accept. - Add dedicated stop and close methods. Existing session methods already have the desired addressing and lifecycle semantics. +- Use separate `subagent_spawned` and `subagent_state_update` notifications, as + an earlier draft of this RFD did. Merged into one upsert-style update to + match the v2 entity pattern. Representing each child with a session ID reuses the most protocol machinery -while the spawn notification and positive capability allowlist capture the +while the announcing update and positive capability allowlist capture the important difference from a user-facing session. ## Revision history +- 2026-09-15: Merged `subagent_spawned` and `subagent_state_update` into a + single upsert-style `subagent_update` following the v2 entity pattern, made + `name`, `task`, and `capabilities` optional, added an explicit `running` + state, removed the Agent-side capability and the per-child `close` + capability, stopped implying that a child inherits the parent's execution + context, and allowed SDKs to buffer updates for unannounced children. Added + the same `subagent_update` to the v2 schema: capability-free, with v2 patch + semantics and an open state enum. - 2026-08-31: Defined the Client-local `disconnected` state for unknown outcomes on a live connection, required pending permission and elicitation requests to resolve before the terminal lifecycle update with defined race From ee65f9c80b7c134ecda753056cf0e7d8f05df8e2 Mon Sep 17 00:00:00 2001 From: Vadim Briliantov Date: Wed, 16 Sep 2026 01:26:46 +0200 Subject: [PATCH 06/10] feat(unstable): merge subagent session updates into upsert-style subagent_update Replace SubagentSpawnedUpdate and SubagentStateUpdate with a single SubagentUpdate where only subagentSessionId is required and omitted or null fields mean unchanged. Add a running state to SubagentState, drop the per-child close capability, and remove the agent-side subagents session capability. Co-Authored-By: Claude Fable 5 --- agent-client-protocol-schema/src/v1/agent.rs | 46 ---- agent-client-protocol-schema/src/v1/client.rs | 244 +++++++++--------- docs/protocol/v1/draft/schema.mdx | 190 ++++++-------- schema/v1/schema.unstable.json | 146 ++++------- 4 files changed, 260 insertions(+), 366 deletions(-) diff --git a/agent-client-protocol-schema/src/v1/agent.rs b/agent-client-protocol-schema/src/v1/agent.rs index 8a827ef11..187f119dd 100644 --- a/agent-client-protocol-schema/src/v1/agent.rs +++ b/agent-client-protocol-schema/src/v1/agent.rs @@ -17,9 +17,6 @@ use super::{ ClientCapabilities, ContentBlock, ExtNotification, ExtRequest, ExtResponse, Meta, SessionId, }; -#[cfg(feature = "unstable_subagents")] -use super::SubagentCapabilities; - #[cfg(feature = "unstable_mcp_over_acp")] use super::mcp::{ MCP_MESSAGE_METHOD_NAME, MessageMcpNotification, MessageMcpRequest, MessageMcpResponse, @@ -4074,20 +4071,6 @@ pub struct SessionCapabilities { #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] #[serde(default)] pub close: Option, - /// **UNSTABLE** - /// - /// This capability is not part of the spec yet, and may be removed or changed at any point. - /// - /// Whether the agent may expose agent-created subagents. - /// - /// Optional and non-nullable. Omission means the agent does not advertise support. - /// Supplying `{}` means the agent may send subagent session updates when the client also - /// advertises support. - #[cfg(feature = "unstable_subagents")] - #[serde_as(deserialize_as = "DefaultOnError")] - #[cfg_attr(feature = "schemars", schemars(with = "SubagentCapabilities", extend("x-deserialize-default-on-error" = true)))] - #[serde(default)] - pub subagents: Option, /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at /// these keys. @@ -4176,18 +4159,6 @@ impl SessionCapabilities { self } - /// **UNSTABLE** - /// - /// This capability is not part of the spec yet, and may be removed or changed at any point. - /// - /// Whether the agent may expose agent-created subagents. - #[cfg(feature = "unstable_subagents")] - #[must_use] - pub fn subagents(mut self, subagents: impl IntoOption) -> Self { - self.subagents = subagents.into_option(); - self - } - /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at /// these keys. @@ -5241,23 +5212,6 @@ mod test_serialization { use super::*; use serde_json::json; - #[cfg(feature = "unstable_subagents")] - #[test] - fn test_subagent_capability_serialization() { - let capabilities = SessionCapabilities::new().subagents(SubagentCapabilities::new()); - assert_eq!( - serde_json::to_value(capabilities).unwrap(), - json!({ "subagents": {} }) - ); - - let omitted: SessionCapabilities = serde_json::from_value(json!({})).unwrap(); - assert!(omitted.subagents.is_none()); - - let null: SessionCapabilities = - serde_json::from_value(json!({ "subagents": null })).unwrap(); - assert!(null.subagents.is_none()); - } - fn test_meta() -> Meta { json!({ "source": "test" }).as_object().unwrap().clone() } diff --git a/agent-client-protocol-schema/src/v1/client.rs b/agent-client-protocol-schema/src/v1/client.rs index b0a985923..0aef17f17 100644 --- a/agent-client-protocol-schema/src/v1/client.rs +++ b/agent-client-protocol-schema/src/v1/client.rs @@ -170,16 +170,9 @@ pub enum SessionUpdate { /// /// This capability is not part of the spec yet, and may be removed or changed at any point. /// - /// Notification that the session spawned a subagent. + /// An upsert for a subagent exposed by this session. #[cfg(feature = "unstable_subagents")] - SubagentSpawned(SubagentSpawnedUpdate), - /// **UNSTABLE** - /// - /// This capability is not part of the spec yet, and may be removed or changed at any point. - /// - /// Notification that a subagent reached a terminal state. - #[cfg(feature = "unstable_subagents")] - SubagentStateUpdate(SubagentStateUpdate), + SubagentUpdate(SubagentUpdate), } /// **UNSTABLE** @@ -457,7 +450,19 @@ impl CompactionSummaryChunk { /// /// This capability is not part of the spec yet, and may be removed or changed at any point. /// -/// Notification that a session spawned a subagent. +/// An upsert for a subagent exposed by its parent session. +/// +/// Sent on the immediate parent session. The first update for an unknown +/// [`SubagentUpdate::subagent_session_id`] announces the child and MUST be sent +/// before any `session/update` bearing the child's session ID. +/// +/// Only the subagent session ID is required. Omitted fields keep their previous +/// value; a child whose state was never reported is `running`. +/// +/// The update that carries a terminal [`SubagentUpdate::state`] MUST be sent +/// after all child session updates and after every pending permission or +/// elicitation request issued for the child has resolved. The Agent MUST NOT +/// send further updates for that child afterwards. #[cfg(feature = "unstable_subagents")] #[serde_as] #[skip_serializing_none] @@ -465,15 +470,40 @@ impl CompactionSummaryChunk { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] #[non_exhaustive] -pub struct SubagentSpawnedUpdate { - /// The opaque session ID used by all subsequent updates for the child. +pub struct SubagentUpdate { + /// The opaque session ID identifying the child in all ACP messages. pub subagent_session_id: SessionId, /// A short, human-readable label for the subagent. - pub name: String, + /// + /// Omitted and `null` both mean unchanged. If never supplied, the Client + /// chooses its own fallback presentation. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub name: Option, /// A human-readable summary of the work delegated to the subagent. - pub task: String, + /// + /// Omitted and `null` both mean unchanged. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub task: Option, /// Client-to-agent operations permitted for this subagent session. - pub capabilities: SubagentSessionCapabilities, + /// + /// Omitted and `null` both mean unchanged. If never supplied, no operations + /// are permitted. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub capabilities: Option, + /// The lifecycle state reached by the subagent. + /// + /// Omitted and `null` both mean unchanged. If never supplied, the child is + /// `running`. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub state: Option, /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at /// these keys. @@ -487,24 +517,51 @@ pub struct SubagentSpawnedUpdate { } #[cfg(feature = "unstable_subagents")] -impl SubagentSpawnedUpdate { - /// Builds a subagent spawn update with all required fields set. +impl SubagentUpdate { + /// Builds a subagent upsert with only its required session ID set. #[must_use] - pub fn new( - subagent_session_id: impl Into, - name: impl Into, - task: impl Into, - capabilities: SubagentSessionCapabilities, - ) -> Self { + pub fn new(subagent_session_id: impl Into) -> Self { Self { subagent_session_id: subagent_session_id.into(), - name: name.into(), - task: task.into(), - capabilities, + name: None, + task: None, + capabilities: None, + state: None, meta: None, } } + /// Sets or leaves unchanged the human-readable label. + #[must_use] + pub fn name(mut self, name: impl IntoOption) -> Self { + self.name = name.into_option(); + self + } + + /// Sets or leaves unchanged the delegated task summary. + #[must_use] + pub fn task(mut self, task: impl IntoOption) -> Self { + self.task = task.into_option(); + self + } + + /// Sets or leaves unchanged the permitted client-to-agent operations. + #[must_use] + pub fn capabilities( + mut self, + capabilities: impl IntoOption, + ) -> Self { + self.capabilities = capabilities.into_option(); + self + } + + /// Sets or leaves unchanged the reported lifecycle state. + #[must_use] + pub fn state(mut self, state: impl IntoOption) -> Self { + self.state = state.into_option(); + self + } + /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at /// these keys. @@ -533,11 +590,6 @@ pub struct SubagentSessionCapabilities { #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub cancel: bool, - /// Whether the client may close this subagent. Omission is equivalent to `false`. - #[serde_as(deserialize_as = "DefaultOnError")] - #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub close: bool, /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at /// these keys. @@ -550,7 +602,7 @@ pub struct SubagentSessionCapabilities { #[cfg(feature = "unstable_subagents")] impl SubagentSessionCapabilities { - /// Builds an empty capability set; cancellation and close are disabled. + /// Builds an empty capability set; cancellation is disabled. #[must_use] pub fn new() -> Self { Self::default() @@ -563,13 +615,6 @@ impl SubagentSessionCapabilities { self } - /// Whether the client may close this subagent. - #[must_use] - pub fn close(mut self, close: bool) -> Self { - self.close = close; - self - } - /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at /// these keys. @@ -580,66 +625,17 @@ impl SubagentSessionCapabilities { } } -/// **UNSTABLE** +/// Lifecycle state of an announced subagent. /// -/// This capability is not part of the spec yet, and may be removed or changed at any point. -/// -/// Notification that a subagent reached a terminal state. -/// -/// Sent on the immediate parent session after all child session updates and -/// after every pending permission or elicitation request issued for the child -/// has resolved. -#[cfg(feature = "unstable_subagents")] -#[serde_as] -#[skip_serializing_none] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct SubagentStateUpdate { - /// The session ID of the subagent whose state changed. - pub subagent_session_id: SessionId, - /// The terminal state reached by the subagent. - pub state: SubagentState, - /// The _meta property is reserved by ACP to allow clients and agents to attach additional - /// metadata to their interactions. Implementations MUST NOT make assumptions about values at - /// these keys. - #[serde_as(deserialize_as = "DefaultOnError")] - #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] - #[serde(default)] - #[serde(rename = "_meta")] - pub meta: Option, -} - -#[cfg(feature = "unstable_subagents")] -impl SubagentStateUpdate { - /// Builds a terminal state update with all required fields set. - #[must_use] - pub fn new(subagent_session_id: impl Into, state: SubagentState) -> Self { - Self { - subagent_session_id: subagent_session_id.into(), - state, - meta: None, - } - } - - /// The _meta property is reserved by ACP to allow clients and agents to attach additional - /// metadata to their interactions. Implementations MUST NOT make assumptions about values at - /// these keys. - #[must_use] - pub fn meta(mut self, meta: impl IntoOption) -> Self { - self.meta = meta.into_option(); - self - } -} - -/// Terminal state of an announced subagent. +/// All states except `running` are terminal. #[cfg(feature = "unstable_subagents")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] #[non_exhaustive] pub enum SubagentState { + /// The subagent is working on its task. This is the initial state. + Running, /// The subagent completed its task successfully. Completed, /// The subagent failed to complete its task. @@ -2485,8 +2481,8 @@ impl ClientCapabilities { /// Capability marker for exposing subagents as restricted ACP sessions. /// /// Supplying `{}` advertises support for subagent lifecycle updates and restricted-session -/// semantics. Both the client and agent must advertise this capability before the agent sends -/// subagent updates. +/// semantics. The client must advertise this capability before the agent sends subagent +/// updates. #[cfg(feature = "unstable_subagents")] #[serde_as] #[skip_serializing_none] @@ -3373,51 +3369,65 @@ mod tests { fn test_subagent_updates_serialization() { use serde_json::json; - let spawned = SessionUpdate::SubagentSpawned(SubagentSpawnedUpdate::new( - "sess_child_1", - "test-investigator", - "Find the cause of the failing integration tests", - SubagentSessionCapabilities::new().cancel(true).close(true), - )); + let announced = SessionUpdate::SubagentUpdate( + SubagentUpdate::new("sess_child_1") + .name("test-investigator".to_string()) + .task("Find the cause of the failing integration tests".to_string()) + .capabilities(SubagentSessionCapabilities::new().cancel(true)), + ); assert_eq!( - serde_json::to_value(spawned).unwrap(), + serde_json::to_value(announced).unwrap(), json!({ - "sessionUpdate": "subagent_spawned", + "sessionUpdate": "subagent_update", "subagentSessionId": "sess_child_1", "name": "test-investigator", "task": "Find the cause of the failing integration tests", "capabilities": { - "cancel": true, - "close": true + "cancel": true } }) ); - let state = SessionUpdate::SubagentStateUpdate(SubagentStateUpdate::new( - "sess_child_1", - SubagentState::Completed, - )); + let completed = SessionUpdate::SubagentUpdate( + SubagentUpdate::new("sess_child_1").state(SubagentState::Completed), + ); assert_eq!( - serde_json::to_value(state).unwrap(), + serde_json::to_value(completed).unwrap(), json!({ - "sessionUpdate": "subagent_state_update", + "sessionUpdate": "subagent_update", "subagentSessionId": "sess_child_1", "state": "completed" }) ); - let disconnected = SessionUpdate::SubagentStateUpdate(SubagentStateUpdate::new( - "sess_child_2", - SubagentState::Disconnected, - )); + let disconnected = SessionUpdate::SubagentUpdate( + SubagentUpdate::new("sess_child_2").state(SubagentState::Disconnected), + ); assert_eq!( serde_json::to_value(disconnected).unwrap(), json!({ - "sessionUpdate": "subagent_state_update", + "sessionUpdate": "subagent_update", "subagentSessionId": "sess_child_2", "state": "disconnected" }) ); + + let minimal: SubagentUpdate = + serde_json::from_value(json!({ "subagentSessionId": "sess_child_3" })).unwrap(); + assert!(minimal.name.is_none()); + assert!(minimal.task.is_none()); + assert!(minimal.capabilities.is_none()); + assert!(minimal.state.is_none()); + + let nulls: SubagentUpdate = serde_json::from_value(json!({ + "subagentSessionId": "sess_child_3", + "name": null, + "task": null, + "capabilities": null, + "state": null + })) + .unwrap(); + assert_eq!(nulls, minimal); } #[cfg(feature = "unstable_subagents")] @@ -3438,12 +3448,10 @@ mod tests { assert!(null.subagents.is_none()); let child_capabilities: SubagentSessionCapabilities = serde_json::from_value(json!({ - "cancel": null, - "close": "yes" + "cancel": null })) .unwrap(); assert!(!child_capabilities.cancel); - assert!(!child_capabilities.close); assert_eq!(serde_json::to_value(child_capabilities).unwrap(), json!({})); } diff --git a/docs/protocol/v1/draft/schema.mdx b/docs/protocol/v1/draft/schema.mdx index 450846eeb..7b6b51749 100644 --- a/docs/protocol/v1/draft/schema.mdx +++ b/docs/protocol/v1/draft/schema.mdx @@ -7293,18 +7293,6 @@ Supplying `\{\}` means the agent supports listing sessions. Optional. Omitted or `null` both mean the agent does not advertise support. Supplying `\{\}` means the agent supports resuming sessions. - -SubagentCapabilities} > - **UNSTABLE** - -This capability is not part of the spec yet, and may be removed or changed at any point. - -Whether the agent may expose agent-created subagents. - -Optional and non-nullable. Omission means the agent does not advertise support. -Supplying `\{\}` means the agent may send subagent session updates when the client also -advertises support. - ## SessionCloseCapabilities @@ -8342,12 +8330,12 @@ Agents MUST only send this update when the Client advertised - + **UNSTABLE** This capability is not part of the spec yet, and may be removed or changed at any point. -Notification that the session spawned a subagent. +An upsert for a subagent exposed by this session. @@ -8359,55 +8347,38 @@ these keys. See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/draft/extensibility) -SubagentSessionCapabilities} required> +SubagentSessionCapabilities | null} > Client-to-agent operations permitted for this subagent session. + +Omitted and `null` both mean unchanged. If never supplied, no operations +are permitted. + - + A short, human-readable label for the subagent. + +Omitted and `null` both mean unchanged. If never supplied, the Client +chooses its own fallback presentation. + - The discriminator value. Must be `"subagent_spawned"`. + The discriminator value. Must be `"subagent_update"`. + +SubagentState | null} > + The lifecycle state reached by the subagent. + +Omitted and `null` both mean unchanged. If never supplied, the child is +`running`. + SessionId} required> - The opaque session ID used by all subsequent updates for the child. + The opaque session ID identifying the child in all ACP messages. - + A human-readable summary of the work delegated to the subagent. - - - - - -**UNSTABLE** - -This capability is not part of the spec yet, and may be removed or changed at any point. - -Notification that a subagent reached a terminal state. - - +Omitted and `null` both mean unchanged. - - The _meta property is reserved by ACP to allow clients and agents to attach - additional metadata to their interactions. Implementations MUST NOT make - assumptions about values at these keys. - - - The discriminator value. Must be `"subagent_state_update"`. - -SubagentState} - required -> - The terminal state reached by the subagent. - -SessionId} - required -> - The session ID of the subagent whose state changed. @@ -8581,8 +8552,8 @@ This capability is not part of the spec yet, and may be removed or changed at an Capability marker for exposing subagents as restricted ACP sessions. Supplying `\{\}` advertises support for subagent lifecycle updates and restricted-session -semantics. Both the client and agent must advertise this capability before the agent sends -subagent updates. +semantics. The client must advertise this capability before the agent sends subagent +updates. **Type:** Object @@ -8615,49 +8586,19 @@ Client-to-agent operations permitted for a specific subagent session. Whether the client may cancel this subagent. Omission is equivalent to `false`. - - Whether the client may close this subagent. Omission is equivalent to `false`. - - -## SubagentSpawnedUpdate - -**UNSTABLE** - -This capability is not part of the spec yet, and may be removed or changed at any point. -Notification that a session spawned a subagent. - -**Type:** Object +## SubagentState -**Properties:** +Lifecycle state of an announced subagent. - - The _meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. +All states except `running` are terminal. -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/draft/extensibility) +**Type:** Union - -SubagentSessionCapabilities} required> - Client-to-agent operations permitted for this subagent session. - - - A short, human-readable label for the subagent. - -SessionId} required> - The opaque session ID used by all subsequent updates for the child. - - - A human-readable summary of the work delegated to the subagent. + + The subagent is working on its task. This is the initial state. -## SubagentState - -Terminal state of an announced subagent. - -**Type:** Union - The subagent completed its task successfully. @@ -8674,40 +8615,67 @@ Terminal state of an announced subagent. The Agent lost the child runtime and cannot determine its task outcome. -## SubagentStateUpdate +## SubagentUpdate **UNSTABLE** This capability is not part of the spec yet, and may be removed or changed at any point. -Notification that a subagent reached a terminal state. +An upsert for a subagent exposed by its parent session. -Sent on the immediate parent session after all child session updates and -after every pending permission or elicitation request issued for the child -has resolved. +Sent on the immediate parent session. The first update for an unknown +`SubagentUpdate::subagent_session_id` announces the child and MUST be sent +before any `session/update` bearing the child's session ID. + +Only the subagent session ID is required. Omitted fields keep their previous +value; a child whose state was never reported is `running`. + +The update that carries a terminal `SubagentUpdate::state` MUST be sent +after all child session updates and after every pending permission or +elicitation request issued for the child has resolved. The Agent MUST NOT +send further updates for that child afterwards. **Type:** Object **Properties:** - - The _meta property is reserved by ACP to allow clients and agents to attach - additional metadata to their interactions. Implementations MUST NOT make - assumptions about values at these keys. + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/draft/extensibility) + -SubagentState} - required -> - The terminal state reached by the subagent. +SubagentSessionCapabilities | null} > + Client-to-agent operations permitted for this subagent session. + +Omitted and `null` both mean unchanged. If never supplied, no operations +are permitted. + -SessionId} - required -> - The session ID of the subagent whose state changed. + + A short, human-readable label for the subagent. + +Omitted and `null` both mean unchanged. If never supplied, the Client +chooses its own fallback presentation. + + +SubagentState | null} > + The lifecycle state reached by the subagent. + +Omitted and `null` both mean unchanged. If never supplied, the child is +`running`. + + +SessionId} required> + The opaque session ID identifying the child in all ACP messages. + + + A human-readable summary of the work delegated to the subagent. + +Omitted and `null` both mean unchanged. + ## Terminal diff --git a/schema/v1/schema.unstable.json b/schema/v1/schema.unstable.json index db124211d..583d563bb 100644 --- a/schema/v1/schema.unstable.json +++ b/schema/v1/schema.unstable.json @@ -2836,15 +2836,6 @@ ], "x-deserialize-default-on-error": true }, - "subagents": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent may expose agent-created subagents.\n\nOptional and non-nullable. Omission means the agent does not advertise support.\nSupplying `{}` means the agent may send subagent session updates when the client also\nadvertises support.", - "x-deserialize-default-on-error": true, - "allOf": [ - { - "$ref": "#/$defs/SubagentCapabilities" - } - ] - }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", "type": ["object", "null"], @@ -2925,18 +2916,6 @@ } } }, - "SubagentCapabilities": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapability marker for exposing subagents as restricted ACP sessions.\n\nSupplying `{}` advertises support for subagent lifecycle updates and restricted-session\nsemantics. Both the client and agent must advertise this capability before the agent sends\nsubagent updates.", - "type": "object", - "properties": { - "_meta": { - "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.", - "type": ["object", "null"], - "x-deserialize-default-on-error": true, - "additionalProperties": true - } - } - }, "AgentAuthCapabilities": { "description": "Authentication-related capabilities supported by the agent.", "type": "object", @@ -5255,34 +5234,18 @@ ] }, { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification that the session spawned a subagent.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAn upsert for a subagent exposed by this session.", "type": "object", "properties": { "sessionUpdate": { "type": "string", - "const": "subagent_spawned" + "const": "subagent_update" } }, "required": ["sessionUpdate"], "allOf": [ { - "$ref": "#/$defs/SubagentSpawnedUpdate" - } - ] - }, - { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification that a subagent reached a terminal state.", - "type": "object", - "properties": { - "sessionUpdate": { - "type": "string", - "const": "subagent_state_update" - } - }, - "required": ["sessionUpdate"], - "allOf": [ - { - "$ref": "#/$defs/SubagentStateUpdate" + "$ref": "#/$defs/SubagentUpdate" } ] } @@ -6060,11 +6023,6 @@ "type": "boolean", "x-deserialize-default-on-error": true }, - "close": { - "description": "Whether the client may close this subagent. Omission is equivalent to `false`.", - "type": "boolean", - "x-deserialize-default-on-error": true - }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.", "type": ["object", "null"], @@ -6073,46 +6031,14 @@ } } }, - "SubagentSpawnedUpdate": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification that a session spawned a subagent.", - "type": "object", - "properties": { - "subagentSessionId": { - "description": "The opaque session ID used by all subsequent updates for the child.", - "allOf": [ - { - "$ref": "#/$defs/SessionId" - } - ] - }, - "name": { - "description": "A short, human-readable label for the subagent.", - "type": "string" - }, - "task": { - "description": "A human-readable summary of the work delegated to the subagent.", - "type": "string" - }, - "capabilities": { - "description": "Client-to-agent operations permitted for this subagent session.", - "allOf": [ - { - "$ref": "#/$defs/SubagentSessionCapabilities" - } - ] - }, - "_meta": { - "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", - "type": ["object", "null"], - "x-deserialize-default-on-error": true, - "additionalProperties": true - } - }, - "required": ["subagentSessionId", "name", "task", "capabilities"] - }, "SubagentState": { - "description": "Terminal state of an announced subagent.", + "description": "Lifecycle state of an announced subagent.\n\nAll states except `running` are terminal.", "oneOf": [ + { + "description": "The subagent is working on its task. This is the initial state.", + "type": "string", + "const": "running" + }, { "description": "The subagent completed its task successfully.", "type": "string", @@ -6135,34 +6061,60 @@ } ] }, - "SubagentStateUpdate": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification that a subagent reached a terminal state.\n\nSent on the immediate parent session after all child session updates and\nafter every pending permission or elicitation request issued for the child\nhas resolved.", + "SubagentUpdate": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAn upsert for a subagent exposed by its parent session.\n\nSent on the immediate parent session. The first update for an unknown\n[`SubagentUpdate::subagent_session_id`] announces the child and MUST be sent\nbefore any `session/update` bearing the child's session ID.\n\nOnly the subagent session ID is required. Omitted fields keep their previous\nvalue; a child whose state was never reported is `running`.\n\nThe update that carries a terminal [`SubagentUpdate::state`] MUST be sent\nafter all child session updates and after every pending permission or\nelicitation request issued for the child has resolved. The Agent MUST NOT\nsend further updates for that child afterwards.", "type": "object", "properties": { "subagentSessionId": { - "description": "The session ID of the subagent whose state changed.", + "description": "The opaque session ID identifying the child in all ACP messages.", "allOf": [ { "$ref": "#/$defs/SessionId" } ] }, + "name": { + "description": "A short, human-readable label for the subagent.\n\nOmitted and `null` both mean unchanged. If never supplied, the Client\nchooses its own fallback presentation.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "task": { + "description": "A human-readable summary of the work delegated to the subagent.\n\nOmitted and `null` both mean unchanged.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "capabilities": { + "description": "Client-to-agent operations permitted for this subagent session.\n\nOmitted and `null` both mean unchanged. If never supplied, no operations\nare permitted.", + "anyOf": [ + { + "$ref": "#/$defs/SubagentSessionCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, "state": { - "description": "The terminal state reached by the subagent.", - "allOf": [ + "description": "The lifecycle state reached by the subagent.\n\nOmitted and `null` both mean unchanged. If never supplied, the child is\n`running`.", + "anyOf": [ { "$ref": "#/$defs/SubagentState" + }, + { + "type": "null" } - ] + ], + "x-deserialize-default-on-error": true }, "_meta": { - "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.", + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", "type": ["object", "null"], "x-deserialize-default-on-error": true, "additionalProperties": true } }, - "required": ["subagentSessionId", "state"] + "required": ["subagentSessionId"] }, "CompleteElicitationNotification": { "description": "Notification sent by the agent when a URL-based elicitation is complete.", @@ -6708,6 +6660,18 @@ } } }, + "SubagentCapabilities": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapability marker for exposing subagents as restricted ACP sessions.\n\nSupplying `{}` advertises support for subagent lifecycle updates and restricted-session\nsemantics. The client must advertise this capability before the agent sends subagent\nupdates.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, "PlanCapabilities": { "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapabilities for receiving `plan_update` and `plan_removed` session updates.", "type": "object", From a06ecf201f6427a5caaaf09973057de7d494eb37 Mon Sep 17 00:00:00 2001 From: Vadim Briliantov Date: Wed, 16 Sep 2026 01:26:46 +0200 Subject: [PATCH 07/10] feat(unstable-v2): add subagent_update to the v2 schema Mirror the v1 subagent types into v2 with v2 conventions: no capability (unknown updates flow into OtherSessionUpdate), MaybeUndefined patch semantics, and an open SubagentState enum that preserves unknown values. Register subagent_update in the known-discriminator guards. Co-Authored-By: Claude Fable 5 --- agent-client-protocol-schema/src/v2/client.rs | 281 ++++++++++++++++++ docs/protocol/v2/draft/schema.mdx | 154 ++++++++++ schema/v2/schema.unstable.json | 133 +++++++++ 3 files changed, 568 insertions(+) diff --git a/agent-client-protocol-schema/src/v2/client.rs b/agent-client-protocol-schema/src/v2/client.rs index b30006e1e..6c4e9e915 100644 --- a/agent-client-protocol-schema/src/v2/client.rs +++ b/agent-client-protocol-schema/src/v2/client.rs @@ -176,6 +176,13 @@ pub enum SessionUpdate { /// A content block appended to a context compaction's retained summary. #[cfg(feature = "unstable_session_compaction")] CompactionSummaryChunk(CompactionSummaryChunk), + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// A subagent exposed by this session has been created or updated. + #[cfg(feature = "unstable_subagents")] + SubagentUpdate(SubagentUpdate), /// Custom or future session update. /// /// Values beginning with `_` are reserved for implementation-specific @@ -457,6 +464,211 @@ impl CompactionSummaryChunk { } } +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// An upsert for a subagent exposed by its parent session. +/// +/// Sent on the immediate parent session. The first update for an unknown +/// [`SubagentUpdate::subagent_session_id`] announces the child and MUST be sent +/// before any `session/update` bearing the child's session ID. No Client +/// capability is required. +/// +/// Only [`SubagentUpdate::subagent_session_id`] is required. Other fields have +/// patch semantics: omitted fields leave the stored value unchanged, `null` +/// clears or unsets the value, and concrete values replace it. A child whose +/// state is unset is `running`; a child whose capabilities are unset permits +/// no operations. +/// +/// The update that carries a terminal [`SubagentUpdate::state`] MUST be sent +/// after all child session updates and after every pending permission or +/// elicitation request issued for the child has resolved. The Agent MUST NOT +/// send further updates for that child afterwards. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SubagentUpdate { + /// The opaque session ID identifying the child in all ACP messages. + pub subagent_session_id: SessionId, + /// A short, human-readable label for the subagent. It need not be unique. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub name: MaybeUndefined, + /// A human-readable summary of the work delegated to the subagent. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub task: MaybeUndefined, + /// Client-to-agent operations permitted for this subagent session. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub capabilities: MaybeUndefined, + /// The reported lifecycle state of the subagent. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub state: MaybeUndefined, + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Omitted means no metadata update; `null` is an + /// explicit clear signal. Implementations MUST NOT make assumptions about values at these keys. + /// + /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + #[serde_as(deserialize_as = "DefaultOnError>")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde( + rename = "_meta", + default, + skip_serializing_if = "MaybeUndefined::is_undefined" + )] + pub meta: MaybeUndefined, +} + +#[cfg(feature = "unstable_subagents")] +impl SubagentUpdate { + /// Builds a subagent upsert with only its required session ID set. + #[must_use] + pub fn new(subagent_session_id: impl Into) -> Self { + Self { + subagent_session_id: subagent_session_id.into(), + name: MaybeUndefined::Undefined, + task: MaybeUndefined::Undefined, + capabilities: MaybeUndefined::Undefined, + state: MaybeUndefined::Undefined, + meta: MaybeUndefined::Undefined, + } + } + + /// Sets, clears, or leaves unchanged the human-readable label. + #[must_use] + pub fn name(mut self, name: impl IntoMaybeUndefined) -> Self { + self.name = name.into_maybe_undefined(); + self + } + + /// Sets, clears, or leaves unchanged the delegated task summary. + #[must_use] + pub fn task(mut self, task: impl IntoMaybeUndefined) -> Self { + self.task = task.into_maybe_undefined(); + self + } + + /// Sets, clears, or leaves unchanged the permitted client-to-agent operations. + #[must_use] + pub fn capabilities( + mut self, + capabilities: impl IntoMaybeUndefined, + ) -> Self { + self.capabilities = capabilities.into_maybe_undefined(); + self + } + + /// Sets, unsets, or leaves unchanged the reported lifecycle state. + #[must_use] + pub fn state(mut self, state: impl IntoMaybeUndefined) -> Self { + self.state = state.into_maybe_undefined(); + self + } + + /// Sets, clears, or leaves unchanged subagent metadata. + #[must_use] + pub fn meta(mut self, meta: impl IntoMaybeUndefined) -> Self { + self.meta = meta.into_maybe_undefined(); + self + } +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// Client-to-agent operations permitted for a specific subagent session. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SubagentSessionCapabilities { + /// Whether the client may cancel this subagent. Omission is equivalent to `false`. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub cancel: bool, + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + #[serde(rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl SubagentSessionCapabilities { + /// Builds an empty capability set; cancellation is disabled. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Whether the client may cancel this subagent. + #[must_use] + pub fn cancel(mut self, cancel: bool) -> Self { + self.cancel = cancel; + self + } + + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// Lifecycle state of an announced subagent. +/// +/// All states except `running` are terminal. +#[cfg(feature = "unstable_subagents")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum SubagentState { + /// The subagent is working on its task. This is the initial state. + Running, + /// The subagent completed its task successfully. + Completed, + /// The subagent failed to complete its task. + Failed, + /// The subagent was cancelled. + Cancelled, + /// The Agent lost the child runtime and cannot determine its task outcome. + Disconnected, + /// Custom or future subagent state. + /// + /// Values beginning with `_` are reserved for implementation-specific + /// extensions. Unknown values that do not begin with `_` are reserved for + /// future ACP variants. + #[serde(untagged)] + Other(String), +} + /// Custom or future session update payload. /// /// This preserves the unknown `sessionUpdate` discriminator and the rest of the @@ -538,6 +750,10 @@ fn is_known_session_update(session_update: &str) -> bool { if session_update == "plan_removed" { return true; } + #[cfg(feature = "unstable_subagents")] + if session_update == "subagent_update" { + return true; + } matches!( session_update, "user_message_chunk" @@ -589,6 +805,8 @@ fn other_session_update_schema(schema: &mut Schema) { "compaction_update", #[cfg(feature = "unstable_session_compaction")] "compaction_summary_chunk", + #[cfg(feature = "unstable_subagents")] + "subagent_update", ], ); } @@ -2631,6 +2849,69 @@ impl AgentNotification { mod tests { use super::*; + #[cfg(feature = "unstable_subagents")] + #[test] + fn subagent_update_serializes_as_upsert() { + use serde_json::json; + + let announced = SessionUpdate::SubagentUpdate( + SubagentUpdate::new("sess_child_1") + .name("test-investigator".to_string()) + .task("Find the cause of the failing integration tests".to_string()) + .capabilities(SubagentSessionCapabilities::new().cancel(true)), + ); + assert_eq!( + serde_json::to_value(&announced).unwrap(), + json!({ + "sessionUpdate": "subagent_update", + "subagentSessionId": "sess_child_1", + "name": "test-investigator", + "task": "Find the cause of the failing integration tests", + "capabilities": { + "cancel": true + } + }) + ); + + let terminal = SessionUpdate::SubagentUpdate( + SubagentUpdate::new("sess_child_1").state(SubagentState::Completed), + ); + assert_eq!( + serde_json::to_value(&terminal).unwrap(), + json!({ + "sessionUpdate": "subagent_update", + "subagentSessionId": "sess_child_1", + "state": "completed" + }) + ); + + // Patch semantics distinguish omitted fields from explicit nulls. + let patched: SubagentUpdate = serde_json::from_value(json!({ + "subagentSessionId": "sess_child_1", + "name": null, + "state": "disconnected" + })) + .unwrap(); + assert!(patched.name.is_null()); + assert!(patched.task.is_undefined()); + assert!(patched.capabilities.is_undefined()); + assert_eq!( + patched.state, + MaybeUndefined::Value(SubagentState::Disconnected) + ); + + // Unknown future states are preserved, not dropped. + let future: SubagentUpdate = serde_json::from_value(json!({ + "subagentSessionId": "sess_child_2", + "state": "paused" + })) + .unwrap(); + assert_eq!( + future.state, + MaybeUndefined::Value(SubagentState::Other("paused".into())) + ); + } + #[cfg(feature = "unstable_session_notices")] #[test] fn notice_preserves_wire_shape_nullable_fields_and_open_severity() { diff --git a/docs/protocol/v2/draft/schema.mdx b/docs/protocol/v2/draft/schema.mdx index fe9e0a3c9..d10819d20 100644 --- a/docs/protocol/v2/draft/schema.mdx +++ b/docs/protocol/v2/draft/schema.mdx @@ -8703,6 +8703,45 @@ A content block appended to a context compaction's retained summary. + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +A subagent exposed by this session has been created or updated. + + + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Omitted means no metadata update; `null` is an +explicit clear signal. Implementations MUST NOT make assumptions about values at these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility) + + +SubagentSessionCapabilities | null} > + Client-to-agent operations permitted for this subagent session. + + + A short, human-readable label for the subagent. It need not be unique. + + + The discriminator value. Must be `"subagent_update"`. + +SubagentState | null} > + The reported lifecycle state of the subagent. + +SessionId} required> + The opaque session ID identifying the child in all ACP messages. + + + A human-readable summary of the work delegated to the subagent. + + + + + Custom or future session update. @@ -9015,6 +9054,121 @@ Optional. Omitted and `null` are equivalent and mean no title is provided. +## SubagentSessionCapabilities + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Client-to-agent operations permitted for a specific subagent session. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach + additional metadata to their interactions. Implementations MUST NOT make + assumptions about values at these keys. + + + Whether the client may cancel this subagent. Omission is equivalent to + `false`. + + +## SubagentState + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Lifecycle state of an announced subagent. + +All states except `running` are terminal. + +**Type:** Union + + + The subagent is working on its task. This is the initial state. + + + + The subagent completed its task successfully. + + + + The subagent failed to complete its task. + + + + The subagent was cancelled. + + + + The Agent lost the child runtime and cannot determine its task outcome. + + + +Custom or future subagent state. + +Values beginning with `_` are reserved for implementation-specific +extensions. Unknown values that do not begin with `_` are reserved for +future ACP variants. + + + +## SubagentUpdate + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +An upsert for a subagent exposed by its parent session. + +Sent on the immediate parent session. The first update for an unknown +`SubagentUpdate::subagent_session_id` announces the child and MUST be sent +before any `session/update` bearing the child's session ID. No Client +capability is required. + +Only `SubagentUpdate::subagent_session_id` is required. Other fields have +patch semantics: omitted fields leave the stored value unchanged, `null` +clears or unsets the value, and concrete values replace it. A child whose +state is unset is `running`; a child whose capabilities are unset permits +no operations. + +The update that carries a terminal `SubagentUpdate::state` MUST be sent +after all child session updates and after every pending permission or +elicitation request issued for the child has resolved. The Agent MUST NOT +send further updates for that child afterwards. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Omitted means no metadata update; `null` is an +explicit clear signal. Implementations MUST NOT make assumptions about values at these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility) + + +SubagentSessionCapabilities | null} > + Client-to-agent operations permitted for this subagent session. + + + A short, human-readable label for the subagent. It need not be unique. + +SubagentState | null} > + The reported lifecycle state of the subagent. + +SessionId} required> + The opaque session ID identifying the child in all ACP messages. + + + A human-readable summary of the work delegated to the subagent. + + ## Terminal A display-only reference to an agent-owned terminal. diff --git a/schema/v2/schema.unstable.json b/schema/v2/schema.unstable.json index 6b5b6bd96..d3fed09e6 100644 --- a/schema/v2/schema.unstable.json +++ b/schema/v2/schema.unstable.json @@ -6038,6 +6038,22 @@ } ] }, + { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA subagent exposed by this session has been created or updated.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "subagent_update" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/SubagentUpdate" + } + ] + }, { "title": "other", "description": "Custom or future session update.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nReceivers that do not understand this update type should preserve the\nraw payload when storing, replaying, proxying, or forwarding session\nhistory, and otherwise ignore it or display it generically.", @@ -6250,6 +6266,16 @@ } }, "required": ["sessionUpdate"] + }, + { + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "subagent_update" + } + }, + "required": ["sessionUpdate"] } ] }, @@ -7496,6 +7522,113 @@ }, "required": ["compactionId", "content"] }, + "SubagentSessionCapabilities": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nClient-to-agent operations permitted for a specific subagent session.", + "type": "object", + "properties": { + "cancel": { + "description": "Whether the client may cancel this subagent. Omission is equivalent to `false`.", + "type": "boolean", + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "SubagentState": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nLifecycle state of an announced subagent.\n\nAll states except `running` are terminal.", + "anyOf": [ + { + "description": "The subagent is working on its task. This is the initial state.", + "type": "string", + "const": "running" + }, + { + "description": "The subagent completed its task successfully.", + "type": "string", + "const": "completed" + }, + { + "description": "The subagent failed to complete its task.", + "type": "string", + "const": "failed" + }, + { + "description": "The subagent was cancelled.", + "type": "string", + "const": "cancelled" + }, + { + "description": "The Agent lost the child runtime and cannot determine its task outcome.", + "type": "string", + "const": "disconnected" + }, + { + "title": "other", + "description": "Custom or future subagent state.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + ] + }, + "SubagentUpdate": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAn upsert for a subagent exposed by its parent session.\n\nSent on the immediate parent session. The first update for an unknown\n[`SubagentUpdate::subagent_session_id`] announces the child and MUST be sent\nbefore any `session/update` bearing the child's session ID. No Client\ncapability is required.\n\nOnly [`SubagentUpdate::subagent_session_id`] is required. Other fields have\npatch semantics: omitted fields leave the stored value unchanged, `null`\nclears or unsets the value, and concrete values replace it. A child whose\nstate is unset is `running`; a child whose capabilities are unset permits\nno operations.\n\nThe update that carries a terminal [`SubagentUpdate::state`] MUST be sent\nafter all child session updates and after every pending permission or\nelicitation request issued for the child has resolved. The Agent MUST NOT\nsend further updates for that child afterwards.", + "type": "object", + "properties": { + "subagentSessionId": { + "description": "The opaque session ID identifying the child in all ACP messages.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "name": { + "description": "A short, human-readable label for the subagent. It need not be unique.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "task": { + "description": "A human-readable summary of the work delegated to the subagent.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "capabilities": { + "description": "Client-to-agent operations permitted for this subagent session.", + "anyOf": [ + { + "$ref": "#/$defs/SubagentSessionCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "state": { + "description": "The reported lifecycle state of the subagent.", + "anyOf": [ + { + "$ref": "#/$defs/SubagentState" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Omitted means no metadata update; `null` is an\nexplicit clear signal. Implementations MUST NOT make assumptions about values at these keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["subagentSessionId"] + }, "CompleteElicitationNotification": { "description": "Notification sent by the agent when a URL-based elicitation is complete.", "type": "object", From 905d8fd8b37a179f8684cf7d40cada7e47c65739 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Fri, 25 Sep 2026 13:05:00 +0200 Subject: [PATCH 08/10] feat(unstable): refine subagent session model Separate reusable child sessions from individual tool-call operations. Add non-owning session references, object cancellation capabilities, and work-state reporting for both protocol versions. Define ownership, routing, best-effort replay, and provider cost semantics in the RFD and draft guides. Regenerate unstable schemas and add serialization and compatibility tests. --- agent-client-protocol-schema/src/v1/client.rs | 807 +++++++++++-- .../src/v1/tool_call.rs | 121 ++ agent-client-protocol-schema/src/v2/client.rs | 503 ++++++-- .../src/v2/tool_call.rs | 152 ++- docs/protocol/v1/draft/schema.mdx | 407 +++++-- docs/protocol/v1/draft/tool-calls.mdx | 61 + docs/protocol/v2/draft/prompt-lifecycle.mdx | 17 + docs/protocol/v2/draft/schema.mdx | 261 ++-- docs/protocol/v2/draft/tool-calls.mdx | 63 + docs/rfds/session-usage.mdx | 14 + docs/rfds/subagents.mdx | 1068 ++++++++++++----- schema/v1/schema.unstable.json | 303 ++++- schema/v2/schema.unstable.json | 173 ++- 13 files changed, 3091 insertions(+), 859 deletions(-) diff --git a/agent-client-protocol-schema/src/v1/client.rs b/agent-client-protocol-schema/src/v1/client.rs index 0aef17f17..facab0b36 100644 --- a/agent-client-protocol-schema/src/v1/client.rs +++ b/agent-client-protocol-schema/src/v1/client.rs @@ -6,9 +6,17 @@ use std::{path::PathBuf, sync::Arc}; use derive_more::{Display, From}; +#[cfg(all(feature = "schemars", feature = "unstable_subagents"))] +use schemars::Schema; use serde::{Deserialize, Serialize}; use serde_with::{DefaultOnError, VecSkipError, serde_as, skip_serializing_none}; +#[cfg(feature = "unstable_subagents")] +use std::collections::BTreeMap; +#[cfg(feature = "unstable_subagents")] +use super::StopReason; +#[cfg(feature = "unstable_end_turn_token_usage")] +use super::Usage; use super::{ CompleteElicitationNotification, CreateElicitationRequest, CreateElicitationResponse, ElicitationCapabilities, @@ -450,60 +458,50 @@ impl CompactionSummaryChunk { /// /// This capability is not part of the spec yet, and may be removed or changed at any point. /// -/// An upsert for a subagent exposed by its parent session. +/// An upsert for a reusable child session associated with its parent session. /// /// Sent on the immediate parent session. The first update for an unknown -/// [`SubagentUpdate::subagent_session_id`] announces the child and MUST be sent -/// before any `session/update` bearing the child's session ID. +/// [`SubagentUpdate::session_id`] announces the child and MUST be sent +/// before any child traffic or reference to the child session. Parents may +/// message and reuse an announced child across multiple operations. +/// Child events are delivered automatically on the same connection; no child +/// load, resume, or subscription is needed. /// /// Only the subagent session ID is required. Omitted fields keep their previous -/// value; a child whose state was never reported is `running`. -/// -/// The update that carries a terminal [`SubagentUpdate::state`] MUST be sent -/// after all child session updates and after every pending permission or -/// elicitation request issued for the child has resolved. The Agent MUST NOT -/// send further updates for that child afterwards. +/// value; a child whose state was never reported has an unknown state. A +/// concrete state replaces the entire previous state object, not the session. +/// The child's title is reported via `session_info_update`; per-operation tasks +/// belong on tool calls referencing the child. #[cfg(feature = "unstable_subagents")] #[serde_as] #[skip_serializing_none] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct SubagentUpdate { /// The opaque session ID identifying the child in all ACP messages. - pub subagent_session_id: SessionId, - /// A short, human-readable label for the subagent. /// - /// Omitted and `null` both mean unchanged. If never supplied, the Client - /// chooses its own fallback presentation. - #[serde_as(deserialize_as = "DefaultOnError")] - #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] - #[serde(default)] - pub name: Option, - /// A human-readable summary of the work delegated to the subagent. - /// - /// Omitted and `null` both mean unchanged. - #[serde_as(deserialize_as = "DefaultOnError")] - #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] - #[serde(default)] - pub task: Option, - /// Client-to-agent operations permitted for this subagent session. + /// Nested inside `update`; the enclosing notification's `sessionId` identifies + /// the immediate parent, not this child. + pub session_id: SessionId, + /// Client-initiated session mutations permitted for this subagent session. /// - /// Omitted and `null` both mean unchanged. If never supplied, no operations - /// are permitted. + /// Omitted and `null` both mean unchanged. If never supplied, no session + /// mutations are permitted. Read-only operations retain their normal protocol + /// semantics and capability requirements. #[serde_as(deserialize_as = "DefaultOnError")] #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] #[serde(default)] pub capabilities: Option, - /// The lifecycle state reached by the subagent. + /// Current state snapshot for the child session. /// - /// Omitted and `null` both mean unchanged. If never supplied, the child is - /// `running`. + /// Omitted and `null` both mean unchanged; a concrete state replaces the + /// previous state object wholesale. If never supplied, the state is unknown. #[serde_as(deserialize_as = "DefaultOnError")] #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] #[serde(default)] - pub state: Option, + pub state: Option, /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at /// these keys. @@ -520,32 +518,16 @@ pub struct SubagentUpdate { impl SubagentUpdate { /// Builds a subagent upsert with only its required session ID set. #[must_use] - pub fn new(subagent_session_id: impl Into) -> Self { + pub fn new(session_id: impl Into) -> Self { Self { - subagent_session_id: subagent_session_id.into(), - name: None, - task: None, + session_id: session_id.into(), capabilities: None, state: None, meta: None, } } - /// Sets or leaves unchanged the human-readable label. - #[must_use] - pub fn name(mut self, name: impl IntoOption) -> Self { - self.name = name.into_option(); - self - } - - /// Sets or leaves unchanged the delegated task summary. - #[must_use] - pub fn task(mut self, task: impl IntoOption) -> Self { - self.task = task.into_option(); - self - } - - /// Sets or leaves unchanged the permitted client-to-agent operations. + /// Sets or leaves unchanged the permitted client-initiated session mutations. #[must_use] pub fn capabilities( mut self, @@ -555,9 +537,9 @@ impl SubagentUpdate { self } - /// Sets or leaves unchanged the reported lifecycle state. + /// Replaces the current state snapshot, or leaves it unchanged when omitted. #[must_use] - pub fn state(mut self, state: impl IntoOption) -> Self { + pub fn state(mut self, state: impl IntoOption) -> Self { self.state = state.into_option(); self } @@ -576,7 +558,10 @@ impl SubagentUpdate { /// /// This capability is not part of the spec yet, and may be removed or changed at any point. /// -/// Client-to-agent operations permitted for a specific subagent session. +/// Client-initiated session mutations permitted for a specific subagent session. +/// +/// A mutation requires an explicit per-child capability; support for the method +/// on ordinary sessions does not grant support on a child. #[cfg(feature = "unstable_subagents")] #[serde_as] #[skip_serializing_none] @@ -585,11 +570,13 @@ impl SubagentUpdate { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct SubagentSessionCapabilities { - /// Whether the client may cancel this subagent. Omission is equivalent to `false`. + /// Permits the client to cancel this child's current work without ending + /// the session. Omitted or `null` means unsupported; an object (including + /// `{}`) means supported. #[serde_as(deserialize_as = "DefaultOnError")] #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub cancel: bool, + #[serde(default)] + pub cancel: Option, /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at /// these keys. @@ -608,10 +595,10 @@ impl SubagentSessionCapabilities { Self::default() } - /// Whether the client may cancel this subagent. + /// Sets or removes permission to cancel this child's current work. #[must_use] - pub fn cancel(mut self, cancel: bool) -> Self { - self.cancel = cancel; + pub fn cancel(mut self, cancel: impl IntoOption) -> Self { + self.cancel = cancel.into_option(); self } @@ -625,25 +612,333 @@ impl SubagentSessionCapabilities { } } -/// Lifecycle state of an announced subagent. +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// Capability to cancel work in a subagent session without ending that session. /// -/// All states except `running` are terminal. +/// Supplying `{}` advertises support; an omitted or `null` `cancel` does not. #[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[non_exhaustive] -pub enum SubagentState { - /// The subagent is working on its task. This is the initial state. - Running, - /// The subagent completed its task successfully. - Completed, - /// The subagent failed to complete its task. - Failed, - /// The subagent was cancelled. - Cancelled, - /// The Agent lost the child runtime and cannot determine its task outcome. - Disconnected, +pub struct SessionCancelCapabilities { + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + /// + /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + #[serde(rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl SessionCancelCapabilities { + /// Builds an empty capability object advertising cancellation support. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + /// + /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// Current foreground-work state of a reusable child session. +/// +/// Each update is a whole-object snapshot. Idle does not terminate the child; +/// the parent can message it again, transitioning it back to running. +/// Background activity may still emit other session updates while idle. +#[cfg(feature = "unstable_subagents")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "state", rename_all = "snake_case")] +#[non_exhaustive] +pub enum StateUpdate { + /// Foreground work is in progress. + Running(RunningStateUpdate), + /// The child is ready to process another prompt. + Idle(IdleStateUpdate), + /// Foreground work is blocked on user action. + RequiresAction(RequiresActionStateUpdate), + /// The Agent cannot currently determine foreground activity. + /// + /// This replaces previously confirmed activity without ending the work or session. + Unknown(UnknownStateUpdate), + /// Custom or future state. + /// + /// Values beginning with `_` are reserved for implementation-specific + /// extensions. Other unknown values are reserved for future ACP variants. + #[serde(untagged)] + Other(OtherStateUpdate), +} + +/// Foreground work is in progress. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct RunningStateUpdate { + /// The _meta property is reserved by ACP for additional metadata. + /// Implementations MUST NOT make assumptions about values at these keys. + /// Optional; omitted and `null` mean no metadata for this state snapshot. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl RunningStateUpdate { + /// Builds an empty running state. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Reserved metadata for extensions. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// The child is ready to process another prompt. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct IdleStateUpdate { + /// Reason foreground work stopped. Optional; omitted or `null` means not reported. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub stop_reason: Option, + /// **UNSTABLE** Token usage for completed foreground work. + /// + /// Optional; omitted or `null` means not reported. + #[cfg(feature = "unstable_end_turn_token_usage")] + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub usage: Option, + /// The _meta property is reserved by ACP for additional metadata. + /// Implementations MUST NOT make assumptions about values at these keys. + /// Optional; omitted and `null` mean no metadata for this state snapshot. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl IdleStateUpdate { + /// Builds an empty idle state. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Reason foreground work stopped. + #[must_use] + pub fn stop_reason(mut self, stop_reason: impl IntoOption) -> Self { + self.stop_reason = stop_reason.into_option(); + self + } + + /// Token usage for completed foreground work. + #[cfg(feature = "unstable_end_turn_token_usage")] + #[must_use] + pub fn usage(mut self, usage: impl IntoOption) -> Self { + self.usage = usage.into_option(); + self + } + + /// Reserved metadata for extensions. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// Foreground work is blocked on user action. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct RequiresActionStateUpdate { + /// The _meta property is reserved by ACP for additional metadata. + /// Implementations MUST NOT make assumptions about values at these keys. + /// Optional; omitted and `null` mean no metadata for this state snapshot. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl RequiresActionStateUpdate { + /// Builds an empty requires-action state. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Reserved metadata for extensions. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// The Agent cannot currently determine foreground activity. +/// +/// Report this when activity becomes unobservable, not merely because the child +/// has been quiet. The Client MUST stop presenting the previous state as confirmed +/// current activity, but may retain it as last known. A later state replaces this +/// snapshot normally. +/// +/// This is not a task outcome or session closure. It does not cancel work, resolve +/// pending requests, or revoke capabilities; capabilities are updated separately. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct UnknownStateUpdate { + /// The _meta property is reserved by ACP for additional metadata. + /// Implementations MUST NOT make assumptions about values at these keys. + /// Optional; omitted and `null` mean no metadata for this state snapshot. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl UnknownStateUpdate { + /// Builds an unknown-activity state. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Sets optional metadata for this state snapshot. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// Custom or future state payload, preserving its discriminator and fields. +#[cfg(feature = "unstable_subagents")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, PartialEq)] +#[cfg_attr(feature = "schemars", schemars(inline))] +#[cfg_attr(feature = "schemars", schemars(transform = other_state_update_schema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct OtherStateUpdate { + /// Unrecognized state discriminator. + pub state: String, + /// Remaining fields in the state object. + #[serde(flatten)] + pub fields: BTreeMap, +} + +#[cfg(feature = "unstable_subagents")] +impl OtherStateUpdate { + /// Builds a custom state, preserving its extension fields. + #[must_use] + pub fn new(state: impl Into, mut fields: BTreeMap) -> Self { + fields.remove("state"); + Self { + state: state.into(), + fields, + } + } +} + +#[cfg(feature = "unstable_subagents")] +impl<'de> Deserialize<'de> for OtherStateUpdate { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let mut fields = BTreeMap::::deserialize(deserializer)?; + let state = fields + .remove("state") + .ok_or_else(|| serde::de::Error::missing_field("state"))?; + let serde_json::Value::String(state) = state else { + return Err(serde::de::Error::custom("`state` must be a string")); + }; + if is_known_state_update(&state) { + return Err(serde::de::Error::custom(format!( + "known state update `{state}` did not match its schema" + ))); + } + Ok(Self { state, fields }) + } +} + +#[cfg(feature = "unstable_subagents")] +const KNOWN_STATE_UPDATE_STATES: &[&str] = &["running", "idle", "requires_action", "unknown"]; + +#[cfg(feature = "unstable_subagents")] +fn is_known_state_update(state: &str) -> bool { + KNOWN_STATE_UPDATE_STATES.contains(&state) +} + +#[cfg(all(feature = "unstable_subagents", feature = "schemars"))] +fn other_state_update_schema(schema: &mut Schema) { + let known = KNOWN_STATE_UPDATE_STATES + .iter() + .map(|state| { + serde_json::json!({ + "properties": { "state": { "const": state, "type": "string" } }, + "required": ["state"], + "type": "object" + }) + }) + .collect::>(); + schema.insert("not".into(), serde_json::json!({ "anyOf": known })); } /// The current mode of the session has changed @@ -2296,12 +2591,13 @@ pub struct ClientCapabilities { /// /// Whether the client understands exposed subagent sessions. /// - /// Optional and non-nullable. Omission means the client does not advertise support. - /// Supplying `{}` means the client understands subagent lifecycle updates and restricted - /// session semantics. + /// Optional and nullable. Omitted or `null` both mean the client does not + /// advertise support. + /// Supplying `{}` means the client understands child associations, work-state + /// snapshots, tool-call session references, and restricted-session semantics. #[cfg(feature = "unstable_subagents")] #[serde_as(deserialize_as = "DefaultOnError")] - #[cfg_attr(feature = "schemars", schemars(with = "SubagentCapabilities", extend("x-deserialize-default-on-error" = true)))] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] #[serde(default)] pub subagents: Option, /// **UNSTABLE** @@ -2478,11 +2774,12 @@ impl ClientCapabilities { /// /// This capability is not part of the spec yet, and may be removed or changed at any point. /// -/// Capability marker for exposing subagents as restricted ACP sessions. +/// Capability marker for exposing reusable child sessions as restricted ACP sessions. /// -/// Supplying `{}` advertises support for subagent lifecycle updates and restricted-session -/// semantics. The client must advertise this capability before the agent sends subagent -/// updates. +/// Supplying `{}` advertises support for child association and state updates, +/// tool-call session references, and restricted-session semantics. The client +/// must advertise this capability before the agent sends subagent updates or +/// session references. #[cfg(feature = "unstable_subagents")] #[serde_as] #[skip_serializing_none] @@ -3369,60 +3666,36 @@ mod tests { fn test_subagent_updates_serialization() { use serde_json::json; - let announced = SessionUpdate::SubagentUpdate( - SubagentUpdate::new("sess_child_1") - .name("test-investigator".to_string()) - .task("Find the cause of the failing integration tests".to_string()) - .capabilities(SubagentSessionCapabilities::new().cancel(true)), - ); + let announced = SessionUpdate::SubagentUpdate(SubagentUpdate::new("sess_child_1")); assert_eq!( - serde_json::to_value(announced).unwrap(), + serde_json::to_value(&announced).unwrap(), json!({ "sessionUpdate": "subagent_update", - "subagentSessionId": "sess_child_1", - "name": "test-investigator", - "task": "Find the cause of the failing integration tests", - "capabilities": { - "cancel": true - } + "sessionId": "sess_child_1" }) ); - - let completed = SessionUpdate::SubagentUpdate( - SubagentUpdate::new("sess_child_1").state(SubagentState::Completed), - ); assert_eq!( - serde_json::to_value(completed).unwrap(), - json!({ - "sessionUpdate": "subagent_update", - "subagentSessionId": "sess_child_1", - "state": "completed" - }) + serde_json::from_value::(serde_json::to_value(&announced).unwrap()) + .unwrap(), + announced ); - - let disconnected = SessionUpdate::SubagentUpdate( - SubagentUpdate::new("sess_child_2").state(SubagentState::Disconnected), + let capable = SubagentUpdate::new("sess_child_1").capabilities( + SubagentSessionCapabilities::new().cancel(SessionCancelCapabilities::new()), ); assert_eq!( - serde_json::to_value(disconnected).unwrap(), + serde_json::to_value(capable).unwrap(), json!({ - "sessionUpdate": "subagent_update", - "subagentSessionId": "sess_child_2", - "state": "disconnected" + "sessionId": "sess_child_1", + "capabilities": {"cancel": {}} }) ); - let minimal: SubagentUpdate = - serde_json::from_value(json!({ "subagentSessionId": "sess_child_3" })).unwrap(); - assert!(minimal.name.is_none()); - assert!(minimal.task.is_none()); + serde_json::from_value(json!({ "sessionId": "sess_child_3" })).unwrap(); assert!(minimal.capabilities.is_none()); assert!(minimal.state.is_none()); let nulls: SubagentUpdate = serde_json::from_value(json!({ - "subagentSessionId": "sess_child_3", - "name": null, - "task": null, + "sessionId": "sess_child_3", "capabilities": null, "state": null })) @@ -3430,6 +3703,214 @@ mod tests { assert_eq!(nulls, minimal); } + #[cfg(feature = "unstable_subagents")] + #[test] + fn test_subagent_notification_keeps_parent_and_child_ids_nested() { + use serde_json::json; + + let notification = SessionNotification::new( + "parent", + SessionUpdate::SubagentUpdate(SubagentUpdate::new("child")), + ); + let wire = json!({ + "sessionId": "parent", + "update": { + "sessionUpdate": "subagent_update", + "sessionId": "child" + } + }); + assert_eq!(serde_json::to_value(¬ification).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + notification + ); + // Neither ID can stand in for the other: both nesting levels require one. + for malformed in [ + json!({"sessionId": "parent", "update": {"sessionUpdate": "subagent_update"}}), + json!({"update": {"sessionUpdate": "subagent_update", "sessionId": "child"}}), + ] { + assert!(serde_json::from_value::(malformed).is_err()); + } + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn test_subagent_states_round_trip() { + use serde_json::json; + + for (wire_state, expected) in [ + ( + json!({"state": "running"}), + StateUpdate::Running(RunningStateUpdate::new()), + ), + ( + json!({"state": "idle", "stopReason": "end_turn"}), + StateUpdate::Idle(IdleStateUpdate::new().stop_reason(StopReason::EndTurn)), + ), + ( + json!({"state": "requires_action"}), + StateUpdate::RequiresAction(RequiresActionStateUpdate::new()), + ), + ( + json!({"state": "unknown"}), + StateUpdate::Unknown(UnknownStateUpdate::new()), + ), + ( + json!({"state": "_waiting", "detail": {"ticket": 7}}), + StateUpdate::Other(OtherStateUpdate::new( + "_waiting", + [("detail".into(), json!({"ticket": 7}))].into(), + )), + ), + ( + json!({"state": "paused", "retryAt": 42}), + StateUpdate::Other(OtherStateUpdate::new( + "paused", + [("retryAt".into(), json!(42))].into(), + )), + ), + ] { + let wire = json!({ + "sessionUpdate": "subagent_update", + "sessionId": "sess_child", + "state": wire_state + }); + let parsed: SessionUpdate = serde_json::from_value(wire.clone()).unwrap(); + let SessionUpdate::SubagentUpdate(update) = &parsed else { + panic!("expected subagent update"); + }; + assert_eq!(update.state.as_ref(), Some(&expected)); + assert_eq!(serde_json::to_value(parsed).unwrap(), wire); + } + + for reason in [ + "end_turn", + "max_tokens", + "max_turn_requests", + "refusal", + "cancelled", + ] { + let state: StateUpdate = serde_json::from_value(json!({ + "state": "idle", "stopReason": reason + })) + .unwrap(); + assert_eq!(serde_json::to_value(state).unwrap()["stopReason"], reason); + } + + // Unknown activity is a replaceable snapshot, not a session outcome. + // Later snapshots still address the same child without stale stop reasons. + for state in [ + StateUpdate::Running(RunningStateUpdate::new()), + StateUpdate::RequiresAction(RequiresActionStateUpdate::new()), + StateUpdate::Unknown(UnknownStateUpdate::new()), + StateUpdate::Running(RunningStateUpdate::new()), + StateUpdate::Idle(IdleStateUpdate::new().stop_reason(StopReason::EndTurn)), + StateUpdate::Unknown(UnknownStateUpdate::new()), + StateUpdate::Running(RunningStateUpdate::new()), + ] { + let update = SubagentUpdate::new("sess_child").state(state.clone()); + let wire = serde_json::to_value(&update).unwrap(); + assert_eq!(wire["sessionId"], "sess_child"); + assert_eq!( + serde_json::from_value::(wire) + .unwrap() + .state, + Some(state) + ); + } + assert_eq!( + serde_json::to_value( + SubagentUpdate::new("sess_child").state(StateUpdate::Idle(IdleStateUpdate::new())) + ) + .unwrap()["state"], + json!({"state": "idle"}) + ); + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn test_subagent_unknown_activity_metadata_and_capabilities() { + use serde_json::json; + + let update = SubagentUpdate::new("child").state(StateUpdate::Unknown( + UnknownStateUpdate::new().meta( + [("source".into(), json!("worker"))] + .into_iter() + .collect::(), + ), + )); + let wire = json!({ + "sessionId": "child", + "state": { + "state": "unknown", + "_meta": { "source": "worker" } + } + }); + assert_eq!(serde_json::to_value(&update).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + update + ); + // Reporting unknown activity does not send a capability revocation. + assert!(update.capabilities.is_none()); + + for meta in [json!(null), json!(false)] { + let state: StateUpdate = serde_json::from_value(json!({ + "state": "unknown", + "_meta": meta + })) + .unwrap(); + assert_eq!(state, StateUpdate::Unknown(UnknownStateUpdate::new())); + assert_eq!( + serde_json::to_value(state).unwrap(), + json!({"state": "unknown"}) + ); + } + // The standard unknown-activity report is recognized, not an extension fallback. + assert!(serde_json::from_value::(json!({"state": "unknown"})).is_err()); + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn test_subagent_malformed_optional_state() { + use serde_json::json; + + for malformed in [json!("running"), json!({}), json!({"state": 4})] { + assert!(serde_json::from_value::(malformed.clone()).is_err()); + let update: SubagentUpdate = serde_json::from_value(json!({ + "sessionId": "child", "state": malformed + })) + .unwrap(); + assert_eq!(update.state, None); + } + let bad_reason: StateUpdate = + serde_json::from_value(json!({"state": "idle", "stopReason": "not_a_reason"})).unwrap(); + assert_eq!(bad_reason, StateUpdate::Idle(IdleStateUpdate::new())); + let null_reason: StateUpdate = + serde_json::from_value(json!({"state": "idle", "stopReason": null})).unwrap(); + assert_eq!(null_reason, StateUpdate::Idle(IdleStateUpdate::new())); + } + + #[cfg(all(feature = "unstable_subagents", feature = "unstable_protocol_v2"))] + #[test] + fn test_subagent_state_v2_wire_parity() { + use serde_json::json; + + for wire in [ + json!({"state": "running"}), + json!({"state": "idle", "stopReason": "cancelled"}), + json!({"state": "requires_action"}), + json!({"state": "unknown"}), + json!({"state": "unknown", "_meta": {"source": "worker"}}), + json!({"state": "_waiting", "detail": {"ticket": 7}}), + ] { + let v1: StateUpdate = serde_json::from_value(wire.clone()).unwrap(); + let v2: crate::v2::StateUpdate = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(v1).unwrap(), wire); + assert_eq!(serde_json::to_value(v2).unwrap(), wire); + } + } + #[cfg(feature = "unstable_subagents")] #[test] fn test_subagent_capability_semantics() { @@ -3446,13 +3927,87 @@ mod tests { let null: ClientCapabilities = serde_json::from_value(json!({ "subagents": null })).unwrap(); assert!(null.subagents.is_none()); + assert_eq!(null, omitted); + let serialized = serde_json::to_value(null).unwrap(); + assert_eq!(serialized, serde_json::to_value(omitted).unwrap()); + assert!(serialized.get("subagents").is_none()); + + for wire in [ + json!({}), + json!({"cancel": null}), + json!({"cancel": true}), + json!({"cancel": false}), + ] { + let child: SubagentSessionCapabilities = serde_json::from_value(wire).unwrap(); + assert!(child.cancel.is_none()); + assert_eq!(serde_json::to_value(child).unwrap(), json!({})); + } + let enabled = SubagentSessionCapabilities::new().cancel(SessionCancelCapabilities::new()); + assert_eq!( + serde_json::to_value(&enabled).unwrap(), + json!({"cancel": {}}) + ); + assert_eq!( + serde_json::from_value::(json!({"cancel": {}})).unwrap(), + enabled + ); + let meta: Meta = [("source".into(), json!("worker"))].into_iter().collect(); + let with_meta = + SubagentSessionCapabilities::new().cancel(SessionCancelCapabilities::new().meta(meta)); + let wire = json!({"cancel": {"_meta": {"source": "worker"}}}); + assert_eq!(serde_json::to_value(&with_meta).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + with_meta + ); + // Each supplied child capability set replaces the previous set. + let removed = SubagentSessionCapabilities::new(); + assert_eq!(serde_json::to_value(&removed).unwrap(), json!({})); + assert!(removed.cancel.is_none()); + assert!(enabled.cancel.is_some()); + assert!(enabled.cancel(None).cancel.is_none()); + let replacement = SubagentUpdate::new("child").capabilities(removed); + assert_eq!( + serde_json::to_value(replacement).unwrap(), + json!({"sessionId": "child", "capabilities": {}}) + ); + } - let child_capabilities: SubagentSessionCapabilities = serde_json::from_value(json!({ - "cancel": null - })) - .unwrap(); - assert!(!child_capabilities.cancel); - assert_eq!(serde_json::to_value(child_capabilities).unwrap(), json!({})); + #[cfg(all(feature = "unstable_subagents", feature = "schemars"))] + #[test] + fn test_subagent_capability_schema_is_optional_and_nullable() { + use serde_json::json; + + let schema = serde_json::to_value(schemars::schema_for!(ClientCapabilities)).unwrap(); + let variants = schema["properties"]["subagents"]["anyOf"] + .as_array() + .expect("subagents must allow the capability object or null"); + assert!(variants.contains(&json!({"type": "null"}))); + assert!( + !schema["required"] + .as_array() + .is_some_and(|required| required.contains(&json!("subagents"))) + ); + let child = + serde_json::to_value(schemars::schema_for!(SubagentSessionCapabilities)).unwrap(); + let variants = child["properties"]["cancel"]["anyOf"] + .as_array() + .expect("cancel must allow the capability object or null"); + assert!(variants.contains(&json!({"type": "null"}))); + assert!( + variants + .iter() + .any(|variant| variant["type"] == "object" || variant.get("$ref").is_some()) + ); + assert!(!variants.iter().any(|variant| variant["type"] == "boolean")); + let cancel = + serde_json::to_value(schemars::schema_for!(SessionCancelCapabilities)).unwrap(); + assert_eq!(cancel["type"], "object"); + assert!( + !child["required"] + .as_array() + .is_some_and(|required| required.contains(&json!("cancel"))) + ); } #[test] diff --git a/agent-client-protocol-schema/src/v1/tool_call.rs b/agent-client-protocol-schema/src/v1/tool_call.rs index 83b1061aa..0e8955f6a 100644 --- a/agent-client-protocol-schema/src/v1/tool_call.rs +++ b/agent-client-protocol-schema/src/v1/tool_call.rs @@ -12,6 +12,8 @@ use serde_with::{DefaultOnError, VecSkipError, serde_as, skip_serializing_none}; use crate::{IntoOption, SkipListener}; +#[cfg(feature = "unstable_subagents")] +use super::SessionId; use super::{ContentBlock, Error, Meta, TerminalId}; /// Represents a tool call that the language model has requested. @@ -580,6 +582,9 @@ pub enum ToolCallContent { /// /// See protocol docs: [Terminal](https://agentclientprotocol.com/protocol/terminals) Terminal(Terminal), + /// **UNSTABLE** Display reference to an already-known session on this ACP connection. + #[cfg(feature = "unstable_subagents")] + Session(SessionReference), } impl> From for ToolCallContent { @@ -686,6 +691,122 @@ impl Terminal { } } +/// **UNSTABLE** Display reference to an already-known session on this ACP connection. +/// +/// The enclosing notification's `params.sessionId` identifies the session whose +/// transcript is updated; this item's `sessionId` links that tool operation to +/// another known session for display. Ordinary session setup or a +/// `subagent_update` announcement establishes a known target. A parent can +/// reference a child, and a child can reference its parent or a sibling. +/// V1 work-state snapshots are carried by `subagent_update` on the parent stream. +/// Parent-child associations and controls are announced separately by +/// `subagent_update`. This item does not create or register a session, reparent +/// it, grant controls, prompt it, subscribe to it, close it, send a message, +/// or change ownership. Reference links can point both ways without making the +/// ownership tree cyclic. A tool call may +/// reference multiple known sessions, and multiple tool calls may reference +/// the same session. Tool-call status describes the operation, not whether +/// the referenced session is idle or terminated. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SessionReference { + /// Identifier of the already-known session linked from this tool operation, + /// not the session used to route the enclosing notification. + pub session_id: SessionId, + /// Optional nullable item metadata. Omission and `null` both mean no metadata. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl SessionReference { + /// Builds a display reference with no item metadata. + #[must_use] + pub fn new(session_id: impl Into) -> Self { + Self { + session_id: session_id.into(), + meta: None, + } + } + + /// Sets item-scoped metadata. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +#[cfg(all(test, feature = "unstable_subagents"))] +mod session_reference_tests { + use super::*; + + #[test] + fn session_reference_serializes_and_requires_id() { + let reference = ToolCallContent::Session(SessionReference::new("child_1")); + let wire = serde_json::json!({"type": "session", "sessionId": "child_1"}); + assert_eq!(serde_json::to_value(&reference).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + reference + ); + for invalid in [ + serde_json::json!({"type": "session"}), + serde_json::json!({"type": "session", "sessionId": null}), + serde_json::json!({"type": "session", "sessionId": 1}), + ] { + assert!(serde_json::from_value::(invalid).is_err()); + } + } + + #[test] + fn session_references_to_known_sessions_share_one_wire_shape() { + let references: Vec<_> = ["child_1", "parent_1", "sibling_1"] + .into_iter() + .map(|id| ToolCallContent::Session(SessionReference::new(id))) + .collect(); + let wire = serde_json::json!([ + {"type": "session", "sessionId": "child_1"}, + {"type": "session", "sessionId": "parent_1"}, + {"type": "session", "sessionId": "sibling_1"} + ]); + assert_eq!(serde_json::to_value(&references).unwrap(), wire); + assert_eq!( + serde_json::from_value::>(wire).unwrap(), + references + ); + } + + #[test] + fn session_reference_meta_is_item_scoped_and_nullable() { + let meta: Meta = serde_json::from_value(serde_json::json!({"source": "test"})).unwrap(); + let reference = SessionReference::new("child_1").meta(meta.clone()); + assert_eq!( + serde_json::to_value(ToolCallContent::Session(reference.clone())).unwrap(), + serde_json::json!({"type": "session", "sessionId": "child_1", "_meta": {"source": "test"}}) + ); + assert_eq!(reference.meta, Some(meta)); + for wire in [ + serde_json::json!({"type": "session", "sessionId": "child_1"}), + serde_json::json!({"type": "session", "sessionId": "child_1", "_meta": null}), + ] { + let ToolCallContent::Session(parsed) = + serde_json::from_value::(wire).unwrap() + else { + panic!("expected session reference"); + }; + assert_eq!(parsed.meta, None); + } + } +} + /// A diff representing file modifications. /// /// Shows changes to files in a format suitable for display in the client UI. diff --git a/agent-client-protocol-schema/src/v2/client.rs b/agent-client-protocol-schema/src/v2/client.rs index 6c4e9e915..f2f30e38e 100644 --- a/agent-client-protocol-schema/src/v2/client.rs +++ b/agent-client-protocol-schema/src/v2/client.rs @@ -468,23 +468,27 @@ impl CompactionSummaryChunk { /// /// This capability is not part of the spec yet, and may be removed or changed at any point. /// -/// An upsert for a subagent exposed by its parent session. +/// An upsert associating a reusable child session with its immediate parent. /// /// Sent on the immediate parent session. The first update for an unknown -/// [`SubagentUpdate::subagent_session_id`] announces the child and MUST be sent -/// before any `session/update` bearing the child's session ID. No Client -/// capability is required. +/// [`SubagentUpdate::session_id`] announces the child and MUST be sent +/// before any request or notification bearing the child's session ID, or any +/// tool-call session reference to it. Child events are delivered automatically; +/// no separate child load, resume, or subscription is needed. +/// Understanding this update, registering child sessions, and applying their +/// operation restrictions are baseline v2 requirements; no Client capability +/// is required. /// -/// Only [`SubagentUpdate::subagent_session_id`] is required. Other fields have +/// Only [`SubagentUpdate::session_id`] is required. Other fields have /// patch semantics: omitted fields leave the stored value unchanged, `null` /// clears or unsets the value, and concrete values replace it. A child whose -/// state is unset is `running`; a child whose capabilities are unset permits -/// no operations. +/// capabilities are unset permits no Client-initiated session mutations. /// -/// The update that carries a terminal [`SubagentUpdate::state`] MUST be sent -/// after all child session updates and after every pending permission or -/// elicitation request issued for the child has resolved. The Agent MUST NOT -/// send further updates for that child afterwards. +/// The child's title is reported through [`SessionInfoUpdate`] on its own +/// stream. Its foreground work uses ordinary [`StateUpdate`] notifications. +/// Completing or cancelling work does not end the association: the parent may +/// message the same child again. Individual operations and their outcomes belong +/// to tool calls referencing the child, not to this association. #[cfg(feature = "unstable_subagents")] #[serde_as] #[skip_serializing_none] @@ -494,27 +498,18 @@ impl CompactionSummaryChunk { #[non_exhaustive] pub struct SubagentUpdate { /// The opaque session ID identifying the child in all ACP messages. - pub subagent_session_id: SessionId, - /// A short, human-readable label for the subagent. It need not be unique. - #[serde_as(deserialize_as = "DefaultOnError")] - #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] - #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] - pub name: MaybeUndefined, - /// A human-readable summary of the work delegated to the subagent. - #[serde_as(deserialize_as = "DefaultOnError")] - #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] - #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] - pub task: MaybeUndefined, - /// Client-to-agent operations permitted for this subagent session. + /// + /// Nested inside `update`; the enclosing notification's `sessionId` identifies + /// the immediate parent, not this child. + pub session_id: SessionId, + /// Client-initiated session mutations permitted for this subagent session. + /// + /// Read-only operations retain their normal protocol semantics and + /// capability requirements. #[serde_as(deserialize_as = "DefaultOnError")] #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] pub capabilities: MaybeUndefined, - /// The reported lifecycle state of the subagent. - #[serde_as(deserialize_as = "DefaultOnError")] - #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] - #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] - pub state: MaybeUndefined, /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Omitted means no metadata update; `null` is an /// explicit clear signal. Implementations MUST NOT make assumptions about values at these keys. @@ -534,32 +529,15 @@ pub struct SubagentUpdate { impl SubagentUpdate { /// Builds a subagent upsert with only its required session ID set. #[must_use] - pub fn new(subagent_session_id: impl Into) -> Self { + pub fn new(session_id: impl Into) -> Self { Self { - subagent_session_id: subagent_session_id.into(), - name: MaybeUndefined::Undefined, - task: MaybeUndefined::Undefined, + session_id: session_id.into(), capabilities: MaybeUndefined::Undefined, - state: MaybeUndefined::Undefined, meta: MaybeUndefined::Undefined, } } - /// Sets, clears, or leaves unchanged the human-readable label. - #[must_use] - pub fn name(mut self, name: impl IntoMaybeUndefined) -> Self { - self.name = name.into_maybe_undefined(); - self - } - - /// Sets, clears, or leaves unchanged the delegated task summary. - #[must_use] - pub fn task(mut self, task: impl IntoMaybeUndefined) -> Self { - self.task = task.into_maybe_undefined(); - self - } - - /// Sets, clears, or leaves unchanged the permitted client-to-agent operations. + /// Sets, clears, or leaves unchanged the permitted client-initiated session mutations. #[must_use] pub fn capabilities( mut self, @@ -569,13 +547,6 @@ impl SubagentUpdate { self } - /// Sets, unsets, or leaves unchanged the reported lifecycle state. - #[must_use] - pub fn state(mut self, state: impl IntoMaybeUndefined) -> Self { - self.state = state.into_maybe_undefined(); - self - } - /// Sets, clears, or leaves unchanged subagent metadata. #[must_use] pub fn meta(mut self, meta: impl IntoMaybeUndefined) -> Self { @@ -588,7 +559,10 @@ impl SubagentUpdate { /// /// This capability is not part of the spec yet, and may be removed or changed at any point. /// -/// Client-to-agent operations permitted for a specific subagent session. +/// Client-initiated session mutations permitted for a specific subagent session. +/// +/// A mutation requires an explicit per-child capability; support for the method +/// on ordinary sessions does not grant support on a child. #[cfg(feature = "unstable_subagents")] #[serde_as] #[skip_serializing_none] @@ -597,11 +571,13 @@ impl SubagentUpdate { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct SubagentSessionCapabilities { - /// Whether the client may cancel this subagent. Omission is equivalent to `false`. + /// Permits the client to cancel this child's current work without ending + /// the session. Omitted or `null` means unsupported; an object (including + /// `{}`) means supported. #[serde_as(deserialize_as = "DefaultOnError")] #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub cancel: bool, + #[serde(default)] + pub cancel: Option, /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at /// these keys. @@ -620,10 +596,10 @@ impl SubagentSessionCapabilities { Self::default() } - /// Whether the client may cancel this subagent. + /// Sets or removes permission to cancel this child's current work. #[must_use] - pub fn cancel(mut self, cancel: bool) -> Self { - self.cancel = cancel; + pub fn cancel(mut self, cancel: impl IntoOption) -> Self { + self.cancel = cancel.into_option(); self } @@ -641,32 +617,46 @@ impl SubagentSessionCapabilities { /// /// This capability is not part of the spec yet, and may be removed or changed at any point. /// -/// Lifecycle state of an announced subagent. +/// Capability to cancel work in a subagent session without ending that session. /// -/// All states except `running` are terminal. +/// Supplying `{}` advertises support; an omitted or `null` `cancel` does not. #[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[non_exhaustive] -pub enum SubagentState { - /// The subagent is working on its task. This is the initial state. - Running, - /// The subagent completed its task successfully. - Completed, - /// The subagent failed to complete its task. - Failed, - /// The subagent was cancelled. - Cancelled, - /// The Agent lost the child runtime and cannot determine its task outcome. - Disconnected, - /// Custom or future subagent state. +pub struct SessionCancelCapabilities { + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. /// - /// Values beginning with `_` are reserved for implementation-specific - /// extensions. Unknown values that do not begin with `_` are reserved for - /// future ACP variants. - #[serde(untagged)] - Other(String), + /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + #[serde(rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl SessionCancelCapabilities { + /// Builds an empty capability object advertising cancellation support. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// The _meta property is reserved by ACP to allow clients and agents to attach additional + /// metadata to their interactions. Implementations MUST NOT make assumptions about values at + /// these keys. + /// + /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } } /// Custom or future session update payload. @@ -1004,6 +994,14 @@ pub enum StateUpdate { Idle(IdleStateUpdate), /// Foreground work is blocked on user action. RequiresAction(RequiresActionStateUpdate), + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// The Agent cannot currently determine foreground activity. + /// This replaces previously confirmed activity without ending the work or session. + #[cfg(feature = "unstable_subagents")] + Unknown(UnknownStateUpdate), /// Custom or future session state. /// /// Values beginning with `_` are reserved for implementation-specific @@ -1170,6 +1168,52 @@ impl RequiresActionStateUpdate { } } +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// The Agent cannot currently determine foreground activity. +/// +/// Report this when activity becomes unobservable, not merely because the session +/// has been quiet. The Client MUST stop presenting the previous state as confirmed +/// current activity, but may retain it as last known. A later state replaces this +/// snapshot normally. +/// +/// This is not a task outcome or session closure. It does not cancel work, resolve +/// pending requests, or revoke capabilities; capabilities are updated separately. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct UnknownStateUpdate { + /// The _meta property is reserved by ACP for additional metadata. + /// Implementations MUST NOT make assumptions about values at these keys. + /// Optional; omitted and `null` mean no metadata for this state snapshot. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl UnknownStateUpdate { + /// Builds an unknown-activity state. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Sets optional metadata for this state snapshot. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + /// Custom or future session state payload. /// /// This preserves the unknown `state` discriminator and the rest of the state @@ -1228,8 +1272,16 @@ impl<'de> Deserialize<'de> for OtherStateUpdate { } } +const KNOWN_STATE_UPDATE_STATES: &[&str] = &[ + "running", + "idle", + "requires_action", + #[cfg(feature = "unstable_subagents")] + "unknown", +]; + fn is_known_state_update(state: &str) -> bool { - matches!(state, "running" | "idle" | "requires_action") + KNOWN_STATE_UPDATE_STATES.contains(&state) } #[cfg(feature = "schemars")] @@ -1237,7 +1289,7 @@ fn other_state_update_schema(schema: &mut Schema) { super::schema_util::reject_known_string_discriminators( schema, "state", - &["running", "idle", "requires_action"], + KNOWN_STATE_UPDATE_STATES, ); } @@ -2854,62 +2906,263 @@ mod tests { fn subagent_update_serializes_as_upsert() { use serde_json::json; - let announced = SessionUpdate::SubagentUpdate( - SubagentUpdate::new("sess_child_1") - .name("test-investigator".to_string()) - .task("Find the cause of the failing integration tests".to_string()) - .capabilities(SubagentSessionCapabilities::new().cancel(true)), - ); + let announced = + SessionUpdate::SubagentUpdate(SubagentUpdate::new("sess_child_1").capabilities( + SubagentSessionCapabilities::new().cancel(SessionCancelCapabilities::new()), + )); + let wire = json!({ + "sessionUpdate": "subagent_update", + "sessionId": "sess_child_1", + "capabilities": { "cancel": {} } + }); + assert_eq!(serde_json::to_value(&announced).unwrap(), wire); assert_eq!( - serde_json::to_value(&announced).unwrap(), - json!({ - "sessionUpdate": "subagent_update", - "subagentSessionId": "sess_child_1", - "name": "test-investigator", - "task": "Find the cause of the failing integration tests", - "capabilities": { - "cancel": true - } - }) + serde_json::from_value::(wire).unwrap(), + announced ); - let terminal = SessionUpdate::SubagentUpdate( - SubagentUpdate::new("sess_child_1").state(SubagentState::Completed), - ); + let minimal = SubagentUpdate::new("sess_child_1"); assert_eq!( - serde_json::to_value(&terminal).unwrap(), + serde_json::to_value(&minimal).unwrap(), json!({ - "sessionUpdate": "subagent_update", - "subagentSessionId": "sess_child_1", - "state": "completed" + "sessionId": "sess_child_1" }) ); + assert!(minimal.capabilities.is_undefined()); + assert!(minimal.meta.is_undefined()); // Patch semantics distinguish omitted fields from explicit nulls. - let patched: SubagentUpdate = serde_json::from_value(json!({ - "subagentSessionId": "sess_child_1", - "name": null, - "state": "disconnected" + let cleared_wire = json!({ + "sessionId": "sess_child_1", + "capabilities": null, + "_meta": null + }); + let cleared: SubagentUpdate = serde_json::from_value(cleared_wire.clone()).unwrap(); + assert!(cleared.capabilities.is_null()); + assert!(cleared.meta.is_null()); + assert_eq!(serde_json::to_value(cleared).unwrap(), cleared_wire); + + let revoked: SubagentUpdate = serde_json::from_value(json!({ + "sessionId": "sess_child_1", + "capabilities": {} })) .unwrap(); - assert!(patched.name.is_null()); - assert!(patched.task.is_undefined()); - assert!(patched.capabilities.is_undefined()); assert_eq!( - patched.state, - MaybeUndefined::Value(SubagentState::Disconnected) + revoked.capabilities, + MaybeUndefined::Value(SubagentSessionCapabilities::new()) + ); + assert!( + matches!(revoked.capabilities, MaybeUndefined::Value(ref child) if child.cancel.is_none()) ); - // Unknown future states are preserved, not dropped. - let future: SubagentUpdate = serde_json::from_value(json!({ - "subagentSessionId": "sess_child_2", - "state": "paused" - })) - .unwrap(); + assert!( + serde_json::from_value::(json!({ + "sessionUpdate": "subagent_update" + })) + .is_err() + ); + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn subagent_cancel_capability_is_optional_object() { + use serde_json::json; + + for wire in [ + json!({}), + json!({"cancel": null}), + json!({"cancel": true}), + json!({"cancel": false}), + ] { + let child: SubagentSessionCapabilities = serde_json::from_value(wire).unwrap(); + assert!(child.cancel.is_none()); + assert_eq!(serde_json::to_value(child).unwrap(), json!({})); + } + let enabled = SubagentSessionCapabilities::new().cancel(SessionCancelCapabilities::new()); + assert_eq!( + serde_json::to_value(&enabled).unwrap(), + json!({"cancel": {}}) + ); assert_eq!( - future.state, - MaybeUndefined::Value(SubagentState::Other("paused".into())) + serde_json::from_value::(json!({"cancel": {}})).unwrap(), + enabled ); + let meta: Meta = [("source".into(), json!("worker"))].into_iter().collect(); + let with_meta = + SubagentSessionCapabilities::new().cancel(SessionCancelCapabilities::new().meta(meta)); + let wire = json!({"cancel": {"_meta": {"source": "worker"}}}); + assert_eq!(serde_json::to_value(&with_meta).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + with_meta + ); + assert!(enabled.cancel.is_some()); + assert!(enabled.cancel(None).cancel.is_none()); + let removed = SubagentUpdate::new("child").capabilities(SubagentSessionCapabilities::new()); + assert_eq!( + serde_json::to_value(removed).unwrap(), + json!({"sessionId": "child", "capabilities": {}}) + ); + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn subagent_notification_keeps_parent_and_child_ids_nested() { + use serde_json::json; + + let notification = UpdateSessionNotification::new( + "parent", + SessionUpdate::SubagentUpdate(SubagentUpdate::new("child")), + ); + let wire = json!({ + "sessionId": "parent", + "update": { + "sessionUpdate": "subagent_update", + "sessionId": "child" + } + }); + assert_eq!(serde_json::to_value(¬ification).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + notification + ); + for malformed in [ + json!({"sessionId": "parent", "update": {"sessionUpdate": "subagent_update"}}), + json!({"update": {"sessionUpdate": "subagent_update", "sessionId": "child"}}), + ] { + assert!(serde_json::from_value::(malformed).is_err()); + } + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn subagent_reuses_normal_child_state_notifications() { + use serde_json::json; + + let association = SubagentUpdate::new("sess_child"); + for state in [ + StateUpdate::Running(RunningStateUpdate::new()), + StateUpdate::RequiresAction(RequiresActionStateUpdate::new()), + StateUpdate::Unknown(UnknownStateUpdate::new()), + StateUpdate::Running(RunningStateUpdate::new()), + StateUpdate::Idle(IdleStateUpdate::new().stop_reason(StopReason::EndTurn)), + StateUpdate::Running(RunningStateUpdate::new()), + StateUpdate::Idle(IdleStateUpdate::new().stop_reason(StopReason::Cancelled)), + ] { + let notification = UpdateSessionNotification::new( + association.session_id.clone(), + SessionUpdate::StateUpdate(state), + ); + let wire = serde_json::to_value(¬ification).unwrap(); + assert_eq!(wire["sessionId"], json!("sess_child")); + assert_eq!(wire["update"]["sessionUpdate"], json!("state_update")); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + notification + ); + } + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn unknown_activity_is_a_known_state_update() { + use serde_json::json; + + let update = SessionUpdate::StateUpdate(StateUpdate::Unknown( + UnknownStateUpdate::new().meta( + [("source".into(), json!("worker"))] + .into_iter() + .collect::(), + ), + )); + let wire = json!({ + "sessionUpdate": "state_update", + "state": "unknown", + "_meta": { "source": "worker" } + }); + assert_eq!(serde_json::to_value(&update).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + update + ); + + for meta in [json!(null), json!(false)] { + let state: StateUpdate = serde_json::from_value(json!({ + "state": "unknown", + "_meta": meta + })) + .unwrap(); + assert_eq!(state, StateUpdate::Unknown(UnknownStateUpdate::new())); + assert_eq!( + serde_json::to_value(state).unwrap(), + json!({"state": "unknown"}) + ); + } + assert!(serde_json::from_value::(json!({"state": "unknown"})).is_err()); + } + + #[cfg(not(feature = "unstable_subagents"))] + #[test] + fn unknown_activity_is_preserved_without_subagents_feature() { + let wire = serde_json::json!({ + "sessionUpdate": "state_update", + "state": "unknown", + "_meta": { "source": "worker" } + }); + let parsed: SessionUpdate = serde_json::from_value(wire.clone()).unwrap(); + let SessionUpdate::StateUpdate(StateUpdate::Other(state)) = &parsed else { + panic!("expected unrecognized state payload"); + }; + assert_eq!(state.state, "unknown"); + assert_eq!(serde_json::to_value(parsed).unwrap(), wire); + } + + #[cfg(all(feature = "unstable_subagents", feature = "schemars"))] + #[test] + fn subagent_schema_only_carries_association_metadata() { + use serde_json::json; + + let schema = serde_json::to_value(schemars::schema_for!(SubagentUpdate)).unwrap(); + let properties = schema["properties"].as_object().unwrap(); + assert!(properties.contains_key("sessionId")); + assert!(properties.contains_key("capabilities")); + assert!(properties.contains_key("_meta")); + assert!(!properties.contains_key("name")); + assert!(!properties.contains_key("task")); + assert!(!properties.contains_key("state")); + let child = + serde_json::to_value(schemars::schema_for!(SubagentSessionCapabilities)).unwrap(); + let variants = child["properties"]["cancel"]["anyOf"] + .as_array() + .expect("cancel must allow the capability object or null"); + assert!(variants.contains(&json!({"type": "null"}))); + assert!( + variants + .iter() + .any(|variant| variant["type"] == "object" || variant.get("$ref").is_some()) + ); + assert!(!variants.iter().any(|variant| variant["type"] == "boolean")); + let cancel = + serde_json::to_value(schemars::schema_for!(SessionCancelCapabilities)).unwrap(); + assert_eq!(cancel["type"], "object"); + assert!( + !child["required"] + .as_array() + .is_some_and(|required| required.contains(&json!("cancel"))) + ); + } + + #[cfg(not(feature = "unstable_subagents"))] + #[test] + fn unsupported_subagent_update_is_preserved() { + let wire = serde_json::json!({ + "sessionUpdate": "subagent_update", + "sessionId": "sess_child", + "capabilities": { "cancel": {} } + }); + let parsed: SessionUpdate = serde_json::from_value(wire.clone()).unwrap(); + assert!(matches!(&parsed, SessionUpdate::Other(_))); + assert_eq!(serde_json::to_value(parsed).unwrap(), wire); } #[cfg(feature = "unstable_session_notices")] diff --git a/agent-client-protocol-schema/src/v2/tool_call.rs b/agent-client-protocol-schema/src/v2/tool_call.rs index b398ffca1..0707d97d7 100644 --- a/agent-client-protocol-schema/src/v2/tool_call.rs +++ b/agent-client-protocol-schema/src/v2/tool_call.rs @@ -12,6 +12,8 @@ use schemars::Schema; use serde::{Deserialize, Serialize}; use serde_with::{DefaultOnError, VecSkipError, serde_as, skip_serializing_none}; +#[cfg(feature = "unstable_subagents")] +use super::SessionId; use super::{AbsolutePath, ContentBlock, MediaType, Meta, Terminal}; use crate::{IntoMaybeUndefined, IntoOption, MaybeUndefined, SkipListener}; @@ -397,6 +399,9 @@ pub enum ToolCallContent { Diff(Diff), /// A display-only reference to an agent-owned terminal. Terminal(Terminal), + /// **UNSTABLE** Display reference to an already-known session on this ACP connection. + #[cfg(feature = "unstable_subagents")] + Session(SessionReference), /// Custom or future tool call content. /// /// Values beginning with `_` are reserved for implementation-specific @@ -467,15 +472,16 @@ impl<'de> Deserialize<'de> for OtherToolCallContent { fn is_known_tool_call_content_type(type_: &str) -> bool { matches!(type_, "content" | "diff" | "terminal") + || (cfg!(feature = "unstable_subagents") && type_ == "session") } #[cfg(feature = "schemars")] fn other_tool_call_content_schema(schema: &mut Schema) { - super::schema_util::reject_known_string_discriminators( - schema, - "type", - &["content", "diff", "terminal"], - ); + #[cfg(feature = "unstable_subagents")] + const KNOWN: &[&str] = &["content", "diff", "terminal", "session"]; + #[cfg(not(feature = "unstable_subagents"))] + const KNOWN: &[&str] = &["content", "diff", "terminal"]; + super::schema_util::reject_known_string_discriminators(schema, "type", KNOWN); } impl> From for ToolCallContent { @@ -496,6 +502,65 @@ impl From for ToolCallContent { } } +#[cfg(feature = "unstable_subagents")] +impl From for ToolCallContent { + fn from(reference: SessionReference) -> Self { + ToolCallContent::Session(reference) + } +} + +/// **UNSTABLE** Display reference to an already-known session on this ACP connection. +/// +/// The enclosing notification's `params.sessionId` identifies the session whose +/// transcript is updated; this item's `sessionId` links that tool operation to +/// another known session for display. Ordinary session setup or a +/// `subagent_update` announcement establishes a known target. A parent can +/// reference a child, and a child can reference its parent or a sibling. +/// Parent-child associations and controls are announced separately by +/// `subagent_update`. This item does not create or register a session, reparent +/// it, grant controls, prompt it, subscribe to it, close it, send a message, +/// or change ownership. Reference links can point both ways without making the +/// ownership tree cyclic. A tool call may +/// reference multiple known sessions, and multiple tool calls may reference +/// the same session. Tool-call status describes the operation, not whether +/// the referenced session is idle or terminated. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SessionReference { + /// Identifier of the already-known session linked from this tool operation, + /// not the session used to route the enclosing notification. + pub session_id: SessionId, + /// Optional nullable item metadata. Omission and `null` both mean no metadata. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl SessionReference { + /// Builds a display reference with no item metadata. + #[must_use] + pub fn new(session_id: impl Into) -> Self { + Self { + session_id: session_id.into(), + meta: None, + } + } + + /// Sets item-scoped metadata. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + /// Standard content block (text, images, resources). #[serde_as] #[skip_serializing_none] @@ -1200,6 +1265,76 @@ mod tests { ); } + #[cfg(feature = "unstable_subagents")] + #[test] + fn session_reference_serializes_and_requires_id() { + let reference = ToolCallContent::from(SessionReference::new("child_1")); + let wire = serde_json::json!({"type": "session", "sessionId": "child_1"}); + assert_eq!(serde_json::to_value(&reference).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + reference + ); + for invalid in [ + serde_json::json!({"type": "session"}), + serde_json::json!({"type": "session", "sessionId": null}), + serde_json::json!({"type": "session", "sessionId": 1}), + ] { + assert!(serde_json::from_value::(invalid).is_err()); + } + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn session_references_to_known_sessions_share_one_wire_shape() { + let references: Vec<_> = ["child_1", "parent_1", "sibling_1"] + .into_iter() + .map(|id| ToolCallContent::from(SessionReference::new(id))) + .collect(); + let wire = serde_json::json!([ + {"type": "session", "sessionId": "child_1"}, + {"type": "session", "sessionId": "parent_1"}, + {"type": "session", "sessionId": "sibling_1"} + ]); + assert_eq!(serde_json::to_value(&references).unwrap(), wire); + assert_eq!( + serde_json::from_value::>(wire).unwrap(), + references + ); + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn session_reference_meta_is_item_scoped_and_nullable() { + let meta: Meta = serde_json::from_value(serde_json::json!({"source": "test"})).unwrap(); + let reference = SessionReference::new("child_1").meta(meta.clone()); + assert_eq!( + serde_json::to_value(ToolCallContent::Session(reference.clone())).unwrap(), + serde_json::json!({"type": "session", "sessionId": "child_1", "_meta": {"source": "test"}}) + ); + assert_eq!(reference.meta, Some(meta)); + for wire in [ + serde_json::json!({"type": "session", "sessionId": "child_1"}), + serde_json::json!({"type": "session", "sessionId": "child_1", "_meta": null}), + ] { + let ToolCallContent::Session(parsed) = + serde_json::from_value::(wire).unwrap() + else { + panic!("expected session reference"); + }; + assert_eq!(parsed.meta, None); + } + } + + #[cfg(not(feature = "unstable_subagents"))] + #[test] + fn session_reference_is_unknown_when_feature_disabled() { + let wire = serde_json::json!({"type": "session", "sessionId": "child_1"}); + let content: ToolCallContent = serde_json::from_value(wire.clone()).unwrap(); + assert!(matches!(content, ToolCallContent::Other(_))); + assert_eq!(serde_json::to_value(content).unwrap(), wire); + } + #[test] fn diff_patch_serializes_git_patch_with_structured_changes() { let patch_text = "diff --git /repo/config.json /repo/config.json\n--- /repo/config.json\n+++ /repo/config.json\n@@ -1 +1 @@\n-old\n+new\n"; @@ -1382,5 +1517,12 @@ mod tests { })) .is_err() ); + #[cfg(feature = "unstable_subagents")] + assert!( + serde_json::from_value::(serde_json::json!({ + "type": "session" + })) + .is_err() + ); } } diff --git a/docs/protocol/v1/draft/schema.mdx b/docs/protocol/v1/draft/schema.mdx index 7b6b51749..d2fab51fc 100644 --- a/docs/protocol/v1/draft/schema.mdx +++ b/docs/protocol/v1/draft/schema.mdx @@ -3211,16 +3211,17 @@ Optional. Omitted or `null` both mean the client does not advertise any session-related extensions. -SubagentCapabilities} > +SubagentCapabilities | null} > **UNSTABLE** This capability is not part of the spec yet, and may be removed or changed at any point. Whether the client understands exposed subagent sessions. -Optional and non-nullable. Omission means the client does not advertise support. -Supplying `\{\}` means the client understands subagent lifecycle updates and restricted -session semantics. +Optional and nullable. Omitted or `null` both mean the client does not +advertise support. +Supplying `\{\}` means the client understands child associations, work-state +snapshots, tool-call session references, and restricted-session semantics. @@ -4696,6 +4697,29 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/d The value to set for the HTTP header. +## IdleStateUpdate + +The child is ready to process another prompt. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP for additional metadata. +Implementations MUST NOT make assumptions about values at these keys. +Optional; omitted and `null` mean no metadata for this state snapshot. + +StopReason | null} > + Reason foreground work stopped. Optional; omitted or `null` means not reported. + +Usage | null} > + **UNSTABLE** Token usage for completed foreground work. + +Optional; omitted or `null` means not reported. + + + ## ImageContent An image provided to or from an LLM. @@ -7125,6 +7149,20 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/d +## RequiresActionStateUpdate + +Foreground work is blocked on user action. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP for additional metadata. Implementations + MUST NOT make assumptions about values at these keys. Optional; omitted and + `null` mean no metadata for this state snapshot. + + ## ResourceLink A resource that the server is capable of reading, included in a prompt or tool call result. @@ -7177,6 +7215,20 @@ The sender or recipient of messages and data in a conversation. The user side of a conversation. +## RunningStateUpdate + +Foreground work is in progress. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP for additional metadata. Implementations + MUST NOT make assumptions about values at these keys. Optional; omitted and + `null` mean no metadata for this state snapshot. + + ## SelectedPermissionOutcome The user selected one of the provided options. @@ -7219,6 +7271,29 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/d +## SessionCancelCapabilities + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Capability to cancel work in a subagent session without ending that session. + +Supplying `\{\}` advertises support; an omitted or `null` `cancel` does not. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/draft/extensibility) + + + ## SessionCapabilities Session capabilities supported by the agent. @@ -7757,6 +7832,41 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/d The current mode the Agent is in. +## SessionReference + +**UNSTABLE** Display reference to an already-known session on this ACP connection. + +The enclosing notification's `params.sessionId` identifies the session whose +transcript is updated; this item's `sessionId` links that tool operation to +another known session for display. Ordinary session setup or a +`subagent_update` announcement establishes a known target. A parent can +reference a child, and a child can reference its parent or a sibling. +V1 work-state snapshots are carried by `subagent_update` on the parent stream. +Parent-child associations and controls are announced separately by +`subagent_update`. This item does not create or register a session, reparent +it, grant controls, prompt it, subscribe to it, close it, send a message, +or change ownership. Reference links can point both ways without making the +ownership tree cyclic. A tool call may +reference multiple known sessions, and multiple tool calls may reference +the same session. Tool-call status describes the operation, not whether +the referenced session is idle or terminated. + +**Type:** Object + +**Properties:** + + + Optional nullable item metadata. Omission and `null` both mean no metadata. + +SessionId} + required +> + Identifier of the already-known session linked from this tool operation, not + the session used to route the enclosing notification. + + ## SessionResumeCapabilities Capabilities for the `session/resume` method. @@ -8348,37 +8458,137 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/d SubagentSessionCapabilities | null} > - Client-to-agent operations permitted for this subagent session. + Client-initiated session mutations permitted for this subagent session. -Omitted and `null` both mean unchanged. If never supplied, no operations -are permitted. +Omitted and `null` both mean unchanged. If never supplied, no session +mutations are permitted. Read-only operations retain their normal protocol +semantics and capability requirements. - - A short, human-readable label for the subagent. +SessionId} required> + The opaque session ID identifying the child in all ACP messages. -Omitted and `null` both mean unchanged. If never supplied, the Client -chooses its own fallback presentation. +Nested inside `update`; the enclosing notification's `sessionId` identifies +the immediate parent, not this child. The discriminator value. Must be `"subagent_update"`. -SubagentState | null} > - The lifecycle state reached by the subagent. +StateUpdate | null} > + Current state snapshot for the child session. -Omitted and `null` both mean unchanged. If never supplied, the child is -`running`. +Omitted and `null` both mean unchanged; a concrete state replaces the +previous state object wholesale. If never supplied, the state is unknown. -SessionId} required> - The opaque session ID identifying the child in all ACP messages. + + + + +## StateUpdate + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Current foreground-work state of a reusable child session. + +Each update is a whole-object snapshot. Idle does not terminate the child; +the parent can message it again, transitioning it back to running. +Background activity may still emit other session updates while idle. + +**Type:** Union + + +Foreground work is in progress. + + + + + The _meta property is reserved by ACP for additional metadata. Implementations + MUST NOT make assumptions about values at these keys. Optional; omitted and + `null` mean no metadata for this state snapshot. + + + The discriminator value. Must be `"running"`. + + + + + + +The child is ready to process another prompt. + + + + + The _meta property is reserved by ACP for additional metadata. +Implementations MUST NOT make assumptions about values at these keys. +Optional; omitted and `null` mean no metadata for this state snapshot. + + + The discriminator value. Must be `"idle"`. + +StopReason | null} > + Reason foreground work stopped. Optional; omitted or `null` means not reported. + +Usage | null} > + **UNSTABLE** Token usage for completed foreground work. + +Optional; omitted or `null` means not reported. + + + + + + + +Foreground work is blocked on user action. + + + + + The _meta property is reserved by ACP for additional metadata. Implementations + MUST NOT make assumptions about values at these keys. Optional; omitted and + `null` mean no metadata for this state snapshot. + + + The discriminator value. Must be `"requires_action"`. + + + + + + +The Agent cannot currently determine foreground activity. + +This replaces previously confirmed activity without ending the work or session. + + + + + The _meta property is reserved by ACP for additional metadata. Implementations + MUST NOT make assumptions about values at these keys. Optional; omitted and + `null` mean no metadata for this state snapshot. + + + The discriminator value. Must be `"unknown"`. - - A human-readable summary of the work delegated to the subagent. -Omitted and `null` both mean unchanged. + + + + +Custom or future state. +Values beginning with `_` are reserved for implementation-specific +extensions. Other unknown values are reserved for future ACP variants. + + + + + Unrecognized state discriminator. @@ -8549,11 +8759,12 @@ Optional. Omitted and `null` are equivalent and mean no title is provided. This capability is not part of the spec yet, and may be removed or changed at any point. -Capability marker for exposing subagents as restricted ACP sessions. +Capability marker for exposing reusable child sessions as restricted ACP sessions. -Supplying `\{\}` advertises support for subagent lifecycle updates and restricted-session -semantics. The client must advertise this capability before the agent sends subagent -updates. +Supplying `\{\}` advertises support for child association and state updates, +tool-call session references, and restricted-session semantics. The client +must advertise this capability before the agent sends subagent updates or +session references. **Type:** Object @@ -8571,48 +8782,24 @@ updates. This capability is not part of the spec yet, and may be removed or changed at any point. -Client-to-agent operations permitted for a specific subagent session. +Client-initiated session mutations permitted for a specific subagent session. + +A mutation requires an explicit per-child capability; support for the method +on ordinary sessions does not grant support on a child. **Type:** Object **Properties:** - - The _meta property is reserved by ACP to allow clients and agents to attach - additional metadata to their interactions. Implementations MUST NOT make - assumptions about values at these keys. - - - Whether the client may cancel this subagent. Omission is equivalent to - `false`. - - -## SubagentState - -Lifecycle state of an announced subagent. - -All states except `running` are terminal. - -**Type:** Union - - - The subagent is working on its task. This is the initial state. - - - - The subagent completed its task successfully. - - - - The subagent failed to complete its task. - - - - The subagent was cancelled. + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. - - - The Agent lost the child runtime and cannot determine its task outcome. +SessionCancelCapabilities | null} > + Permits the client to cancel this child's current work without ending +the session. Omitted or `null` means unsupported; an object (including +`\{\}`) means supported. ## SubagentUpdate @@ -8621,19 +8808,20 @@ All states except `running` are terminal. This capability is not part of the spec yet, and may be removed or changed at any point. -An upsert for a subagent exposed by its parent session. +An upsert for a reusable child session associated with its parent session. Sent on the immediate parent session. The first update for an unknown -`SubagentUpdate::subagent_session_id` announces the child and MUST be sent -before any `session/update` bearing the child's session ID. +`SubagentUpdate::session_id` announces the child and MUST be sent +before any child traffic or reference to the child session. Parents may +message and reuse an announced child across multiple operations. +Child events are delivered automatically on the same connection; no child +load, resume, or subscription is needed. Only the subagent session ID is required. Omitted fields keep their previous -value; a child whose state was never reported is `running`. - -The update that carries a terminal `SubagentUpdate::state` MUST be sent -after all child session updates and after every pending permission or -elicitation request issued for the child has resolved. The Agent MUST NOT -send further updates for that child afterwards. +value; a child whose state was never reported has an unknown state. A +concrete state replaces the entire previous state object, not the session. +The child's title is reported via `session_info_update`; per-operation tasks +belong on tool calls referencing the child. **Type:** Object @@ -8648,33 +8836,25 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/d SubagentSessionCapabilities | null} > - Client-to-agent operations permitted for this subagent session. + Client-initiated session mutations permitted for this subagent session. -Omitted and `null` both mean unchanged. If never supplied, no operations -are permitted. +Omitted and `null` both mean unchanged. If never supplied, no session +mutations are permitted. Read-only operations retain their normal protocol +semantics and capability requirements. - - A short, human-readable label for the subagent. - -Omitted and `null` both mean unchanged. If never supplied, the Client -chooses its own fallback presentation. - - -SubagentState | null} > - The lifecycle state reached by the subagent. +SessionId} required> + The opaque session ID identifying the child in all ACP messages. -Omitted and `null` both mean unchanged. If never supplied, the child is -`running`. +Nested inside `update`; the enclosing notification's `sessionId` identifies +the immediate parent, not this child. -SessionId} required> - The opaque session ID identifying the child in all ACP messages. - - - A human-readable summary of the work delegated to the subagent. +StateUpdate | null} > + Current state snapshot for the child session. -Omitted and `null` both mean unchanged. +Omitted and `null` both mean unchanged; a concrete state replaces the +previous state object wholesale. If never supplied, the state is unknown. @@ -8994,6 +9174,29 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/d + +**UNSTABLE** Display reference to an already-known session on this ACP connection. + + + + + Optional nullable item metadata. Omission and `null` both mean no metadata. + +SessionId} + required +> + Identifier of the already-known session linked from this tool operation, not + the session used to route the enclosing notification. + + + The discriminator value. Must be `"session"`. + + + + + ## ToolCallId Unique identifier for a tool call within a session. @@ -9166,6 +9369,32 @@ See protocol docs: [Creating](https://agentclientprotocol.com/protocol/v1/draft/ Other tool types (default). +## UnknownStateUpdate + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +The Agent cannot currently determine foreground activity. + +Report this when activity becomes unobservable, not merely because the child +has been quiet. The Client MUST stop presenting the previous state as confirmed +current activity, but may retain it as last known. A later state replaces this +snapshot normally. + +This is not a task outcome or session closure. It does not cancel work, resolve +pending requests, or revoke capabilities; capabilities are updated separately. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP for additional metadata. Implementations + MUST NOT make assumptions about values at these keys. Optional; omitted and + `null` mean no metadata for this state snapshot. + + ## UnstructuredCommandInput All text that was typed after the command name is provided as input. diff --git a/docs/protocol/v1/draft/tool-calls.mdx b/docs/protocol/v1/draft/tool-calls.mdx index bb5125940..4028b4482 100644 --- a/docs/protocol/v1/draft/tool-calls.mdx +++ b/docs/protocol/v1/draft/tool-calls.mdx @@ -301,6 +301,67 @@ When a terminal is embedded in a tool call, the Client displays live output as i Learn more about Terminals +### Session References (unstable) + +A tool call can link to an already-known session on this ACP connection. An +ordinary session setup or a `subagent_update` announcement establishes a known +target; the target may be a child, parent, or sibling session. Agents **MUST NOT** +send this content unless the Client advertised `clientCapabilities.subagents`, +as defined in the [Subagent Sessions RFD](/rfds/subagents): + +```json +{ + "type": "session", + "sessionId": "child_session_id" +} +``` + +`sessionId` is required. Optional nullable `_meta` belongs to this content item; +omission and `null` both mean no item metadata. This is only a display +reference: it does not create or register a session, reparent it, grant controls, +prompt it, subscribe to it, close it, send a message by itself, or change +ownership. Parent-child associations and controls are announced separately by +`subagent_update` on the parent session. + +The notification's `params.sessionId` remains the session owning the operation +and routes its update; the content item's `sessionId` links the operation to +another known session. For example, these are **params fragments** (not complete +notifications): + +```json +{ + "sessionId": "parent_session_id", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "call_delegate", + "title": "Delegate task", + "content": [{ "type": "session", "sessionId": "child_session_id" }] + } +} +``` + +```json +{ + "sessionId": "child_session_id", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "call_reply", + "title": "Reply to parent", + "content": [{ "type": "session", "sessionId": "parent_session_id" }] + } +} +``` + +The child-to-parent case can show a reply interaction, but the reference does +not send that reply. Reference links can point both ways without making the +ownership tree cyclic. The child's own text, plan, and tool updates use +`params.sessionId: "child_session_id"`. V1 reports the child's work state through +`subagent_update.state` on the parent stream, rather than a child-addressed +`state_update` notification. One operation can reference several known sessions, +and several operations can reference the same session. Keep each operation's +title, input, output, and status on its tool call; completing the tool call does +not mean the referenced session is idle or terminated. + ## Following the Agent Tool calls can report file locations they're working with, enabling Clients to implement "follow-along" features that track which files the Agent is accessing or modifying in real-time. diff --git a/docs/protocol/v2/draft/prompt-lifecycle.mdx b/docs/protocol/v2/draft/prompt-lifecycle.mdx index fd54ddc34..25cc3b75d 100644 --- a/docs/protocol/v2/draft/prompt-lifecycle.mdx +++ b/docs/protocol/v2/draft/prompt-lifecycle.mdx @@ -559,6 +559,23 @@ Custom or future stop reasons can be used when Clients can display a generic sto Foreground work is blocked on user action. + + The Agent cannot currently determine foreground activity. This experimental + state is introduced by the subagents RFD and gated by `unstable_subagents` in + the Rust schema. For example, a remote child feed may be lost while ACP + remains connected. + + +Agents report `unknown` on actual loss of observability, not merely after +silence. Clients must no longer present a prior `running` or `requires_action` +as confirmed live activity, though they may retain the last-known state for +context. A subsequent `running`, `requires_action`, or `idle` replaces +`unknown` for the same session. `unknown` is neither a work outcome nor a +closure or cancellation, and does not resolve pending requests or automatically +revoke child mutation capabilities; the Agent updates those capabilities +separately if a control becomes unavailable. See the experimental +[subagents RFD](/rfds/subagents) for v1 and recovery behavior. + Background activity **MAY** continue and emit other `session/update` notifications while the Agent reports `idle`. These notifications do not change the state. ## Cancellation diff --git a/docs/protocol/v2/draft/schema.mdx b/docs/protocol/v2/draft/schema.mdx index d10819d20..b3e529ad0 100644 --- a/docs/protocol/v2/draft/schema.mdx +++ b/docs/protocol/v2/draft/schema.mdx @@ -7611,6 +7611,29 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/d +## SessionCancelCapabilities + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Capability to cancel work in a subagent session without ending that session. + +Supplying `\{\}` advertises support; an omitted or `null` `cancel` does not. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility) + + + ## SessionCapabilities Session capabilities supported by the agent. @@ -8068,6 +8091,40 @@ An opaque cursor used to paginate `session/list` results. **Type:** `string` +## SessionReference + +**UNSTABLE** Display reference to an already-known session on this ACP connection. + +The enclosing notification's `params.sessionId` identifies the session whose +transcript is updated; this item's `sessionId` links that tool operation to +another known session for display. Ordinary session setup or a +`subagent_update` announcement establishes a known target. A parent can +reference a child, and a child can reference its parent or a sibling. +Parent-child associations and controls are announced separately by +`subagent_update`. This item does not create or register a session, reparent +it, grant controls, prompt it, subscribe to it, close it, send a message, +or change ownership. Reference links can point both ways without making the +ownership tree cyclic. A tool call may +reference multiple known sessions, and multiple tool calls may reference +the same session. Tool-call status describes the operation, not whether +the referenced session is idle or terminated. + +**Type:** Object + +**Properties:** + + + Optional nullable item metadata. Omission and `null` both mean no metadata. + +SessionId} + required +> + Identifier of the already-known session linked from this tool operation, not + the session used to route the enclosing notification. + + ## SessionUpdate Different types of updates that can be sent while a session exists. @@ -8721,23 +8778,22 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/d SubagentSessionCapabilities | null} > - Client-to-agent operations permitted for this subagent session. + Client-initiated session mutations permitted for this subagent session. + +Read-only operations retain their normal protocol semantics and +capability requirements. + - - A short, human-readable label for the subagent. It need not be unique. +SessionId} required> + The opaque session ID identifying the child in all ACP messages. + +Nested inside `update`; the enclosing notification's `sessionId` identifies +the immediate parent, not this child. + The discriminator value. Must be `"subagent_update"`. -SubagentState | null} > - The reported lifecycle state of the subagent. - -SessionId} required> - The opaque session ID identifying the child in all ACP messages. - - - A human-readable summary of the work delegated to the subagent. - @@ -8854,6 +8910,28 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/d + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +The Agent cannot currently determine foreground activity. +This replaces previously confirmed activity without ending the work or session. + + + + + The _meta property is reserved by ACP for additional metadata. Implementations + MUST NOT make assumptions about values at these keys. Optional; omitted and + `null` mean no metadata for this state snapshot. + + + The discriminator value. Must be `"unknown"`. + + + + + Custom or future session state. @@ -9060,61 +9138,24 @@ Optional. Omitted and `null` are equivalent and mean no title is provided. This capability is not part of the spec yet, and may be removed or changed at any point. -Client-to-agent operations permitted for a specific subagent session. +Client-initiated session mutations permitted for a specific subagent session. + +A mutation requires an explicit per-child capability; support for the method +on ordinary sessions does not grant support on a child. **Type:** Object **Properties:** - - The _meta property is reserved by ACP to allow clients and agents to attach - additional metadata to their interactions. Implementations MUST NOT make - assumptions about values at these keys. - - - Whether the client may cancel this subagent. Omission is equivalent to - `false`. - - -## SubagentState - -**UNSTABLE** - -This capability is not part of the spec yet, and may be removed or changed at any point. - -Lifecycle state of an announced subagent. - -All states except `running` are terminal. - -**Type:** Union - - - The subagent is working on its task. This is the initial state. - - - - The subagent completed its task successfully. - - - - The subagent failed to complete its task. - - - - The subagent was cancelled. - - - - The Agent lost the child runtime and cannot determine its task outcome. + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. - - -Custom or future subagent state. - -Values beginning with `_` are reserved for implementation-specific -extensions. Unknown values that do not begin with `_` are reserved for -future ACP variants. - +SessionCancelCapabilities | null} > + Permits the client to cancel this child's current work without ending +the session. Omitted or `null` means unsupported; an object (including +`\{\}`) means supported. ## SubagentUpdate @@ -9123,23 +9164,27 @@ future ACP variants. This capability is not part of the spec yet, and may be removed or changed at any point. -An upsert for a subagent exposed by its parent session. +An upsert associating a reusable child session with its immediate parent. Sent on the immediate parent session. The first update for an unknown -`SubagentUpdate::subagent_session_id` announces the child and MUST be sent -before any `session/update` bearing the child's session ID. No Client -capability is required. - -Only `SubagentUpdate::subagent_session_id` is required. Other fields have +`SubagentUpdate::session_id` announces the child and MUST be sent +before any request or notification bearing the child's session ID, or any +tool-call session reference to it. Child events are delivered automatically; +no separate child load, resume, or subscription is needed. +Understanding this update, registering child sessions, and applying their +operation restrictions are baseline v2 requirements; no Client capability +is required. + +Only `SubagentUpdate::session_id` is required. Other fields have patch semantics: omitted fields leave the stored value unchanged, `null` clears or unsets the value, and concrete values replace it. A child whose -state is unset is `running`; a child whose capabilities are unset permits -no operations. +capabilities are unset permits no Client-initiated session mutations. -The update that carries a terminal `SubagentUpdate::state` MUST be sent -after all child session updates and after every pending permission or -elicitation request issued for the child has resolved. The Agent MUST NOT -send further updates for that child afterwards. +The child's title is reported through `SessionInfoUpdate` on its own +stream. Its foreground work uses ordinary `StateUpdate` notifications. +Completing or cancelling work does not end the association: the parent may +message the same child again. Individual operations and their outcomes belong +to tool calls referencing the child, not to this association. **Type:** Object @@ -9154,19 +9199,18 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/d SubagentSessionCapabilities | null} > - Client-to-agent operations permitted for this subagent session. - - - A short, human-readable label for the subagent. It need not be unique. - -SubagentState | null} > - The reported lifecycle state of the subagent. + Client-initiated session mutations permitted for this subagent session. + +Read-only operations retain their normal protocol semantics and +capability requirements. + -SessionId} required> +SessionId} required> The opaque session ID identifying the child in all ACP messages. - - - A human-readable summary of the work delegated to the subagent. + +Nested inside `update`; the enclosing notification's `sessionId` identifies +the immediate parent, not this child. + ## Terminal @@ -9568,6 +9612,29 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/d + +**UNSTABLE** Display reference to an already-known session on this ACP connection. + + + + + Optional nullable item metadata. Omission and `null` both mean no metadata. + +SessionId} + required +> + Identifier of the already-known session linked from this tool operation, not + the session used to route the enclosing notification. + + + The discriminator value. Must be `"session"`. + + + + + Custom or future tool call content. @@ -9842,6 +9909,32 @@ future ACP variants. +## UnknownStateUpdate + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +The Agent cannot currently determine foreground activity. + +Report this when activity becomes unobservable, not merely because the session +has been quiet. The Client MUST stop presenting the previous state as confirmed +current activity, but may retain it as last known. A later state replaces this +snapshot normally. + +This is not a task outcome or session closure. It does not cancel work, resolve +pending requests, or revoke capabilities; capabilities are updated separately. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP for additional metadata. Implementations + MUST NOT make assumptions about values at these keys. Optional; omitted and + `null` mean no metadata for this state snapshot. + + ## Usage **UNSTABLE** diff --git a/docs/protocol/v2/draft/tool-calls.mdx b/docs/protocol/v2/draft/tool-calls.mdx index 2b10f6ede..e24e7b43f 100644 --- a/docs/protocol/v2/draft/tool-calls.mdx +++ b/docs/protocol/v2/draft/tool-calls.mdx @@ -457,6 +457,69 @@ sequences, so decoders retain parser state across chunks. Optional nullable `_meta` is chunk-scoped; omission and `null` both mean no chunk metadata was provided. +### Session References (unstable) + +A tool call can link to an already-known session on this ACP connection. An +ordinary session setup or a `subagent_update` announcement establishes a known +target; the target may be a child, parent, or sibling session: + +```json +{ + "type": "session", + "sessionId": "child_session_id" +} +``` + +`sessionId` is required. Optional nullable `_meta` belongs to this content item; +omission and `null` both mean no item metadata. This is only a display +reference: it does not create or register a session, reparent it, grant controls, +prompt it, subscribe to it, close it, send a message by itself, or change +ownership. Parent-child associations and controls are announced separately by +`subagent_update` on the parent session. + +The notification's `params.sessionId` remains the session owning the operation +and routes its update; the content item's `sessionId` links the operation to +another known session. For example, these are **params fragments** (not complete +notifications): + +```json +{ + "sessionId": "parent_session_id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call_delegate", + "title": "Delegate task", + "content": [{ "type": "session", "sessionId": "child_session_id" }] + } +} +``` + +```json +{ + "sessionId": "child_session_id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call_reply", + "title": "Reply to parent", + "content": [{ "type": "session", "sessionId": "parent_session_id" }] + } +} +``` + +The child-to-parent case can show a reply interaction, but the reference does +not send that reply. Reference links can point both ways without making the +ownership tree cyclic. The child's own text, state, and tool updates use +`params.sessionId: "child_session_id"`. One operation can reference several +known sessions, and several operations can reference the same session. Keep each +operation's title, input, output, and status on its tool call; completing the +tool call does not mean the referenced session is idle or terminated. + +Understanding associations and these references is part of the v2 baseline +described in the [Subagent Sessions RFD](/rfds/subagents); a dedicated subagent +UI is optional. Older decoders can preserve this item as unknown content, but +that compatibility fallback is not sufficient implementation of the subagent +contract. + ### Diffs File modifications shown as diffs. A diff always includes structured file diff --git a/docs/rfds/session-usage.mdx b/docs/rfds/session-usage.mdx index fae489399..6f4c8568d 100644 --- a/docs/rfds/session-usage.mdx +++ b/docs/rfds/session-usage.mdx @@ -184,6 +184,20 @@ Cost is cumulative session state, similar to context window utilization: - Both cost and context window are session-level metrics that can change when agents compact, switch models, or restore sessions - Cost is optional because not all agents track it +### Can clients add costs across parent and child sessions? + +Not from the session relationship alone. A parent's provider-reported +cumulative cost may already include subagents or internal helper calls, and +separately reported child costs may overlap it. Exposing those children should +not discard a useful parent total or silently change its accounting scope. + +Clients can display each session's reported cost, but should not infer a tree +total or exclusive shares without an accounting contract that establishes the +values' coverage and how any overlap is handled. Missing child costs are unknown, +not zero. +The [Subagent Sessions RFD](/rfds/subagents#cost-reporting-without-inferred-aggregation) +preserves provider totals without introducing a new cost-scope field. + ### Why not assume USD for cost? Agents may bill in different currencies: diff --git a/docs/rfds/subagents.mdx b/docs/rfds/subagents.mdx index d28cb8978..447f81bf1 100644 --- a/docs/rfds/subagents.mdx +++ b/docs/rfds/subagents.mdx @@ -9,17 +9,20 @@ Author(s): Vadim Briliantov > What are you proposing to change? -Allow an Agent to expose subagents that it creates while handling a prompt. Each +Allow an Agent to expose subagents that it creates when delegating work. Each subagent is represented by its own ACP session ID, so its messages, thoughts, -plans, tool calls, and lifecycle can be displayed independently from the parent +plans, tool calls, and current work can be displayed independently from the parent session. -The parent Agent announces and tracks each subagent with upsert-style -`subagent_update` session updates. Subsequent `session/update` notifications -use the subagent's session ID. A subagent session is observational by default: -the Client cannot prompt, queue a message for, or steer it. An Agent may -separately advertise that a particular subagent can be cancelled by the -Client. +The Agent associates a reusable child session with its parent through an +upsert-style `subagent_update`. Child events then flow automatically on the +same connection. Individual delegate, send-message, and wait operations can be +represented by tool calls that reference the child session. + +The association is not a task or a one-shot lifetime. The parent can message +the same child again after earlier work finishes. Client-initiated session +mutations remain disabled unless explicitly advertised for that child; this +proposal initially defines only cancellation of current work. ## Status quo @@ -35,10 +38,10 @@ hide it, or encode it in custom tool calls. Clients cannot reliably: - render concurrent work as separate activity; - associate plans, messages, and tool calls with the worker that produced them; -- show a subagent's name and assigned task; +- distinguish a worker's current title from the history of tasks sent to it; - track nested subagents; - cancel one subagent without cancelling the parent turn; or -- distinguish a completed subagent from one that is still running. +- distinguish an idle child from one that is still processing work. Treating a subagent as an ordinary user-facing session is also inaccurate. It would imply that Clients can call methods such as `session/prompt`, or future @@ -51,9 +54,10 @@ accept user input for that worker. ### Capability negotiation -Add an optional `subagents` object to `ClientCapabilities`. Its presence means -the Client understands the session updates and restricted-session semantics in -this RFD. The field is optional and non-nullable. An omitted field means the +In v1, add an optional `subagents` object to `ClientCapabilities`. A non-null +object means the Client understands the association updates, tool-call session +references, and restricted-session semantics in this RFD. The field is optional +and nullable, like adjacent capability objects. Omission or `null` means the capability is not supported; `{}` means it is supported. ```json @@ -64,29 +68,29 @@ capability is not supported; `{}` means it is supported. } ``` -An Agent **MUST NOT** send the updates defined by this RFD unless the Client -advertised `subagents`. It may still use subagents internally and present their -results through the parent session. +In v1, an Agent **MUST NOT** send the updates or session-reference content +defined by this RFD unless the Client advertised `subagents`. It may still use +subagents internally and present their results through the parent session. There is no Agent-side capability. Subagent visibility flows only from Agent to Client, so the Client capability alone is enough to prevent sending updates the other side cannot understand; a Client learns that a particular Agent exposes -subagents by receiving the first update. The Client capability itself exists -only because v1 Clients predate this RFD. In ACP v2, subagent updates are part +subagents by receiving the first update. In ACP v2, subagent updates are part of the baseline session model and support is assumed rather than negotiated; see [Subagents in ACP v2](#subagents-in-acp-v2). -### Announcing and updating a subagent +### Announcing and updating an association -All subagent information travels in a single `subagent_update` session update -with upsert semantics, following the entity-update pattern ACP v2 uses for tool -calls, messages, and terminals. It is sent on the immediate parent session: the -first update for an unknown `subagentSessionId` announces the child, and later -updates for the same ID modify it. +`subagent_update` associates a child session with its immediate parent. The +first update for an unknown `update.sessionId` announces the association; +later updates patch it. The child's conversation, title, and work are separate +from this relationship. When an Agent creates a subagent that it wants to expose, it **MUST** send the -announcing `subagent_update` before sending any updates bearing the child's -session ID: +announcing `subagent_update` before sending any request or notification bearing +the child's session ID, or any tool-call session reference to it. This includes +permission, elicitation, filesystem, and terminal requests where supported by +the protocol version: ```json { @@ -96,49 +100,79 @@ session ID: "sessionId": "sess_parent", "update": { "sessionUpdate": "subagent_update", - "subagentSessionId": "sess_child_1", - "name": "test-investigator", - "task": "Find the cause of the failing integration tests", + "sessionId": "sess_child_1", "capabilities": { - "cancel": true + "cancel": {} } } } } ``` -Only `subagentSessionId` is required. For every other field, omission and -`null` are equivalent and mean the field is unchanged; a concrete value -replaces the previous one. Fields can therefore be revised mid-run — a task -summary that becomes more specific as work proceeds, for example — but not -cleared: - -- `subagentSessionId` is an opaque `SessionId` unique within the ACP - connection. It identifies the child in all subsequent ACP messages. -- `name` is an optional short, human-readable label. It need not be unique. If - it was never supplied, the Client chooses its own fallback presentation. -- `task` is an optional human-readable summary of the work delegated to the - child. It is descriptive, not a prompt that the Client can edit or resubmit. -- `capabilities` optionally describes the Client-to-Agent operations permitted - for this specific child session. `cancel` is an optional, non-nullable - boolean whose omission is equivalent to `false`. If `capabilities` was never - supplied, no operations are permitted. -- `state` is the optional lifecycle state defined in [Lifecycle](#lifecycle). - If it was never supplied, the child is `running`, so the announcing update - usually omits it. +Only `update.sessionId` is required: + +- `update.sessionId` is an opaque `SessionId` unique within the ACP + connection. It identifies the same child across repeated delegations and + **MUST NOT** be reused for an unrelated session. +- `capabilities` optionally describes the Client-initiated session mutations + permitted for this specific child session. `cancel` is an optional, + nullable capability object: omitted or `null` means unsupported, while `{}` + advertises support. The object may include optional nullable `_meta`; omitted + or `null` `_meta` means no capability metadata. If + `capabilities` was never supplied, no session mutations are permitted. +- `state` is a **v1-only** optional current-work snapshot, defined in + [Current work state](#current-work-state). It is not a lifecycle outcome. +- `_meta` optionally carries association metadata. + +In v1, these optional fields are nullable: omission and `null` both mean +unchanged; a concrete value replaces the whole previous field. In v2, omission +means unchanged and `null` clears the field. A concrete `capabilities: {}` +disables all Client-initiated mutations in either version. + +The `capabilities` object is replaced as a whole, not patched recursively. +For example, `capabilities: { "cancel": null }` disables individual cancellation +in either version. This is different from outer `capabilities: null`, which +means unchanged in v1 and clears the capability set in v2. The outer `sessionId` establishes the immediate parent. This supports arbitrary nesting without adding a second parent identifier. If a child spawns another subagent, the Agent sends `subagent_update` with the child's session ID as the -outer `sessionId`. +outer `sessionId`. A child has one immediate parent; the association cannot +reparent an existing session, refer to itself, or create a cycle. + +This association describes ownership and management, not the set of sessions +that may interact with the child. Tool-call references describe interactions +and may point from a child back to its parent or to another known session +without changing the ownership tree. + +Adapters **MUST** distinguish the child's conversation identity from the IDs of +individual provider tasks, turns, and tool calls. Completing a task does not +justify allocating a new ACP session ID for the same child conversation. +Provider IDs may be used directly only when their scope and lifetime meet +these requirements; otherwise the adapter maintains a stable mapping. The Agent **MUST** send the announcing update even if it expects the child to -finish very quickly, so that the Client never receives child updates it cannot -attribute to an announced session. How a Client tracks or renders announced -children is its own concern; the ordering guarantee is the protocol contract. -SDKs **MAY** additionally tolerate non-conforming Agents by buffering updates -for unknown session IDs until the announcing update arrives, but Agents -**MUST NOT** rely on such tolerance. +finish very quickly, so that the Client can attribute every child request and +notification to an announced session. How a Client renders announced children +is its own concern; registering the child for routing before handling its +traffic is the protocol contract. SDKs **MAY** additionally tolerate +non-conforming Agents by buffering child traffic until the announcing update +arrives, but Agents **MUST NOT** rely on such tolerance. + +Provider callbacks need not arrive in announcement order. If an interaction +arrives before the corresponding spawn metadata, the adapter can announce a +minimal association as soon as it knows the child's identity and immediate +parent, or buffer the interaction until it can do so. It **MUST NOT** guess +parentage or temporarily announce a nested child under the root. + +Exposure is optional. If the runtime cannot supply sufficient identity and +parentage without blocking the operation, the Agent may keep that operation +unexposed and represent it through the parent instead. It **MUST NOT** later +move an already-issued interaction to a different session. Once a child is +exposed, new interactions known to belong to that child's work **MUST** use its +session ID rather than silently falling back to the root. A parent's own +request for permission to create or delegate to a child remains a parent +interaction; it does not require announcing a child that does not yet exist. The child's execution context — its working directory, MCP servers, and available tools — is chosen by the Agent and is not guaranteed to match the @@ -151,10 +185,21 @@ the Client imposed on the parent session. A future RFD may add explicit per-child execution-context fields if Clients need to display or negotiate them. -### Independent update streams +### Automatic child event delivery + +Announcement registers the child for routing on the existing ACP connection. +The Agent then sends child events without waiting for another Client request. +This RFD does not introduce `session/attach`, `session/subscribe`, or a +per-child event opt-in. Clients **MUST NOT** call `session/load` or +`session/resume` on a child to begin receiving its events. -After the announcing update, the Agent sends existing ACP updates for the child -in the normal form, using the child's session ID: +Automatic delivery is an ACP contract, not an assumption about the provider +API. The adapter remains responsible for enabling child output, attaching any +provider-side listeners, and restoring those listeners when reconnecting. + +Existing session updates are addressed to the child in the normal way. For +example, its current display title uses `session_info_update`, not a duplicate +`name` field on the parent association: ```json { @@ -163,39 +208,48 @@ in the normal form, using the child's session ID: "params": { "sessionId": "sess_child_1", "update": { - "sessionUpdate": "plan", - "entries": [ - { - "content": "Reproduce the failing test", - "priority": "high", - "status": "in_progress" - } - ] + "sessionUpdate": "session_info_update", + "title": "Test investigator" } } } ``` Updates from the parent and any number of children may be interleaved. Ordering -is defined by the transport order within each session; no ordering between -different sessions is implied. +is defined by the transport order within each session, subject to the +announcement requirement above. In v2, Agents **MUST NOT** place an announcement +and traffic that depends on it in the same JSON-RPC batch, since batch entries +may be processed in any order. Subagents may use Agent-to-Client methods such as filesystem, terminal, and permission requests when the Client advertised those capabilities. A subagent session is therefore not "read-only" with respect to the workspace. It is restricted only in the direction of user interaction: the Client observes it -and may use explicitly advertised lifecycle controls, but cannot submit new +and may use explicitly advertised controls, but cannot submit new work to it. Existing permission and security boundaries apply equally to parent and child activity. -### Lifecycle +### Operations reference sessions -`state` is one of `running`, `completed`, `failed`, `cancelled`, or -`disconnected`. A child whose state was never reported is `running`; every -other state is terminal. +Add a `session` variant to `ToolCallContent`, containing a required, +non-nullable `sessionId` and optional nullable `_meta`. Omitted or `null` +`_meta` means no metadata for that content item. -The Agent **MUST** report a terminal state for every announced subagent by -sending a `subagent_update` on its immediate parent session: +A session reference is a display anchor, like a terminal reference. It does +not create the target session, change its parent, subscribe to events, grant +mutation capabilities, send a message, or control its lifetime. The target +**MUST** already be known to the Client through ordinary session setup or a +`subagent_update` announcement. It can be an ordinary parent session, an +announced child, or another known session on the connection. A reference is +not an alternative way to register an unknown session. + +Multiple operations may reference the same session, and removing a reference +from tool-call content does not close the target. Bidirectional reference +links do not create an ownership cycle. Clients should use navigation or +bounded previews rather than recursively expanding references without a limit. + +For example, this v2 tool call reports successful delivery of a follow-up +message to the previously announced child: ```json { @@ -204,207 +258,486 @@ sending a `subagent_update` on its immediate parent session: "params": { "sessionId": "sess_parent", "update": { - "sessionUpdate": "subagent_update", - "subagentSessionId": "sess_child_1", - "state": "completed" + "sessionUpdate": "tool_call_update", + "toolCallId": "send_follow_up", + "title": "Ask the test investigator to check Windows", + "status": "completed", + "rawInput": { + "message": "Also check whether the failure occurs on Windows." + }, + "content": [ + { + "type": "session", + "sessionId": "sess_child_1" + } + ] } } } ``` -`disconnected` means the Agent can no longer associate the announced child -with a live runtime and therefore does not know its task outcome. It is a -terminal state for the exposed ACP child, not a claim that the delegated task -failed or was cancelled. An Agent **MUST NOT** report `failed` or `cancelled` -solely because a connection ended or a child runtime could not be restored. - -The update that carries the terminal state **MUST** be sent after all child -`session/update` notifications and after every pending Agent-to-Client request -for the child has resolved, as defined in -[Pending permission and elicitation requests](#pending-permission-and-elicitation-requests). -The Agent **MUST NOT** send further `subagent_update`s for that child -afterwards. A Client may then retain the child for display or discard its -transient state. - -### Unknown outcomes on a live connection - -Two failures can leave an announced child without a terminal lifecycle update -outside of the `session/load` flow below: - -- the ACP connection ends while the child is still running; or -- the parent `session/prompt` request returns — with any stop reason or with an - error — before the child's terminal update was received. Under the - [v1 scope rule](#scope-in-acp-v1) this is an Agent bug, but Clients still - need a defined outcome. - -In both cases the Client **MUST** immediately place the child and its announced -descendants in a local `disconnected` state: the task outcome is unknown. The -Client **MUST NOT** present such a child as `failed` or `cancelled`, **MUST -NOT** continue presenting it as running, and **MUST NOT** wait indefinitely for -a terminal update. - -Waiting for further updates is not a real alternative: after the connection -ends nothing more can arrive, and after `session/prompt` returns, v1 ties -`session/update` to the active prompt turn, so there is no defined channel -through which the child's actual outcome could still be delivered. - -This local state is presentational only. The Client **MUST NOT** emit a wire -message to report it: `session/update` flows only from Agent to Client, and no -Client-to-Agent notification exists for subagent state. - -Because the Client synthesized this state rather than receiving it, it is -provisional. History replayed by a subsequent `session/load` of the parent, -including `disconnected` updates synthesized by the orphan-recovery flow below, -is authoritative and replaces it. If a late terminal `subagent_update` for the -child nevertheless arrives on a still-live connection, the Client **MAY** -accept it in place of the local state, but **MUST NOT** return the child to a -running presentation. +Communication in the other direction uses the same content shape. If the +child sends findings to its parent through a tool operation, the child owns +that tool call and the reference points back to the parent: -### Reconnection and replay +```json +{ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "sess_child_1", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "report_to_parent", + "title": "Send findings to the parent", + "status": "completed", + "rawInput": { + "message": "The failure also occurs on Windows." + }, + "content": [ + { + "type": "session", + "sessionId": "sess_parent" + } + ] + } + } +} +``` -The Client reconnects only the parent session. It **MUST NOT** call -`session/load` or `session/resume` with a subagent session ID. This RFD does not -define restoration of child runtimes: loading or resuming a parent restores -only the parent runtime. - -The authoritative flow for reconstructing the child tree is: - -1. The Client calls `session/load` with the parent session ID. -2. The Agent examines persisted child history. Every announced child without a - corresponding terminal `subagent_update` identifies an orphan. -3. The Agent terminates each orphan with a `subagent_update` whose state is - `disconnected`. For nested orphans, descendants are terminated before - their parents, and every terminal update is sent on the immediate parent - session. -4. The Agent replays the parent and child history in its original order, - preserving the child session IDs. Each synthesized `disconnected` update is - sent after all persisted updates for that child. -5. The Client reconstructs the session tree from announcing `subagent_update`s - and treats `disconnected` as terminal. Replayed child IDs are historical - display identifiers and cannot be loaded or resumed independently. - -An implementation may persist synthesized terminal updates. Repeated loads -**MUST** produce the same terminal result, **MUST NOT** revive an orphan, and -**MUST NOT** report it as `failed` or `cancelled` merely because its runtime was -not restored. - -`session/resume` does not replay or reconstruct child history. The Agent -**MUST NOT** send a standalone child terminal update during resume because the -Client may not have received the corresponding announcing update. A -Client that retained UI state from the interrupted connection keeps showing -unterminated children in the local `disconnected` state defined in -[Unknown outcomes on a live connection](#unknown-outcomes-on-a-live-connection). -If it needs the authoritative child tree and terminal states, it uses -`session/load` instead. +This reports the Agent's own communication operation; it does not invoke +Client-to-Agent `session/prompt` or create a reverse `subagent_update`. + +V1 uses the same content item with its existing `tool_call` announcement and +`tool_call_update` pattern. + +The operation's `title`, `rawInput`, `rawOutput`, content, and status describe +that operation only. A blocking delegation may remain in progress until the +child returns a result; a send operation may complete as soon as delivery +succeeds; a wait operation may report a result later. The Agent reports the +semantics of the actual operation rather than inventing a blocking task around +every message. In particular, a completed send **MUST NOT** be interpreted as +the child having finished processing it. + +The child's current title can change without rewriting previous operations' +titles or instructions. There is no session-wide `task` field: successive +assignments belong to their respective operations. Agents that delegate +outside tool invocations can still announce and stream child sessions without +inventing a tool call. + +### Why session IDs appear in different places + +These IDs answer different questions; they are not competing ways to route +the same event: + +| Location | Purpose | +| ----------------------------------------------------------- | --------------------------------------------------------------------------------- | +| Outer `params.sessionId` | Which session's event stream and session-local entities does this update address? | +| `params.update.sessionId` in `subagent_update` | Which child is being associated with the immediate parent named by the outer ID? | +| Tool-call content `{ "type": "session", "sessionId": "…" }` | Which already-known session does this particular operation reference? | + +For example, with parent `sess_parent` and child `sess_child_1`: + +1. An association update has outer `sessionId: "sess_parent"` and + `update.sessionId: "sess_child_1"`. This registers the parent-child + relationship and the child's capabilities. +2. A send or wait tool call still has outer `sessionId: "sess_parent"`, but its + content references `sessionId: "sess_child_1"`. The operation belongs to the + parent's history; the reference lets the Client display or navigate to the + child involved. +3. The child's own messages, plans, and tool calls have outer + `sessionId: "sess_child_1"`. They update the child's history, not the + referencing parent tool call. In v2 its work-state notifications use this + same child-addressed stream; v1 reports work state through the association + as described below. +4. A child-to-parent message tool call has outer `sessionId: "sess_child_1"` + and a content reference to `sessionId: "sess_parent"`. Its direction does + not reverse the parent-child association. + +A later operation can reference the same child, and one wait operation can +reference several children. Consequently, content references cannot replace +the outer routing ID. They also cannot replace the association announcement: +an operation does not establish parentage or own the referenced session. +Clients can show child activity inline with a reference without copying that +activity into the parent session's protocol history. + +### Current work state + +There is no separate subagent lifecycle enum. Work can start and stop many +times within the same child session. Becoming idle, completing an operation, +or cancelling current work does not permanently end the association. + +V2 uses the child's ordinary `state_update` notifications and their existing +foreground-work semantics. The parent association has no `state` field in v2. +For example: -### Restricted session methods +```json +{ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "sess_child_1", + "update": { + "sessionUpdate": "state_update", + "state": "idle", + "stopReason": "end_turn" + } + } +} +``` -The Client **MUST NOT** call `session/new`, `session/load`, `session/resume`, -`session/fork`, `session/prompt`, `session/close`, or any queueing or steering -method with a subagent session ID. Subagents are created, assigned work, and -released by their parent, not by the Client. +V1 has no equivalent notification and no Client-issued child prompt request +whose response could report completion. It therefore carries the same +`StateUpdate` payload as the optional `state` field of `subagent_update`: -If `capabilities.cancel` is `true`, the Client may send the existing -`session/cancel` notification with the subagent session ID. This cancels only -that subagent and its descendants. It does not cancel its parent or siblings. -After cancellation finishes, the Agent sends a `subagent_update` with -`state: "cancelled"`. +```json +{ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "sess_parent", + "update": { + "sessionUpdate": "subagent_update", + "sessionId": "sess_child_1", + "state": { + "state": "idle", + "stopReason": "end_turn" + } + } + } +} +``` -Unlike cancellation of a user-facing session, there is no child -`session/prompt` request to complete with a `cancelled` stop reason. The -terminal `subagent_update` is the Client's confirmation that cancellation -finished. +The v1 payload mirrors the v2 state object: + +- `running`: foreground work is in progress. +- `requires_action`: foreground work is blocked on user action. +- `unknown`: the Agent cannot currently determine foreground activity, for + example after losing a remote child's event feed while the ACP connection + remains live. This is a nonterminal work state, not a work outcome. +- `idle`: foreground work is not in progress; the parent may assign more work. + Optional nullable `stopReason` uses v1's existing `StopReason`. Omitted or + `null` means no reason was reported. When the draft end-turn usage feature is + supported, optional nullable `usage` follows the same convention. +- Each known state may contain optional nullable `_meta`; omitted and `null` + both mean no metadata for that snapshot. + +The v1 Agent **MUST** report `running` when child foreground work starts or +resumes and `idle` when it stops. It **SHOULD** report `requires_action` while +foreground work is blocked on user action, and include `stopReason` when the +reason for stopping is known. Cancellation uses `idle` with +`stopReason: "cancelled"`, not a terminal child state. + +When observability of child foreground work is actually lost, the Agent +**MUST** report `unknown` using the existing state payload: v1 sends +`subagent_update` with `"state": { "state": "unknown" }` on the parent stream; +v2 sends `state_update` with `"state": "unknown"` on the child stream. Silence +alone is not evidence of lost observability. The Client **MUST NOT** continue +presenting an earlier `running` or `requires_action` as confirmed live activity +after `unknown`, though it may retain that last-known value for context. A +subsequent `running`, `requires_action`, or `idle` replaces `unknown` for the +same child ID. `unknown` neither closes or cancels the child nor resolves +pending requests; it does not automatically revoke mutation capabilities. +If a control is no longer available, the Agent updates `capabilities` +separately. V1 `state: null` still means unchanged, not `unknown`. + +The outer v1 `state` field is a replacement snapshot, not a nested patch: +omission or `null` leaves the previous snapshot unchanged, while a concrete +object replaces it entirely. For example, `{ "state": "running" }` replaces an +earlier idle snapshot and its stop reason. If no state has been reported, the +Client has no current-work state; announcement alone does not imply `running`. + +Unrecognized state objects preserve both their discriminator and payload. Values +beginning with `_` are implementation-specific; other unknown values are +reserved for future ACP states. An unknown value does not imply completion or +closure. V2 keeps its open state and stop-reason handling, with `unknown` added +to ordinary `StateUpdate` under the experimental `unstable_subagents` feature. + +Idle is not a successful task outcome or a declaration that the session has +been released. As in ordinary v2 sessions, background activity may still emit +updates while foreground work is idle. Operation results and failures are +reported through the relevant tool calls or other ordinary session output. + +### Connection loss + +Connection availability is separate from reported work state. After losing +the ACP connection, the Client **MUST NOT** continue presenting a last-known +running state as confirmed live activity, or infer that unfinished work +succeeded, failed, or was cancelled. It may show the child as disconnected or +its current activity as unknown while retaining recorded outcomes. This +Client-local uncertainty differs from an Agent-reported `unknown` state while +ACP is connected. + +This is local presentation, not a synthesized wire state or a permanent change +to the session's history. Fresh state reported after reconnecting is +authoritative and may be `running` again for the same child ID. Neither the +parent's prompt response nor its transition to idle implies a lost connection +or a stopped child. -The Client **MUST NOT** infer individual cancellation support merely because -the Agent can spawn subagents. The per-child flag is authoritative. +### Reconnection and replay -### Parent cancellation and failure +The Client reconnects through the ordinary parent session. Direct child +`session/load` and `session/resume` are not supported by this proposal. +The Agent decides whether parent reconnection reattaches surviving child +runtimes, restores them, or only makes their recorded history available. +The protocol does not prescribe a runtime-recovery algorithm. + +The replay entry point depends on the protocol version: + +- **ACP v1:** `session/load` requests history replay. `session/resume` restores + the parent without replaying history. +- **ACP v2:** `session/resume` with `replayFrom: { "type": "start" }` requests + full history replay. Omitted or `null` `replayFrom` means no history replay. + v2 has no `session/load` method. + +The parent session's ordinary replay obligations are unchanged. In addition, +the Agent **SHOULD** replay available recorded associations and child history +on a best-effort basis. Provider retention, process restarts, and missing +transcripts may leave some or all child history unavailable; complete child +history is not a prerequisite for supporting live subagent sessions. + +The following rules still apply to whatever is replayed: + +- Replayed child updates **MUST** preserve their original session identities, + known parentage, and per-session ordering. Child announcements precede child + traffic, and session-reference targets must already be known. +- If identity and parentage are recoverable but conversation content is not, + the Agent may replay only the association. This is historical context, not + evidence of a live runtime or a complete transcript. +- If an association cannot be reconstructed truthfully, the Agent **MUST NOT** + guess its parent or issue traffic for an unannounced child. It may omit that + child's replay and omit unresolved session-reference items while preserving + the parent operation's available title, input, output, and other content. +- The Agent **SHOULD** make known gaps apparent through a supported advisory + mechanism rather than imply that child replay is complete. Missing history + is not evidence that a child never existed, stopped, failed, or was cancelled. +- All history selected for this replay **MUST** be sent before the response. + The response completes the best-effort child replay; it does not certify + that every child record was available. Delayed historical child state must + not be sent after that boundary as if it were fresh activity. + +Adapters **SHOULD** retain or reconstruct child identity, associations, and +ACP-visible conversation data where practical. This does not require a durable +copy of every child event. Replay can use provider transcripts or retained +snapshots rather than reproduce the original wire chunking. These best-effort +history rules do not weaken identity or announcement requirements for live +child traffic after reconnection. + +Capability negotiation still applies during replay. V1 Clients that did not +advertise `subagents` receive ordinary parent history, not unsupported child +updates or session-reference content. Parent-level results and summaries can +still be replayed. + +The parent load/resume response is the freshness boundary: + +1. When loading or resuming a parent, the Client **MUST** invalidate cached live + work state and mutation authorization for its children. Historical data may + be retained for display. +2. Child state and capabilities replayed before the response are historical. + They **MUST NOT** enable current mutation controls or be presented as + confirmed live activity. Replay **MUST NOT** reissue historical permission, + elicitation, or other Agent-to-Client requests. +3. After responding, the Agent **MUST** reannounce each continuing child and + its complete chain of intermediate ancestors up to the resumed parent, in + parent-before-child order, before sending fresh child traffic or session + references. This includes ancestors whose + runtimes were not restored: an association registers identity and routing, + not a live runtime. Preserve the original IDs and immediate parentage; + surviving grandchildren **MUST NOT** be reparented to the root. Each + reannouncement **MUST** contain a complete current `capabilities` object; + a routing-only ancestor may use `{}` and omit work state (or report + `unknown` if its activity cannot be determined). This also applies to + non-replaying resume, so the Client need not retain an earlier session tree. +4. Fresh child work state is reported after the response, either in the v1 + reannouncement or through subsequent state updates. Until fresh state is + reported, the child's current activity remains unconfirmed. + +Surviving runtimes may continue to execute during replay, but the Agent **MUST** +queue their fresh notifications and requests until after the response and +current reannouncements. In v2, the response and traffic that depends on this +boundary **MUST NOT** share a JSON-RPC batch. Current association snapshots +after a non-replaying resume are not conversation-history replay. + +For example, if `root` → `coordinator` → `worker` was previously announced +and only `worker` resumes, the Agent first reannounces `coordinator` under +`root` (possibly with `capabilities: {}` and no confirmed activity), then +`worker` under `coordinator`, before sending fresh `worker` traffic. It does +not announce `worker` directly under `root`. + +The Agent **SHOULD** report current child work state when it can determine it. +Historical running states are not proof of current activity. An Agent **MUST +NOT** manufacture a failure, cancellation, or permanent orphan outcome solely +because the persisted history lacks a final update or a runtime could not be +restored. A history-only child remains displayable, with unconfirmed current +activity and no enabled mutations. -Cancelling or closing a parent session **MUST** cascade to all running -descendants. The Agent **MUST** emit a terminal `subagent_update` for each -announced descendant before completing the parent cancellation or close. -Closing a subagent's parent — via the existing `sessionCapabilities.close` -capability — is also how a Client releases child resources; there is no -child-level close. +### Restricted session methods -A child failure does not automatically fail or cancel its parent. The parent -Agent decides whether to recover, delegate the work again, or report the -failure in its own output. +Client-initiated methods that modify a child session or its runtime are +disabled unless explicitly enabled by that child's capabilities. This is an +allowlist, not a list of individual prohibited methods: Clients **MUST NOT** +invoke an unadvertised mutation, including a future method, merely because the +Agent supports it for ordinary sessions. + +Mutations include prompting, queueing, steering, cancellation, closing, +deletion, mode or configuration changes, and forking. Loading or resuming also +changes runtime attachment and is not a read-only history query. The only +mutation capability defined by this RFD is `cancel`; direct child load, resume, +and close are therefore not allowed. The parent Agent's own communication with +its children is not a Client-initiated mutation and is not restricted by these +capabilities. + +Read-only operations retain their normal protocol semantics and capability +requirements; this RFD does not grant access to additional sessions or define +a new history-query method. Responses to Agent-to-Client requests are not +Client-initiated session mutations and **MUST** still be delivered. + +Restricted children are discovered through parent associations rather than +ordinary standalone `session/list` entries. This avoids offering load, resume, +or prompt controls that are not available for them. + +If forking a parent copies child history, the Agent **MUST** consistently remap +the copied session IDs and references to them, including references back to the +forked root. Each copied child still has one parent and a distinct identity. +References to sessions outside the copied tree do not make those sessions +part of the copy; unresolved targets follow the replay rules above. Copying +history does not itself create live child runtimes. Parent deletion keeps its +ordinary history-management semantics; it is not a substitute for closing +active sessions. + +### Cancellation and resource ownership + +If `capabilities.cancel` is a non-null object, the Client may send the existing +`session/cancel` notification with the child's session ID. This cancels current +work in that child and its active descendants, not its parent or siblings. +Cancelling a parent likewise cascades to its active descendants. The per-child +capability controls individual Client cancellation; it does not prevent the Agent +from cancelling its own delegated work. + +An omitted or `null` per-child `cancel` capability does not waive ancestor +cancellation. Adapters must implement that ownership policy explicitly rather +than assume that interrupting a provider's root turn stops every background +child. Advertise individual cancellation only when the adapter can target the +child's current work correctly in that runtime mode. + +Cancellation retains ordinary session semantics: abort the affected work and +send its pending updates before confirming cancellation. A child confirms +cancellation with `idle` and `stopReason: "cancelled"` through its v1 state +snapshot or ordinary v2 notification. There is no Client-issued child +`session/prompt` response to wait for. A cancellation racing with already-ended +work does not rewrite that work's outcome. + +Acceptance of a provider stop/interrupt command is not, by itself, evidence that +the work has stopped. The Agent reports cancellation from actual work-state or +completion evidence, not merely from a successful command acknowledgement. If +activity becomes unobservable, it reports `unknown` rather than inventing a +cancelled outcome. + +The Client **SHOULD** optimistically mark the affected unfinished tool calls as +cancelled, as specified for [v1 prompt cancellation](/protocol/v1/prompt-turn#cancellation) +and [v2 work cancellation](/protocol/v2/prompt-lifecycle#cancellation). It +**SHOULD** still accept subsequent Agent updates for those calls. This local +presentation is not a new v1 wire-level tool status. + +Cancellation does not close the child session or prevent the parent from +assigning later work to it. Completing or failing one operation also does not +close the session or automatically fail its parent. + +The Agent owns child resources. Closing the ordinary parent session cancels +its active descendant work and releases the associated active resources under +the existing close contract. It does not erase child history or require a new +terminal-state notification. Whether child runtimes can later be restored is +left to the Agent. ### Pending permission and elicitation requests -A child, or one of its descendants, can own pending Agent-to-Client requests -such as `session/request_permission` and `elicitation/create` when the child or -an ancestor is cancelled or closed. - -Every such request **MUST** resolve before the `subagent_update` that carries -the child's terminal state. The Agent **MUST NOT** send the terminal update -while a request it issued for that child or its descendants is still -outstanding. Resolution is either a Client response — including the `cancelled` -permission outcome or the `cancel` elicitation action — or -[request cancellation](/protocol/v1/cancellation) by the Agent, which still -completes the request with a response or a `-32800` error. - -Mirroring [prompt-turn cancellation](/protocol/v1/prompt-turn#cancellation), -when the Client sends `session/cancel` for a child, or cancels or closes a -parent whose cancellation cascades to the child, it **MUST** respond to -pending `session/request_permission` requests for the child and its descendants -with the `cancelled` outcome and **SHOULD** respond to pending -`elicitation/create` requests with the `cancel` action. - -Responses travel from Client to Agent while the terminal update travels from -Agent to Client, so the two can cross on the wire: - -- If the Client receives the terminal `subagent_update` while it still - holds an unanswered permission or elicitation request for that child or its - descendants, it **MUST** resolve the request immediately with the `cancelled` - outcome or the `cancel` action and release the interactive control. The - request remains attributed to the terminated child's history: a terminal - child **MUST NOT** retain a live control, and the control **MUST NOT** move - to the parent or any other session. -- If the Agent receives a permission or elicitation response for a child that - it has started terminating, the response still resolves the request, but the - Agent **MUST** treat it as if it were `cancelled`: it **MUST NOT** start new - child activity based on it and **MUST NOT** send child `session/update` - notifications after the terminal update. - -### Scope in ACP v1 - -ACP v1 ties `session/update` closely to an active prompt turn. To avoid adding a -second prompt-lifecycle change to this RFD, all announced subagents and their -terminal `subagent_update`s **MUST** complete before the parent -`session/prompt` request returns. Detached background subagents that outlive the -parent turn are out of scope for this initial proposal. +Optimistically updating a tool's display does not resolve its JSON-RPC +requests. When the Client cancels child work, directly or through an ancestor, +it **MUST** respond to pending `session/request_permission` requests for the +affected work with the `cancelled` outcome, following the existing cancellation +rule. It **SHOULD** dismiss affected elicitation controls and respond with the +`cancel` action. The same cleanup applies when closing the parent. + +Generic [`$/cancel_request`](/protocol/v1/cancellation) remains optional. +Agents may use it to cancel an abandoned child request, including when the +Agent stops work on its own. Advertising subagent support does not add a +requirement to implement that generic mechanism. + +If the Client learns that an interactive request's associated work has ended +and the request no longer applies, it **SHOULD** dismiss the control and send +the corresponding cancellation response. Idle alone is not a cancellation of +unrelated background requests. Requests remain attributed to their original +child session and **MUST NOT** move to the parent or a later operation. + +A late response still resolves its original request, but the Agent **MUST NOT** +use it to restart cancelled or otherwise abandoned work, or to authorize a new +assignment to the same child. A request **MUST NOT** receive more than one +response. There is no new child-termination barrier that requires every +outstanding request in a reusable session to resolve before reporting work +state. + +### Cost reporting without inferred aggregation + +Exposing child sessions does not make existing `usage_update.cost` values +exclusive or additive. Cost reporting remains optional. An Agent may continue +reporting the provider's cumulative cost for the parent session even when it +includes child work or internal helper calls. It **MUST NOT** change that +accounting scope merely because the Client can now display subagents. No +exclusive breakdown is required, and an inclusive provider total need not be +discarded. + +Child costs remain optional and may overlap the parent's reported cost. +Missing child cost is unknown, not zero. Agents **MUST NOT** fabricate an +exclusive parent or child amount from incomplete token counts or an aggregate +total. + +Clients **MUST NOT** infer a combined tree total by adding parent and child +values, or infer exclusive costs by subtracting them, unless a separate +accounting contract establishes their coverage and how any overlap is handled. +Display each session's latest reported cumulative value without claiming an +accounting scope that has not been established. Do not add successive updates +or count a session again for each tool-call reference. + +For example, a parent's reported USD 1.20 may already include a child's USD +0.30. Both values can be shown, but the Client must not synthesize USD 1.50 as +the combined cost from the session tree alone. A missing cost for another child +does not invalidate the provider's USD 1.20 report. + +This RFD adds no cost-scope field or accounting negotiation. A future usage +extension can define explicit inclusive/exclusive breakdowns where available. +Amounts in different currencies must not be added without conversion under +such an accounting contract. Context-window usage remains local to each session +and is not summed across the tree. ### Subagents in ACP v2 -The v2 schema carries the same `subagent_update`, adapted to v2 conventions: +The two versions use the same association and session-reference model, with +these differences: -- **No capability.** v2 Clients preserve and otherwise ignore unknown - `sessionUpdate` types, so an Agent may send `subagent_update` without prior - negotiation. The `subagents` Client capability is v1-only. +- **Baseline support, no capability.** v2 Clients **MUST** understand + `subagent_update`, register child sessions for routing, apply their operation + restrictions, understand session references, and attribute their requests and + notifications correctly. + Clients may choose not to render a dedicated subagent UI, but cannot merely + ignore the announcement as an unknown update. The `subagents` Client + capability is v1-only. - **v2 patch semantics.** As with tool calls and terminals, only - `subagentSessionId` is required; for other fields, omission leaves the stored + `update.sessionId` is required; for other fields, omission leaves the stored value unchanged while `null` clears or unsets it (in v1, `null` is instead - equivalent to omission). A child whose `state` is unset is `running`; a child - whose `capabilities` are unset permits no operations. -- **Open state enum.** Unknown `state` values are preserved rather than - dropped, matching other v2 status enums. Values beginning with `_` are - reserved for implementation-specific extensions; other unknown values are - reserved for future ACP states. - -The restricted-session rules, cancellation flow, pending-request resolution, -and orphan recovery defined above apply to v2 unchanged. The v1 scope rule -above does not carry over as-is: the proposed v2 prompt lifecycle decouples -session updates from the prompt turn and resolves `session/prompt` at -acceptance, which is expected to make detached subagents that outlive the -parent turn expressible — and it removes the "prompt returned without a -terminal update" trigger, leaving connection loss as the only cause for the -Client-local `disconnected` state in -[Unknown outcomes on a live connection](#unknown-outcomes-on-a-live-connection). -Defining detached-subagent semantics stays out of scope for this RFD until the -v2 prompt lifecycle stabilizes; until then, v2 Agents **SHOULD** still -terminate announced children within the turn. + equivalent to omission). A child whose `capabilities` are unset permits no + Client-initiated session mutations. +- **Ordinary child work state.** V2's `subagent_update` has no `state` field. + V2 uses `state_update` on the child's own stream. V1 embeds the corresponding + state object in the parent association update instead. +- **Resume with replay.** Available child history is replayed best-effort + through `session/resume` with `replayFrom: { "type": "start" }`, not + `session/load`. + +The restricted-session rules, cancellation flow, and connection-loss handling +defined above also apply to v2. The [v2 prompt lifecycle](/protocol/v2/prompt-lifecycle) resolves +`session/prompt` at acceptance and reports foreground work through +`state_update`. Neither that response nor the parent's transition to `idle` +implies that every child has finished. ## Shiny future @@ -423,14 +756,13 @@ summarizing their output in the parent session. > Tell me more about your implementation. What is your detailed implementation plan? -1. Add `SubagentCapabilities` to the client initialization capabilities. -2. Add `SubagentSessionCapabilities`, `SubagentUpdate`, and `SubagentState` - schema types. -3. Add the `subagent_update` variant to `SessionUpdate` and regenerate all SDK - schemas. -4. Mirror the `subagent_update` types into the v2 schema behind the same - unstable flag — capability-free, with v2 patch semantics and an open state - enum — so the v1 and v2 wire shapes stay aligned. +1. Add the v1 `SubagentCapabilities` marker and the `SubagentUpdate` association + and `SubagentSessionCapabilities` types in both versions. +2. Add a `SessionReference` tool-call content variant in both versions. +3. Mirror the v2 `StateUpdate` payload in v1 and embed it in the v1 association + update. V2 continues to use its existing child-session state notification. +4. Keep the additions behind `unstable_subagents` and regenerate schemas and + reference documentation. 5. Ship SDK releases that carry the draft `subagents` capability and update types — or at minimum preserve them when deserializing and re-serializing — before adapter rollout. SDKs that strip the draft fields force temporary @@ -438,14 +770,79 @@ summarizing their output in the parent session. Such bridges are compatibility shims, not an alternative protocol, and are retired once SDK support ships. SDK preservation of the draft fields is an explicit prerequisite for the validation step below. -6. Update example Clients to keep a session tree and route updates by session - ID. SDKs may buffer updates for unknown child session IDs until the - announcing update arrives, as tolerance for non-conforming Agents. -7. Update example Agents to announce a child before its first update and to - emit exactly one terminal `subagent_update`, including `disconnected` for - orphaned children that cannot be restored after reconnection. -8. Validate the design in at least one Agent with native subagents and one - Client with concurrent-session UI before stabilization. +6. Provide an SDK/transport path that sends the parent load/resume response + before releasing fresh child traffic. For example, use an explicit responder + or an after-response hook. A handler that only returns a response value + cannot satisfy the ordering rule by sending fresh notifications just before + returning. Do not rely on arbitrary delays or assumed event-loop timing. +7. Update example Clients to route child events automatically, retain reusable + session identities, and render per-operation session references. Adapters + must separately enable and restore provider-side child event delivery. +8. Implement stable provider-to-ACP identity mapping, truthful interaction + attribution, and cancellation of current work. Retain or reconstruct child + history where practical, but do not make complete child transcripts a + prerequisite for live exposure. +9. Exercise the [validation scenarios](#validation-scenarios) with captured + native events, including reordered callbacks and reconnects. +10. Validate this revision against both Claude Code and Codex adapter mappings + and a Client with concurrent-session UI before stabilization. Schema tests + and the existence of provider APIs alone are not end-to-end validation. + +### Provider integration notes + +The following inspected versions provide concrete inputs for adapters; they +are not a claim that all versions or runtime modes have equivalent behavior, +or that the existing adapters already implement this revision. + +- **Claude Agent SDK 0.3.280:** the [published declarations](https://unpkg.com/@anthropic-ai/claude-agent-sdk@0.3.280/sdk.d.ts) + expose forwarded child messages through `forwardSubagentText` and + `parent_tool_use_id`, task lifecycle events, permission `agentID` and + `toolUseID`, `stopTask(taskId)`, and child transcript APIs such as + `listSubagents` and `getSubagentMessages`. Adapters must correlate these IDs + rather than make a provider's task/tool ID the lifetime of an ACP session. + Permission callbacks can precede spawn metadata. Query cost totals may + already include child and internal work. +- **Codex 0.156.1:** thread/turn identifiers, thread status and approval/input + wait flags, `turn/interrupt(threadId, turnId)`, and thread history support + the corresponding mapping. The server + [attaches listeners to newly created threads](https://github.com/openai/codex/blob/b412ff32c417f855c2b2d1581b77058eed87c84b/codex-rs/app-server/src/lib.rs#L1265-L1283); + the adapter still owns ACP routing and must verify listener restoration for + existing threads on reconnect. Per-thread token usage is not a monetary + cost report. + +These mappings must be validated against the adapter's pinned dependencies. +For example, a provider's generic paused status does not necessarily mean +`requires_action`, and acknowledging an interrupt is not a completed +cancellation. Likewise, a decoder preserving a new field is not sufficient +implementation of child routing or the replay freshness boundary. + +### Validation scenarios + +- Send two assignments to the same child conversation: keep its ACP session + ID, use distinct operation tool calls, and allow `running → idle → running`. +- Reference the parent from a child-owned message tool call, and reference a + sibling from another operation. Verify that event ownership and the + parent-child tree do not change. +- Complete a send operation before the child finishes processing it, and keep + receiving child events after the parent's prompt response. +- Deliver a child permission callback before spawn metadata, including a + nested child: verify early truthful announcement or buffering, no guessed + parent, and the unexposed-operation fallback when necessary. +- Cancel a child and then an ancestor while interactions are pending: verify + native work is targeted correctly, acknowledgement is not confused with + completion, and late responses cannot authorize later assignments. +- Replay after restarting the adapter with complete, partial, and unavailable + child history. Preserve IDs and relationships for whatever is replayed, + omit unresolved references rather than guess ancestry, and do not infer an + outcome from a gap. Verify that live traffic follows the parent response + and current capability snapshots, including routing-only ancestors. +- Resume without history and restore provider listeners before forwarding live + child events. No additional Client attach/subscribe request is required. +- Lose a child's activity feed while ACP stays connected: report `unknown` + without inventing an outcome, and accept later state for the same child. +- Report an inclusive parent cost with missing or overlapping child costs: + retain the parent report and do not synthesize a tree total or exclusive + shares from the references. ## Frequently asked questions @@ -481,78 +878,84 @@ are valid and defines the required cascade behavior. ### Why one `subagent_update` type instead of separate spawn and state notifications? -An earlier draft used a `subagent_spawned` notification plus a -`subagent_state_update` for the terminal state. ACP v2 instead treats tool -calls, messages, and terminals as entities maintained by a single upsert-style -update, and subagents now follow that pattern so the design carries into v2 -unchanged: the first update announces the entity, later updates patch -individual fields, and one of them reports the terminal state. A single type -also lets descriptive fields such as `task` be revised mid-run without adding -further notification types. +The upsert announces an association and patches its capabilities without a +separate create method. Ordinary session updates already handle the child's +title, conversation, and, in v2, foreground work. V1 embeds a state snapshot +only because it lacks the existing v2 state notification. Neither version +needs a second, permanently terminal subagent lifecycle. -### Why is the child identified by `subagentSessionId` rather than `sessionId`? +### Why are there two `sessionId` fields in an association update? -A `subagent_update` travels on the parent's stream, where the enclosing -`params.sessionId` already denotes the parent. Reusing the key name `sessionId` -for a different session inside the same message would invite routing bugs in -code that dispatches by field name. The longer name states which of the two -sessions it identifies. +The fields have different scopes: `params.sessionId` is the parent whose stream +receives the association update, while `params.update.sessionId` is the child +being associated with it. The tagged `SessionUpdate` payload is flattened +inside `update`, not into `params`, so these names do not collide. A tool-call +session reference uses its own `sessionId` to identify the known session +referenced by that operation. See [Why session IDs appear in different places](#why-session-ids-appear-in-different-places). ### Why is `cancel` the only per-child capability? -It is the smallest control validated by the reference integrations: stopping a -runaway worker is the lifecycle action users reliably need, and it maps -directly onto the existing `session/cancel` notification. An earlier draft also -advertised a per-child `close`, but the parent Agent owns the child's runtime -and releases its resources when the child terminates or when the parent session -is closed, so a Client-initiated close had no clear job. `capabilities` is an -object precisely so that future per-child controls — `close`, `prompt`, -`steer`, or configuration changes — can be added without a breaking change once -interoperable semantics emerge. - -### Why does the `disconnected` state exist? - -`completed`, `failed`, and `cancelled` are claims about the delegated task's -outcome. After a crash or reconnection, an Agent can find an announced child it -can no longer associate with a live runtime, and the parent session's own state -cannot express which of its children ended how. Without `disconnected`, the -Agent would have to guess (`failed`), lie (`cancelled`), or leave the child -running forever in the Client's presentation. The parent being able to -re-prompt or recover on its own does not remove the need to close out the -child's announced history honestly. +Stopping current work is a useful minimal control and maps to +`session/cancel`. The Agent retains ownership of child resources; the Client +does not need to attach, subscribe, or close each child merely to display it. +The capability object leaves room for future controls without making those +operations available implicitly. + +### Why not subscribe to individual child streams? + +Selective delivery could reduce bandwidth and processing costs for large +session trees, so it is worth revisiting. Clients can already choose which +sessions to render or expand without changing event delivery. + +A future subscription proposal would need to distinguish transcript delivery +from ownership announcements, capabilities, current work state, cancellation, +and permission or elicitation requests. Hiding a transcript must not strand an +interaction or make an unobserved child appear stopped. It would also need +defined catch-up or snapshot behavior when a Client attaches, including gaps +in retained history and ordering relative to live events. + +This RFD keeps automatic delivery and does not add attach/subscribe methods. + +### Why remove the terminal `disconnected` state? + +The earlier draft correctly avoided inventing success, failure, or +cancellation when the outcome was unknown. It unnecessarily made that +uncertainty a permanent property of the child session. Connection availability +and recorded operation outcomes are now separate: Clients show unconfirmed +activity as unknown, and Agents may restore the same child without rewriting +history or pretending to know what happened while disconnected. ### Why not model a subagent as a tool call? -A tool call is useful for a compact parent-level summary, but it cannot contain -an independent stream of plans, messages, nested tool calls, or child -subagents. A session is the existing ACP abstraction that already provides -those streams. +The two represent different things. A tool call models a particular +delegation, message, or wait; its session-reference content places the child +in the relevant feed entry. The reusable session owns the independent stream +of messages, plans, and nested activity. A single child can therefore appear +in several operations without sharing their titles, inputs, or completion +statuses. ### Why not allow prompting or steering a subagent? -Some runtimes can message a running worker, but others create a worker with a -fixed task and only allow waiting or cancellation. Making conversational input -part of the baseline would exclude those implementations and blur ownership of -the delegated task. A future RFD can add an explicit per-child `prompt` or -`steer` capability if interoperable semantics emerge. +The parent Agent can message or reuse a child according to its runtime's +semantics. This proposal does not expose the same control to the Client: +direct user intervention may be unsupported or interfere with orchestration. +A future per-child capability could opt into it without assuming every Agent +can implement it. -### Why is the field named `task` rather than `description`? +### Where are the child's name and task? -Early draft implementations of this proposal used a `description` field. -Adapters have since converged on `task`: it names the work delegated to the -child rather than describing the child itself, which keeps it distinct from -`name`. `task` is the only canonical field in this RFD. An implementation that -shipped against an early draft may temporarily accept `description` as an -input fallback while migrating, but it emits `task`, and Clients are not -required to understand `description`. +The current display title uses `session_info_update.title`, just like other +sessions. A particular assignment's label and instructions belong in that +operation's tool-call title and input or content. Updating the session title +does not change an earlier operation's label. The association therefore has +neither `name` nor `task`, and does not need a competing `description` field. ### Is `session/fork` sufficient for subagents? No. Forking is initiated by the Client and creates a normal session derived -from existing context. This RFD covers sessions initiated by an Agent during a -turn, with a parent relationship and restricted Client controls. An Agent may -use a fork internally to implement a subagent, but the wire semantics are -different. +from existing context. This RFD covers Agent-created sessions with a parent +relationship and restricted Client controls. An Agent may use a fork +internally to implement a subagent, but the wire semantics are different. ### What alternative approaches did you consider, and why did you settle on this one? @@ -567,6 +970,10 @@ different. - Use separate `subagent_spawned` and `subagent_state_update` notifications, as an earlier draft of this RFD did. Merged into one upsert-style update to match the v2 entity pattern. +- Treat every completed task as the end of a child session. Rejected because + Agents can reuse a child for later work. +- Require child subscriptions or independent load/resume. Deferred: association + already gives the Client automatic event delivery on the parent connection. Representing each child with a session ID reuses the most protocol machinery while the announcing update and positive capability allowlist capture the @@ -574,6 +981,19 @@ important difference from a user-facing session. ## Revision history +- 2026-09-24: Separated reusable session associations from tool-call operations, + added tool-call session references, and moved display titles to ordinary + session info updates. Replaced the terminal lifecycle enum with v1 work-state + snapshots and normal v2 state notifications. Made event delivery automatic, + left runtime restoration to the Agent, retained ordinary optimistic + cancellation without requiring generic request cancellation, and added + nonterminal unknown activity and routing-only ancestor recovery. Clarified + the distinct session ID roles, incorporated provider integration and + validation requirements, and preserved provider-reported costs without + inferred cross-session aggregation. Aligned cancellation with object + capabilities, allowed references back to parents and other known sessions, + and made child-history replay explicitly best-effort while retaining live + identity and ordering guarantees. - 2026-09-15: Merged `subagent_spawned` and `subagent_state_update` into a single upsert-style `subagent_update` following the v2 entity pattern, made `name`, `task`, and `capabilities` optional, added an explicit `running` diff --git a/schema/v1/schema.unstable.json b/schema/v1/schema.unstable.json index 583d563bb..c84bb755c 100644 --- a/schema/v1/schema.unstable.json +++ b/schema/v1/schema.unstable.json @@ -619,6 +619,22 @@ "$ref": "#/$defs/Terminal" } ] + }, + { + "description": "**UNSTABLE** Display reference to an already-known session on this ACP connection.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/SessionReference" + } + ] } ], "discriminator": { @@ -1094,6 +1110,27 @@ }, "required": ["terminalId"] }, + "SessionReference": { + "description": "**UNSTABLE** Display reference to an already-known session on this ACP connection.\n\nThe enclosing notification's `params.sessionId` identifies the session whose\ntranscript is updated; this item's `sessionId` links that tool operation to\nanother known session for display. Ordinary session setup or a\n`subagent_update` announcement establishes a known target. A parent can\nreference a child, and a child can reference its parent or a sibling.\nV1 work-state snapshots are carried by `subagent_update` on the parent stream.\nParent-child associations and controls are announced separately by\n`subagent_update`. This item does not create or register a session, reparent\nit, grant controls, prompt it, subscribe to it, close it, send a message,\nor change ownership. Reference links can point both ways without making the\nownership tree cyclic. A tool call may\nreference multiple known sessions, and multiple tool calls may reference\nthe same session. Tool-call status describes the operation, not whether\nthe referenced session is idle or terminated.", + "type": "object", + "properties": { + "sessionId": { + "description": "Identifier of the already-known session linked from this tool operation,\nnot the session used to route the enclosing notification.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "_meta": { + "description": "Optional nullable item metadata. Omission and `null` both mean no metadata.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId"] + }, "ToolCallLocation": { "description": "A file location being accessed or modified by a tool.\n\nEnables clients to implement \"follow-along\" features that track\nwhich files the agent is working with in real-time.\n\nSee protocol docs: [Following the Agent](https://agentclientprotocol.com/protocol/tool-calls#following-the-agent)", "type": "object", @@ -6015,12 +6052,19 @@ "required": ["compactionId", "content"] }, "SubagentSessionCapabilities": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nClient-to-agent operations permitted for a specific subagent session.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nClient-initiated session mutations permitted for a specific subagent session.\n\nA mutation requires an explicit per-child capability; support for the method\non ordinary sessions does not grant support on a child.", "type": "object", "properties": { "cancel": { - "description": "Whether the client may cancel this subagent. Omission is equivalent to `false`.", - "type": "boolean", + "description": "Permits the client to cancel this child's current work without ending\nthe session. Omitted or `null` means unsupported; an object (including\n`{}`) means supported.", + "anyOf": [ + { + "$ref": "#/$defs/SessionCancelCapabilities" + }, + { + "type": "null" + } + ], "x-deserialize-default-on-error": true }, "_meta": { @@ -6031,60 +6075,230 @@ } } }, - "SubagentState": { - "description": "Lifecycle state of an announced subagent.\n\nAll states except `running` are terminal.", - "oneOf": [ + "SessionCancelCapabilities": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapability to cancel work in a subagent session without ending that session.\n\nSupplying `{}` advertises support; an omitted or `null` `cancel` does not.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "StateUpdate": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCurrent foreground-work state of a reusable child session.\n\nEach update is a whole-object snapshot. Idle does not terminate the child;\nthe parent can message it again, transitioning it back to running.\nBackground activity may still emit other session updates while idle.", + "anyOf": [ { - "description": "The subagent is working on its task. This is the initial state.", - "type": "string", - "const": "running" + "description": "Foreground work is in progress.", + "type": "object", + "properties": { + "state": { + "type": "string", + "const": "running" + } + }, + "required": ["state"], + "allOf": [ + { + "$ref": "#/$defs/RunningStateUpdate" + } + ] }, { - "description": "The subagent completed its task successfully.", - "type": "string", - "const": "completed" + "description": "The child is ready to process another prompt.", + "type": "object", + "properties": { + "state": { + "type": "string", + "const": "idle" + } + }, + "required": ["state"], + "allOf": [ + { + "$ref": "#/$defs/IdleStateUpdate" + } + ] }, { - "description": "The subagent failed to complete its task.", - "type": "string", - "const": "failed" + "description": "Foreground work is blocked on user action.", + "type": "object", + "properties": { + "state": { + "type": "string", + "const": "requires_action" + } + }, + "required": ["state"], + "allOf": [ + { + "$ref": "#/$defs/RequiresActionStateUpdate" + } + ] }, { - "description": "The subagent was cancelled.", - "type": "string", - "const": "cancelled" + "description": "The Agent cannot currently determine foreground activity.\n\nThis replaces previously confirmed activity without ending the work or session.", + "type": "object", + "properties": { + "state": { + "type": "string", + "const": "unknown" + } + }, + "required": ["state"], + "allOf": [ + { + "$ref": "#/$defs/UnknownStateUpdate" + } + ] }, { - "description": "The Agent lost the child runtime and cannot determine its task outcome.", - "type": "string", - "const": "disconnected" + "title": "other", + "description": "Custom or future state.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Other unknown values are reserved for future ACP variants.", + "type": "object", + "properties": { + "state": { + "description": "Unrecognized state discriminator.", + "type": "string" + } + }, + "required": ["state"], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "state": { + "type": "string", + "const": "running" + } + }, + "required": ["state"] + }, + { + "type": "object", + "properties": { + "state": { + "type": "string", + "const": "idle" + } + }, + "required": ["state"] + }, + { + "type": "object", + "properties": { + "state": { + "type": "string", + "const": "requires_action" + } + }, + "required": ["state"] + }, + { + "type": "object", + "properties": { + "state": { + "type": "string", + "const": "unknown" + } + }, + "required": ["state"] + } + ] + }, + "additionalProperties": true } ] }, + "RunningStateUpdate": { + "description": "Foreground work is in progress.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP for additional metadata.\nImplementations MUST NOT make assumptions about values at these keys.\nOptional; omitted and `null` mean no metadata for this state snapshot.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "IdleStateUpdate": { + "description": "The child is ready to process another prompt.", + "type": "object", + "properties": { + "stopReason": { + "description": "Reason foreground work stopped. Optional; omitted or `null` means not reported.", + "anyOf": [ + { + "$ref": "#/$defs/StopReason" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "usage": { + "description": "**UNSTABLE** Token usage for completed foreground work.\n\nOptional; omitted or `null` means not reported.", + "anyOf": [ + { + "$ref": "#/$defs/Usage" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP for additional metadata.\nImplementations MUST NOT make assumptions about values at these keys.\nOptional; omitted and `null` mean no metadata for this state snapshot.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "RequiresActionStateUpdate": { + "description": "Foreground work is blocked on user action.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP for additional metadata.\nImplementations MUST NOT make assumptions about values at these keys.\nOptional; omitted and `null` mean no metadata for this state snapshot.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "UnknownStateUpdate": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe Agent cannot currently determine foreground activity.\n\nReport this when activity becomes unobservable, not merely because the child\nhas been quiet. The Client MUST stop presenting the previous state as confirmed\ncurrent activity, but may retain it as last known. A later state replaces this\nsnapshot normally.\n\nThis is not a task outcome or session closure. It does not cancel work, resolve\npending requests, or revoke capabilities; capabilities are updated separately.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP for additional metadata.\nImplementations MUST NOT make assumptions about values at these keys.\nOptional; omitted and `null` mean no metadata for this state snapshot.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, "SubagentUpdate": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAn upsert for a subagent exposed by its parent session.\n\nSent on the immediate parent session. The first update for an unknown\n[`SubagentUpdate::subagent_session_id`] announces the child and MUST be sent\nbefore any `session/update` bearing the child's session ID.\n\nOnly the subagent session ID is required. Omitted fields keep their previous\nvalue; a child whose state was never reported is `running`.\n\nThe update that carries a terminal [`SubagentUpdate::state`] MUST be sent\nafter all child session updates and after every pending permission or\nelicitation request issued for the child has resolved. The Agent MUST NOT\nsend further updates for that child afterwards.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAn upsert for a reusable child session associated with its parent session.\n\nSent on the immediate parent session. The first update for an unknown\n[`SubagentUpdate::session_id`] announces the child and MUST be sent\nbefore any child traffic or reference to the child session. Parents may\nmessage and reuse an announced child across multiple operations.\nChild events are delivered automatically on the same connection; no child\nload, resume, or subscription is needed.\n\nOnly the subagent session ID is required. Omitted fields keep their previous\nvalue; a child whose state was never reported has an unknown state. A\nconcrete state replaces the entire previous state object, not the session.\nThe child's title is reported via `session_info_update`; per-operation tasks\nbelong on tool calls referencing the child.", "type": "object", "properties": { - "subagentSessionId": { - "description": "The opaque session ID identifying the child in all ACP messages.", + "sessionId": { + "description": "The opaque session ID identifying the child in all ACP messages.\n\nNested inside `update`; the enclosing notification's `sessionId` identifies\nthe immediate parent, not this child.", "allOf": [ { "$ref": "#/$defs/SessionId" } ] }, - "name": { - "description": "A short, human-readable label for the subagent.\n\nOmitted and `null` both mean unchanged. If never supplied, the Client\nchooses its own fallback presentation.", - "type": ["string", "null"], - "x-deserialize-default-on-error": true - }, - "task": { - "description": "A human-readable summary of the work delegated to the subagent.\n\nOmitted and `null` both mean unchanged.", - "type": ["string", "null"], - "x-deserialize-default-on-error": true - }, "capabilities": { - "description": "Client-to-agent operations permitted for this subagent session.\n\nOmitted and `null` both mean unchanged. If never supplied, no operations\nare permitted.", + "description": "Client-initiated session mutations permitted for this subagent session.\n\nOmitted and `null` both mean unchanged. If never supplied, no session\nmutations are permitted. Read-only operations retain their normal protocol\nsemantics and capability requirements.", "anyOf": [ { "$ref": "#/$defs/SubagentSessionCapabilities" @@ -6096,10 +6310,10 @@ "x-deserialize-default-on-error": true }, "state": { - "description": "The lifecycle state reached by the subagent.\n\nOmitted and `null` both mean unchanged. If never supplied, the child is\n`running`.", + "description": "Current state snapshot for the child session.\n\nOmitted and `null` both mean unchanged; a concrete state replaces the\nprevious state object wholesale. If never supplied, the state is unknown.", "anyOf": [ { - "$ref": "#/$defs/SubagentState" + "$ref": "#/$defs/StateUpdate" }, { "type": "null" @@ -6114,7 +6328,7 @@ "additionalProperties": true } }, - "required": ["subagentSessionId"] + "required": ["sessionId"] }, "CompleteElicitationNotification": { "description": "Notification sent by the agent when a URL-based elicitation is complete.", @@ -6487,13 +6701,16 @@ "x-deserialize-default-on-error": true }, "subagents": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the client understands exposed subagent sessions.\n\nOptional and non-nullable. Omission means the client does not advertise support.\nSupplying `{}` means the client understands subagent lifecycle updates and restricted\nsession semantics.", - "x-deserialize-default-on-error": true, - "allOf": [ + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the client understands exposed subagent sessions.\n\nOptional and nullable. Omitted or `null` both mean the client does not\nadvertise support.\nSupplying `{}` means the client understands child associations, work-state\nsnapshots, tool-call session references, and restricted-session semantics.", + "anyOf": [ { "$ref": "#/$defs/SubagentCapabilities" + }, + { + "type": "null" } - ] + ], + "x-deserialize-default-on-error": true }, "plan": { "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the client supports `plan_update` and `plan_removed` session updates.\n\nOptional. Omitted or `null` both mean the client does not advertise support.\nSupplying `{}` means the client can receive both update types.", @@ -6661,7 +6878,7 @@ } }, "SubagentCapabilities": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapability marker for exposing subagents as restricted ACP sessions.\n\nSupplying `{}` advertises support for subagent lifecycle updates and restricted-session\nsemantics. The client must advertise this capability before the agent sends subagent\nupdates.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapability marker for exposing reusable child sessions as restricted ACP sessions.\n\nSupplying `{}` advertises support for child association and state updates,\ntool-call session references, and restricted-session semantics. The client\nmust advertise this capability before the agent sends subagent updates or\nsession references.", "type": "object", "properties": { "_meta": { diff --git a/schema/v2/schema.unstable.json b/schema/v2/schema.unstable.json index d3fed09e6..8d9d052bb 100644 --- a/schema/v2/schema.unstable.json +++ b/schema/v2/schema.unstable.json @@ -1028,6 +1028,22 @@ } ] }, + { + "description": "**UNSTABLE** Display reference to an already-known session on this ACP connection.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/SessionReference" + } + ] + }, { "title": "other", "description": "Custom or future tool call content.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nReceivers that do not understand this content type should preserve the\nraw payload when storing, replaying, proxying, or forwarding tool call\noutput, and otherwise ignore it or display it generically.", @@ -1070,6 +1086,16 @@ } }, "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session" + } + }, + "required": ["type"] } ] }, @@ -2030,6 +2056,27 @@ }, "required": ["terminalId"] }, + "SessionReference": { + "description": "**UNSTABLE** Display reference to an already-known session on this ACP connection.\n\nThe enclosing notification's `params.sessionId` identifies the session whose\ntranscript is updated; this item's `sessionId` links that tool operation to\nanother known session for display. Ordinary session setup or a\n`subagent_update` announcement establishes a known target. A parent can\nreference a child, and a child can reference its parent or a sibling.\nParent-child associations and controls are announced separately by\n`subagent_update`. This item does not create or register a session, reparent\nit, grant controls, prompt it, subscribe to it, close it, send a message,\nor change ownership. Reference links can point both ways without making the\nownership tree cyclic. A tool call may\nreference multiple known sessions, and multiple tool calls may reference\nthe same session. Tool-call status describes the operation, not whether\nthe referenced session is idle or terminated.", + "type": "object", + "properties": { + "sessionId": { + "description": "Identifier of the already-known session linked from this tool operation,\nnot the session used to route the enclosing notification.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "_meta": { + "description": "Optional nullable item metadata. Omission and `null` both mean no metadata.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId"] + }, "ToolCallLocation": { "description": "A file location being accessed or modified by a tool.\n\nEnables clients to implement \"follow-along\" features that track\nwhich files the agent is working with in real-time.\n\nSee protocol docs: [Following the Agent](https://agentclientprotocol.com/protocol/v2/draft/tool-calls#following-the-agent)", "type": "object", @@ -6553,6 +6600,18 @@ } } }, + "UnknownStateUpdate": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe Agent cannot currently determine foreground activity.\n\nReport this when activity becomes unobservable, not merely because the session\nhas been quiet. The Client MUST stop presenting the previous state as confirmed\ncurrent activity, but may retain it as last known. A later state replaces this\nsnapshot normally.\n\nThis is not a task outcome or session closure. It does not cancel work, resolve\npending requests, or revoke capabilities; capabilities are updated separately.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP for additional metadata.\nImplementations MUST NOT make assumptions about values at these keys.\nOptional; omitted and `null` mean no metadata for this state snapshot.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, "StateUpdate": { "description": "The state of the agent's foreground work has changed.\n\nBackground activity can continue and emit other `session/update` notifications\nwhile `idle`. Those notifications do not change this state.", "anyOf": [ @@ -6604,6 +6663,22 @@ } ] }, + { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe Agent cannot currently determine foreground activity.\nThis replaces previously confirmed activity without ending the work or session.", + "type": "object", + "properties": { + "state": { + "type": "string", + "const": "unknown" + } + }, + "required": ["state"], + "allOf": [ + { + "$ref": "#/$defs/UnknownStateUpdate" + } + ] + }, { "title": "other", "description": "Custom or future session state.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", @@ -6646,6 +6721,16 @@ } }, "required": ["state"] + }, + { + "type": "object", + "properties": { + "state": { + "type": "string", + "const": "unknown" + } + }, + "required": ["state"] } ] }, @@ -7523,12 +7608,19 @@ "required": ["compactionId", "content"] }, "SubagentSessionCapabilities": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nClient-to-agent operations permitted for a specific subagent session.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nClient-initiated session mutations permitted for a specific subagent session.\n\nA mutation requires an explicit per-child capability; support for the method\non ordinary sessions does not grant support on a child.", "type": "object", "properties": { "cancel": { - "description": "Whether the client may cancel this subagent. Omission is equivalent to `false`.", - "type": "boolean", + "description": "Permits the client to cancel this child's current work without ending\nthe session. Omitted or `null` means unsupported; an object (including\n`{}`) means supported.", + "anyOf": [ + { + "$ref": "#/$defs/SessionCancelCapabilities" + }, + { + "type": "null" + } + ], "x-deserialize-default-on-error": true }, "_meta": { @@ -7539,65 +7631,32 @@ } } }, - "SubagentState": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nLifecycle state of an announced subagent.\n\nAll states except `running` are terminal.", - "anyOf": [ - { - "description": "The subagent is working on its task. This is the initial state.", - "type": "string", - "const": "running" - }, - { - "description": "The subagent completed its task successfully.", - "type": "string", - "const": "completed" - }, - { - "description": "The subagent failed to complete its task.", - "type": "string", - "const": "failed" - }, - { - "description": "The subagent was cancelled.", - "type": "string", - "const": "cancelled" - }, - { - "description": "The Agent lost the child runtime and cannot determine its task outcome.", - "type": "string", - "const": "disconnected" - }, - { - "title": "other", - "description": "Custom or future subagent state.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", - "type": "string" + "SessionCancelCapabilities": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapability to cancel work in a subagent session without ending that session.\n\nSupplying `{}` advertises support; an omitted or `null` `cancel` does not.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true } - ] + } }, "SubagentUpdate": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAn upsert for a subagent exposed by its parent session.\n\nSent on the immediate parent session. The first update for an unknown\n[`SubagentUpdate::subagent_session_id`] announces the child and MUST be sent\nbefore any `session/update` bearing the child's session ID. No Client\ncapability is required.\n\nOnly [`SubagentUpdate::subagent_session_id`] is required. Other fields have\npatch semantics: omitted fields leave the stored value unchanged, `null`\nclears or unsets the value, and concrete values replace it. A child whose\nstate is unset is `running`; a child whose capabilities are unset permits\nno operations.\n\nThe update that carries a terminal [`SubagentUpdate::state`] MUST be sent\nafter all child session updates and after every pending permission or\nelicitation request issued for the child has resolved. The Agent MUST NOT\nsend further updates for that child afterwards.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAn upsert associating a reusable child session with its immediate parent.\n\nSent on the immediate parent session. The first update for an unknown\n[`SubagentUpdate::session_id`] announces the child and MUST be sent\nbefore any request or notification bearing the child's session ID, or any\ntool-call session reference to it. Child events are delivered automatically;\nno separate child load, resume, or subscription is needed.\nUnderstanding this update, registering child sessions, and applying their\noperation restrictions are baseline v2 requirements; no Client capability\nis required.\n\nOnly [`SubagentUpdate::session_id`] is required. Other fields have\npatch semantics: omitted fields leave the stored value unchanged, `null`\nclears or unsets the value, and concrete values replace it. A child whose\ncapabilities are unset permits no Client-initiated session mutations.\n\nThe child's title is reported through [`SessionInfoUpdate`] on its own\nstream. Its foreground work uses ordinary [`StateUpdate`] notifications.\nCompleting or cancelling work does not end the association: the parent may\nmessage the same child again. Individual operations and their outcomes belong\nto tool calls referencing the child, not to this association.", "type": "object", "properties": { - "subagentSessionId": { - "description": "The opaque session ID identifying the child in all ACP messages.", + "sessionId": { + "description": "The opaque session ID identifying the child in all ACP messages.\n\nNested inside `update`; the enclosing notification's `sessionId` identifies\nthe immediate parent, not this child.", "allOf": [ { "$ref": "#/$defs/SessionId" } ] }, - "name": { - "description": "A short, human-readable label for the subagent. It need not be unique.", - "type": ["string", "null"], - "x-deserialize-default-on-error": true - }, - "task": { - "description": "A human-readable summary of the work delegated to the subagent.", - "type": ["string", "null"], - "x-deserialize-default-on-error": true - }, "capabilities": { - "description": "Client-to-agent operations permitted for this subagent session.", + "description": "Client-initiated session mutations permitted for this subagent session.\n\nRead-only operations retain their normal protocol semantics and\ncapability requirements.", "anyOf": [ { "$ref": "#/$defs/SubagentSessionCapabilities" @@ -7608,18 +7667,6 @@ ], "x-deserialize-default-on-error": true }, - "state": { - "description": "The reported lifecycle state of the subagent.", - "anyOf": [ - { - "$ref": "#/$defs/SubagentState" - }, - { - "type": "null" - } - ], - "x-deserialize-default-on-error": true - }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Omitted means no metadata update; `null` is an\nexplicit clear signal. Implementations MUST NOT make assumptions about values at these keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility)", "type": ["object", "null"], @@ -7627,7 +7674,7 @@ "additionalProperties": true } }, - "required": ["subagentSessionId"] + "required": ["sessionId"] }, "CompleteElicitationNotification": { "description": "Notification sent by the agent when a URL-based elicitation is complete.", From 414baf312e71c3a7c0587175d7cccb72da0f16a9 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Fri, 25 Sep 2026 13:54:44 +0200 Subject: [PATCH 09/10] fix(unstable): gate subagent usage import on both features The v1 client only uses Usage in the subagent work-state payload. Require both unstable_subagents and unstable_end_turn_token_usage so usage-only feature combinations do not fail CI with warnings denied. Validated all 95 depth-two feature configurations with CARGO_BUILD_WARNINGS=deny. --- agent-client-protocol-schema/src/v1/client.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/agent-client-protocol-schema/src/v1/client.rs b/agent-client-protocol-schema/src/v1/client.rs index 2bbaa38b1..a1ec90a84 100644 --- a/agent-client-protocol-schema/src/v1/client.rs +++ b/agent-client-protocol-schema/src/v1/client.rs @@ -15,7 +15,10 @@ use std::collections::BTreeMap; #[cfg(feature = "unstable_subagents")] use super::StopReason; -#[cfg(feature = "unstable_end_turn_token_usage")] +#[cfg(all( + feature = "unstable_subagents", + feature = "unstable_end_turn_token_usage" +))] use super::Usage; use super::{ CompleteElicitationNotification, CreateElicitationRequest, CreateElicitationResponse, From b1329440b4a94fedb9e4863de24915d56e6e7c10 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 28 Sep 2026 16:42:32 +0200 Subject: [PATCH 10/10] feat(unstable): add session-directed message updates Replace tool-call session references with message upserts and chunks, with optional participant metadata and mirrored v2 child state snapshots. Align new v1 patches with tri-state semantics and refine subagent recovery and documentation. --- agent-client-protocol-schema/src/v1/client.rs | 715 ++++++++++++++++-- .../src/v1/tool_call.rs | 121 --- agent-client-protocol-schema/src/v2/client.rs | 693 ++++++++++++++++- .../src/v2/tool_call.rs | 152 +--- docs/protocol/v1/draft/prompt-turn.mdx | 85 ++- docs/protocol/v1/draft/schema.mdx | 300 ++++++-- docs/protocol/v1/draft/tool-calls.mdx | 61 -- docs/protocol/v2/draft/prompt-lifecycle.mdx | 53 ++ docs/protocol/v2/draft/schema.mdx | 266 +++++-- docs/protocol/v2/draft/tool-calls.mdx | 63 -- docs/rfds/session-usage.mdx | 14 - docs/rfds/subagents.mdx | 685 ++++++++++------- schema/v1/schema.unstable.json | 200 +++-- schema/v2/schema.unstable.json | 232 ++++-- 14 files changed, 2660 insertions(+), 980 deletions(-) diff --git a/agent-client-protocol-schema/src/v1/client.rs b/agent-client-protocol-schema/src/v1/client.rs index a1ec90a84..e3bbc86ba 100644 --- a/agent-client-protocol-schema/src/v1/client.rs +++ b/agent-client-protocol-schema/src/v1/client.rs @@ -181,9 +181,490 @@ pub enum SessionUpdate { /// /// This capability is not part of the spec yet, and may be removed or changed at any point. /// - /// An upsert for a subagent exposed by this session. + /// Announces a child session created and owned by this session, or updates + /// that ownership association's metadata. #[cfg(feature = "unstable_subagents")] SubagentUpdate(SubagentUpdate), + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// A message upsert observed in this session's transcript, sent to or + /// received from another session. + #[cfg(feature = "unstable_subagents")] + SessionMessage(SessionMessage), + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// One content block appended to a sent or received session message. + #[cfg(feature = "unstable_subagents")] + SessionMessageChunk(SessionMessageChunk), +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// A content block appended to a transcript-local message in arrival order. +/// Endpoints are optional identity metadata, not content patches: omitted or +/// `null` does not clear a known endpoint. Agents SHOULD supply available +/// endpoints on the first event; later events may omit them or enrich missing +/// endpoints. Supplied endpoints must agree with the enclosing transcript. +/// Live IDs refer to known sessions; history may retain unavailable counterparts. +/// Missing identities permit generic inter-session UI, not guessed participants +/// or human authorship. +/// Chunk metadata applies only to that chunk. This does not instruct the Client +/// to deliver content or imply that the recipient processed it. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SessionMessageChunk { + /// Identifier of this message within the enclosing session's transcript. + pub message_id: MessageId, + /// Optional sending session identity; omission or `null` retains a known value. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub sender_session_id: Option, + /// Optional receiving session identity; omission or `null` retains a known value. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub recipient_session_id: Option, + /// A single content block appended to the message. + pub content: ContentBlock, + /// Optional and nullable chunk-scoped metadata; omitted or `null` means none. + /// + /// Implementations MUST NOT make assumptions about values in `_meta`. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl SessionMessageChunk { + /// Builds a single streamed content block without chunk metadata. + #[must_use] + pub fn new(message_id: impl Into, content: ContentBlock) -> Self { + Self { + message_id: message_id.into(), + sender_session_id: None, + recipient_session_id: None, + content, + meta: None, + } + } + + /// Supplies the sending session identity, when known. + #[must_use] + pub fn sender_session_id(mut self, sender_session_id: impl IntoOption) -> Self { + self.sender_session_id = sender_session_id.into_option(); + self + } + + /// Supplies the receiving session identity, when known. + #[must_use] + pub fn recipient_session_id( + mut self, + recipient_session_id: impl IntoOption, + ) -> Self { + self.recipient_session_id = recipient_session_id.into_option(); + self + } + + /// Sets optional chunk-scoped metadata. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// An upsert of an inter-session message in the enclosing transcript. +/// `messageId` is local to that transcript; separate views may use independent +/// IDs. Endpoints are optional identity metadata, not content patches: omitted +/// or `null` retains a known endpoint. Agents SHOULD supply available endpoints +/// on the first event; later events may omit them or enrich missing endpoints. +/// Supplied endpoints must agree with the enclosing transcript. Live IDs refer +/// to known sessions; history may retain unavailable counterparts. Missing +/// identities permit generic inter-session UI, not guessed participants or +/// human authorship. +/// Omitted `content` and `_meta` leave their stored values unchanged; `null` +/// clears them. A concrete `content` array replaces existing content +/// (`[]` also clears it); later chunks append. This neither changes session +/// ownership nor instructs the Client to deliver content. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SessionMessage { + /// Identifier of this message within the enclosing session's transcript. + pub message_id: MessageId, + /// Optional sending session identity; omission or `null` retains a known value. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub sender_session_id: Option, + /// Optional receiving session identity; omission or `null` retains a known value. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub recipient_session_id: Option, + /// Omitted leaves content unchanged; `null` or `[]` clears it. + /// A non-empty array replaces all content. + #[serde_as(deserialize_as = "DefaultOnError>>")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub content: MaybeUndefined>, + /// Omitted leaves metadata unchanged; `null` removes it. + /// + /// Implementations MUST NOT make assumptions about values in `_meta`. + #[serde_as(deserialize_as = "DefaultOnError>")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde( + default, + rename = "_meta", + skip_serializing_if = "MaybeUndefined::is_undefined" + )] + pub meta: MaybeUndefined, +} + +#[cfg(feature = "unstable_subagents")] +impl SessionMessage { + /// Builds a message upsert with its transcript-local ID. + #[must_use] + pub fn new(message_id: impl Into) -> Self { + Self { + message_id: message_id.into(), + sender_session_id: None, + recipient_session_id: None, + content: MaybeUndefined::Undefined, + meta: MaybeUndefined::Undefined, + } + } + + /// Supplies the sending session identity, when known. + #[must_use] + pub fn sender_session_id(mut self, sender_session_id: impl IntoOption) -> Self { + self.sender_session_id = sender_session_id.into_option(); + self + } + + /// Supplies the receiving session identity, when known. + #[must_use] + pub fn recipient_session_id( + mut self, + recipient_session_id: impl IntoOption, + ) -> Self { + self.recipient_session_id = recipient_session_id.into_option(); + self + } + + /// Replaces, clears, or omits the complete content patch. + #[must_use] + pub fn content(mut self, content: impl IntoMaybeUndefined>) -> Self { + self.content = content.into_maybe_undefined(); + self + } + + /// Sets, clears, or omits the metadata patch. + #[must_use] + pub fn meta(mut self, meta: impl IntoMaybeUndefined) -> Self { + self.meta = meta.into_maybe_undefined(); + self + } +} + +#[cfg(all(test, feature = "unstable_subagents"))] +mod session_message_tests { + use super::*; + use serde_json::{Value, json}; + + #[test] + fn envelopes_preserve_participants_and_multimodal_content() { + let content = json!([ + {"type": "text", "text": "Please inspect this"}, + {"type": "image", "data": "aGVsbG8=", "mimeType": "image/png"} + ]); + for (transcript, sender, recipient, id) in [ + ("parent", "parent", "child", "sent-1"), + ("child", "parent", "child", "received-9"), + ("child", "child", "parent", "sent-2"), + ] { + let wire = json!({"sessionId": transcript, "update": { + "sessionUpdate": "session_message", "messageId": id, + "senderSessionId": sender, "recipientSessionId": recipient, "content": content + }}); + let decoded: SessionNotification = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(&decoded).unwrap(), wire); + assert!(matches!(decoded.update, SessionUpdate::SessionMessage(_))); + let message = SessionMessage::new(id) + .sender_session_id(SessionId::new(sender)) + .recipient_session_id(SessionId::new(recipient)) + .content(vec![]); + assert_eq!(serde_json::to_value(message).unwrap()["content"], json!([])); + } + } + + #[test] + fn message_upserts_validate_ids_and_patch_fields() { + let base = json!({"sessionUpdate": "session_message", "messageId": "m1", + "senderSessionId": "parent", "recipientSessionId": "child", "content": []}); + { + let key = "messageId"; + let mut missing = base.clone(); + missing.as_object_mut().unwrap().remove(key); + assert!( + serde_json::from_value::(missing).is_err(), + "{key}" + ); + let mut null = base.clone(); + null[key] = Value::Null; + assert!( + serde_json::from_value::(null).is_err(), + "{key}" + ); + let mut non_string = base.clone(); + non_string[key] = json!(42); + assert!( + serde_json::from_value::(non_string).is_err(), + "{key}" + ); + } + for content in [None, Some(json!({}))] { + let mut wire = base.clone(); + wire.as_object_mut().unwrap().remove("content"); + if let Some(content) = content { + wire["content"] = content; + } + let decoded: SessionUpdate = serde_json::from_value(wire).unwrap(); + let encoded = serde_json::to_value(decoded).unwrap(); + let mut unchanged = base.clone(); + unchanged.as_object_mut().unwrap().remove("content"); + assert_eq!(encoded, unchanged); + } + let clear = SessionMessage::new("m1") + .sender_session_id(SessionId::new("parent")) + .recipient_session_id(SessionId::new("child")) + .content(None) + .meta(None); + let mut clear_wire = base.clone(); + clear_wire["content"] = Value::Null; + clear_wire["_meta"] = Value::Null; + assert_eq!( + serde_json::to_value(&clear).unwrap()["content"], + Value::Null + ); + let clear_update: SessionUpdate = serde_json::from_value(clear_wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(clear_update).unwrap(), clear_wire); + assert!(clear.content.is_null()); + assert!(clear.meta.is_null()); + for meta in [None, Some(Value::Null), Some(json!({"tag": "value"}))] { + let mut wire = base.clone(); + if let Some(meta) = meta { + wire["_meta"] = meta; + } + let decoded: SessionUpdate = serde_json::from_value(wire.clone()).unwrap(); + let encoded = serde_json::to_value(decoded).unwrap(); + assert_eq!(encoded, wire); + } + let metadata_only = json!({"sessionUpdate": "session_message", + "messageId": "m1", "senderSessionId": "parent", + "recipientSessionId": "child", "_meta": {"tag": "value"}}); + let decoded: SessionUpdate = serde_json::from_value(metadata_only.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), metadata_only); + let unset = SessionMessage::new("m1"); + assert_eq!( + serde_json::to_value(&unset).unwrap(), + json!({"messageId": "m1"}) + ); + assert!(unset.content.is_undefined()); + assert!(unset.meta.is_undefined()); + let reset = SessionMessage::new("m1").content(vec![]); + assert_eq!(serde_json::to_value(reset).unwrap()["content"], json!([])); + let malformed: SessionMessage = serde_json::from_value(json!({ + "messageId": "m1", "senderSessionId": "parent", "recipientSessionId": "child", + "content": false, "_meta": false + })) + .unwrap(); + assert!(malformed.content.is_undefined()); + assert!(malformed.meta.is_undefined()); + } + + #[test] + fn first_chunk_and_upsert_share_transcript_local_identity() { + for (transcript, id) in [("parent", "sent-1"), ("child", "received-9")] { + let wire = json!({"sessionId": transcript, "update": { + "sessionUpdate": "session_message_chunk", "messageId": id, + "senderSessionId": "parent", "recipientSessionId": "child", + "content": {"type": "text", "text": "first"} + }}); + let decoded: SessionNotification = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), wire); + let upsert = SessionMessage::new(id) + .sender_session_id(SessionId::new("parent")) + .recipient_session_id(SessionId::new("child")); + let chunk = SessionMessageChunk::new( + id, + ContentBlock::Text(crate::v1::TextContent::new("first")), + ) + .sender_session_id(SessionId::new("parent")) + .recipient_session_id(SessionId::new("child")); + assert_eq!(chunk.message_id, upsert.message_id); + assert_eq!(chunk.sender_session_id, upsert.sender_session_id); + assert_eq!(chunk.recipient_session_id, upsert.recipient_session_id); + assert_eq!( + serde_json::to_value(SessionUpdate::SessionMessageChunk(chunk)).unwrap(), + wire["update"] + ); + } + let wire = json!({"sessionId": "child", "update": { + "sessionUpdate": "session_message_chunk", "messageId": "received-9", + "senderSessionId": "parent", "recipientSessionId": "child", + "content": {"type": "text", "text": "first"} + }}); + let base = wire["update"].clone(); + for key in ["messageId", "content"] { + let mut missing = base.clone(); + missing.as_object_mut().unwrap().remove(key); + assert!( + serde_json::from_value::(missing).is_err(), + "{key}" + ); + let mut null = base.clone(); + null[key] = Value::Null; + assert!( + serde_json::from_value::(null).is_err(), + "{key}" + ); + let mut non_string = base.clone(); + non_string[key] = json!(42); + assert!( + serde_json::from_value::(non_string).is_err(), + "{key}" + ); + } + for meta in [None, Some(Value::Null), Some(json!({"chunk": true}))] { + let mut value = base.clone(); + if let Some(meta) = meta { + value["_meta"] = meta; + } + let decoded: SessionUpdate = serde_json::from_value(value.clone()).unwrap(); + let encoded = serde_json::to_value(decoded).unwrap(); + if value["_meta"].is_null() { + assert_eq!(encoded, base); + } else { + assert_eq!(encoded, value); + } + } + } + + #[cfg(feature = "schemars")] + #[test] + fn schema_requires_ids_and_chunk_content() { + let schema = serde_json::to_value(schemars::schema_for!(SessionMessage)).unwrap(); + let required = schema["required"].as_array().unwrap(); + assert_eq!(required, &vec![json!("messageId")]); + let chunk = serde_json::to_value(schemars::schema_for!(SessionMessageChunk)).unwrap(); + let required = chunk["required"].as_array().unwrap(); + assert_eq!(required.len(), 2); + assert!(required.contains(&json!("messageId"))); + assert!(required.contains(&json!("content"))); + } + + #[test] + fn endpoints_can_arrive_late_or_be_omitted_from_later_events() { + let block = ContentBlock::Text(crate::v1::TextContent::new("hello")); + let minimal = SessionMessage::new("m1"); + let first = SessionMessageChunk::new("m1", block.clone()); + assert_eq!( + serde_json::to_value(&minimal).unwrap(), + json!({"messageId": "m1"}) + ); + assert_eq!( + serde_json::to_value(&first).unwrap(), + json!({"messageId": "m1", "content": {"type": "text", "text": "hello"}}) + ); + let enriched = SessionMessage::new("m1") + .sender_session_id(SessionId::new("parent")) + .recipient_session_id(SessionId::new("child")); + assert_eq!(enriched.sender_session_id, Some(SessionId::new("parent"))); + assert_eq!(enriched.recipient_session_id, Some(SessionId::new("child"))); + let later = + SessionMessageChunk::new("m1", block).sender_session_id(SessionId::new("parent")); + assert_eq!(later.sender_session_id, Some(SessionId::new("parent"))); + assert_eq!(later.recipient_session_id, None); + for update in [ + SessionUpdate::SessionMessage(minimal), + SessionUpdate::SessionMessageChunk(first), + SessionUpdate::SessionMessage(enriched), + SessionUpdate::SessionMessageChunk(later), + ] { + let wire = serde_json::to_value(&update).unwrap(); + let decoded: SessionUpdate = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), wire); + } + } + + #[test] + fn invalid_or_null_endpoints_are_absent_but_message_id_is_required() { + for (kind, content) in [ + ("session_message", None), + ( + "session_message_chunk", + Some(json!({"type": "text", "text": "hello"})), + ), + ] { + let mut base = json!({"sessionUpdate": kind, "messageId": "m1", + "senderSessionId": null, "recipientSessionId": 42}); + if let Some(content) = content { + base["content"] = content; + } + let decoded: SessionUpdate = serde_json::from_value(base.clone()).unwrap(); + let encoded = serde_json::to_value(decoded).unwrap(); + base.as_object_mut().unwrap().remove("senderSessionId"); + base.as_object_mut().unwrap().remove("recipientSessionId"); + assert_eq!(encoded, base); + for bad in [Value::Null, json!(42)] { + let mut invalid = base.clone(); + invalid["messageId"] = bad; + assert!(serde_json::from_value::(invalid).is_err()); + } + } + } +} + +#[cfg(all(test, not(feature = "unstable_subagents")))] +mod disabled_session_message_tests { + use super::*; + use serde_json::json; + + #[test] + fn message_updates_require_subagents_gate() { + for (kind, content) in [ + ("session_message", json!([])), + ( + "session_message_chunk", + json!({"type": "text", "text": "hello"}), + ), + ] { + let wire = json!({"sessionUpdate": kind, "messageId": "m1", "content": content}); + assert!(serde_json::from_value::(wire).is_err()); + } + } } /// **UNSTABLE** @@ -463,20 +944,25 @@ impl CompactionSummaryChunk { /// /// This capability is not part of the spec yet, and may be removed or changed at any point. /// -/// An upsert for a reusable child session associated with its parent session. +/// Notification that the enclosing parent session created and owns a child session. +/// +/// Later updates modify the existing association's metadata, not its ownership. /// /// Sent on the immediate parent session. The first update for an unknown /// [`SubagentUpdate::session_id`] announces the child and MUST be sent -/// before any child traffic or reference to the child session. Parents may -/// message and reuse an announced child across multiple operations. +/// before any live child traffic or live message naming the child as sender +/// or recipient. Parents may message and reuse an announced child across +/// multiple operations. /// Child events are delivered automatically on the same connection; no child /// load, resume, or subscription is needed. /// -/// Only the subagent session ID is required. Omitted fields keep their previous -/// value; a child whose state was never reported has an unknown state. A -/// concrete state replaces the entire previous state object, not the session. -/// The child's title is reported via `session_info_update`; per-operation tasks -/// belong on tool calls referencing the child. +/// Only the subagent session ID is required. Omitted patch fields keep their +/// previous values; `null` clears them. Clearing capabilities disables child +/// mutations. Clearing state leaves current activity unset/unconfirmed: it does +/// not imply idle, stop work, or create an `unknown` state snapshot. A concrete +/// state replaces the entire previous state object, not the session. +/// The title and description provide the parent's display metadata for the +/// child. They do not replace the content of individual messages or operations. #[cfg(feature = "unstable_subagents")] #[serde_as] #[skip_serializing_none] @@ -490,33 +976,57 @@ pub struct SubagentUpdate { /// Nested inside `update`; the enclosing notification's `sessionId` identifies /// the immediate parent, not this child. pub session_id: SessionId, + /// The parent's human-readable display title for this child. It need not be unique. + /// + /// Omitted means unchanged; `null` clears it. If unset, the Client chooses + /// a fallback presentation. + #[serde_as(deserialize_as = "DefaultOnError>")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub title: MaybeUndefined, + /// The parent's human-readable description of the child's role or purpose. + /// + /// Omitted means unchanged; `null` clears it. If unset, the Client chooses + /// a fallback presentation. This is current display metadata, not the + /// history of instructions sent to the child. + #[serde_as(deserialize_as = "DefaultOnError>")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub description: MaybeUndefined, /// Client-initiated session mutations permitted for this subagent session. /// - /// Omitted and `null` both mean unchanged. If never supplied, no session - /// mutations are permitted. Read-only operations retain their normal protocol - /// semantics and capability requirements. - #[serde_as(deserialize_as = "DefaultOnError")] + /// Omitted means unchanged; `null` clears the capability set and disables + /// child mutations. If never supplied, no session mutations are permitted. + /// Read-only operations retain their normal protocol semantics and capability + /// requirements. A concrete object replaces the whole capability set. + #[serde_as(deserialize_as = "DefaultOnError>")] #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] - #[serde(default)] - pub capabilities: Option, + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub capabilities: MaybeUndefined, /// Current state snapshot for the child session. /// - /// Omitted and `null` both mean unchanged; a concrete state replaces the - /// previous state object wholesale. If never supplied, the state is unknown. - #[serde_as(deserialize_as = "DefaultOnError")] + /// Omitted means unchanged; `null` clears the current activity without + /// asserting idle or sending an `unknown` snapshot. A concrete state + /// replaces the previous state object wholesale. If never supplied, the + /// current activity is unset/unconfirmed. + #[serde_as(deserialize_as = "DefaultOnError>")] #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] - #[serde(default)] - pub state: Option, + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub state: MaybeUndefined, /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at /// these keys. /// /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - #[serde_as(deserialize_as = "DefaultOnError")] + /// Omitted means unchanged; `null` removes the metadata. + #[serde_as(deserialize_as = "DefaultOnError>")] #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] - #[serde(default)] - #[serde(rename = "_meta")] - pub meta: Option, + #[serde( + default, + rename = "_meta", + skip_serializing_if = "MaybeUndefined::is_undefined" + )] + pub meta: MaybeUndefined, } #[cfg(feature = "unstable_subagents")] @@ -526,35 +1036,51 @@ impl SubagentUpdate { pub fn new(session_id: impl Into) -> Self { Self { session_id: session_id.into(), - capabilities: None, - state: None, - meta: None, + title: MaybeUndefined::Undefined, + description: MaybeUndefined::Undefined, + capabilities: MaybeUndefined::Undefined, + state: MaybeUndefined::Undefined, + meta: MaybeUndefined::Undefined, } } - /// Sets or leaves unchanged the permitted client-initiated session mutations. + /// Sets, clears, or omits the parent's display title patch. + #[must_use] + pub fn title(mut self, title: impl IntoMaybeUndefined) -> Self { + self.title = title.into_maybe_undefined(); + self + } + + /// Sets, clears, or omits the parent's description patch. + #[must_use] + pub fn description(mut self, description: impl IntoMaybeUndefined) -> Self { + self.description = description.into_maybe_undefined(); + self + } + + /// Replaces, clears, or omits the permitted client-initiated mutations patch. #[must_use] pub fn capabilities( mut self, - capabilities: impl IntoOption, + capabilities: impl IntoMaybeUndefined, ) -> Self { - self.capabilities = capabilities.into_option(); + self.capabilities = capabilities.into_maybe_undefined(); self } - /// Replaces the current state snapshot, or leaves it unchanged when omitted. + /// Replaces, clears, or omits the current activity patch. #[must_use] - pub fn state(mut self, state: impl IntoOption) -> Self { - self.state = state.into_option(); + pub fn state(mut self, state: impl IntoMaybeUndefined) -> Self { + self.state = state.into_maybe_undefined(); self } /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at - /// these keys. + /// these keys. Sets, clears, or omits this metadata patch. #[must_use] - pub fn meta(mut self, meta: impl IntoOption) -> Self { - self.meta = meta.into_option(); + pub fn meta(mut self, meta: impl IntoMaybeUndefined) -> Self { + self.meta = meta.into_maybe_undefined(); self } } @@ -2607,7 +3133,7 @@ pub struct ClientCapabilities { /// Optional and nullable. Omitted or `null` both mean the client does not /// advertise support. /// Supplying `{}` means the client understands child associations, work-state - /// snapshots, tool-call session references, and restricted-session semantics. + /// snapshots, session-directed messages, and restricted-session semantics. #[cfg(feature = "unstable_subagents")] #[serde_as(deserialize_as = "DefaultOnError")] #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] @@ -2790,9 +3316,9 @@ impl ClientCapabilities { /// Capability marker for exposing reusable child sessions as restricted ACP sessions. /// /// Supplying `{}` advertises support for child association and state updates, -/// tool-call session references, and restricted-session semantics. The client +/// session-directed messages, and restricted-session semantics. The client /// must advertise this capability before the agent sends subagent updates or -/// session references. +/// session-directed messages. #[cfg(feature = "unstable_subagents")] #[serde_as] #[skip_serializing_none] @@ -3799,8 +4325,8 @@ mod tests { ); let minimal: SubagentUpdate = serde_json::from_value(json!({ "sessionId": "sess_child_3" })).unwrap(); - assert!(minimal.capabilities.is_none()); - assert!(minimal.state.is_none()); + assert!(minimal.capabilities.is_undefined()); + assert!(minimal.state.is_undefined()); let nulls: SubagentUpdate = serde_json::from_value(json!({ "sessionId": "sess_child_3", @@ -3808,7 +4334,104 @@ mod tests { "state": null })) .unwrap(); - assert_eq!(nulls, minimal); + assert!(nulls.capabilities.is_null()); + assert!(nulls.state.is_null()); + assert_eq!( + serde_json::to_value(nulls).unwrap(), + json!({"sessionId": "sess_child_3", "capabilities": null, "state": null}) + ); + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn test_subagent_display_metadata() { + use serde_json::json; + + let update = SubagentUpdate::new("child") + .title("Test investigator".to_string()) + .description("Investigates platform-specific test failures.".to_string()); + let wire = json!({ + "sessionId": "child", + "title": "Test investigator", + "description": "Investigates platform-specific test failures." + }); + assert_eq!(serde_json::to_value(&update).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + update + ); + + let minimal = SubagentUpdate::new("child"); + assert_eq!( + serde_json::to_value(&minimal).unwrap(), + json!({"sessionId": "child"}) + ); + let cleared: SubagentUpdate = serde_json::from_value(json!({ + "sessionId": "child", "title": null, "description": null + })) + .unwrap(); + assert!(cleared.title.is_null()); + assert!(cleared.description.is_null()); + assert_eq!( + serde_json::to_value(cleared).unwrap(), + json!({"sessionId": "child", "title": null, "description": null}) + ); + let invalid: SubagentUpdate = serde_json::from_value(json!({ + "sessionId": "child", "title": false, "description": false + })) + .unwrap(); + assert_eq!(invalid, minimal); + + let title_only: SubagentUpdate = serde_json::from_value(json!({ + "sessionId": "child", "title": "Updated title" + })) + .unwrap(); + assert_eq!( + title_only.title.value().map(String::as_str), + Some("Updated title") + ); + assert!(title_only.description.is_undefined()); + } + + #[cfg(feature = "unstable_subagents")] + #[test] + fn subagent_patch_fields_preserve_omitted_null_and_concrete() { + use serde_json::json; + + let omitted = SubagentUpdate::new("child"); + for field in ["title", "description", "capabilities", "state", "_meta"] { + let wire = json!({"sessionId": "child", field: null}); + let decoded: SubagentUpdate = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), wire, "{field}"); + let malformed = json!({"sessionId": "child", field: false}); + let decoded: SubagentUpdate = serde_json::from_value(malformed).unwrap(); + assert_eq!(decoded, omitted, "{field}"); + } + let concrete = json!({ + "sessionId": "child", "title": "Investigator", + "description": "Tests", "capabilities": {"cancel": {}}, + "state": {"state": "running"}, "_meta": {"source": "parent"} + }); + let decoded: SubagentUpdate = serde_json::from_value(concrete.clone()).unwrap(); + assert!(decoded.title.value().is_some()); + assert!(decoded.description.value().is_some()); + assert!(decoded.capabilities.value().is_some()); + assert!(decoded.state.value().is_some()); + assert!(decoded.meta.value().is_some()); + assert_eq!(serde_json::to_value(decoded).unwrap(), concrete); + let cleared = SubagentUpdate::new("child") + .title(None) + .description(None) + .capabilities(None) + .state(None) + .meta(None); + assert_eq!( + serde_json::to_value(cleared).unwrap(), + json!({ + "sessionId": "child", "title": null, "description": null, + "capabilities": null, "state": null, "_meta": null + }) + ); } #[cfg(feature = "unstable_subagents")] @@ -3887,7 +4510,7 @@ mod tests { let SessionUpdate::SubagentUpdate(update) = &parsed else { panic!("expected subagent update"); }; - assert_eq!(update.state.as_ref(), Some(&expected)); + assert_eq!(update.state.value(), Some(&expected)); assert_eq!(serde_json::to_value(parsed).unwrap(), wire); } @@ -3923,7 +4546,7 @@ mod tests { serde_json::from_value::(wire) .unwrap() .state, - Some(state) + MaybeUndefined::Value(state) ); } assert_eq!( @@ -3960,7 +4583,7 @@ mod tests { update ); // Reporting unknown activity does not send a capability revocation. - assert!(update.capabilities.is_none()); + assert!(update.capabilities.is_undefined()); for meta in [json!(null), json!(false)] { let state: StateUpdate = serde_json::from_value(json!({ @@ -3989,7 +4612,7 @@ mod tests { "sessionId": "child", "state": malformed })) .unwrap(); - assert_eq!(update.state, None); + assert!(update.state.is_undefined()); } let bad_reason: StateUpdate = serde_json::from_value(json!({"state": "idle", "stopReason": "not_a_reason"})).unwrap(); diff --git a/agent-client-protocol-schema/src/v1/tool_call.rs b/agent-client-protocol-schema/src/v1/tool_call.rs index 44d6172e2..605fa00d9 100644 --- a/agent-client-protocol-schema/src/v1/tool_call.rs +++ b/agent-client-protocol-schema/src/v1/tool_call.rs @@ -12,8 +12,6 @@ use serde_with::{DefaultOnError, VecSkipError, serde_as, skip_serializing_none}; use crate::{IntoOption, SkipListener}; -#[cfg(feature = "unstable_subagents")] -use super::SessionId; use super::{ContentBlock, Error, Meta, TerminalId}; /// Represents a tool call that the language model has requested. @@ -556,9 +554,6 @@ pub enum ToolCallContent { /// /// See protocol docs: [Terminal](https://agentclientprotocol.com/protocol/terminals) Terminal(Terminal), - /// **UNSTABLE** Display reference to an already-known session on this ACP connection. - #[cfg(feature = "unstable_subagents")] - Session(SessionReference), } impl> From for ToolCallContent { @@ -665,122 +660,6 @@ impl Terminal { } } -/// **UNSTABLE** Display reference to an already-known session on this ACP connection. -/// -/// The enclosing notification's `params.sessionId` identifies the session whose -/// transcript is updated; this item's `sessionId` links that tool operation to -/// another known session for display. Ordinary session setup or a -/// `subagent_update` announcement establishes a known target. A parent can -/// reference a child, and a child can reference its parent or a sibling. -/// V1 work-state snapshots are carried by `subagent_update` on the parent stream. -/// Parent-child associations and controls are announced separately by -/// `subagent_update`. This item does not create or register a session, reparent -/// it, grant controls, prompt it, subscribe to it, close it, send a message, -/// or change ownership. Reference links can point both ways without making the -/// ownership tree cyclic. A tool call may -/// reference multiple known sessions, and multiple tool calls may reference -/// the same session. Tool-call status describes the operation, not whether -/// the referenced session is idle or terminated. -#[cfg(feature = "unstable_subagents")] -#[serde_as] -#[skip_serializing_none] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct SessionReference { - /// Identifier of the already-known session linked from this tool operation, - /// not the session used to route the enclosing notification. - pub session_id: SessionId, - /// Optional nullable item metadata. Omission and `null` both mean no metadata. - #[serde_as(deserialize_as = "DefaultOnError")] - #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] - #[serde(default, rename = "_meta")] - pub meta: Option, -} - -#[cfg(feature = "unstable_subagents")] -impl SessionReference { - /// Builds a display reference with no item metadata. - #[must_use] - pub fn new(session_id: impl Into) -> Self { - Self { - session_id: session_id.into(), - meta: None, - } - } - - /// Sets item-scoped metadata. - #[must_use] - pub fn meta(mut self, meta: impl IntoOption) -> Self { - self.meta = meta.into_option(); - self - } -} - -#[cfg(all(test, feature = "unstable_subagents"))] -mod session_reference_tests { - use super::*; - - #[test] - fn session_reference_serializes_and_requires_id() { - let reference = ToolCallContent::Session(SessionReference::new("child_1")); - let wire = serde_json::json!({"type": "session", "sessionId": "child_1"}); - assert_eq!(serde_json::to_value(&reference).unwrap(), wire); - assert_eq!( - serde_json::from_value::(wire).unwrap(), - reference - ); - for invalid in [ - serde_json::json!({"type": "session"}), - serde_json::json!({"type": "session", "sessionId": null}), - serde_json::json!({"type": "session", "sessionId": 1}), - ] { - assert!(serde_json::from_value::(invalid).is_err()); - } - } - - #[test] - fn session_references_to_known_sessions_share_one_wire_shape() { - let references: Vec<_> = ["child_1", "parent_1", "sibling_1"] - .into_iter() - .map(|id| ToolCallContent::Session(SessionReference::new(id))) - .collect(); - let wire = serde_json::json!([ - {"type": "session", "sessionId": "child_1"}, - {"type": "session", "sessionId": "parent_1"}, - {"type": "session", "sessionId": "sibling_1"} - ]); - assert_eq!(serde_json::to_value(&references).unwrap(), wire); - assert_eq!( - serde_json::from_value::>(wire).unwrap(), - references - ); - } - - #[test] - fn session_reference_meta_is_item_scoped_and_nullable() { - let meta: Meta = serde_json::from_value(serde_json::json!({"source": "test"})).unwrap(); - let reference = SessionReference::new("child_1").meta(meta.clone()); - assert_eq!( - serde_json::to_value(ToolCallContent::Session(reference.clone())).unwrap(), - serde_json::json!({"type": "session", "sessionId": "child_1", "_meta": {"source": "test"}}) - ); - assert_eq!(reference.meta, Some(meta)); - for wire in [ - serde_json::json!({"type": "session", "sessionId": "child_1"}), - serde_json::json!({"type": "session", "sessionId": "child_1", "_meta": null}), - ] { - let ToolCallContent::Session(parsed) = - serde_json::from_value::(wire).unwrap() - else { - panic!("expected session reference"); - }; - assert_eq!(parsed.meta, None); - } - } -} - /// A diff representing file modifications. /// /// Shows changes to files in a format suitable for display in the client UI. diff --git a/agent-client-protocol-schema/src/v2/client.rs b/agent-client-protocol-schema/src/v2/client.rs index f2f30e38e..b9d23d7b7 100644 --- a/agent-client-protocol-schema/src/v2/client.rs +++ b/agent-client-protocol-schema/src/v2/client.rs @@ -180,9 +180,25 @@ pub enum SessionUpdate { /// /// This capability is not part of the spec yet, and may be removed or changed at any point. /// - /// A subagent exposed by this session has been created or updated. + /// Announces a child session created and owned by this session, or updates + /// that ownership association's metadata. #[cfg(feature = "unstable_subagents")] SubagentUpdate(SubagentUpdate), + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// A message upsert observed in this session's transcript, sent to or + /// received from another session. + #[cfg(feature = "unstable_subagents")] + SessionMessage(SessionMessage), + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// One content block appended to a sent or received session message. + #[cfg(feature = "unstable_subagents")] + SessionMessageChunk(SessionMessageChunk), /// Custom or future session update. /// /// Values beginning with `_` are reserved for implementation-specific @@ -196,6 +212,432 @@ pub enum SessionUpdate { Other(OtherSessionUpdate), } +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// A content block appended to a transcript-local message in arrival order. +/// Endpoints are optional identity metadata, not content patches: omitted or +/// `null` does not clear a known endpoint. Agents SHOULD supply available +/// endpoints on the first event; later events may omit them or enrich missing +/// endpoints. Supplied endpoints must agree with the enclosing transcript. +/// Live IDs refer to known sessions; history may retain unavailable counterparts. +/// Missing identities permit generic inter-session UI, not guessed participants +/// or human authorship. +/// Chunk metadata applies only to that chunk. This does not instruct the Client +/// to deliver content or imply that the recipient processed it. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SessionMessageChunk { + /// Identifier of this message within the enclosing session's transcript. + pub message_id: MessageId, + /// Optional sending session identity; omission or `null` retains a known value. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub sender_session_id: Option, + /// Optional receiving session identity; omission or `null` retains a known value. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub recipient_session_id: Option, + /// A single content block appended to the message. + pub content: ContentBlock, + /// Optional and nullable chunk-scoped metadata; omitted or `null` means none. + /// + /// Implementations MUST NOT make assumptions about values in `_meta`. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, rename = "_meta")] + pub meta: Option, +} + +#[cfg(feature = "unstable_subagents")] +impl SessionMessageChunk { + /// Builds a single streamed content block without chunk metadata. + #[must_use] + pub fn new(message_id: impl Into, content: ContentBlock) -> Self { + Self { + message_id: message_id.into(), + sender_session_id: None, + recipient_session_id: None, + content, + meta: None, + } + } + + /// Supplies the sending session identity, when known. + #[must_use] + pub fn sender_session_id(mut self, sender_session_id: impl IntoOption) -> Self { + self.sender_session_id = sender_session_id.into_option(); + self + } + + /// Supplies the receiving session identity, when known. + #[must_use] + pub fn recipient_session_id( + mut self, + recipient_session_id: impl IntoOption, + ) -> Self { + self.recipient_session_id = recipient_session_id.into_option(); + self + } + + /// Sets optional chunk-scoped metadata. + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// **UNSTABLE** +/// +/// This capability is not part of the spec yet, and may be removed or changed at any point. +/// +/// An upsert of an inter-session message in the enclosing transcript. +/// `messageId` is local to that transcript; separate views may use independent +/// IDs. Endpoints are optional identity metadata, not content patches: omitted +/// or `null` retains a known endpoint. Agents SHOULD supply available endpoints +/// on the first event; later events may omit them or enrich missing endpoints. +/// Supplied endpoints must agree with the enclosing transcript. Live IDs refer +/// to known sessions; history may retain unavailable counterparts. Missing +/// identities permit generic inter-session UI, not guessed participants or +/// human authorship. +/// A concrete `content` array replaces existing content; later chunks append. +/// This neither changes session ownership nor instructs the Client to deliver content. +#[cfg(feature = "unstable_subagents")] +#[serde_as] +#[skip_serializing_none] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SessionMessage { + /// Identifier of this message within the enclosing session's transcript. + pub message_id: MessageId, + /// Optional sending session identity; omission or `null` retains a known value. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub sender_session_id: Option, + /// Optional receiving session identity; omission or `null` retains a known value. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default)] + pub recipient_session_id: Option, + /// Omitted leaves content unchanged; `null` clears it; a concrete array + /// replaces the whole content collection. + #[serde_as(deserialize_as = "DefaultOnError>>")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub content: MaybeUndefined>, + /// Omitted leaves metadata unchanged; `null` clears it. + /// + /// Implementations MUST NOT make assumptions about values in `_meta`. + #[serde_as(deserialize_as = "DefaultOnError>")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde( + default, + rename = "_meta", + skip_serializing_if = "MaybeUndefined::is_undefined" + )] + pub meta: MaybeUndefined, +} + +#[cfg(feature = "unstable_subagents")] +impl SessionMessage { + /// Builds a message upsert with its transcript-local ID. + #[must_use] + pub fn new(message_id: impl Into) -> Self { + Self { + message_id: message_id.into(), + sender_session_id: None, + recipient_session_id: None, + content: MaybeUndefined::Undefined, + meta: MaybeUndefined::Undefined, + } + } + + /// Supplies the sending session identity, when known. + #[must_use] + pub fn sender_session_id(mut self, sender_session_id: impl IntoOption) -> Self { + self.sender_session_id = sender_session_id.into_option(); + self + } + + /// Supplies the receiving session identity, when known. + #[must_use] + pub fn recipient_session_id( + mut self, + recipient_session_id: impl IntoOption, + ) -> Self { + self.recipient_session_id = recipient_session_id.into_option(); + self + } + + /// Replaces, clears, or leaves unchanged the message content. + #[must_use] + pub fn content(mut self, content: impl IntoMaybeUndefined>) -> Self { + self.content = content.into_maybe_undefined(); + self + } + + /// Sets, clears, or leaves unchanged message metadata. + #[must_use] + pub fn meta(mut self, meta: impl IntoMaybeUndefined) -> Self { + self.meta = meta.into_maybe_undefined(); + self + } +} + +#[cfg(all(test, feature = "unstable_subagents"))] +mod session_message_tests { + use super::*; + use serde_json::{Value, json}; + + #[test] + fn transcript_envelopes_and_multimodal_content() { + for (transcript, sender, recipient, id) in [ + ("parent", "parent", "child", "sent-1"), + ("child", "parent", "child", "received-9"), + ("child", "child", "parent", "sent-2"), + ] { + let wire = json!({"sessionId": transcript, "update": { + "sessionUpdate": "session_message", "messageId": id, + "senderSessionId": sender, "recipientSessionId": recipient, "content": [ + {"type": "text", "text": "hello"}, + {"type": "image", "data": "aGVsbG8=", "mimeType": "image/png"} + ] + }}); + let decoded: UpdateSessionNotification = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), wire); + } + } + + #[test] + fn chunks_and_upserts_share_transcript_local_identity() { + for (transcript, id) in [("parent", "sent-1"), ("child", "received-9")] { + let wire = json!({"sessionId": transcript, "update": { + "sessionUpdate": "session_message_chunk", "messageId": id, + "senderSessionId": "parent", "recipientSessionId": "child", + "content": {"type": "text", "text": "hello"} + }}); + let decoded: UpdateSessionNotification = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), wire); + let upsert = SessionMessage::new(id) + .sender_session_id(SessionId::new("parent")) + .recipient_session_id(SessionId::new("child")) + .content(vec![]); + let chunk = SessionMessageChunk::new( + id, + ContentBlock::Text(crate::v2::TextContent::new("hello")), + ) + .sender_session_id(SessionId::new("parent")) + .recipient_session_id(SessionId::new("child")); + assert_eq!(chunk.message_id, upsert.message_id); + assert_eq!(chunk.sender_session_id, upsert.sender_session_id); + assert_eq!(chunk.recipient_session_id, upsert.recipient_session_id); + assert_eq!( + serde_json::to_value(SessionUpdate::SessionMessageChunk(chunk)).unwrap(), + wire["update"] + ); + assert_eq!(serde_json::to_value(upsert).unwrap()["content"], json!([])); + } + } + + #[test] + fn required_ids_and_chunk_content_with_upsert_patch_semantics() { + for (kind, content) in [ + ("session_message", json!([])), + ( + "session_message_chunk", + json!({"type": "text", "text": "hello"}), + ), + ] { + let base = json!({"sessionUpdate": kind, "messageId": "m1", + "senderSessionId": "parent", "recipientSessionId": "child", "content": content}); + { + let key = "messageId"; + let mut missing = base.clone(); + missing.as_object_mut().unwrap().remove(key); + assert!( + serde_json::from_value::(missing).is_err(), + "{kind} {key}" + ); + let mut null = base.clone(); + null[key] = Value::Null; + assert!( + serde_json::from_value::(null).is_err(), + "{kind} {key}" + ); + let mut non_string = base.clone(); + non_string[key] = json!(42); + assert!( + serde_json::from_value::(non_string).is_err(), + "{kind} {key}" + ); + } + if kind == "session_message_chunk" { + for content in [None, Some(Value::Null), Some(json!([]))] { + let mut invalid = base.clone(); + invalid.as_object_mut().unwrap().remove("content"); + if let Some(content) = content { + invalid["content"] = content; + } + assert!(serde_json::from_value::(invalid).is_err()); + } + } else { + for content in [None, Some(Value::Null), Some(json!([])), Some(json!({}))] { + let mut wire = base.clone(); + wire.as_object_mut().unwrap().remove("content"); + if let Some(content) = content { + wire["content"] = content; + } + let decoded: SessionUpdate = serde_json::from_value(wire.clone()).unwrap(); + let encoded = serde_json::to_value(decoded).unwrap(); + if wire["content"].is_object() { + wire.as_object_mut().unwrap().remove("content"); + } + assert_eq!(encoded, wire); + } + } + for meta in [None, Some(Value::Null), Some(json!({"tag": "value"}))] { + let mut wire = base.clone(); + if let Some(meta) = meta { + wire["_meta"] = meta; + } + let decoded: SessionUpdate = serde_json::from_value(wire.clone()).unwrap(); + let encoded = serde_json::to_value(decoded).unwrap(); + if kind == "session_message" { + assert_eq!(encoded, wire); + } else if wire["_meta"].is_null() { + assert_eq!(encoded, base); + } else { + assert_eq!(encoded, wire); + } + } + } + let metadata_only = json!({"sessionUpdate": "session_message", + "messageId": "m1", "senderSessionId": "parent", + "recipientSessionId": "child", "_meta": {"tag": "value"}}); + let decoded: SessionUpdate = serde_json::from_value(metadata_only.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), metadata_only); + let cleared = SessionMessage::new("m1") + .sender_session_id(SessionId::new("parent")) + .recipient_session_id(SessionId::new("child")) + .content(None) + .meta(None); + assert_eq!( + serde_json::to_value(cleared).unwrap(), + json!({"messageId": "m1", "senderSessionId": "parent", + "recipientSessionId": "child", "content": null, "_meta": null}) + ); + } + + #[cfg(feature = "schemars")] + #[test] + fn schemas_require_ids_and_chunk_content_but_not_upsert_content() { + let upsert = serde_json::to_value(schemars::schema_for!(SessionMessage)).unwrap(); + let required = upsert["required"].as_array().unwrap(); + assert_eq!(required, &vec![json!("messageId")]); + let chunk = serde_json::to_value(schemars::schema_for!(SessionMessageChunk)).unwrap(); + let required = chunk["required"].as_array().unwrap(); + assert_eq!(required.len(), 2); + assert!(required.contains(&json!("messageId"))); + assert!(required.contains(&json!("content"))); + } + + #[test] + fn endpoints_can_arrive_late_or_be_omitted_from_later_events() { + let block = ContentBlock::Text(crate::v2::TextContent::new("hello")); + let minimal = SessionMessage::new("m1"); + let first = SessionMessageChunk::new("m1", block.clone()); + assert_eq!( + serde_json::to_value(&minimal).unwrap(), + json!({"messageId": "m1"}) + ); + assert_eq!( + serde_json::to_value(&first).unwrap(), + json!({"messageId": "m1", "content": {"type": "text", "text": "hello"}}) + ); + let enriched = SessionMessage::new("m1") + .sender_session_id(SessionId::new("parent")) + .recipient_session_id(SessionId::new("child")); + assert_eq!(enriched.sender_session_id, Some(SessionId::new("parent"))); + assert_eq!(enriched.recipient_session_id, Some(SessionId::new("child"))); + let later = + SessionMessageChunk::new("m1", block).sender_session_id(SessionId::new("parent")); + assert_eq!(later.sender_session_id, Some(SessionId::new("parent"))); + assert_eq!(later.recipient_session_id, None); + for update in [ + SessionUpdate::SessionMessage(minimal), + SessionUpdate::SessionMessageChunk(first), + SessionUpdate::SessionMessage(enriched), + SessionUpdate::SessionMessageChunk(later), + ] { + let wire = serde_json::to_value(&update).unwrap(); + let decoded: SessionUpdate = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), wire); + } + } + + #[test] + fn invalid_or_null_endpoints_are_absent_but_message_id_is_required() { + for (kind, content) in [ + ("session_message", None), + ( + "session_message_chunk", + Some(json!({"type": "text", "text": "hello"})), + ), + ] { + let mut base = json!({"sessionUpdate": kind, "messageId": "m1", + "senderSessionId": null, "recipientSessionId": 42}); + if let Some(content) = content { + base["content"] = content; + } + let decoded: SessionUpdate = serde_json::from_value(base.clone()).unwrap(); + let encoded = serde_json::to_value(decoded).unwrap(); + base.as_object_mut().unwrap().remove("senderSessionId"); + base.as_object_mut().unwrap().remove("recipientSessionId"); + assert_eq!(encoded, base); + for bad in [Value::Null, json!(42)] { + let mut invalid = base.clone(); + invalid["messageId"] = bad; + assert!(serde_json::from_value::(invalid).is_err()); + } + } + } +} + +#[cfg(all(test, not(feature = "unstable_subagents")))] +mod disabled_session_message_tests { + use super::*; + use serde_json::json; + + #[test] + fn preserves_both_unknown_message_discriminators() { + for (kind, content) in [ + ("session_message", json!([])), + ( + "session_message_chunk", + json!({"type": "text", "text": "hello"}), + ), + ] { + let wire = json!({"sessionUpdate": kind, "messageId": "m1", + "senderSessionId": "parent", "recipientSessionId": "child", "content": content}); + let decoded: SessionUpdate = serde_json::from_value(wire.clone()).unwrap(); + assert!(matches!(decoded, SessionUpdate::Other(_))); + assert_eq!(serde_json::to_value(decoded).unwrap(), wire); + } + } +} + /// **UNSTABLE** /// /// This capability is not part of the spec yet, and may be removed or changed at any point. @@ -468,27 +910,35 @@ impl CompactionSummaryChunk { /// /// This capability is not part of the spec yet, and may be removed or changed at any point. /// -/// An upsert associating a reusable child session with its immediate parent. +/// Notification that the enclosing parent session created and owns a child session. +/// +/// Later updates modify the existing association's metadata, not its ownership. /// /// Sent on the immediate parent session. The first update for an unknown /// [`SubagentUpdate::session_id`] announces the child and MUST be sent -/// before any request or notification bearing the child's session ID, or any -/// tool-call session reference to it. Child events are delivered automatically; -/// no separate child load, resume, or subscription is needed. +/// before any live request or notification bearing the child's session ID, +/// including messages naming it as sender or recipient. Child events are +/// delivered automatically; no separate child load, resume, or subscription is needed. /// Understanding this update, registering child sessions, and applying their /// operation restrictions are baseline v2 requirements; no Client capability /// is required. /// /// Only [`SubagentUpdate::session_id`] is required. Other fields have /// patch semantics: omitted fields leave the stored value unchanged, `null` -/// clears or unsets the value, and concrete values replace it. A child whose -/// capabilities are unset permits no Client-initiated session mutations. +/// clears or unsets the value, and concrete values replace it. For `state`, +/// `null` removes the current state report and leaves activity unconfirmed; +/// it does not report idle or cancellation. A child whose capabilities are +/// unset permits no Client-initiated session mutations. /// -/// The child's title is reported through [`SessionInfoUpdate`] on its own -/// stream. Its foreground work uses ordinary [`StateUpdate`] notifications. +/// The title and description provide the parent's display metadata for the +/// child. Agents SHOULD mirror known child state changes in the parent's +/// [`SubagentUpdate::state`] using the same [`StateUpdate`] snapshot as the +/// ordinary `state_update` notification on the child session. This reports +/// child state, not a separate parent-owned lifecycle. Consumers should +/// treat duplicate reports idempotently. /// Completing or cancelling work does not end the association: the parent may -/// message the same child again. Individual operations and their outcomes belong -/// to tool calls referencing the child, not to this association. +/// message the same child again. Individual messages and their outcomes do not +/// change this association. #[cfg(feature = "unstable_subagents")] #[serde_as] #[skip_serializing_none] @@ -502,6 +952,22 @@ pub struct SubagentUpdate { /// Nested inside `update`; the enclosing notification's `sessionId` identifies /// the immediate parent, not this child. pub session_id: SessionId, + /// The parent's human-readable display title for this child. It need not be unique. + /// + /// Optional and nullable. Omitted means unchanged; `null` clears it. If no + /// title is set, the Client chooses a fallback presentation. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub title: MaybeUndefined, + /// The parent's human-readable description of the child's role or purpose. + /// + /// Optional and nullable. Omitted means unchanged; `null` clears it. This is + /// current display metadata, not the history of instructions sent to the child. + #[serde_as(deserialize_as = "DefaultOnError")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub description: MaybeUndefined, /// Client-initiated session mutations permitted for this subagent session. /// /// Read-only operations retain their normal protocol semantics and @@ -510,6 +976,15 @@ pub struct SubagentUpdate { #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] pub capabilities: MaybeUndefined, + /// The child's current foreground state, mirrored onto its parent association. + /// + /// Optional and nullable. Omitted means unchanged; `null` removes the + /// current report and leaves activity unconfirmed (not idle or cancelled). + /// A concrete [`StateUpdate`] replaces the entire previous snapshot. + #[serde_as(deserialize_as = "DefaultOnError>")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub state: MaybeUndefined, /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Omitted means no metadata update; `null` is an /// explicit clear signal. Implementations MUST NOT make assumptions about values at these keys. @@ -532,11 +1007,28 @@ impl SubagentUpdate { pub fn new(session_id: impl Into) -> Self { Self { session_id: session_id.into(), + title: MaybeUndefined::Undefined, + description: MaybeUndefined::Undefined, capabilities: MaybeUndefined::Undefined, + state: MaybeUndefined::Undefined, meta: MaybeUndefined::Undefined, } } + /// Sets, clears, or leaves unchanged the parent's display title for the child. + #[must_use] + pub fn title(mut self, title: impl IntoMaybeUndefined) -> Self { + self.title = title.into_maybe_undefined(); + self + } + + /// Sets, clears, or leaves unchanged the parent's description of the child. + #[must_use] + pub fn description(mut self, description: impl IntoMaybeUndefined) -> Self { + self.description = description.into_maybe_undefined(); + self + } + /// Sets, clears, or leaves unchanged the permitted client-initiated session mutations. #[must_use] pub fn capabilities( @@ -547,6 +1039,13 @@ impl SubagentUpdate { self } + /// Replaces, clears, or leaves unchanged the child's mirrored state snapshot. + #[must_use] + pub fn state(mut self, state: impl IntoMaybeUndefined) -> Self { + self.state = state.into_maybe_undefined(); + self + } + /// Sets, clears, or leaves unchanged subagent metadata. #[must_use] pub fn meta(mut self, meta: impl IntoMaybeUndefined) -> Self { @@ -741,7 +1240,10 @@ fn is_known_session_update(session_update: &str) -> bool { return true; } #[cfg(feature = "unstable_subagents")] - if session_update == "subagent_update" { + if matches!( + session_update, + "subagent_update" | "session_message" | "session_message_chunk" + ) { return true; } matches!( @@ -797,6 +1299,10 @@ fn other_session_update_schema(schema: &mut Schema) { "compaction_summary_chunk", #[cfg(feature = "unstable_subagents")] "subagent_update", + #[cfg(feature = "unstable_subagents")] + "session_message", + #[cfg(feature = "unstable_subagents")] + "session_message_chunk", ], ); } @@ -2929,16 +3435,19 @@ mod tests { }) ); assert!(minimal.capabilities.is_undefined()); + assert!(minimal.state.is_undefined()); assert!(minimal.meta.is_undefined()); // Patch semantics distinguish omitted fields from explicit nulls. let cleared_wire = json!({ "sessionId": "sess_child_1", "capabilities": null, + "state": null, "_meta": null }); let cleared: SubagentUpdate = serde_json::from_value(cleared_wire.clone()).unwrap(); assert!(cleared.capabilities.is_null()); + assert!(cleared.state.is_null()); assert!(cleared.meta.is_null()); assert_eq!(serde_json::to_value(cleared).unwrap(), cleared_wire); @@ -3005,6 +3514,56 @@ mod tests { ); } + #[cfg(feature = "unstable_subagents")] + #[test] + fn subagent_display_metadata_uses_patch_semantics() { + use serde_json::json; + + let update = SubagentUpdate::new("child") + .title("Test investigator".to_string()) + .description("Investigates platform-specific test failures.".to_string()); + let wire = json!({ + "sessionId": "child", + "title": "Test investigator", + "description": "Investigates platform-specific test failures." + }); + assert_eq!(serde_json::to_value(&update).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + update + ); + + let minimal = SubagentUpdate::new("child"); + assert!(minimal.title.is_undefined()); + assert!(minimal.description.is_undefined()); + assert_eq!( + serde_json::to_value(&minimal).unwrap(), + json!({"sessionId": "child"}) + ); + let cleared_wire = json!({ + "sessionId": "child", "title": null, "description": null + }); + let cleared: SubagentUpdate = serde_json::from_value(cleared_wire.clone()).unwrap(); + assert!(cleared.title.is_null()); + assert!(cleared.description.is_null()); + assert_eq!(serde_json::to_value(cleared).unwrap(), cleared_wire); + + let title_only: SubagentUpdate = serde_json::from_value(json!({ + "sessionId": "child", "title": "Updated title" + })) + .unwrap(); + assert_eq!( + title_only.title, + MaybeUndefined::Value("Updated title".to_string()) + ); + assert!(title_only.description.is_undefined()); + let malformed: SubagentUpdate = serde_json::from_value(json!({ + "sessionId": "child", "title": false, "description": false + })) + .unwrap(); + assert_eq!(malformed, minimal); + } + #[cfg(feature = "unstable_subagents")] #[test] fn subagent_notification_keeps_parent_and_child_ids_nested() { @@ -3036,7 +3595,7 @@ mod tests { #[cfg(feature = "unstable_subagents")] #[test] - fn subagent_reuses_normal_child_state_notifications() { + fn subagent_mirrors_normal_child_state_notifications() { use serde_json::json; let association = SubagentUpdate::new("sess_child"); @@ -3049,20 +3608,96 @@ mod tests { StateUpdate::Running(RunningStateUpdate::new()), StateUpdate::Idle(IdleStateUpdate::new().stop_reason(StopReason::Cancelled)), ] { - let notification = UpdateSessionNotification::new( + let child_notification = UpdateSessionNotification::new( association.session_id.clone(), - SessionUpdate::StateUpdate(state), + SessionUpdate::StateUpdate(state.clone()), + ); + let parent_notification = UpdateSessionNotification::new( + "sess_parent", + SessionUpdate::SubagentUpdate(association.clone().state(state)), + ); + let child_wire = serde_json::to_value(&child_notification).unwrap(); + let parent_wire = serde_json::to_value(&parent_notification).unwrap(); + assert_eq!(child_wire["sessionId"], json!("sess_child")); + assert_eq!(child_wire["update"]["sessionUpdate"], json!("state_update")); + assert_eq!(parent_wire["sessionId"], json!("sess_parent")); + assert_eq!(parent_wire["update"]["sessionId"], json!("sess_child")); + let mut child_snapshot = child_wire["update"].clone(); + child_snapshot + .as_object_mut() + .unwrap() + .remove("sessionUpdate"); + assert_eq!(parent_wire["update"]["state"], child_snapshot); + assert_eq!( + serde_json::from_value::(child_wire).unwrap(), + child_notification ); - let wire = serde_json::to_value(¬ification).unwrap(); - assert_eq!(wire["sessionId"], json!("sess_child")); - assert_eq!(wire["update"]["sessionUpdate"], json!("state_update")); assert_eq!( - serde_json::from_value::(wire).unwrap(), - notification + serde_json::from_value::(parent_wire).unwrap(), + parent_notification ); } } + #[cfg(feature = "unstable_subagents")] + #[test] + fn subagent_state_uses_whole_snapshot_patch_semantics() { + use serde_json::json; + + let minimal = SubagentUpdate::new("child"); + assert!(minimal.state.is_undefined()); + assert_eq!( + serde_json::to_value(&minimal).unwrap(), + json!({"sessionId": "child"}) + ); + + let cleared = minimal.clone().state(None::); + assert!(cleared.state.is_null()); + assert_eq!( + serde_json::to_value(&cleared).unwrap(), + json!({"sessionId": "child", "state": null}) + ); + assert_eq!( + serde_json::from_value::(json!({"sessionId": "child", "state": null})) + .unwrap(), + cleared + ); + + for snapshot in [ + json!({"state": "running", "_meta": {"source": "worker"}}), + json!({"state": "idle", "stopReason": "end_turn"}), + json!({"state": "requires_action"}), + json!({"state": "unknown"}), + json!({"state": "_custom", "detail": {"phase": 2}}), + ] { + let state: StateUpdate = serde_json::from_value(snapshot.clone()).unwrap(); + let update = minimal.clone().state(state.clone()); + let wire = json!({"sessionId": "child", "state": snapshot}); + assert_eq!(serde_json::to_value(&update).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + update + ); + assert_eq!(update.state, MaybeUndefined::Value(state)); + } + + // Each concrete value is the entire replacement snapshot, not a merge + // with a previous state's stop reason or metadata. + let idle = SubagentUpdate::new("child").state(StateUpdate::Idle( + IdleStateUpdate::new().stop_reason(StopReason::Cancelled), + )); + let running = idle.state(StateUpdate::Running(RunningStateUpdate::new())); + assert_eq!( + serde_json::to_value(running).unwrap(), + json!({"sessionId": "child", "state": {"state": "running"}}) + ); + for invalid in [json!(false), json!(42), json!({}), json!({"state": 7})] { + let parsed: SubagentUpdate = + serde_json::from_value(json!({"sessionId": "child", "state": invalid})).unwrap(); + assert_eq!(parsed, minimal); + } + } + #[cfg(feature = "unstable_subagents")] #[test] fn unknown_activity_is_a_known_state_update() { @@ -3119,17 +3754,31 @@ mod tests { #[cfg(all(feature = "unstable_subagents", feature = "schemars"))] #[test] - fn subagent_schema_only_carries_association_metadata() { + fn subagent_schema_carries_optional_nullable_state_snapshot() { use serde_json::json; let schema = serde_json::to_value(schemars::schema_for!(SubagentUpdate)).unwrap(); let properties = schema["properties"].as_object().unwrap(); assert!(properties.contains_key("sessionId")); + assert!(properties.contains_key("title")); + assert!(properties.contains_key("description")); assert!(properties.contains_key("capabilities")); + assert!(properties.contains_key("state")); assert!(properties.contains_key("_meta")); assert!(!properties.contains_key("name")); assert!(!properties.contains_key("task")); - assert!(!properties.contains_key("state")); + assert_eq!(schema["required"], json!(["sessionId"])); + assert_eq!( + properties["state"]["x-deserialize-default-on-error"], + json!(true) + ); + let state_variants = properties["state"]["anyOf"].as_array().unwrap(); + assert!(state_variants.contains(&json!({"type": "null"}))); + assert!( + state_variants + .iter() + .any(|variant| variant["$ref"] == "#/$defs/StateUpdate") + ); let child = serde_json::to_value(schemars::schema_for!(SubagentSessionCapabilities)).unwrap(); let variants = child["properties"]["cancel"]["anyOf"] diff --git a/agent-client-protocol-schema/src/v2/tool_call.rs b/agent-client-protocol-schema/src/v2/tool_call.rs index d49814d51..24918fbee 100644 --- a/agent-client-protocol-schema/src/v2/tool_call.rs +++ b/agent-client-protocol-schema/src/v2/tool_call.rs @@ -12,8 +12,6 @@ use schemars::Schema; use serde::{Deserialize, Serialize}; use serde_with::{DefaultOnError, VecSkipError, serde_as, skip_serializing_none}; -#[cfg(feature = "unstable_subagents")] -use super::SessionId; use super::{AbsolutePath, ContentBlock, MediaType, Meta, Terminal}; use crate::{IntoMaybeUndefined, IntoOption, MaybeUndefined, SkipListener}; @@ -387,9 +385,6 @@ pub enum ToolCallContent { Diff(Diff), /// A display-only reference to an agent-owned terminal. Terminal(Terminal), - /// **UNSTABLE** Display reference to an already-known session on this ACP connection. - #[cfg(feature = "unstable_subagents")] - Session(SessionReference), /// Custom or future tool call content. /// /// Values beginning with `_` are reserved for implementation-specific @@ -460,16 +455,15 @@ impl<'de> Deserialize<'de> for OtherToolCallContent { fn is_known_tool_call_content_type(type_: &str) -> bool { matches!(type_, "content" | "diff" | "terminal") - || (cfg!(feature = "unstable_subagents") && type_ == "session") } #[cfg(feature = "schemars")] fn other_tool_call_content_schema(schema: &mut Schema) { - #[cfg(feature = "unstable_subagents")] - const KNOWN: &[&str] = &["content", "diff", "terminal", "session"]; - #[cfg(not(feature = "unstable_subagents"))] - const KNOWN: &[&str] = &["content", "diff", "terminal"]; - super::schema_util::reject_known_string_discriminators(schema, "type", KNOWN); + super::schema_util::reject_known_string_discriminators( + schema, + "type", + &["content", "diff", "terminal"], + ); } impl> From for ToolCallContent { @@ -490,65 +484,6 @@ impl From for ToolCallContent { } } -#[cfg(feature = "unstable_subagents")] -impl From for ToolCallContent { - fn from(reference: SessionReference) -> Self { - ToolCallContent::Session(reference) - } -} - -/// **UNSTABLE** Display reference to an already-known session on this ACP connection. -/// -/// The enclosing notification's `params.sessionId` identifies the session whose -/// transcript is updated; this item's `sessionId` links that tool operation to -/// another known session for display. Ordinary session setup or a -/// `subagent_update` announcement establishes a known target. A parent can -/// reference a child, and a child can reference its parent or a sibling. -/// Parent-child associations and controls are announced separately by -/// `subagent_update`. This item does not create or register a session, reparent -/// it, grant controls, prompt it, subscribe to it, close it, send a message, -/// or change ownership. Reference links can point both ways without making the -/// ownership tree cyclic. A tool call may -/// reference multiple known sessions, and multiple tool calls may reference -/// the same session. Tool-call status describes the operation, not whether -/// the referenced session is idle or terminated. -#[cfg(feature = "unstable_subagents")] -#[serde_as] -#[skip_serializing_none] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct SessionReference { - /// Identifier of the already-known session linked from this tool operation, - /// not the session used to route the enclosing notification. - pub session_id: SessionId, - /// Optional nullable item metadata. Omission and `null` both mean no metadata. - #[serde_as(deserialize_as = "DefaultOnError")] - #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))] - #[serde(default, rename = "_meta")] - pub meta: Option, -} - -#[cfg(feature = "unstable_subagents")] -impl SessionReference { - /// Builds a display reference with no item metadata. - #[must_use] - pub fn new(session_id: impl Into) -> Self { - Self { - session_id: session_id.into(), - meta: None, - } - } - - /// Sets item-scoped metadata. - #[must_use] - pub fn meta(mut self, meta: impl IntoOption) -> Self { - self.meta = meta.into_option(); - self - } -} - /// Standard content block (text, images, resources). #[serde_as] #[skip_serializing_none] @@ -1331,76 +1266,6 @@ mod tests { ); } - #[cfg(feature = "unstable_subagents")] - #[test] - fn session_reference_serializes_and_requires_id() { - let reference = ToolCallContent::from(SessionReference::new("child_1")); - let wire = serde_json::json!({"type": "session", "sessionId": "child_1"}); - assert_eq!(serde_json::to_value(&reference).unwrap(), wire); - assert_eq!( - serde_json::from_value::(wire).unwrap(), - reference - ); - for invalid in [ - serde_json::json!({"type": "session"}), - serde_json::json!({"type": "session", "sessionId": null}), - serde_json::json!({"type": "session", "sessionId": 1}), - ] { - assert!(serde_json::from_value::(invalid).is_err()); - } - } - - #[cfg(feature = "unstable_subagents")] - #[test] - fn session_references_to_known_sessions_share_one_wire_shape() { - let references: Vec<_> = ["child_1", "parent_1", "sibling_1"] - .into_iter() - .map(|id| ToolCallContent::from(SessionReference::new(id))) - .collect(); - let wire = serde_json::json!([ - {"type": "session", "sessionId": "child_1"}, - {"type": "session", "sessionId": "parent_1"}, - {"type": "session", "sessionId": "sibling_1"} - ]); - assert_eq!(serde_json::to_value(&references).unwrap(), wire); - assert_eq!( - serde_json::from_value::>(wire).unwrap(), - references - ); - } - - #[cfg(feature = "unstable_subagents")] - #[test] - fn session_reference_meta_is_item_scoped_and_nullable() { - let meta: Meta = serde_json::from_value(serde_json::json!({"source": "test"})).unwrap(); - let reference = SessionReference::new("child_1").meta(meta.clone()); - assert_eq!( - serde_json::to_value(ToolCallContent::Session(reference.clone())).unwrap(), - serde_json::json!({"type": "session", "sessionId": "child_1", "_meta": {"source": "test"}}) - ); - assert_eq!(reference.meta, Some(meta)); - for wire in [ - serde_json::json!({"type": "session", "sessionId": "child_1"}), - serde_json::json!({"type": "session", "sessionId": "child_1", "_meta": null}), - ] { - let ToolCallContent::Session(parsed) = - serde_json::from_value::(wire).unwrap() - else { - panic!("expected session reference"); - }; - assert_eq!(parsed.meta, None); - } - } - - #[cfg(not(feature = "unstable_subagents"))] - #[test] - fn session_reference_is_unknown_when_feature_disabled() { - let wire = serde_json::json!({"type": "session", "sessionId": "child_1"}); - let content: ToolCallContent = serde_json::from_value(wire.clone()).unwrap(); - assert!(matches!(content, ToolCallContent::Other(_))); - assert_eq!(serde_json::to_value(content).unwrap(), wire); - } - #[test] fn diff_patch_serializes_git_patch_with_structured_changes() { let patch_text = "diff --git /repo/config.json /repo/config.json\n--- /repo/config.json\n+++ /repo/config.json\n@@ -1 +1 @@\n-old\n+new\n"; @@ -1583,12 +1448,5 @@ mod tests { })) .is_err() ); - #[cfg(feature = "unstable_subagents")] - assert!( - serde_json::from_value::(serde_json::json!({ - "type": "session" - })) - .is_err() - ); } } diff --git a/docs/protocol/v1/draft/prompt-turn.mdx b/docs/protocol/v1/draft/prompt-turn.mdx index 30f54daa9..a873fe7c7 100644 --- a/docs/protocol/v1/draft/prompt-turn.mdx +++ b/docs/protocol/v1/draft/prompt-turn.mdx @@ -11,29 +11,78 @@ Before sending prompts, Clients **MUST** first complete the [initialization](/pr Agents send `session/update` notifications to report streamed content, tool activity, and changes to session state. The `sessionUpdate` field inside `params.update` identifies the variant: -| `sessionUpdate` value | Description | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `user_message_chunk` | A streamed chunk of a user message. | -| `agent_message_chunk` | A streamed chunk of the Agent's response. | -| `agent_thought_chunk` | A streamed chunk of the Agent's reasoning. | -| `tool_call` | A new [tool call](/protocol/v1/draft/tool-calls#creating). | -| `tool_call_update` | A change to a [tool call's status or results](/protocol/v1/draft/tool-calls#updating). | -| `plan` | The Agent's [execution plan](/protocol/v1/draft/agent-plan). | -| `plan_update` | **Draft only.** A [content update for a plan](/protocol/v1/draft/agent-plan#plan-operations) identified by ID. Requires the Client's `plan` capability. | -| `plan_removed` | **Draft only.** [Removal of a plan](/protocol/v1/draft/agent-plan#removing-plans) identified by ID. Requires the Client's `plan` capability. | -| `available_commands_update` | The set of [available slash commands](/protocol/v1/draft/slash-commands) is ready or has changed. | -| `current_mode_update` | The current [session mode](/protocol/v1/draft/session-modes#from-the-agent) has changed. | -| `config_option_update` | The session's [configuration options](/protocol/v1/draft/session-config-options#from-the-agent) have changed. | -| `session_info_update` | [Session metadata](/protocol/v1/draft/session-list#updating-session-metadata), such as the title or last update time, has changed. | -| `usage_update` | The session's [context window usage and cumulative cost](#session-usage-updates). | -| `notice` | **Draft only.** A live [advisory notice](#session-notices) that is not part of session history. Requires the Client's `session.notices` capability. | -| `compaction_update` | **Draft only.** Creates or updates a [context compaction](#session-compaction). Requires the Client's `session.compaction` capability. | -| `compaction_summary_chunk` | **Draft only.** Appends content to a [compaction's retained summary](#session-compaction). Requires the Client's `session.compaction` capability. | +| `sessionUpdate` value | Description | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `user_message_chunk` | A streamed chunk of a user message. | +| `agent_message_chunk` | A streamed chunk of the Agent's response. | +| `agent_thought_chunk` | A streamed chunk of the Agent's reasoning. | +| `subagent_update` | **Draft only.** Announces an owned child session or updates its parent-owned metadata and work state. Requires the Client's `subagents` capability. | +| `session_message` | **Draft only.** Creates or updates a [message addressed to another session](#session-directed-messages-unstable). Requires the Client's `subagents` capability. | +| `session_message_chunk` | **Draft only.** Appends content to a [session-directed message](#session-directed-messages-unstable). Requires the Client's `subagents` capability. | +| `tool_call` | A new [tool call](/protocol/v1/draft/tool-calls#creating). | +| `tool_call_update` | A change to a [tool call's status or results](/protocol/v1/draft/tool-calls#updating). | +| `plan` | The Agent's [execution plan](/protocol/v1/draft/agent-plan). | +| `plan_update` | **Draft only.** A [content update for a plan](/protocol/v1/draft/agent-plan#plan-operations) identified by ID. Requires the Client's `plan` capability. | +| `plan_removed` | **Draft only.** [Removal of a plan](/protocol/v1/draft/agent-plan#removing-plans) identified by ID. Requires the Client's `plan` capability. | +| `available_commands_update` | The set of [available slash commands](/protocol/v1/draft/slash-commands) is ready or has changed. | +| `current_mode_update` | The current [session mode](/protocol/v1/draft/session-modes#from-the-agent) has changed. | +| `config_option_update` | The session's [configuration options](/protocol/v1/draft/session-config-options#from-the-agent) have changed. | +| `session_info_update` | [Session metadata](/protocol/v1/draft/session-list#updating-session-metadata), such as the title or last update time, has changed. | +| `usage_update` | The session's [context window usage and cumulative cost](#session-usage-updates). | +| `notice` | **Draft only.** A live [advisory notice](#session-notices) that is not part of session history. Requires the Client's `session.notices` capability. | +| `compaction_update` | **Draft only.** Creates or updates a [context compaction](#session-compaction). Requires the Client's `session.compaction` capability. | +| `compaction_summary_chunk` | **Draft only.** Appends content to a [compaction's retained summary](#session-compaction). Requires the Client's `session.compaction` capability. | This is the complete set of variants defined in the current draft [`SessionUpdate` schema](/protocol/v1/draft/schema#sessionupdate), which describes each payload in detail. Draft-only variants are unstable and may change or be removed. The walkthrough below illustrates common uses of these updates. Session updates are not limited to active prompt turns. For example, Agents may advertise commands after creating a session, and replay user and Agent messages when [loading a session](/protocol/v1/draft/session-setup#loading-sessions). +### Session-directed messages (unstable) + +When the Client advertises `subagents: {}`, the Agent may report outgoing and +incoming messages between sessions, separately from user-facing responses and +tool calls. The outer `params.sessionId` identifies the transcript being +updated. Every message and chunk requires a `messageId` scoped to that +transcript. Optional `senderSessionId` and `recipientSessionId` metadata +identify the participants. Include them on the first update when available; +later updates may omit them or add missing identities. + +For example, an incoming message in the child can begin with a streamed chunk: + +```json +{ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "child_session", + "update": { + "sessionUpdate": "session_message_chunk", + "messageId": "received_message_1", + "senderSessionId": "parent_session", + "recipientSessionId": "child_session", + "content": { "type": "text", "text": "Please investigate " } + } + } +} +``` + +Every chunk carries one normal `ContentBlock`; later chunks append content +for the same `messageId`. `session_message` can supply a whole +`content` array to replace accumulated content. Its optional `content` and +`_meta` fields leave prior values unchanged when omitted; `null` clears them. +`content: []` also clears content. Chunk metadata is local to the chunk, not a +message-level patch. + +Clients can render "To …" with a recipient link or "From …" with a sender link. +Without sufficient participant metadata, use a generic inter-session +presentation and add links when the metadata arrives. Omitted or `null` +participant IDs mean not supplied and leave previously known identities intact. +The two views have independent message IDs. The Agent reports each side only +when observed; Clients must not synthesize an incoming entry from an outgoing +one or forward the content themselves. Neither view acknowledges completed +recipient processing. See the [Subagent Sessions RFD](/rfds/subagents#session-directed-messages) +for participant registration, history gaps, and ownership rules. + ## The Prompt Turn Lifecycle A prompt turn follows a structured flow that enables rich interactions between the user, Agent, and any connected tools. diff --git a/docs/protocol/v1/draft/schema.mdx b/docs/protocol/v1/draft/schema.mdx index ad057aa1a..822e0f553 100644 --- a/docs/protocol/v1/draft/schema.mdx +++ b/docs/protocol/v1/draft/schema.mdx @@ -3338,7 +3338,7 @@ Whether the client understands exposed subagent sessions. Optional and nullable. Omitted or `null` both mean the client does not advertise support. Supplying `\{\}` means the client understands child associations, work-state -snapshots, tool-call session references, and restricted-session semantics. +snapshots, session-directed messages, and restricted-session semantics. @@ -7915,6 +7915,90 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/d +## SessionMessage + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +An upsert of an inter-session message in the enclosing transcript. +`messageId` is local to that transcript; separate views may use independent +IDs. Endpoints are optional identity metadata, not content patches: omitted +or `null` retains a known endpoint. Agents SHOULD supply available endpoints +on the first event; later events may omit them or enrich missing endpoints. +Supplied endpoints must agree with the enclosing transcript. Live IDs refer +to known sessions; history may retain unavailable counterparts. Missing +identities permit generic inter-session UI, not guessed participants or +human authorship. +Omitted `content` and `_meta` leave their stored values unchanged; `null` +clears them. A concrete `content` array replaces existing content +(`[]` also clears it); later chunks append. This neither changes session +ownership nor instructs the Client to deliver content. + +**Type:** Object + +**Properties:** + + + Omitted leaves metadata unchanged; `null` removes it. + +Implementations MUST NOT make assumptions about values in `_meta`. + + +ContentBlock[] | null} > + Omitted leaves content unchanged; `null` or `[]` clears it. +A non-empty array replaces all content. + +MessageId} required> + Identifier of this message within the enclosing session's transcript. + +SessionId | null} > + Optional receiving session identity; omission or `null` retains a known value. + +SessionId | null} > + Optional sending session identity; omission or `null` retains a known value. + + +## SessionMessageChunk + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +A content block appended to a transcript-local message in arrival order. +Endpoints are optional identity metadata, not content patches: omitted or +`null` does not clear a known endpoint. Agents SHOULD supply available +endpoints on the first event; later events may omit them or enrich missing +endpoints. Supplied endpoints must agree with the enclosing transcript. +Live IDs refer to known sessions; history may retain unavailable counterparts. +Missing identities permit generic inter-session UI, not guessed participants +or human authorship. +Chunk metadata applies only to that chunk. This does not instruct the Client +to deliver content or imply that the recipient processed it. + +**Type:** Object + +**Properties:** + + + Optional and nullable chunk-scoped metadata; omitted or `null` means none. + +Implementations MUST NOT make assumptions about values in `_meta`. + + +ContentBlock} required> + A single content block appended to the message. + +MessageId} required> + Identifier of this message within the enclosing session's transcript. + +SessionId | null} > + Optional receiving session identity; omission or `null` retains a known value. + +SessionId | null} > + Optional sending session identity; omission or `null` retains a known value. + + ## SessionMode A mode the agent can operate in. @@ -7972,41 +8056,6 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/d The current mode the Agent is in. -## SessionReference - -**UNSTABLE** Display reference to an already-known session on this ACP connection. - -The enclosing notification's `params.sessionId` identifies the session whose -transcript is updated; this item's `sessionId` links that tool operation to -another known session for display. Ordinary session setup or a -`subagent_update` announcement establishes a known target. A parent can -reference a child, and a child can reference its parent or a sibling. -V1 work-state snapshots are carried by `subagent_update` on the parent stream. -Parent-child associations and controls are announced separately by -`subagent_update`. This item does not create or register a session, reparent -it, grant controls, prompt it, subscribe to it, close it, send a message, -or change ownership. Reference links can point both ways without making the -ownership tree cyclic. A tool call may -reference multiple known sessions, and multiple tool calls may reference -the same session. Tool-call status describes the operation, not whether -the referenced session is idle or terminated. - -**Type:** Object - -**Properties:** - - - Optional nullable item metadata. Omission and `null` both mean no metadata. - -SessionId} - required -> - Identifier of the already-known session linked from this tool operation, not - the session used to route the enclosing notification. - - ## SessionResumeCapabilities Capabilities for the `session/resume` method. @@ -8577,7 +8626,8 @@ Agents MUST only send this update when the Client advertised This capability is not part of the spec yet, and may be removed or changed at any point. -An upsert for a subagent exposed by this session. +Announces a child session created and owned by this session, or updates +that ownership association's metadata. @@ -8587,14 +8637,24 @@ metadata to their interactions. Implementations MUST NOT make assumptions about these keys. See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/draft/extensibility) +Omitted means unchanged; `null` removes the metadata. SubagentSessionCapabilities | null} > Client-initiated session mutations permitted for this subagent session. -Omitted and `null` both mean unchanged. If never supplied, no session -mutations are permitted. Read-only operations retain their normal protocol -semantics and capability requirements. +Omitted means unchanged; `null` clears the capability set and disables +child mutations. If never supplied, no session mutations are permitted. +Read-only operations retain their normal protocol semantics and capability +requirements. A concrete object replaces the whole capability set. + + + + The parent's human-readable description of the child's role or purpose. + +Omitted means unchanged; `null` clears it. If unset, the Client chooses +a fallback presentation. This is current display metadata, not the +history of instructions sent to the child. SessionId} required> @@ -8610,9 +8670,88 @@ the immediate parent, not this child. StateUpdate | null} > Current state snapshot for the child session. -Omitted and `null` both mean unchanged; a concrete state replaces the -previous state object wholesale. If never supplied, the state is unknown. +Omitted means unchanged; `null` clears the current activity without +asserting idle or sending an `unknown` snapshot. A concrete state +replaces the previous state object wholesale. If never supplied, the +current activity is unset/unconfirmed. + + + + The parent's human-readable display title for this child. It need not be unique. + +Omitted means unchanged; `null` clears it. If unset, the Client chooses +a fallback presentation. + + + + + + + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +A message upsert observed in this session's transcript, sent to or +received from another session. + + + + + Omitted leaves metadata unchanged; `null` removes it. + +Implementations MUST NOT make assumptions about values in `_meta`. + + +ContentBlock[] | null} > + Omitted leaves content unchanged; `null` or `[]` clears it. +A non-empty array replaces all content. + +MessageId} required> + Identifier of this message within the enclosing session's transcript. + +SessionId | null} > + Optional receiving session identity; omission or `null` retains a known value. + +SessionId | null} > + Optional sending session identity; omission or `null` retains a known value. + + + The discriminator value. Must be `"session_message"`. + + + + + + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +One content block appended to a sent or received session message. + + + + + Optional and nullable chunk-scoped metadata; omitted or `null` means none. +Implementations MUST NOT make assumptions about values in `_meta`. + + +ContentBlock} required> + A single content block appended to the message. + +MessageId} required> + Identifier of this message within the enclosing session's transcript. + +SessionId | null} > + Optional receiving session identity; omission or `null` retains a known value. + +SessionId | null} > + Optional sending session identity; omission or `null` retains a known value. + + + The discriminator value. Must be `"session_message_chunk"`. @@ -8894,9 +9033,9 @@ This capability is not part of the spec yet, and may be removed or changed at an Capability marker for exposing reusable child sessions as restricted ACP sessions. Supplying `\{\}` advertises support for child association and state updates, -tool-call session references, and restricted-session semantics. The client +session-directed messages, and restricted-session semantics. The client must advertise this capability before the agent sends subagent updates or -session references. +session-directed messages. **Type:** Object @@ -8940,20 +9079,25 @@ the session. Omitted or `null` means unsupported; an object (including This capability is not part of the spec yet, and may be removed or changed at any point. -An upsert for a reusable child session associated with its parent session. +Notification that the enclosing parent session created and owns a child session. + +Later updates modify the existing association's metadata, not its ownership. Sent on the immediate parent session. The first update for an unknown `SubagentUpdate::session_id` announces the child and MUST be sent -before any child traffic or reference to the child session. Parents may -message and reuse an announced child across multiple operations. +before any live child traffic or live message naming the child as sender +or recipient. Parents may message and reuse an announced child across +multiple operations. Child events are delivered automatically on the same connection; no child load, resume, or subscription is needed. -Only the subagent session ID is required. Omitted fields keep their previous -value; a child whose state was never reported has an unknown state. A -concrete state replaces the entire previous state object, not the session. -The child's title is reported via `session_info_update`; per-operation tasks -belong on tool calls referencing the child. +Only the subagent session ID is required. Omitted patch fields keep their +previous values; `null` clears them. Clearing capabilities disables child +mutations. Clearing state leaves current activity unset/unconfirmed: it does +not imply idle, stop work, or create an `unknown` state snapshot. A concrete +state replaces the entire previous state object, not the session. +The title and description provide the parent's display metadata for the +child. They do not replace the content of individual messages or operations. **Type:** Object @@ -8965,14 +9109,24 @@ metadata to their interactions. Implementations MUST NOT make assumptions about these keys. See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/draft/extensibility) +Omitted means unchanged; `null` removes the metadata. SubagentSessionCapabilities | null} > Client-initiated session mutations permitted for this subagent session. -Omitted and `null` both mean unchanged. If never supplied, no session -mutations are permitted. Read-only operations retain their normal protocol -semantics and capability requirements. +Omitted means unchanged; `null` clears the capability set and disables +child mutations. If never supplied, no session mutations are permitted. +Read-only operations retain their normal protocol semantics and capability +requirements. A concrete object replaces the whole capability set. + + + + The parent's human-readable description of the child's role or purpose. + +Omitted means unchanged; `null` clears it. If unset, the Client chooses +a fallback presentation. This is current display metadata, not the +history of instructions sent to the child. SessionId} required> @@ -8985,8 +9139,17 @@ the immediate parent, not this child. StateUpdate | null} > Current state snapshot for the child session. -Omitted and `null` both mean unchanged; a concrete state replaces the -previous state object wholesale. If never supplied, the state is unknown. +Omitted means unchanged; `null` clears the current activity without +asserting idle or sending an `unknown` snapshot. A concrete state +replaces the previous state object wholesale. If never supplied, the +current activity is unset/unconfirmed. + + + + The parent's human-readable display title for this child. It need not be unique. + +Omitted means unchanged; `null` clears it. If unset, the Client chooses +a fallback presentation. @@ -9302,29 +9465,6 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/d - -**UNSTABLE** Display reference to an already-known session on this ACP connection. - - - - - Optional nullable item metadata. Omission and `null` both mean no metadata. - -SessionId} - required -> - Identifier of the already-known session linked from this tool operation, not - the session used to route the enclosing notification. - - - The discriminator value. Must be `"session"`. - - - - - ## ToolCallId Unique identifier for a tool call within a session. diff --git a/docs/protocol/v1/draft/tool-calls.mdx b/docs/protocol/v1/draft/tool-calls.mdx index 4056ede7f..8ba795156 100644 --- a/docs/protocol/v1/draft/tool-calls.mdx +++ b/docs/protocol/v1/draft/tool-calls.mdx @@ -318,67 +318,6 @@ When a terminal is embedded in a tool call, the Client displays live output as i Learn more about Terminals -### Session References (unstable) - -A tool call can link to an already-known session on this ACP connection. An -ordinary session setup or a `subagent_update` announcement establishes a known -target; the target may be a child, parent, or sibling session. Agents **MUST NOT** -send this content unless the Client advertised `clientCapabilities.subagents`, -as defined in the [Subagent Sessions RFD](/rfds/subagents): - -```json -{ - "type": "session", - "sessionId": "child_session_id" -} -``` - -`sessionId` is required. Optional nullable `_meta` belongs to this content item; -omission and `null` both mean no item metadata. This is only a display -reference: it does not create or register a session, reparent it, grant controls, -prompt it, subscribe to it, close it, send a message by itself, or change -ownership. Parent-child associations and controls are announced separately by -`subagent_update` on the parent session. - -The notification's `params.sessionId` remains the session owning the operation -and routes its update; the content item's `sessionId` links the operation to -another known session. For example, these are **params fragments** (not complete -notifications): - -```json -{ - "sessionId": "parent_session_id", - "update": { - "sessionUpdate": "tool_call", - "toolCallId": "call_delegate", - "title": "Delegate task", - "content": [{ "type": "session", "sessionId": "child_session_id" }] - } -} -``` - -```json -{ - "sessionId": "child_session_id", - "update": { - "sessionUpdate": "tool_call", - "toolCallId": "call_reply", - "title": "Reply to parent", - "content": [{ "type": "session", "sessionId": "parent_session_id" }] - } -} -``` - -The child-to-parent case can show a reply interaction, but the reference does -not send that reply. Reference links can point both ways without making the -ownership tree cyclic. The child's own text, plan, and tool updates use -`params.sessionId: "child_session_id"`. V1 reports the child's work state through -`subagent_update.state` on the parent stream, rather than a child-addressed -`state_update` notification. One operation can reference several known sessions, -and several operations can reference the same session. Keep each operation's -title, input, output, and status on its tool call; completing the tool call does -not mean the referenced session is idle or terminated. - ## Following the Agent Tool calls can report file locations they're working with, enabling Clients to implement "follow-along" features that track which files the Agent is accessing or modifying in real-time. diff --git a/docs/protocol/v2/draft/prompt-lifecycle.mdx b/docs/protocol/v2/draft/prompt-lifecycle.mdx index 0cc981ad7..5b036eb02 100644 --- a/docs/protocol/v2/draft/prompt-lifecycle.mdx +++ b/docs/protocol/v2/draft/prompt-lifecycle.mdx @@ -21,6 +21,9 @@ Agents send `session/update` notifications to report streamed content, tool acti | `agent_message` | Creates or updates an Agent message identified by `messageId`. | | `agent_thought_chunk` | A streamed chunk of the Agent's reasoning. | | `agent_thought` | Creates or updates an Agent reasoning message identified by `messageId`. | +| `subagent_update` | **Draft only.** Announces an owned child session or updates its display metadata, capabilities, and mirrored work-state snapshot. | +| `session_message` | **Draft only.** Creates or updates a [message addressed to another session](#session-directed-messages-unstable). | +| `session_message_chunk` | **Draft only.** Appends content to a [session-directed message](#session-directed-messages-unstable). | | `state_update` | A change to the Agent's [foreground work state](#session-states). | | `tool_call_content_chunk` | Appends a [content chunk to a tool call](/protocol/v2/draft/tool-calls#streaming-content). | | `tool_call_update` | Creates or updates a [tool call](/protocol/v2/draft/tool-calls#reporting). | @@ -42,6 +45,51 @@ The protocol also supports [custom and future variants](/protocol/v2/draft/exten Session updates are not limited to prompt-driven foreground work. For example, Agents may advertise commands after creating a session, and background activity may emit updates while the Agent is [idle](#session-states). +### Session-directed messages (unstable) + +The Agent may report outgoing and incoming messages between sessions, +separately from user-facing responses and tool calls. The outer +`params.sessionId` identifies the transcript being updated. Every +`session_message` or `session_message_chunk` requires `messageId`, scoped to +that transcript. Optional `senderSessionId` and `recipientSessionId` metadata +identify the participants. Include them on the first update when available; +later updates may omit them or add missing identities. + +For example, an incoming message in the child can begin with a streamed chunk: + +```json +{ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "child_session", + "update": { + "sessionUpdate": "session_message_chunk", + "messageId": "received_message_1", + "senderSessionId": "parent_session", + "recipientSessionId": "child_session", + "content": { "type": "text", "text": "Please investigate " } + } + } +} +``` + +Chunks append normal message content for the same IDs. A `session_message` +upsert may replace the whole `content` array or update message metadata. +Omission leaves either field unchanged; `null` clears it. `content: []` also +clears accumulated content. Chunk `_meta` is scoped to that chunk and does not +patch message metadata. + +Clients can render "To …" with a recipient link or "From …" with a sender link. +Without sufficient participant metadata, use a generic inter-session +presentation and add links when the metadata arrives. Omitted or `null` +participant IDs mean not supplied and leave previously known identities intact. +The two views have independent message IDs. The Agent reports each side only +when observed; Clients must not synthesize an incoming entry from an outgoing +one or forward the content themselves. Neither view acknowledges completed +recipient processing. See the [Subagent Sessions RFD](/rfds/subagents#session-directed-messages) +for participant registration, history gaps, and ownership rules. + ## Prompt Lifecycle A typical prompt-driven flow enables rich interactions between the user, Agent, and any connected tools. @@ -598,6 +646,11 @@ Custom or future stop reasons can be used when Clients can display a generic sto `state_update` reports foreground work. +For exposed subagents, Agents **SHOULD** also mirror the same snapshot in the +immediate parent's `subagent_update.state`. This keeps the parent's roster and +the child's own stream informed about one logical state; duplicate snapshots +do not represent additional work or usage. See [current child work state](/rfds/subagents#current-work-state). + Foreground work is in progress. diff --git a/docs/protocol/v2/draft/schema.mdx b/docs/protocol/v2/draft/schema.mdx index 14adae3f1..0351f1804 100644 --- a/docs/protocol/v2/draft/schema.mdx +++ b/docs/protocol/v2/draft/schema.mdx @@ -8219,38 +8219,86 @@ An opaque cursor used to paginate `session/list` results. **Type:** `string` -## SessionReference +## SessionMessage -**UNSTABLE** Display reference to an already-known session on this ACP connection. +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. -The enclosing notification's `params.sessionId` identifies the session whose -transcript is updated; this item's `sessionId` links that tool operation to -another known session for display. Ordinary session setup or a -`subagent_update` announcement establishes a known target. A parent can -reference a child, and a child can reference its parent or a sibling. -Parent-child associations and controls are announced separately by -`subagent_update`. This item does not create or register a session, reparent -it, grant controls, prompt it, subscribe to it, close it, send a message, -or change ownership. Reference links can point both ways without making the -ownership tree cyclic. A tool call may -reference multiple known sessions, and multiple tool calls may reference -the same session. Tool-call status describes the operation, not whether -the referenced session is idle or terminated. +An upsert of an inter-session message in the enclosing transcript. +`messageId` is local to that transcript; separate views may use independent +IDs. Endpoints are optional identity metadata, not content patches: omitted +or `null` retains a known endpoint. Agents SHOULD supply available endpoints +on the first event; later events may omit them or enrich missing endpoints. +Supplied endpoints must agree with the enclosing transcript. Live IDs refer +to known sessions; history may retain unavailable counterparts. Missing +identities permit generic inter-session UI, not guessed participants or +human authorship. +A concrete `content` array replaces existing content; later chunks append. +This neither changes session ownership nor instructs the Client to deliver content. **Type:** Object **Properties:** - - Optional nullable item metadata. Omission and `null` both mean no metadata. + + Omitted leaves metadata unchanged; `null` clears it. + +Implementations MUST NOT make assumptions about values in `_meta`. + -SessionId} - required -> - Identifier of the already-known session linked from this tool operation, not - the session used to route the enclosing notification. +ContentBlock[] | null} > + Omitted leaves content unchanged; `null` clears it; a concrete array +replaces the whole content collection. + +MessageId} required> + Identifier of this message within the enclosing session's transcript. + +SessionId | null} > + Optional receiving session identity; omission or `null` retains a known value. + +SessionId | null} > + Optional sending session identity; omission or `null` retains a known value. + + +## SessionMessageChunk + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +A content block appended to a transcript-local message in arrival order. +Endpoints are optional identity metadata, not content patches: omitted or +`null` does not clear a known endpoint. Agents SHOULD supply available +endpoints on the first event; later events may omit them or enrich missing +endpoints. Supplied endpoints must agree with the enclosing transcript. +Live IDs refer to known sessions; history may retain unavailable counterparts. +Missing identities permit generic inter-session UI, not guessed participants +or human authorship. +Chunk metadata applies only to that chunk. This does not instruct the Client +to deliver content or imply that the recipient processed it. + +**Type:** Object + +**Properties:** + + + Optional and nullable chunk-scoped metadata; omitted or `null` means none. + +Implementations MUST NOT make assumptions about values in `_meta`. + + +ContentBlock} required> + A single content block appended to the message. + +MessageId} required> + Identifier of this message within the enclosing session's transcript. + +SessionId | null} > + Optional receiving session identity; omission or `null` retains a known value. + +SessionId | null} > + Optional sending session identity; omission or `null` retains a known value. ## SessionUpdate @@ -9013,7 +9061,8 @@ A content block appended to a context compaction's retained summary. This capability is not part of the spec yet, and may be removed or changed at any point. -A subagent exposed by this session has been created or updated. +Announces a child session created and owned by this session, or updates +that ownership association's metadata. @@ -9031,6 +9080,13 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/d Read-only operations retain their normal protocol semantics and capability requirements. + + + The parent's human-readable description of the child's role or purpose. + +Optional and nullable. Omitted means unchanged; `null` clears it. This is +current display metadata, not the history of instructions sent to the child. + SessionId} required> The opaque session ID identifying the child in all ACP messages. @@ -9042,6 +9098,91 @@ the immediate parent, not this child. The discriminator value. Must be `"subagent_update"`. +StateUpdate | null} > + The child's current foreground state, mirrored onto its parent association. + +Optional and nullable. Omitted means unchanged; `null` removes the +current report and leaves activity unconfirmed (not idle or cancelled). +A concrete `StateUpdate` replaces the entire previous snapshot. + + + + The parent's human-readable display title for this child. It need not be unique. + +Optional and nullable. Omitted means unchanged; `null` clears it. If no +title is set, the Client chooses a fallback presentation. + + + + + + + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +A message upsert observed in this session's transcript, sent to or +received from another session. + + + + + Omitted leaves metadata unchanged; `null` clears it. + +Implementations MUST NOT make assumptions about values in `_meta`. + + +ContentBlock[] | null} > + Omitted leaves content unchanged; `null` clears it; a concrete array +replaces the whole content collection. + +MessageId} required> + Identifier of this message within the enclosing session's transcript. + +SessionId | null} > + Optional receiving session identity; omission or `null` retains a known value. + +SessionId | null} > + Optional sending session identity; omission or `null` retains a known value. + + + The discriminator value. Must be `"session_message"`. + + + + + + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +One content block appended to a sent or received session message. + + + + + Optional and nullable chunk-scoped metadata; omitted or `null` means none. + +Implementations MUST NOT make assumptions about values in `_meta`. + + +ContentBlock} required> + A single content block appended to the message. + +MessageId} required> + Identifier of this message within the enclosing session's transcript. + +SessionId | null} > + Optional receiving session identity; omission or `null` retains a known value. + +SessionId | null} > + Optional sending session identity; omission or `null` retains a known value. + + + The discriminator value. Must be `"session_message_chunk"`. + @@ -9412,27 +9553,35 @@ the session. Omitted or `null` means unsupported; an object (including This capability is not part of the spec yet, and may be removed or changed at any point. -An upsert associating a reusable child session with its immediate parent. +Notification that the enclosing parent session created and owns a child session. + +Later updates modify the existing association's metadata, not its ownership. Sent on the immediate parent session. The first update for an unknown `SubagentUpdate::session_id` announces the child and MUST be sent -before any request or notification bearing the child's session ID, or any -tool-call session reference to it. Child events are delivered automatically; -no separate child load, resume, or subscription is needed. +before any live request or notification bearing the child's session ID, +including messages naming it as sender or recipient. Child events are +delivered automatically; no separate child load, resume, or subscription is needed. Understanding this update, registering child sessions, and applying their operation restrictions are baseline v2 requirements; no Client capability is required. Only `SubagentUpdate::session_id` is required. Other fields have patch semantics: omitted fields leave the stored value unchanged, `null` -clears or unsets the value, and concrete values replace it. A child whose -capabilities are unset permits no Client-initiated session mutations. - -The child's title is reported through `SessionInfoUpdate` on its own -stream. Its foreground work uses ordinary `StateUpdate` notifications. +clears or unsets the value, and concrete values replace it. For `state`, +`null` removes the current state report and leaves activity unconfirmed; +it does not report idle or cancellation. A child whose capabilities are +unset permits no Client-initiated session mutations. + +The title and description provide the parent's display metadata for the +child. Agents SHOULD mirror known child state changes in the parent's +`SubagentUpdate::state` using the same `StateUpdate` snapshot as the +ordinary `state_update` notification on the child session. This reports +child state, not a separate parent-owned lifecycle. Consumers should +treat duplicate reports idempotently. Completing or cancelling work does not end the association: the parent may -message the same child again. Individual operations and their outcomes belong -to tool calls referencing the child, not to this association. +message the same child again. Individual messages and their outcomes do not +change this association. **Type:** Object @@ -9452,6 +9601,13 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/d Read-only operations retain their normal protocol semantics and capability requirements. + + + The parent's human-readable description of the child's role or purpose. + +Optional and nullable. Omitted means unchanged; `null` clears it. This is +current display metadata, not the history of instructions sent to the child. + SessionId} required> The opaque session ID identifying the child in all ACP messages. @@ -9459,6 +9615,21 @@ capability requirements. Nested inside `update`; the enclosing notification's `sessionId` identifies the immediate parent, not this child. + +StateUpdate | null} > + The child's current foreground state, mirrored onto its parent association. + +Optional and nullable. Omitted means unchanged; `null` removes the +current report and leaves activity unconfirmed (not idle or cancelled). +A concrete `StateUpdate` replaces the entire previous snapshot. + + + + The parent's human-readable display title for this child. It need not be unique. + +Optional and nullable. Omitted means unchanged; `null` clears it. If no +title is set, the Client chooses a fallback presentation. + ## Terminal @@ -9860,29 +10031,6 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/d - -**UNSTABLE** Display reference to an already-known session on this ACP connection. - - - - - Optional nullable item metadata. Omission and `null` both mean no metadata. - -SessionId} - required -> - Identifier of the already-known session linked from this tool operation, not - the session used to route the enclosing notification. - - - The discriminator value. Must be `"session"`. - - - - - Custom or future tool call content. diff --git a/docs/protocol/v2/draft/tool-calls.mdx b/docs/protocol/v2/draft/tool-calls.mdx index 14e55140d..16e3c9b21 100644 --- a/docs/protocol/v2/draft/tool-calls.mdx +++ b/docs/protocol/v2/draft/tool-calls.mdx @@ -477,69 +477,6 @@ sequences, so decoders retain parser state across chunks. Optional nullable `_meta` is chunk-scoped; omission and `null` both mean no chunk metadata was provided. -### Session References (unstable) - -A tool call can link to an already-known session on this ACP connection. An -ordinary session setup or a `subagent_update` announcement establishes a known -target; the target may be a child, parent, or sibling session: - -```json -{ - "type": "session", - "sessionId": "child_session_id" -} -``` - -`sessionId` is required. Optional nullable `_meta` belongs to this content item; -omission and `null` both mean no item metadata. This is only a display -reference: it does not create or register a session, reparent it, grant controls, -prompt it, subscribe to it, close it, send a message by itself, or change -ownership. Parent-child associations and controls are announced separately by -`subagent_update` on the parent session. - -The notification's `params.sessionId` remains the session owning the operation -and routes its update; the content item's `sessionId` links the operation to -another known session. For example, these are **params fragments** (not complete -notifications): - -```json -{ - "sessionId": "parent_session_id", - "update": { - "sessionUpdate": "tool_call_update", - "toolCallId": "call_delegate", - "title": "Delegate task", - "content": [{ "type": "session", "sessionId": "child_session_id" }] - } -} -``` - -```json -{ - "sessionId": "child_session_id", - "update": { - "sessionUpdate": "tool_call_update", - "toolCallId": "call_reply", - "title": "Reply to parent", - "content": [{ "type": "session", "sessionId": "parent_session_id" }] - } -} -``` - -The child-to-parent case can show a reply interaction, but the reference does -not send that reply. Reference links can point both ways without making the -ownership tree cyclic. The child's own text, state, and tool updates use -`params.sessionId: "child_session_id"`. One operation can reference several -known sessions, and several operations can reference the same session. Keep each -operation's title, input, output, and status on its tool call; completing the -tool call does not mean the referenced session is idle or terminated. - -Understanding associations and these references is part of the v2 baseline -described in the [Subagent Sessions RFD](/rfds/subagents); a dedicated subagent -UI is optional. Older decoders can preserve this item as unknown content, but -that compatibility fallback is not sufficient implementation of the subagent -contract. - ### Diffs File modifications shown as diffs. A diff always includes structured file diff --git a/docs/rfds/session-usage.mdx b/docs/rfds/session-usage.mdx index 6f4c8568d..fae489399 100644 --- a/docs/rfds/session-usage.mdx +++ b/docs/rfds/session-usage.mdx @@ -184,20 +184,6 @@ Cost is cumulative session state, similar to context window utilization: - Both cost and context window are session-level metrics that can change when agents compact, switch models, or restore sessions - Cost is optional because not all agents track it -### Can clients add costs across parent and child sessions? - -Not from the session relationship alone. A parent's provider-reported -cumulative cost may already include subagents or internal helper calls, and -separately reported child costs may overlap it. Exposing those children should -not discard a useful parent total or silently change its accounting scope. - -Clients can display each session's reported cost, but should not infer a tree -total or exclusive shares without an accounting contract that establishes the -values' coverage and how any overlap is handled. Missing child costs are unknown, -not zero. -The [Subagent Sessions RFD](/rfds/subagents#cost-reporting-without-inferred-aggregation) -preserves provider totals without introducing a new cost-scope field. - ### Why not assume USD for cost? Agents may bill in different currencies: diff --git a/docs/rfds/subagents.mdx b/docs/rfds/subagents.mdx index 447f81bf1..0a9208ff7 100644 --- a/docs/rfds/subagents.mdx +++ b/docs/rfds/subagents.mdx @@ -16,8 +16,9 @@ session. The Agent associates a reusable child session with its parent through an upsert-style `subagent_update`. Child events then flow automatically on the -same connection. Individual delegate, send-message, and wait operations can be -represented by tool calls that reference the child session. +same connection. Messages between sessions use `session_message` upserts or +`session_message_chunk` notifications with normal message content and optional +sender and recipient metadata, rather than tool-call content. The association is not a task or a one-shot lifetime. The parent can message the same child again after earlier work finishes. Client-initiated session @@ -55,10 +56,10 @@ accept user input for that worker. ### Capability negotiation In v1, add an optional `subagents` object to `ClientCapabilities`. A non-null -object means the Client understands the association updates, tool-call session -references, and restricted-session semantics in this RFD. The field is optional -and nullable, like adjacent capability objects. Omission or `null` means the -capability is not supported; `{}` means it is supported. +object means the Client understands the association updates, session-directed +messages and chunks, and restricted-session semantics in this RFD. The field +is optional and nullable, like adjacent capability objects. Omission or `null` +means the capability is not supported; `{}` means it is supported. ```json { @@ -68,8 +69,8 @@ capability is not supported; `{}` means it is supported. } ``` -In v1, an Agent **MUST NOT** send the updates or session-reference content -defined by this RFD unless the Client advertised `subagents`. It may still use +In v1, an Agent **MUST NOT** send the updates defined by this RFD +unless the Client advertised `subagents`. It may still use subagents internally and present their results through the parent session. There is no Agent-side capability. Subagent visibility flows only from Agent to @@ -81,16 +82,17 @@ see [Subagents in ACP v2](#subagents-in-acp-v2). ### Announcing and updating an association -`subagent_update` associates a child session with its immediate parent. The -first update for an unknown `update.sessionId` announces the association; -later updates patch it. The child's conversation, title, and work are separate -from this relationship. +`subagent_update` notifies the Client that the enclosing parent session has +created and owns another session. The first update for an unknown +`update.sessionId` announces that ownership association; later updates patch +its metadata without creating a new child or transferring ownership. This is +not a message to the child or merely a link to a related session. When an Agent creates a subagent that it wants to expose, it **MUST** send the announcing `subagent_update` before sending any request or notification bearing -the child's session ID, or any tool-call session reference to it. This includes -permission, elicitation, filesystem, and terminal requests where supported by -the protocol version: +the child's session ID, including any live session message naming it as sender +or recipient. This includes permission, elicitation, filesystem, and terminal +requests where supported by the protocol version: ```json { @@ -101,6 +103,8 @@ the protocol version: "update": { "sessionUpdate": "subagent_update", "sessionId": "sess_child_1", + "title": "Test investigator", + "description": "Investigates platform-specific test failures.", "capabilities": { "cancel": {} } @@ -114,25 +118,39 @@ Only `update.sessionId` is required: - `update.sessionId` is an opaque `SessionId` unique within the ACP connection. It identifies the same child across repeated delegations and **MUST NOT** be reused for an unrelated session. +- `title` is an optional human-readable label for the child in its parent's + view. It need not be unique; the Client chooses a fallback if none is supplied. +- `description` is an optional human-readable description of the child's role + or purpose. It is current display metadata, not a transcript of instructions. - `capabilities` optionally describes the Client-initiated session mutations permitted for this specific child session. `cancel` is an optional, nullable capability object: omitted or `null` means unsupported, while `{}` advertises support. The object may include optional nullable `_meta`; omitted or `null` `_meta` means no capability metadata. If `capabilities` was never supplied, no session mutations are permitted. -- `state` is a **v1-only** optional current-work snapshot, defined in +- `state` is an optional current-work snapshot, defined in [Current work state](#current-work-state). It is not a lifecycle outcome. - `_meta` optionally carries association metadata. -In v1, these optional fields are nullable: omission and `null` both mean -unchanged; a concrete value replaces the whole previous field. In v2, omission -means unchanged and `null` clears the field. A concrete `capabilities: {}` -disables all Client-initiated mutations in either version. +In both versions, these optional fields are nullable patches: omission means +unchanged, `null` clears the field, and a concrete value replaces the whole +previous field. Clearing `title` or `description` removes the parent's display +override. Clearing `state` leaves current activity unconfirmed, not idle. +Both `capabilities: null` and `capabilities: {}` disable Client-initiated +mutations. + +The parent stream carries `title` and `description` so Clients can maintain a +subagent roster without collecting metadata from every child stream. These are +the parent's labels for its child. The child's ordinary `session_info_update` +can still describe its own conversation; it does not patch the parent +association's display metadata. Updating either title does not rewrite the +content of earlier messages or operations. The `capabilities` object is replaced as a whole, not patched recursively. For example, `capabilities: { "cancel": null }` disables individual cancellation -in either version. This is different from outer `capabilities: null`, which -means unchanged in v1 and clears the capability set in v2. +in either version. Outer `capabilities: null` clears the entire capability set. +Capability advertisements inside that set are not patches: omitted or `null` +`cancel` means unsupported, not unchanged. The outer `sessionId` establishes the immediate parent. This supports arbitrary nesting without adding a second parent identifier. If a child spawns another @@ -141,23 +159,21 @@ outer `sessionId`. A child has one immediate parent; the association cannot reparent an existing session, refer to itself, or create a cycle. This association describes ownership and management, not the set of sessions -that may interact with the child. Tool-call references describe interactions -and may point from a child back to its parent or to another known session +that may interact with the child. Session-directed messages describe communication +and may go from a child back to its parent or to another known session without changing the ownership tree. -Adapters **MUST** distinguish the child's conversation identity from the IDs of -individual provider tasks, turns, and tool calls. Completing a task does not -justify allocating a new ACP session ID for the same child conversation. -Provider IDs may be used directly only when their scope and lifetime meet -these requirements; otherwise the adapter maintains a stable mapping. +Session identity follows the exposed conversation, not an individual provider +task, turn, or tool call. Completing a task does not create a new conversation. +Provider IDs can be used directly when their scope and lifetime meet the +identity requirements above; otherwise adapters can derive suitable ACP IDs +or maintain a mapping. -The Agent **MUST** send the announcing update even if it expects the child to -finish very quickly, so that the Client can attribute every child request and -notification to an announced session. How a Client renders announced children -is its own concern; registering the child for routing before handling its -traffic is the protocol contract. SDKs **MAY** additionally tolerate -non-conforming Agents by buffering child traffic until the announcing update -arrives, but Agents **MUST NOT** rely on such tolerance. +Announcement ordering also applies to short-lived work. Clients attribute +subsequent traffic to the announced child and apply its restricted-session +semantics; routing internals and presentation are implementation choices. +SDKs may tolerate non-conforming Agents by buffering early traffic, but that +tolerance does not replace the Agent's announcement obligation. Provider callbacks need not arrive in announcement order. If an interaction arrives before the corresponding spawn metadata, the adapter can announce a @@ -194,12 +210,13 @@ per-child event opt-in. Clients **MUST NOT** call `session/load` or `session/resume` on a child to begin receiving its events. Automatic delivery is an ACP contract, not an assumption about the provider -API. The adapter remains responsible for enabling child output, attaching any -provider-side listeners, and restoring those listeners when reconnecting. +API. Depending on the provider, adapters may need to enable child output, +attach listeners, or restore subscriptions when reconnecting. A provider that +already delivers the necessary events requires no additional mechanism. Existing session updates are addressed to the child in the normal way. For -example, its current display title uses `session_info_update`, not a duplicate -`name` field on the parent association: +example, the child can report its own conversation title separately from the +parent's roster label: ```json { @@ -209,7 +226,7 @@ example, its current display title uses `session_info_update`, not a duplicate "sessionId": "sess_child_1", "update": { "sessionUpdate": "session_info_update", - "title": "Test investigator" + "title": "Windows integration test failure" } } } @@ -229,27 +246,60 @@ and may use explicitly advertised controls, but cannot submit new work to it. Existing permission and security boundaries apply equally to parent and child activity. -### Operations reference sessions - -Add a `session` variant to `ToolCallContent`, containing a required, -non-nullable `sessionId` and optional nullable `_meta`. Omitted or `null` -`_meta` means no metadata for that content item. - -A session reference is a display anchor, like a terminal reference. It does -not create the target session, change its parent, subscribe to events, grant -mutation capabilities, send a message, or control its lifetime. The target -**MUST** already be known to the Client through ordinary session setup or a -`subagent_update` announcement. It can be an ordinary parent session, an -announced child, or another known session on the connection. A reference is -not an alternative way to register an unknown session. - -Multiple operations may reference the same session, and removing a reference -from tool-call content does not close the target. Bidirectional reference -links do not create an ownership cycle. Clients should use navigation or -bounded previews rather than recursively expanding references without a limit. - -For example, this v2 tool call reports successful delivery of a follow-up -message to the previously announced child: +### Session-directed messages + +Add `session_message` and `session_message_chunk` variants to `SessionUpdate` +in both versions. These report a session's outgoing or incoming messages to or +from another session, not responses addressed to the human user and not tool +calls. They use the existing `session/update` notification, not a new JSON-RPC +method or a new `ContentBlock` type. + +The enclosing `params.sessionId` identifies the transcript being updated, not +necessarily the sender. Each update has: + +- `messageId`: required, non-nullable, and unique within the enclosing session, + just like other message IDs. It identifies the entry in that transcript, not + a globally shared message or either participant. +- `senderSessionId`: optional and nullable. Identifies the sending session + when supplied. +- `recipientSessionId`: optional and nullable. Identifies the receiving session + when supplied. +- `content`: a `session_message` may replace the whole `ContentBlock[]`; + `session_message_chunk` requires one non-nullable `ContentBlock` to append. + Normal text, images, audio, and resource content use their existing shapes. +- `_meta`: optional and nullable. It updates message-scoped metadata on + `session_message`; it is chunk-scoped on `session_message_chunk`. + +Agents **SHOULD** include participant IDs on the first message update or chunk +when available. Later events may omit them or supply previously missing +identities. For these identity fields, omission and `null` mean not supplied: +the Client retains any previously reported value. They do not use the +null-clearing semantics of the mutable `content` and `_meta` fields. + +Known participant information must remain consistent with the enclosing +transcript and earlier updates for that message. When both IDs are known, +they identify different sessions and the outer `params.sessionId` matches one +of them. Matching the sender represents the outgoing view; matching the +recipient represents the incoming view. Supplied live participant IDs refer +to sessions known through ordinary setup or a `subagent_update` announcement; +missing participants do not prevent displaying the message. + +Whole-message updates use the same patch convention in both versions: +omitted `content` and `_meta` leave the stored value unchanged, `null` clears +the field, and concrete values replace the whole field. `content: []` also +clears accumulated content. New messages start with empty content and no +metadata when those fields are omitted. Chunk `_meta` is not a message patch: +omitted or `null` means no metadata for that chunk. + +Clients should render outgoing entries as "To …" with a recipient link and +incoming entries as "From …" with a sender link. Both use normal message +content while remaining distinct from user messages and answers to the user. +Neither needs a tool-call card, title, execution status, or a raw-input viewer. +When participant metadata is insufficient, Clients can use a generic +inter-session presentation and add links when identities arrive. Missing +metadata is not evidence of human authorship or confirmed receipt. + +For example, the parent's outgoing view of a message to its child is: ```json { @@ -258,17 +308,14 @@ message to the previously announced child: "params": { "sessionId": "sess_parent", "update": { - "sessionUpdate": "tool_call_update", - "toolCallId": "send_follow_up", - "title": "Ask the test investigator to check Windows", - "status": "completed", - "rawInput": { - "message": "Also check whether the failure occurs on Windows." - }, + "sessionUpdate": "session_message", + "messageId": "msg_to_child_1", + "senderSessionId": "sess_parent", + "recipientSessionId": "sess_child_1", "content": [ { - "type": "session", - "sessionId": "sess_child_1" + "type": "text", + "text": "Also check whether the failure occurs on Windows." } ] } @@ -276,9 +323,9 @@ message to the previously announced child: } ``` -Communication in the other direction uses the same content shape. If the -child sends findings to its parent through a tool operation, the child owns -that tool call and the reference points back to the parent: +If the Agent observes that message in the child's conversation, it can report +the incoming view using the same shape. The sender and recipient stay the +same; the outer session and transcript-local message ID change: ```json { @@ -287,17 +334,14 @@ that tool call and the reference points back to the parent: "params": { "sessionId": "sess_child_1", "update": { - "sessionUpdate": "tool_call_update", - "toolCallId": "report_to_parent", - "title": "Send findings to the parent", - "status": "completed", - "rawInput": { - "message": "The failure also occurs on Windows." - }, + "sessionUpdate": "session_message", + "messageId": "msg_from_parent_1", + "senderSessionId": "sess_parent", + "recipientSessionId": "sess_child_1", "content": [ { - "type": "session", - "sessionId": "sess_parent" + "type": "text", + "text": "Also check whether the failure occurs on Windows." } ] } @@ -305,61 +349,117 @@ that tool call and the reference points back to the parent: } ``` -This reports the Agent's own communication operation; it does not invoke -Client-to-Agent `session/prompt` or create a reverse `subagent_update`. +These notifications report Agent-owned communication; the Client does not +forward the content or invoke `session/prompt` because it received one. Neither +form acknowledges recipient processing or establishes new ownership. Actual +delivery is handled by the Agent. + +The Agent reports each side only when it can observe that side: an outgoing +entry is not sufficient evidence to manufacture an incoming entry. Clients +**MUST NOT** infer receipt or synthesize an Agent-reported incoming transcript +entry solely from an outgoing entry. This does not prevent a clearly labeled +projection of outgoing messages in a cross-session view. The two views have +independently scoped message IDs, and Clients +**MUST NOT** assume equal IDs identify the same communication across sessions. +No cross-session message-correlation ID is defined here. + +An incoming entry uses this message kind, not an additional ordinary user +message that would misattribute the communication. Rendering the same entry +in multiple views or quoting it in a summary is a presentation choice. Its +content reflects what was observed in that conversation; it need not match +the outgoing view byte for byte if the Agent transformed the content during +delivery. Neither view indicates that the recipient finished processing the +message. + +Normal tool calls remain available for actual tools, including tools that +perform delegation or waiting. They report those operations' inputs, outputs, +and outcomes. Agents do not need to invent a tool call to report a directed +message, and this RFD adds no session-reference variant to `ToolCallContent`. + +#### Streaming + +A directed message may start with a chunk on either side; a prior whole-message +notification and complete participant metadata are not required: -V1 uses the same content item with its existing `tool_call` announcement and -`tool_call_update` pattern. +```json +{ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "sess_child_1", + "update": { + "sessionUpdate": "session_message_chunk", + "messageId": "msg_2", + "content": { + "type": "text", + "text": "Start by reproducing " + } + } + } +} +``` -The operation's `title`, `rawInput`, `rawOutput`, content, and status describe -that operation only. A blocking delegation may remain in progress until the -child returns a result; a send operation may complete as soon as delivery -succeeds; a wait operation may report a result later. The Agent reports the -semantics of the actual operation rather than inventing a blocking task around -every message. In particular, a completed send **MUST NOT** be interpreted as -the child having finished processing it. +Subsequent chunks append normal message content for the same transcript-local +`messageId`. Participant metadata can arrive with a later chunk or a +metadata-only upsert, without repeating or replacing the content: -The child's current title can change without rewriting previous operations' -titles or instructions. There is no session-wide `task` field: successive -assignments belong to their respective operations. Agents that delegate -outside tool invocations can still announce and stream child sessions without -inventing a tool call. +```json +{ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "sess_child_1", + "update": { + "sessionUpdate": "session_message", + "messageId": "msg_2", + "senderSessionId": "sess_parent", + "recipientSessionId": "sess_child_1" + } + } +} +``` + +A `session_message` is an upsert: when it supplies a content array, that array +replaces all content accumulated for the message rather than appending. It can +also update metadata without resending content. Later chunks append to the +stored content; chunk metadata does not patch message metadata. ### Why session IDs appear in different places These IDs answer different questions; they are not competing ways to route the same event: -| Location | Purpose | -| ----------------------------------------------------------- | --------------------------------------------------------------------------------- | -| Outer `params.sessionId` | Which session's event stream and session-local entities does this update address? | -| `params.update.sessionId` in `subagent_update` | Which child is being associated with the immediate parent named by the outer ID? | -| Tool-call content `{ "type": "session", "sessionId": "…" }` | Which already-known session does this particular operation reference? | +| Location | Purpose | +| ---------------------------------------------------------------- | ---------------------------------------------------------------- | +| Outer `params.sessionId` | Which session's transcript or entities does this update address? | +| `params.update.sessionId` in `subagent_update` | Which child is owned by the parent named by the outer ID? | +| `params.update.senderSessionId` in a session message or chunk | Which session sent, or is sending, this message? | +| `params.update.recipientSessionId` in a session message or chunk | Which session is this message addressed to? | For example, with parent `sess_parent` and child `sess_child_1`: 1. An association update has outer `sessionId: "sess_parent"` and `update.sessionId: "sess_child_1"`. This registers the parent-child relationship and the child's capabilities. -2. A send or wait tool call still has outer `sessionId: "sess_parent"`, but its - content references `sessionId: "sess_child_1"`. The operation belongs to the - parent's history; the reference lets the Client display or navigate to the - child involved. -3. The child's own messages, plans, and tool calls have outer +2. An outgoing message has outer `sessionId: "sess_parent"`, + `senderSessionId: "sess_parent"`, and `recipientSessionId: "sess_child_1"`. + This updates the parent's history with a link to the child. +3. The received view has outer `sessionId: "sess_child_1"` and the same sender + and recipient. Its independent message ID identifies the entry in the + child's history, where the Client can link back to the parent. +4. The child's own messages, plans, and tool calls have outer `sessionId: "sess_child_1"`. They update the child's history, not the - referencing parent tool call. In v2 its work-state notifications use this - same child-addressed stream; v1 reports work state through the association - as described below. -4. A child-to-parent message tool call has outer `sessionId: "sess_child_1"` - and a content reference to `sessionId: "sess_parent"`. Its direction does - not reverse the parent-child association. - -A later operation can reference the same child, and one wait operation can -reference several children. Consequently, content references cannot replace -the outer routing ID. They also cannot replace the association announcement: -an operation does not establish parentage or own the referenced session. -Clients can show child activity inline with a reference without copying that -activity into the parent session's protocol history. + parent session. V2 work-state notifications use this child-addressed stream + and are mirrored on the parent association; v1 reports work state through + the association as described below. +5. A reply reverses the sender and recipient, not the ownership association. + The Agent can report its outgoing view in the child's transcript and its + incoming view in the parent's transcript. + +Communication links can point both ways without making the ownership tree +cyclic. Neither participant ID replaces the outer routing ID or the separate +ownership announcement. The Client may navigate between the conversations +without treating a message as a transfer of ownership or control. ### Current work state @@ -368,8 +468,17 @@ times within the same child session. Becoming idle, completing an operation, or cancelling current work does not permanently end the association. V2 uses the child's ordinary `state_update` notifications and their existing -foreground-work semantics. The parent association has no `state` field in v2. -For example: +foreground-work semantics. Agents **SHOULD** also mirror each reported child +state on the immediate parent's `subagent_update.state`, so both the parent's +roster and the child's own stream carry the information. + +These are two reports of one logical child state, not independent lifecycles. +Mirrored reports use the same `StateUpdate` snapshot and are idempotent: a +second copy does not represent additional work, completion, or token usage. +Copies of a transition precede later transitions on either path, so a delayed +mirror cannot overwrite newer state. + +For example, a v2 child reports: ```json { @@ -386,9 +495,9 @@ For example: } ``` -V1 has no equivalent notification and no Client-issued child prompt request -whose response could report completion. It therefore carries the same -`StateUpdate` payload as the optional `state` field of `subagent_update`: +The parent-carried snapshot has the same shape in both versions. V2 emits it +alongside the child's notification; v1 uses it because it has no equivalent +child notification or Client-issued child prompt response: ```json { @@ -422,30 +531,36 @@ The v1 payload mirrors the v2 state object: - Each known state may contain optional nullable `_meta`; omitted and `null` both mean no metadata for that snapshot. -The v1 Agent **MUST** report `running` when child foreground work starts or -resumes and `idle` when it stops. It **SHOULD** report `requires_action` while -foreground work is blocked on user action, and include `stopReason` when the -reason for stopping is known. Cancellation uses `idle` with -`stopReason: "cancelled"`, not a terminal child state. +When the v1 Agent observes child foreground work starting, resuming, or +stopping, it **MUST** report the corresponding `running` or `idle` state. +It **SHOULD** report `requires_action` while observed foreground work is +blocked on user action, and include `stopReason` when the reason for stopping +is known. This does not require reconstructing unobserved intermediate +transitions or guessing activity from provider callbacks. Cancellation uses +`idle` with `stopReason: "cancelled"`, not a terminal child state. When observability of child foreground work is actually lost, the Agent **MUST** report `unknown` using the existing state payload: v1 sends `subagent_update` with `"state": { "state": "unknown" }` on the parent stream; v2 sends `state_update` with `"state": "unknown"` on the child stream. Silence -alone is not evidence of lost observability. The Client **MUST NOT** continue +alone is not evidence of lost observability. V2 mirrors this snapshot on the +parent association as well. The Client **MUST NOT** continue presenting an earlier `running` or `requires_action` as confirmed live activity after `unknown`, though it may retain that last-known value for context. A subsequent `running`, `requires_action`, or `idle` replaces `unknown` for the same child ID. `unknown` neither closes or cancels the child nor resolves pending requests; it does not automatically revoke mutation capabilities. If a control is no longer available, the Agent updates `capabilities` -separately. V1 `state: null` still means unchanged, not `unknown`. +separately. -The outer v1 `state` field is a replacement snapshot, not a nested patch: -omission or `null` leaves the previous snapshot unchanged, while a concrete -object replaces it entirely. For example, `{ "state": "running" }` replaces an -earlier idle snapshot and its stop reason. If no state has been reported, the -Client has no current-work state; announcement alone does not imply `running`. +The association's `state` field is a replacement snapshot, not a nested patch: +omission leaves the previous snapshot unchanged, `null` clears it, and a +concrete object replaces it entirely. For example, `{ "state": "running" }` +replaces an earlier idle snapshot and its stop reason. If no state has been +reported, or the snapshot is cleared, the Client has no confirmed current-work +state; neither announcement nor clearing implies `running`, `idle`, or +cancellation. A later state report on either supported path can supply the +current snapshot again. Unrelated association updates need not repeat it. Unrecognized state objects preserve both their discriminator and payload. Values beginning with `_` are implementation-specific; other unknown values are @@ -500,14 +615,21 @@ The following rules still apply to whatever is replayed: - Replayed child updates **MUST** preserve their original session identities, known parentage, and per-session ordering. Child announcements precede child - traffic, and session-reference targets must already be known. + traffic. - If identity and parentage are recoverable but conversation content is not, the Agent may replay only the association. This is historical context, not evidence of a live runtime or a complete transcript. - If an association cannot be reconstructed truthfully, the Agent **MUST NOT** guess its parent or issue traffic for an unannounced child. It may omit that - child's replay and omit unresolved session-reference items while preserving - the parent operation's available title, input, output, and other content. + child's own replay. +- A recorded directed message in a known session's history may still be + replayed with its available participant metadata and content when the + other participant's association or history is unavailable. This applies to + incoming as well as outgoing entries. A historical address is not an + announcement. Clients **MUST NOT** infer ownership, live activity, or controls + from it; they can show the other participant as unavailable until its session + becomes known. Agents **MUST NOT** invent either participant ID or reinterpret + the content as an ordinary message to or from the human user. - The Agent **SHOULD** make known gaps apparent through a supported advisory mechanism rather than imply that child replay is complete. Missing history is not evidence that a child never existed, stopped, failed, or was cancelled. @@ -525,44 +647,63 @@ child traffic after reconnection. Capability negotiation still applies during replay. V1 Clients that did not advertise `subagents` receive ordinary parent history, not unsupported child -updates or session-reference content. Parent-level results and summaries can -still be replayed. - -The parent load/resume response is the freshness boundary: - -1. When loading or resuming a parent, the Client **MUST** invalidate cached live - work state and mutation authorization for its children. Historical data may - be retained for display. -2. Child state and capabilities replayed before the response are historical. - They **MUST NOT** enable current mutation controls or be presented as - confirmed live activity. Replay **MUST NOT** reissue historical permission, - elicitation, or other Agent-to-Client requests. -3. After responding, the Agent **MUST** reannounce each continuing child and - its complete chain of intermediate ancestors up to the resumed parent, in - parent-before-child order, before sending fresh child traffic or session - references. This includes ancestors whose - runtimes were not restored: an association registers identity and routing, - not a live runtime. Preserve the original IDs and immediate parentage; - surviving grandchildren **MUST NOT** be reparented to the root. Each - reannouncement **MUST** contain a complete current `capabilities` object; - a routing-only ancestor may use `{}` and omit work state (or report - `unknown` if its activity cannot be determined). This also applies to - non-replaying resume, so the Client need not retain an earlier session tree. -4. Fresh child work state is reported after the response, either in the v1 - reannouncement or through subsequent state updates. Until fresh state is - reported, the child's current activity remains unconfirmed. - -Surviving runtimes may continue to execute during replay, but the Agent **MUST** -queue their fresh notifications and requests until after the response and -current reannouncements. In v2, the response and traffic that depends on this -boundary **MUST NOT** share a JSON-RPC batch. Current association snapshots -after a non-replaying resume are not conversation-history replay. +updates or session-directed message variants. Parent-level results and +summaries can still be replayed. + +Reconnection separates routing context from current work state and controls. +When loading or resuming a parent, the Client **MUST** invalidate cached live +work state and mutation authorization for its children. Historical data may +remain visible, but effective child capabilities start empty and current work +state starts unconfirmed. Historical capability patches never populate that +effective set; omitting a current capability field cannot revive an earlier +`cancel` grant. The Client **MUST NOT** enable child mutations until the +load/resume succeeds and a current capability grants them. If the operation +fails, current state and authorization received during that attempt are +invalidated. + +The Client need not retain an earlier session tree. Before using a child in +live traffic, the Agent establishes the child's complete ancestor chain in +parent-before-child order during this load/resume. Replayed associations can +provide that routing context and need not be repeated merely because the +response has been sent. Missing intermediate ancestors still need announcing, +even if their runtimes were not restored; an association establishes identity, +not a live runtime. + +**When replay is requested**, the successful response is the freshness boundary +for child work-state and mutation-capability snapshots: + +- Before the response, these snapshots are historical. They **MUST NOT** + authorize Client-initiated mutations or establish confirmed current activity. +- Current snapshots are sent after the response. Normal association and state + updates suffice; no extra announcement phase is needed for already-known + associations. An Agent can omit capabilities when none are enabled, because + the effective set starts empty rather than inheriting historical grants. +- In v2, the response and current snapshots that depend on this boundary + **MUST NOT** share a JSON-RPC batch. + +Replay **MUST NOT** reissue recorded permission, elicitation, or other +Agent-to-Client requests. Newly issued requests are live and may proceed once +their child and ancestors are announced, under ordinary request handling. +Responding to them does not enable historical Client-initiated controls. +Other live transcript traffic can also proceed if the Agent preserves +per-session ordering and ensures subsequent replay cannot overwrite or +duplicate newer live content. Buffering affected traffic is one way to meet +those requirements, not a requirement to stop every child stream. + +**When no replay is requested**, announcements, current snapshots, and ordinary +child traffic may arrive before or after the resume response, subject to +announcement ordering. These snapshots are current observations, not history +replay; controls still wait for successful resume. Agent-to-Client requests +retain their ordinary handling; their responses need not wait for resume to +finish. For example, if `root` → `coordinator` → `worker` was previously announced -and only `worker` resumes, the Agent first reannounces `coordinator` under -`root` (possibly with `capabilities: {}` and no confirmed activity), then -`worker` under `coordinator`, before sending fresh `worker` traffic. It does -not announce `worker` directly under `root`. +and only `worker` resumes, the Agent establishes `coordinator` under `root`, +then `worker` under `coordinator`, before sending fresh `worker` traffic. +Replay may already have supplied these associations. Without replay they can +be announced before the resume response. A routing-only ancestor can omit +capabilities and work state; it has no enabled controls or confirmed activity. +The worker is not reparented directly under `root`. The Agent **SHOULD** report current child work state when it can determine it. Historical running states are not proof of current activity. An Agent **MUST @@ -597,10 +738,10 @@ ordinary standalone `session/list` entries. This avoids offering load, resume, or prompt controls that are not available for them. If forking a parent copies child history, the Agent **MUST** consistently remap -the copied session IDs and references to them, including references back to the -forked root. Each copied child still has one parent and a distinct identity. -References to sessions outside the copied tree do not make those sessions -part of the copy; unresolved targets follow the replay rules above. Copying +the copied session IDs in both message participant fields, including references +to the forked root. Each copied child still has one parent and a distinct +identity. Participants outside the copied tree do not become part of the copy; +unavailable participants follow the historical replay rules above. Copying history does not itself create live child runtimes. Parent deletion keeps its ordinary history-management semantics; it is not a substitute for closing active sessions. @@ -615,15 +756,16 @@ capability controls individual Client cancellation; it does not prevent the Agen from cancelling its own delegated work. An omitted or `null` per-child `cancel` capability does not waive ancestor -cancellation. Adapters must implement that ownership policy explicitly rather -than assume that interrupting a provider's root turn stops every background -child. Advertise individual cancellation only when the adapter can target the -child's current work correctly in that runtime mode. +cancellation. Adapters should verify whether the provider already supplies +that cascade or needs additional descendant cancellation. Advertise individual +cancellation only when the adapter can target the child's current work +correctly in that runtime mode. Cancellation retains ordinary session semantics: abort the affected work and send its pending updates before confirming cancellation. A child confirms cancellation with `idle` and `stopReason: "cancelled"` through its v1 state -snapshot or ordinary v2 notification. There is no Client-issued child +snapshot or ordinary v2 notification, mirrored on the v2 parent association. +There is no Client-issued child `session/prompt` response to wait for. A cancellation racing with already-ended work does not rewrite that work's outcome. @@ -681,10 +823,9 @@ state. Exposing child sessions does not make existing `usage_update.cost` values exclusive or additive. Cost reporting remains optional. An Agent may continue reporting the provider's cumulative cost for the parent session even when it -includes child work or internal helper calls. It **MUST NOT** change that -accounting scope merely because the Client can now display subagents. No -exclusive breakdown is required, and an inclusive provider total need not be -discarded. +includes child work or internal helper calls. Exposing subagents does not +require changing that accounting scope, producing an exclusive breakdown, or +discarding a useful inclusive total. Child costs remain optional and may overlap the parent's reported cost. Missing child cost is unknown, not zero. Agents **MUST NOT** fabricate an @@ -694,9 +835,9 @@ total. Clients **MUST NOT** infer a combined tree total by adding parent and child values, or infer exclusive costs by subtracting them, unless a separate accounting contract establishes their coverage and how any overlap is handled. -Display each session's latest reported cumulative value without claiming an -accounting scope that has not been established. Do not add successive updates -or count a session again for each tool-call reference. +Clients may display each session's latest reported cumulative value without +claiming an accounting scope that has not been established. Successive +cumulative updates and repeated messages to a session are not additional costs. For example, a parent's reported USD 1.20 may already include a child's USD 0.30. Both values can be shown, but the Client must not synthesize USD 1.50 as @@ -711,24 +852,20 @@ and is not summed across the tree. ### Subagents in ACP v2 -The two versions use the same association and session-reference model, with +The two versions use the same ownership and directed-message model, with these differences: - **Baseline support, no capability.** v2 Clients **MUST** understand - `subagent_update`, register child sessions for routing, apply their operation - restrictions, understand session references, and attribute their requests and - notifications correctly. + `subagent_update`, apply child operation restrictions, understand directed + message snapshots/chunks, and attribute requests and notifications to the + correct sessions. Clients may choose not to render a dedicated subagent UI, but cannot merely ignore the announcement as an unknown update. The `subagents` Client capability is v1-only. -- **v2 patch semantics.** As with tool calls and terminals, only - `update.sessionId` is required; for other fields, omission leaves the stored - value unchanged while `null` clears or unsets it (in v1, `null` is instead - equivalent to omission). A child whose `capabilities` are unset permits no - Client-initiated session mutations. -- **Ordinary child work state.** V2's `subagent_update` has no `state` field. - V2 uses `state_update` on the child's own stream. V1 embeds the corresponding - state object in the parent association update instead. +- **Mirrored child work state.** V2 keeps `state_update` on the child's own + stream and mirrors the same state in the parent association. V1 uses only + the parent-carried snapshot. Both describe the child's ordinary foreground + work, not a separate subagent lifecycle. - **Resume with replay.** Available child history is replayed best-effort through `session/resume` with `replayFrom: { "type": "start" }`, not `session/load`. @@ -756,11 +893,17 @@ summarizing their output in the parent session. > Tell me more about your implementation. What is your detailed implementation plan? +This is a project rollout and validation plan, not a requirement for every +implementation to use the named SDKs, providers, or integration techniques. + 1. Add the v1 `SubagentCapabilities` marker and the `SubagentUpdate` association and `SubagentSessionCapabilities` types in both versions. -2. Add a `SessionReference` tool-call content variant in both versions. +2. Add `SessionMessage` and `SessionMessageChunk` update variants in both + versions, using normal content and optional sender and recipient session + metadata for incoming and outgoing views. 3. Mirror the v2 `StateUpdate` payload in v1 and embed it in the v1 association - update. V2 continues to use its existing child-session state notification. + update. In v2, mirror ordinary child-session state notifications in the + parent association as well. 4. Keep the additions behind `unstable_subagents` and regenerate schemas and reference documentation. 5. Ship SDK releases that carry the draft `subagents` capability and update @@ -770,15 +913,17 @@ summarizing their output in the parent session. Such bridges are compatibility shims, not an alternative protocol, and are retired once SDK support ships. SDK preservation of the draft fields is an explicit prerequisite for the validation step below. -6. Provide an SDK/transport path that sends the parent load/resume response - before releasing fresh child traffic. For example, use an explicit responder - or an after-response hook. A handler that only returns a response value - cannot satisfy the ordering rule by sending fresh notifications just before - returning. Do not rely on arbitrary delays or assumed event-loop timing. +6. Support the freshness boundary for replayed child state and capabilities. + For example, an SDK could offer an explicit responder or after-response hook + for publishing current snapshots after replay completes. Non-replaying + resume does not need such a hook, and other live child traffic need not be + globally buffered. Any ordering mechanism should provide the required + ordering rather than rely on arbitrary delays. 7. Update example Clients to route child events automatically, retain reusable - session identities, and render per-operation session references. Adapters - must separately enable and restore provider-side child event delivery. -8. Implement stable provider-to-ACP identity mapping, truthful interaction + session identities, and render session-directed messages distinctly from + user-facing responses and tool calls. Enable or restore provider-side child + delivery where that provider requires it. +8. Establish stable provider-to-ACP session identity, truthful interaction attribution, and cancellation of current work. Retain or reconstruct child history where practical, but do not make complete child transcripts a prerequisite for live exposure. @@ -798,19 +943,18 @@ or that the existing adapters already implement this revision. expose forwarded child messages through `forwardSubagentText` and `parent_tool_use_id`, task lifecycle events, permission `agentID` and `toolUseID`, `stopTask(taskId)`, and child transcript APIs such as - `listSubagents` and `getSubagentMessages`. Adapters must correlate these IDs - rather than make a provider's task/tool ID the lifetime of an ACP session. + `listSubagents` and `getSubagentMessages`. Adapters may need to correlate + these IDs where their scopes differ from ACP conversation identity. Permission callbacks can precede spawn metadata. Query cost totals may already include child and internal work. - **Codex 0.156.1:** thread/turn identifiers, thread status and approval/input wait flags, `turn/interrupt(threadId, turnId)`, and thread history support the corresponding mapping. The server [attaches listeners to newly created threads](https://github.com/openai/codex/blob/b412ff32c417f855c2b2d1581b77058eed87c84b/codex-rs/app-server/src/lib.rs#L1265-L1283); - the adapter still owns ACP routing and must verify listener restoration for - existing threads on reconnect. Per-thread token usage is not a monetary - cost report. + adapters should also check delivery for existing threads on reconnect. + Per-thread token usage is not a monetary cost report. -These mappings must be validated against the adapter's pinned dependencies. +These integration examples should be checked against the adapter's pinned dependencies. For example, a provider's generic paused status does not necessarily mean `requires_action`, and acknowledging an interrupt is not a completed cancellation. Likewise, a decoder preserving a new field is not sufficient @@ -819,11 +963,24 @@ implementation of child routing or the replay freshness boundary. ### Validation scenarios - Send two assignments to the same child conversation: keep its ACP session - ID, use distinct operation tool calls, and allow `running → idle → running`. -- Reference the parent from a child-owned message tool call, and reference a - sibling from another operation. Verify that event ownership and the - parent-child tree do not change. -- Complete a send operation before the child finishes processing it, and keep + ID, use distinct message IDs, and allow `running → idle → running`. +- Mirror v2 child work state to the parent association. Verify that both + streams converge on the same state and duplicate snapshots do not count + work completion or usage twice. +- Send messages to a parent and a sibling from a child's stream. Verify that + each message has the correct sender, recipient, and content, and that the + parent-child tree does not change. +- Report the received view in the recipient's transcript, with its own message + ID and a link to the sender. Verify that neither the Client nor the adapter + invents an Agent-reported incoming entry from outgoing traffic alone. + Clearly labeled UI projections remain possible, without duplicating the + communication as an ordinary user message. +- Start a directed message with a chunk, interleave another message, and + replace accumulated content with a whole snapshot. Start without participant + metadata, add it later without replacing content, and omit it on subsequent + chunks. Verify that missing metadata remains an inter-session message rather + than being attributed to a human or guessed sender. +- Finish reporting a message before the child finishes processing it, and keep receiving child events after the parent's prompt response. - Deliver a child permission callback before spawn metadata, including a nested child: verify early truthful announcement or buffering, no guessed @@ -833,16 +990,22 @@ implementation of child routing or the replay freshness boundary. completion, and late responses cannot authorize later assignments. - Replay after restarting the adapter with complete, partial, and unavailable child history. Preserve IDs and relationships for whatever is replayed, - omit unresolved references rather than guess ancestry, and do not infer an - outcome from a gap. Verify that live traffic follows the parent response - and current capability snapshots, including routing-only ancestors. -- Resume without history and restore provider listeners before forwarding live - child events. No additional Client attach/subscribe request is required. + preserve historical senders and recipients without guessing ancestry, and + do not infer an outcome from a gap. Verify that current work state and + controls depend on post-response snapshots, not historical capabilities. + An omitted current capability set must leave controls disabled. +- Issue a live child request during replay after announcing its ancestor chain. + Verify ordinary request handling without enabling historical controls, and + that replay cannot overwrite or duplicate newer live transcript content. +- Resume without history, including current announcements before the response. + Verify that controls remain disabled until success, failed resume invalidates + attempted live state, and routing-only ancestors are retained. No additional + Client attach/subscribe request or universal after-response hook is required. - Lose a child's activity feed while ACP stays connected: report `unknown` without inventing an outcome, and accept later state for the same child. - Report an inclusive parent cost with missing or overlapping child costs: retain the parent report and do not synthesize a tree total or exclusive - shares from the references. + shares from the message traffic. ## Frequently asked questions @@ -879,19 +1042,22 @@ are valid and defines the required cascade behavior. ### Why one `subagent_update` type instead of separate spawn and state notifications? The upsert announces an association and patches its capabilities without a -separate create method. Ordinary session updates already handle the child's -title, conversation, and, in v2, foreground work. V1 embeds a state snapshot -only because it lacks the existing v2 state notification. Neither version -needs a second, permanently terminal subagent lifecycle. +separate create method. It also carries the parent's title and description +for the child. Ordinary session updates handle the child's own conversation +and, in v2, foreground work. Both versions carry a state snapshot on the +association so the parent has roster information; v2 mirrors the child's +ordinary notification. Neither version needs a second, permanently terminal +subagent lifecycle. ### Why are there two `sessionId` fields in an association update? The fields have different scopes: `params.sessionId` is the parent whose stream receives the association update, while `params.update.sessionId` is the child being associated with it. The tagged `SessionUpdate` payload is flattened -inside `update`, not into `params`, so these names do not collide. A tool-call -session reference uses its own `sessionId` to identify the known session -referenced by that operation. See [Why session IDs appear in different places](#why-session-ids-appear-in-different-places). +inside `update`, not into `params`, so these names do not collide. Directed +messages instead use `senderSessionId` and `recipientSessionId` for their +participants; the outer `sessionId` chooses the transcript being updated. +See [Why session IDs appear in different places](#why-session-ids-appear-in-different-places). ### Why is `cancel` the only per-child capability? @@ -927,12 +1093,13 @@ history or pretending to know what happened while disconnected. ### Why not model a subagent as a tool call? -The two represent different things. A tool call models a particular -delegation, message, or wait; its session-reference content places the child -in the relevant feed entry. The reusable session owns the independent stream -of messages, plans, and nested activity. A single child can therefore appear -in several operations without sharing their titles, inputs, or completion -statuses. +The two represent different things. A session owns a conversation; a tool call +reports execution of a tool. An outgoing or incoming message should be +renderable as ordinary content with a recipient or sender link, without +requiring a tool-call card or a raw-input viewer. The directed-message updates +provide that representation and support streaming. Actual tool operations can +still be reported normally, but their status does not define a child's lifetime +or acknowledge that it processed a message. ### Why not allow prompting or steering a subagent? @@ -942,13 +1109,18 @@ direct user intervention may be unsupported or interfere with orchestration. A future per-child capability could opt into it without assuming every Agent can implement it. -### Where are the child's name and task? +### What do the association's title and description describe? + +They describe the child in its parent's view, so Clients can show multiple +children directly from parent-session updates. `title` is a short display +label; `description` supplies its role or purpose. The child's conversation may +also have an ordinary session title, which does not replace these parent-owned +labels. -The current display title uses `session_info_update.title`, just like other -sessions. A particular assignment's label and instructions belong in that -operation's tool-call title and input or content. Updating the session title -does not change an earlier operation's label. The association therefore has -neither `name` nor `task`, and does not need a competing `description` field. +These fields are not the contents of the latest message sent to the child. +Messages and operations retain their own content and history even when the +parent revises the child's display metadata. There is no session-wide `task` +field that rewrites earlier instructions. ### Is `session/fork` sufficient for subagents? @@ -982,16 +1154,17 @@ important difference from a user-facing session. ## Revision history - 2026-09-24: Separated reusable session associations from tool-call operations, - added tool-call session references, and moved display titles to ordinary - session info updates. Replaced the terminal lifecycle enum with v1 work-state - snapshots and normal v2 state notifications. Made event delivery automatic, + added whole and streamed session-directed messages with incoming and + outgoing views, and distinguished parent-owned display metadata from the + child's ordinary conversation metadata. Replaced the terminal lifecycle enum + with v1 work-state snapshots and normal v2 state notifications. Made event delivery automatic, left runtime restoration to the Agent, retained ordinary optimistic cancellation without requiring generic request cancellation, and added nonterminal unknown activity and routing-only ancestor recovery. Clarified the distinct session ID roles, incorporated provider integration and validation requirements, and preserved provider-reported costs without inferred cross-session aggregation. Aligned cancellation with object - capabilities, allowed references back to parents and other known sessions, + capabilities, allowed messages back to parents and other known sessions, and made child-history replay explicitly best-effort while retaining live identity and ordering guarantees. - 2026-09-15: Merged `subagent_spawned` and `subagent_state_update` into a diff --git a/schema/v1/schema.unstable.json b/schema/v1/schema.unstable.json index 9eecad055..d1010cffe 100644 --- a/schema/v1/schema.unstable.json +++ b/schema/v1/schema.unstable.json @@ -619,22 +619,6 @@ "$ref": "#/$defs/Terminal" } ] - }, - { - "description": "**UNSTABLE** Display reference to an already-known session on this ACP connection.", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session" - } - }, - "required": ["type"], - "allOf": [ - { - "$ref": "#/$defs/SessionReference" - } - ] } ], "discriminator": { @@ -1110,27 +1094,6 @@ }, "required": ["terminalId"] }, - "SessionReference": { - "description": "**UNSTABLE** Display reference to an already-known session on this ACP connection.\n\nThe enclosing notification's `params.sessionId` identifies the session whose\ntranscript is updated; this item's `sessionId` links that tool operation to\nanother known session for display. Ordinary session setup or a\n`subagent_update` announcement establishes a known target. A parent can\nreference a child, and a child can reference its parent or a sibling.\nV1 work-state snapshots are carried by `subagent_update` on the parent stream.\nParent-child associations and controls are announced separately by\n`subagent_update`. This item does not create or register a session, reparent\nit, grant controls, prompt it, subscribe to it, close it, send a message,\nor change ownership. Reference links can point both ways without making the\nownership tree cyclic. A tool call may\nreference multiple known sessions, and multiple tool calls may reference\nthe same session. Tool-call status describes the operation, not whether\nthe referenced session is idle or terminated.", - "type": "object", - "properties": { - "sessionId": { - "description": "Identifier of the already-known session linked from this tool operation,\nnot the session used to route the enclosing notification.", - "allOf": [ - { - "$ref": "#/$defs/SessionId" - } - ] - }, - "_meta": { - "description": "Optional nullable item metadata. Omission and `null` both mean no metadata.", - "type": ["object", "null"], - "x-deserialize-default-on-error": true, - "additionalProperties": true - } - }, - "required": ["sessionId"] - }, "ToolCallLocation": { "description": "A file location being accessed or modified by a tool.\n\nEnables clients to implement \"follow-along\" features that track\nwhich files the agent is working with in real-time.\n\nSee protocol docs: [Following the Agent](https://agentclientprotocol.com/protocol/tool-calls#following-the-agent)", "type": "object", @@ -5271,7 +5234,7 @@ ] }, { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAn upsert for a subagent exposed by this session.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAnnounces a child session created and owned by this session, or updates\nthat ownership association's metadata.", "type": "object", "properties": { "sessionUpdate": { @@ -5285,6 +5248,38 @@ "$ref": "#/$defs/SubagentUpdate" } ] + }, + { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA message upsert observed in this session's transcript, sent to or\nreceived from another session.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "session_message" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/SessionMessage" + } + ] + }, + { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nOne content block appended to a sent or received session message.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "session_message_chunk" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/SessionMessageChunk" + } + ] } ], "discriminator": { @@ -6286,7 +6281,7 @@ } }, "SubagentUpdate": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAn upsert for a reusable child session associated with its parent session.\n\nSent on the immediate parent session. The first update for an unknown\n[`SubagentUpdate::session_id`] announces the child and MUST be sent\nbefore any child traffic or reference to the child session. Parents may\nmessage and reuse an announced child across multiple operations.\nChild events are delivered automatically on the same connection; no child\nload, resume, or subscription is needed.\n\nOnly the subagent session ID is required. Omitted fields keep their previous\nvalue; a child whose state was never reported has an unknown state. A\nconcrete state replaces the entire previous state object, not the session.\nThe child's title is reported via `session_info_update`; per-operation tasks\nbelong on tool calls referencing the child.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification that the enclosing parent session created and owns a child session.\n\nLater updates modify the existing association's metadata, not its ownership.\n\nSent on the immediate parent session. The first update for an unknown\n[`SubagentUpdate::session_id`] announces the child and MUST be sent\nbefore any live child traffic or live message naming the child as sender\nor recipient. Parents may message and reuse an announced child across\nmultiple operations.\nChild events are delivered automatically on the same connection; no child\nload, resume, or subscription is needed.\n\nOnly the subagent session ID is required. Omitted patch fields keep their\nprevious values; `null` clears them. Clearing capabilities disables child\nmutations. Clearing state leaves current activity unset/unconfirmed: it does\nnot imply idle, stop work, or create an `unknown` state snapshot. A concrete\nstate replaces the entire previous state object, not the session.\nThe title and description provide the parent's display metadata for the\nchild. They do not replace the content of individual messages or operations.", "type": "object", "properties": { "sessionId": { @@ -6297,8 +6292,18 @@ } ] }, + "title": { + "description": "The parent's human-readable display title for this child. It need not be unique.\n\nOmitted means unchanged; `null` clears it. If unset, the Client chooses\na fallback presentation.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "description": { + "description": "The parent's human-readable description of the child's role or purpose.\n\nOmitted means unchanged; `null` clears it. If unset, the Client chooses\na fallback presentation. This is current display metadata, not the\nhistory of instructions sent to the child.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, "capabilities": { - "description": "Client-initiated session mutations permitted for this subagent session.\n\nOmitted and `null` both mean unchanged. If never supplied, no session\nmutations are permitted. Read-only operations retain their normal protocol\nsemantics and capability requirements.", + "description": "Client-initiated session mutations permitted for this subagent session.\n\nOmitted means unchanged; `null` clears the capability set and disables\nchild mutations. If never supplied, no session mutations are permitted.\nRead-only operations retain their normal protocol semantics and capability\nrequirements. A concrete object replaces the whole capability set.", "anyOf": [ { "$ref": "#/$defs/SubagentSessionCapabilities" @@ -6310,7 +6315,7 @@ "x-deserialize-default-on-error": true }, "state": { - "description": "Current state snapshot for the child session.\n\nOmitted and `null` both mean unchanged; a concrete state replaces the\nprevious state object wholesale. If never supplied, the state is unknown.", + "description": "Current state snapshot for the child session.\n\nOmitted means unchanged; `null` clears the current activity without\nasserting idle or sending an `unknown` snapshot. A concrete state\nreplaces the previous state object wholesale. If never supplied, the\ncurrent activity is unset/unconfirmed.", "anyOf": [ { "$ref": "#/$defs/StateUpdate" @@ -6322,7 +6327,7 @@ "x-deserialize-default-on-error": true }, "_meta": { - "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)\nOmitted means unchanged; `null` removes the metadata.", "type": ["object", "null"], "x-deserialize-default-on-error": true, "additionalProperties": true @@ -6330,6 +6335,113 @@ }, "required": ["sessionId"] }, + "SessionMessage": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAn upsert of an inter-session message in the enclosing transcript.\n`messageId` is local to that transcript; separate views may use independent\nIDs. Endpoints are optional identity metadata, not content patches: omitted\nor `null` retains a known endpoint. Agents SHOULD supply available endpoints\non the first event; later events may omit them or enrich missing endpoints.\nSupplied endpoints must agree with the enclosing transcript. Live IDs refer\nto known sessions; history may retain unavailable counterparts. Missing\nidentities permit generic inter-session UI, not guessed participants or\nhuman authorship.\nOmitted `content` and `_meta` leave their stored values unchanged; `null`\nclears them. A concrete `content` array replaces existing content\n(`[]` also clears it); later chunks append. This neither changes session\nownership nor instructs the Client to deliver content.", + "type": "object", + "properties": { + "messageId": { + "description": "Identifier of this message within the enclosing session's transcript.", + "allOf": [ + { + "$ref": "#/$defs/MessageId" + } + ] + }, + "senderSessionId": { + "description": "Optional sending session identity; omission or `null` retains a known value.", + "anyOf": [ + { + "$ref": "#/$defs/SessionId" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "recipientSessionId": { + "description": "Optional receiving session identity; omission or `null` retains a known value.", + "anyOf": [ + { + "$ref": "#/$defs/SessionId" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "content": { + "description": "Omitted leaves content unchanged; `null` or `[]` clears it.\nA non-empty array replaces all content.", + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "Omitted leaves metadata unchanged; `null` removes it.\n\nImplementations MUST NOT make assumptions about values in `_meta`.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["messageId"] + }, + "SessionMessageChunk": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA content block appended to a transcript-local message in arrival order.\nEndpoints are optional identity metadata, not content patches: omitted or\n`null` does not clear a known endpoint. Agents SHOULD supply available\nendpoints on the first event; later events may omit them or enrich missing\nendpoints. Supplied endpoints must agree with the enclosing transcript.\nLive IDs refer to known sessions; history may retain unavailable counterparts.\nMissing identities permit generic inter-session UI, not guessed participants\nor human authorship.\nChunk metadata applies only to that chunk. This does not instruct the Client\nto deliver content or imply that the recipient processed it.", + "type": "object", + "properties": { + "messageId": { + "description": "Identifier of this message within the enclosing session's transcript.", + "allOf": [ + { + "$ref": "#/$defs/MessageId" + } + ] + }, + "senderSessionId": { + "description": "Optional sending session identity; omission or `null` retains a known value.", + "anyOf": [ + { + "$ref": "#/$defs/SessionId" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "recipientSessionId": { + "description": "Optional receiving session identity; omission or `null` retains a known value.", + "anyOf": [ + { + "$ref": "#/$defs/SessionId" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "content": { + "description": "A single content block appended to the message.", + "allOf": [ + { + "$ref": "#/$defs/ContentBlock" + } + ] + }, + "_meta": { + "description": "Optional and nullable chunk-scoped metadata; omitted or `null` means none.\n\nImplementations MUST NOT make assumptions about values in `_meta`.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["messageId", "content"] + }, "CompleteElicitationNotification": { "description": "Notification sent by the agent when a URL-based elicitation is complete.", "type": "object", @@ -6701,7 +6813,7 @@ "x-deserialize-default-on-error": true }, "subagents": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the client understands exposed subagent sessions.\n\nOptional and nullable. Omitted or `null` both mean the client does not\nadvertise support.\nSupplying `{}` means the client understands child associations, work-state\nsnapshots, tool-call session references, and restricted-session semantics.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the client understands exposed subagent sessions.\n\nOptional and nullable. Omitted or `null` both mean the client does not\nadvertise support.\nSupplying `{}` means the client understands child associations, work-state\nsnapshots, session-directed messages, and restricted-session semantics.", "anyOf": [ { "$ref": "#/$defs/SubagentCapabilities" @@ -6894,7 +7006,7 @@ "type": "object" }, "SubagentCapabilities": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapability marker for exposing reusable child sessions as restricted ACP sessions.\n\nSupplying `{}` advertises support for child association and state updates,\ntool-call session references, and restricted-session semantics. The client\nmust advertise this capability before the agent sends subagent updates or\nsession references.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapability marker for exposing reusable child sessions as restricted ACP sessions.\n\nSupplying `{}` advertises support for child association and state updates,\nsession-directed messages, and restricted-session semantics. The client\nmust advertise this capability before the agent sends subagent updates or\nsession-directed messages.", "type": "object", "properties": { "_meta": { diff --git a/schema/v2/schema.unstable.json b/schema/v2/schema.unstable.json index f615ea2ed..9a46b52ec 100644 --- a/schema/v2/schema.unstable.json +++ b/schema/v2/schema.unstable.json @@ -1028,22 +1028,6 @@ } ] }, - { - "description": "**UNSTABLE** Display reference to an already-known session on this ACP connection.", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session" - } - }, - "required": ["type"], - "allOf": [ - { - "$ref": "#/$defs/SessionReference" - } - ] - }, { "title": "other", "description": "Custom or future tool call content.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nReceivers that do not understand this content type should preserve the\nraw payload when storing, replaying, proxying, or forwarding tool call\noutput, and otherwise ignore it or display it generically.", @@ -1086,16 +1070,6 @@ } }, "required": ["type"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session" - } - }, - "required": ["type"] } ] }, @@ -2056,27 +2030,6 @@ }, "required": ["terminalId"] }, - "SessionReference": { - "description": "**UNSTABLE** Display reference to an already-known session on this ACP connection.\n\nThe enclosing notification's `params.sessionId` identifies the session whose\ntranscript is updated; this item's `sessionId` links that tool operation to\nanother known session for display. Ordinary session setup or a\n`subagent_update` announcement establishes a known target. A parent can\nreference a child, and a child can reference its parent or a sibling.\nParent-child associations and controls are announced separately by\n`subagent_update`. This item does not create or register a session, reparent\nit, grant controls, prompt it, subscribe to it, close it, send a message,\nor change ownership. Reference links can point both ways without making the\nownership tree cyclic. A tool call may\nreference multiple known sessions, and multiple tool calls may reference\nthe same session. Tool-call status describes the operation, not whether\nthe referenced session is idle or terminated.", - "type": "object", - "properties": { - "sessionId": { - "description": "Identifier of the already-known session linked from this tool operation,\nnot the session used to route the enclosing notification.", - "allOf": [ - { - "$ref": "#/$defs/SessionId" - } - ] - }, - "_meta": { - "description": "Optional nullable item metadata. Omission and `null` both mean no metadata.", - "type": ["object", "null"], - "x-deserialize-default-on-error": true, - "additionalProperties": true - } - }, - "required": ["sessionId"] - }, "ToolCallLocation": { "description": "A file location being accessed or modified by a tool.\n\nEnables clients to implement \"follow-along\" features that track\nwhich files the agent is working with in real-time.\n\nSee protocol docs: [Following the Agent](https://agentclientprotocol.com/protocol/v2/draft/tool-calls#following-the-agent)", "type": "object", @@ -6099,7 +6052,7 @@ ] }, { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA subagent exposed by this session has been created or updated.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAnnounces a child session created and owned by this session, or updates\nthat ownership association's metadata.", "type": "object", "properties": { "sessionUpdate": { @@ -6114,6 +6067,38 @@ } ] }, + { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA message upsert observed in this session's transcript, sent to or\nreceived from another session.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "session_message" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/SessionMessage" + } + ] + }, + { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nOne content block appended to a sent or received session message.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "session_message_chunk" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/SessionMessageChunk" + } + ] + }, { "title": "other", "description": "Custom or future session update.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nReceivers that do not understand this update type should preserve the\nraw payload when storing, replaying, proxying, or forwarding session\nhistory, and otherwise ignore it or display it generically.", @@ -6336,6 +6321,26 @@ } }, "required": ["sessionUpdate"] + }, + { + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "session_message" + } + }, + "required": ["sessionUpdate"] + }, + { + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "session_message_chunk" + } + }, + "required": ["sessionUpdate"] } ] }, @@ -7653,7 +7658,7 @@ } }, "SubagentUpdate": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAn upsert associating a reusable child session with its immediate parent.\n\nSent on the immediate parent session. The first update for an unknown\n[`SubagentUpdate::session_id`] announces the child and MUST be sent\nbefore any request or notification bearing the child's session ID, or any\ntool-call session reference to it. Child events are delivered automatically;\nno separate child load, resume, or subscription is needed.\nUnderstanding this update, registering child sessions, and applying their\noperation restrictions are baseline v2 requirements; no Client capability\nis required.\n\nOnly [`SubagentUpdate::session_id`] is required. Other fields have\npatch semantics: omitted fields leave the stored value unchanged, `null`\nclears or unsets the value, and concrete values replace it. A child whose\ncapabilities are unset permits no Client-initiated session mutations.\n\nThe child's title is reported through [`SessionInfoUpdate`] on its own\nstream. Its foreground work uses ordinary [`StateUpdate`] notifications.\nCompleting or cancelling work does not end the association: the parent may\nmessage the same child again. Individual operations and their outcomes belong\nto tool calls referencing the child, not to this association.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification that the enclosing parent session created and owns a child session.\n\nLater updates modify the existing association's metadata, not its ownership.\n\nSent on the immediate parent session. The first update for an unknown\n[`SubagentUpdate::session_id`] announces the child and MUST be sent\nbefore any live request or notification bearing the child's session ID,\nincluding messages naming it as sender or recipient. Child events are\ndelivered automatically; no separate child load, resume, or subscription is needed.\nUnderstanding this update, registering child sessions, and applying their\noperation restrictions are baseline v2 requirements; no Client capability\nis required.\n\nOnly [`SubagentUpdate::session_id`] is required. Other fields have\npatch semantics: omitted fields leave the stored value unchanged, `null`\nclears or unsets the value, and concrete values replace it. For `state`,\n`null` removes the current state report and leaves activity unconfirmed;\nit does not report idle or cancellation. A child whose capabilities are\nunset permits no Client-initiated session mutations.\n\nThe title and description provide the parent's display metadata for the\nchild. Agents SHOULD mirror known child state changes in the parent's\n[`SubagentUpdate::state`] using the same [`StateUpdate`] snapshot as the\nordinary `state_update` notification on the child session. This reports\nchild state, not a separate parent-owned lifecycle. Consumers should\ntreat duplicate reports idempotently.\nCompleting or cancelling work does not end the association: the parent may\nmessage the same child again. Individual messages and their outcomes do not\nchange this association.", "type": "object", "properties": { "sessionId": { @@ -7664,6 +7669,16 @@ } ] }, + "title": { + "description": "The parent's human-readable display title for this child. It need not be unique.\n\nOptional and nullable. Omitted means unchanged; `null` clears it. If no\ntitle is set, the Client chooses a fallback presentation.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "description": { + "description": "The parent's human-readable description of the child's role or purpose.\n\nOptional and nullable. Omitted means unchanged; `null` clears it. This is\ncurrent display metadata, not the history of instructions sent to the child.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, "capabilities": { "description": "Client-initiated session mutations permitted for this subagent session.\n\nRead-only operations retain their normal protocol semantics and\ncapability requirements.", "anyOf": [ @@ -7676,6 +7691,18 @@ ], "x-deserialize-default-on-error": true }, + "state": { + "description": "The child's current foreground state, mirrored onto its parent association.\n\nOptional and nullable. Omitted means unchanged; `null` removes the\ncurrent report and leaves activity unconfirmed (not idle or cancelled).\nA concrete [`StateUpdate`] replaces the entire previous snapshot.", + "anyOf": [ + { + "$ref": "#/$defs/StateUpdate" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Omitted means no metadata update; `null` is an\nexplicit clear signal. Implementations MUST NOT make assumptions about values at these keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility)", "type": ["object", "null"], @@ -7685,6 +7712,113 @@ }, "required": ["sessionId"] }, + "SessionMessage": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAn upsert of an inter-session message in the enclosing transcript.\n`messageId` is local to that transcript; separate views may use independent\nIDs. Endpoints are optional identity metadata, not content patches: omitted\nor `null` retains a known endpoint. Agents SHOULD supply available endpoints\non the first event; later events may omit them or enrich missing endpoints.\nSupplied endpoints must agree with the enclosing transcript. Live IDs refer\nto known sessions; history may retain unavailable counterparts. Missing\nidentities permit generic inter-session UI, not guessed participants or\nhuman authorship.\nA concrete `content` array replaces existing content; later chunks append.\nThis neither changes session ownership nor instructs the Client to deliver content.", + "type": "object", + "properties": { + "messageId": { + "description": "Identifier of this message within the enclosing session's transcript.", + "allOf": [ + { + "$ref": "#/$defs/MessageId" + } + ] + }, + "senderSessionId": { + "description": "Optional sending session identity; omission or `null` retains a known value.", + "anyOf": [ + { + "$ref": "#/$defs/SessionId" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "recipientSessionId": { + "description": "Optional receiving session identity; omission or `null` retains a known value.", + "anyOf": [ + { + "$ref": "#/$defs/SessionId" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "content": { + "description": "Omitted leaves content unchanged; `null` clears it; a concrete array\nreplaces the whole content collection.", + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "Omitted leaves metadata unchanged; `null` clears it.\n\nImplementations MUST NOT make assumptions about values in `_meta`.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["messageId"] + }, + "SessionMessageChunk": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA content block appended to a transcript-local message in arrival order.\nEndpoints are optional identity metadata, not content patches: omitted or\n`null` does not clear a known endpoint. Agents SHOULD supply available\nendpoints on the first event; later events may omit them or enrich missing\nendpoints. Supplied endpoints must agree with the enclosing transcript.\nLive IDs refer to known sessions; history may retain unavailable counterparts.\nMissing identities permit generic inter-session UI, not guessed participants\nor human authorship.\nChunk metadata applies only to that chunk. This does not instruct the Client\nto deliver content or imply that the recipient processed it.", + "type": "object", + "properties": { + "messageId": { + "description": "Identifier of this message within the enclosing session's transcript.", + "allOf": [ + { + "$ref": "#/$defs/MessageId" + } + ] + }, + "senderSessionId": { + "description": "Optional sending session identity; omission or `null` retains a known value.", + "anyOf": [ + { + "$ref": "#/$defs/SessionId" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "recipientSessionId": { + "description": "Optional receiving session identity; omission or `null` retains a known value.", + "anyOf": [ + { + "$ref": "#/$defs/SessionId" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "content": { + "description": "A single content block appended to the message.", + "allOf": [ + { + "$ref": "#/$defs/ContentBlock" + } + ] + }, + "_meta": { + "description": "Optional and nullable chunk-scoped metadata; omitted or `null` means none.\n\nImplementations MUST NOT make assumptions about values in `_meta`.", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["messageId", "content"] + }, "CompleteElicitationNotification": { "description": "Notification sent by the agent when a URL-based elicitation is complete.", "type": "object",