Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions rust/src/generated/api_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1541,6 +1541,32 @@ pub struct AgentDiscoveryPathList {
pub paths: Vec<AgentDiscoveryPath>,
}

/// An authored action that transfers the conversation to another custom agent.
///
/// <div class="warning">
///
/// **Experimental.** This type is part of an experimental wire-protocol surface
/// and may change or be removed in future SDK or CLI releases.
///
/// </div>
#[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<String>,
/// Optional prompt supplied to the target agent.
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
/// Whether the host should submit the handoff prompt immediately. Defaults to false.
#[serde(skip_serializing_if = "Option::is_none")]
pub send: Option<bool>,
}

/// Agent metadata, including identifiers, display details, source, tools, model, MCP servers, skills, and file path.
///
/// <div class="warning">
Expand All @@ -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<Vec<CustomAgentHandoff>>,
/// 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.
Expand Down
94 changes: 79 additions & 15 deletions rust/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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<Vec<String>>,
/// Actions that transfer the conversation to another custom agent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handoffs: Option<Vec<CustomAgentHandoff>>,
/// Model identifier for this agent (e.g. `"claude-haiku-4.5"`).
///
/// When set, the runtime will attempt to use this model for the agent,
Expand Down Expand Up @@ -730,6 +733,15 @@ impl CustomAgentConfig {
self
}

/// Set the handoff actions in display order.
pub fn with_handoffs<I>(mut self, handoffs: I) -> Self
where
I: IntoIterator<Item = CustomAgentHandoff>,
{
self.handoffs = Some(handoffs.into_iter().collect());
self
}

/// Set the model identifier for this agent.
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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() {
Expand Down
90 changes: 87 additions & 3 deletions rust/tests/api_types_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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");
Expand Down
Loading