From 8ce8b4963c8c213c6913b72e34453c14362daec9 Mon Sep 17 00:00:00 2001 From: Pierce Boggan Date: Tue, 1 Sep 2026 17:15:05 -0600 Subject: [PATCH] Add custom agent handoff metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/src/generated/api_types.rs | 29 ++++++++++ rust/src/types.rs | 94 +++++++++++++++++++++++++++------ rust/tests/api_types_test.rs | 90 +++++++++++++++++++++++++++++-- 3 files changed, 195 insertions(+), 18 deletions(-) diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index b1d9896f3e..5e8a49a430 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -1541,6 +1541,32 @@ pub struct AgentDiscoveryPathList { pub paths: Vec, } +/// An authored action that transfers the conversation to another custom agent. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomAgentHandoff { + /// Identifier of the custom agent that receives the handoff. + pub agent: String, + /// Human-readable action label shown by the host UI. + pub label: String, + /// Optional model id selected for the target agent. + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Optional prompt supplied to the target agent. + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, + /// Whether the host should submit the handoff prompt immediately. Defaults to false. + #[serde(skip_serializing_if = "Option::is_none")] + pub send: Option, +} + /// Agent metadata, including identifiers, display details, source, tools, model, MCP servers, skills, and file path. /// ///
@@ -1556,6 +1582,9 @@ pub struct AgentInfo { pub description: String, /// Human-readable display name pub display_name: String, + /// Authored handoff actions, in display order. Omitted when the agent defines no handoffs. + #[serde(skip_serializing_if = "Option::is_none")] + pub handoffs: Option>, /// Stable identifier for selection. For most agents this is the same as `name`; for plugin/builtin agents it may differ. Always populated; defaults to `name` when no distinct id was assigned. pub id: String, /// MCP server configurations attached to this agent, keyed by server name. Server config shape mirrors the MCP `mcpServers` schema. diff --git a/rust/src/types.rs b/rust/src/types.rs index ee3ac3df26..0c45fca4fe 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -626,8 +626,8 @@ impl Serialize for CommandDefinition { /// Configures a custom agent (sub-agent) for the session. /// -/// Custom agents have their own prompt, tool allowlist, and optionally -/// their own MCP servers and skill set. The agent named in +/// Custom agents have their own prompt, tool allowlist, handoff actions, and +/// optionally their own MCP servers and skill set. The agent named in /// [`SessionConfig::agent`] (or the runtime default) is the active one /// when the session starts. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -656,6 +656,9 @@ pub struct CustomAgentConfig { /// Skill names to preload into this agent's context at startup. #[serde(default, skip_serializing_if = "Option::is_none")] pub skills: Option>, + /// Actions that transfer the conversation to another custom agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub handoffs: Option>, /// Model identifier for this agent (e.g. `"claude-haiku-4.5"`). /// /// When set, the runtime will attempt to use this model for the agent, @@ -730,6 +733,15 @@ impl CustomAgentConfig { self } + /// Set the handoff actions in display order. + pub fn with_handoffs(mut self, handoffs: I) -> Self + where + I: IntoIterator, + { + self.handoffs = Some(handoffs.into_iter().collect()); + self + } + /// Set the model identifier for this agent. pub fn with_model(mut self, model: impl Into) -> Self { self.model = Some(model.into()); @@ -5941,12 +5953,12 @@ impl InputFormat { /// [`crate::rpc`]; they live here so the crate-root /// `pub use types::*` surfaces them alongside hand-written SDK types. pub use crate::generated::api_types::{ - Model, ModelBilling, ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, - ModelCapabilities, ModelCapabilitiesLimits, ModelCapabilitiesLimitsVision, - ModelCapabilitiesSupports, ModelList, ModelPolicy, PermissionDecision, - PermissionDecisionApproveOnce, PermissionDecisionContext, PermissionDecisionOutcome, - PermissionDecisionReject, PermissionDecisionSource, PermissionDecisionSurface, - PermissionDecisionUserNotAvailable, PermissionResponseCapability, + CustomAgentHandoff, Model, ModelBilling, ModelBillingTokenPrices, + ModelBillingTokenPricesLongContext, ModelCapabilities, ModelCapabilitiesLimits, + ModelCapabilitiesLimitsVision, ModelCapabilitiesSupports, ModelList, ModelPolicy, + PermissionDecision, PermissionDecisionApproveOnce, PermissionDecisionContext, + PermissionDecisionOutcome, PermissionDecisionReject, PermissionDecisionSource, + PermissionDecisionSurface, PermissionDecisionUserNotAvailable, PermissionResponseCapability, }; /// Permission categories the CLI may request approval for. @@ -6053,13 +6065,14 @@ mod tests { use super::{ AgentMode, Attachment, AttachmentLineRange, AttachmentSelectionPosition, AttachmentSelectionRange, AutoTier, AzureProviderOptions, CapiSessionOptions, - ConnectionState, CopilotExpAssignmentResponse, CustomAgentConfig, DeliveryMode, - ExpConfigEntry, ExpFlagValue, ExtensionInfo, GitHubMcpToolConfig, GitHubReferenceType, - InfiniteSessionConfig, LargeToolOutputConfig, McpServerConfig, McpStdioServerConfig, - MemoryConfiguration, NamedProviderConfig, PermissionResponseCapability, ProviderConfig, - ProviderModelConfig, ReasoningSummary, ResumeSessionConfig, SessionConfig, SessionEvent, - SessionId, SystemMessageConfig, Tool, ToolBinaryResult, ToolResult, ToolResultExpanded, - ToolResultResponse, ensure_attachment_display_names, + ConnectionState, CopilotExpAssignmentResponse, CustomAgentConfig, CustomAgentHandoff, + DeliveryMode, ExpConfigEntry, ExpFlagValue, ExtensionInfo, GitHubMcpToolConfig, + GitHubReferenceType, InfiniteSessionConfig, LargeToolOutputConfig, McpServerConfig, + McpStdioServerConfig, MemoryConfiguration, NamedProviderConfig, + PermissionResponseCapability, ProviderConfig, ProviderModelConfig, ReasoningSummary, + ResumeSessionConfig, SessionConfig, SessionEvent, SessionId, SystemMessageConfig, Tool, + ToolBinaryResult, ToolResult, ToolResultExpanded, ToolResultResponse, + ensure_attachment_display_names, }; use crate::generated::session_events::TypedSessionEvent; @@ -6178,6 +6191,57 @@ mod tests { assert!(wire.get("reasoningEffort").is_none()); } + #[test] + fn custom_agent_config_handoffs_round_trip_in_order() { + let agent = CustomAgentConfig::new("planner", "Plan the work.").with_handoffs([ + CustomAgentHandoff { + label: "Implement".to_string(), + agent: "implementer".to_string(), + prompt: None, + send: None, + model: None, + }, + CustomAgentHandoff { + label: "Review".to_string(), + agent: "reviewer".to_string(), + prompt: Some("Review the implementation.".to_string()), + send: Some(true), + model: Some("gpt-5.4".to_string()), + }, + ]); + + let wire = serde_json::to_value(&agent).unwrap(); + assert_eq!( + wire["handoffs"], + json!([ + { + "label": "Implement", + "agent": "implementer" + }, + { + "label": "Review", + "agent": "reviewer", + "prompt": "Review the implementation.", + "send": true, + "model": "gpt-5.4" + } + ]) + ); + + let decoded: CustomAgentConfig = serde_json::from_value(wire).unwrap(); + let handoffs = decoded.handoffs.unwrap(); + assert_eq!(handoffs.len(), 2); + assert_eq!(handoffs[0].label, "Implement"); + assert_eq!(handoffs[1].label, "Review"); + } + + #[test] + fn custom_agent_config_omits_handoffs_when_none() { + let agent = CustomAgentConfig::new("default-agent", "prompt"); + let wire = serde_json::to_value(&agent).unwrap(); + assert!(wire.get("handoffs").is_none()); + } + #[test] #[should_panic(expected = "tool parameter schema must be a JSON object")] fn tool_with_parameters_panics_on_non_object_value() { diff --git a/rust/tests/api_types_test.rs b/rust/tests/api_types_test.rs index 8ed40e7c76..e062591f23 100644 --- a/rust/tests/api_types_test.rs +++ b/rust/tests/api_types_test.rs @@ -3,14 +3,15 @@ #![allow(clippy::unwrap_used)] -use github_copilot_sdk::AutoTier; use github_copilot_sdk::rpc::{ - Extension, ExtensionList, ExtensionSource, ExtensionStatus, ExtensionsDisableRequest, - ExtensionsEnableRequest, FleetStartRequest, FleetStartResult, TasksStartAgentRequest, + AgentInfo, AgentList, Extension, ExtensionList, ExtensionSource, ExtensionStatus, + ExtensionsDisableRequest, ExtensionsEnableRequest, FleetStartRequest, FleetStartResult, + ServerAgentList, TasksStartAgentRequest, }; use github_copilot_sdk::session_events::{ PermissionRequest, PermissionRequestedData, SessionEventData, TypedSessionEvent, }; +use github_copilot_sdk::{AutoTier, CustomAgentHandoff}; #[test] fn session_events_deserialize_auto_tier() { @@ -51,6 +52,89 @@ fn session_events_deserialize_auto_tier() { } } +#[test] +fn agent_info_handoffs_round_trip_in_order() { + let wire = serde_json::json!({ + "id": "planner", + "name": "planner", + "displayName": "Planner", + "description": "Plans work", + "handoffs": [ + { + "label": "Implement", + "agent": "implementer" + }, + { + "label": "Review", + "agent": "reviewer", + "prompt": "Review the implementation.", + "send": true, + "model": "gpt-5.4" + } + ] + }); + + let info: AgentInfo = serde_json::from_value(wire.clone()).unwrap(); + let handoffs: &[CustomAgentHandoff] = info.handoffs.as_deref().unwrap(); + assert_eq!(handoffs.len(), 2); + assert_eq!(handoffs[0].label, "Implement"); + assert_eq!(handoffs[0].agent, "implementer"); + assert_eq!(handoffs[0].send, None); + assert_eq!(handoffs[1].label, "Review"); + assert_eq!( + handoffs[1].prompt.as_deref(), + Some("Review the implementation.") + ); + assert_eq!(handoffs[1].send, Some(true)); + assert_eq!(handoffs[1].model.as_deref(), Some("gpt-5.4")); + assert_eq!(serde_json::to_value(info).unwrap(), wire); +} + +#[test] +fn agent_info_omits_handoffs_when_absent() { + let info: AgentInfo = serde_json::from_value(serde_json::json!({ + "id": "planner", + "name": "planner", + "displayName": "Planner", + "description": "Plans work" + })) + .unwrap(); + assert!(info.handoffs.is_none()); + assert!( + serde_json::to_value(info) + .unwrap() + .get("handoffs") + .is_none() + ); +} + +#[test] +fn agent_list_rpcs_expose_handoffs() { + let wire = serde_json::json!({ + "agents": [{ + "id": "planner", + "name": "planner", + "displayName": "Planner", + "description": "Plans work", + "handoffs": [{ + "label": "Implement", + "agent": "implementer" + }] + }] + }); + + let discovered: ServerAgentList = serde_json::from_value(wire.clone()).unwrap(); + let session_agents: AgentList = serde_json::from_value(wire).unwrap(); + assert_eq!( + discovered.agents[0].handoffs.as_ref().unwrap()[0].agent, + "implementer" + ); + assert_eq!( + session_agents.agents[0].handoffs.as_ref().unwrap()[0].agent, + "implementer" + ); +} + #[test] fn extension_running_has_expected_status_and_source() { let extension = running_extension("project:demo", "demo");