Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions docs/features/custom-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,10 +253,14 @@ try (var client = new CopilotClient()) {
| `mcpServers` | `object` | | MCP server configurations specific to this agent |
| `infer` | `boolean` | | Whether the runtime can auto-select this agent (default: `true`) |
| `skills` | `string[]` | | Skill names to preload into the agent's context at startup |
| `model` | `string` | | Model identifier to use while this agent runs |
| `reasoningEffort` | `string` | | Reasoning effort to use while this agent runs |

> [!TIP]
> A good `description` helps the runtime match user intent to the right agent. Be specific about the agent's expertise and capabilities.

Set `model` and `reasoningEffort` to override the parent session's model settings while a custom agent runs. Omit either property to leave that setting unspecified; the SDK does not add a per-agent default. Python uses `reasoning_effort`, .NET uses `ReasoningEffort`, Go uses `ReasoningEffort`, Java uses `setReasoningEffort`, and Rust uses `with_reasoning_effort`.

In addition to per-agent configuration above, you can set `agent` on the **session config** itself to pre-select which custom agent is active when the session starts. See [Selecting an Agent at Session Creation](#selecting-an-agent-at-session-creation) below.

| Session Config Property | Type | Description |
Expand Down
7 changes: 7 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2657,6 +2657,13 @@ public sealed class CustomAgentConfig
/// </summary>
[JsonPropertyName("model")]
public string? Model { get; set; }

/// <summary>
/// Reasoning effort level for this agent's model.
/// When omitted, no per-agent reasoning effort is sent to the runtime.
/// </summary>
[JsonPropertyName("reasoningEffort")]
public string? ReasoningEffort { get; set; }
Comment thread
roji marked this conversation as resolved.
}

/// <summary>
Expand Down
3 changes: 2 additions & 1 deletion dotnet/test/Unit/CloneTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ public void SessionConfig_Clone_CopiesAllProperties()
IncludeSubAgentStreamingEvents = false,
McpServers = new Dictionary<string, McpServerConfig> { ["server1"] = new McpStdioServerConfig { Command = "echo" } },
McpOAuthTokenStorage = McpOAuthTokenStorageMode.Persistent,
CustomAgents = [new CustomAgentConfig { Name = "agent1", Model = "claude-haiku-4.5" }],
CustomAgents = [new CustomAgentConfig { Name = "agent1", Model = "claude-haiku-4.5", ReasoningEffort = "high" }],
Agent = "agent1",
Capi = new CapiSessionOptions { EnableWebSocketResponses = false },
Cloud = new CloudSessionOptions
Expand Down Expand Up @@ -128,6 +128,7 @@ public void SessionConfig_Clone_CopiesAllProperties()
Assert.Equal(original.McpOAuthTokenStorage, clone.McpOAuthTokenStorage);
Assert.Equal(original.CustomAgents.Count, clone.CustomAgents!.Count);
Assert.Equal(original.CustomAgents[0].Model, clone.CustomAgents[0].Model);
Assert.Equal(original.CustomAgents[0].ReasoningEffort, clone.CustomAgents[0].ReasoningEffort);
Assert.Equal(original.Agent, clone.Agent);
Assert.Same(original.Capi, clone.Capi);
Assert.Same(original.Cloud, clone.Cloud);
Expand Down
31 changes: 31 additions & 0 deletions dotnet/test/Unit/SerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,37 @@ namespace GitHub.Copilot.Test.Unit;
/// </summary>
public class SerializationTests
{
[Fact]
public void CustomAgentConfig_SerializesReasoningEffort_WithSdkOptions()
{
var options = GetSerializerOptions();
Comment thread
roji marked this conversation as resolved.
Outdated
var original = new CustomAgentConfig
{
Name = "reasoning-agent",
Prompt = "Think carefully.",
ReasoningEffort = "high"
};

var json = JsonSerializer.Serialize(original, options);
using var document = JsonDocument.Parse(json);
Assert.Equal("high", document.RootElement.GetProperty("reasoningEffort").GetString());
}

[Fact]
public void CustomAgentConfig_OmitsReasoningEffortWhenUnset_WithSdkOptions()
{
var options = GetSerializerOptions();
Comment thread
roji marked this conversation as resolved.
Outdated
var original = new CustomAgentConfig
{
Name = "default-agent",
Prompt = "Use runtime defaults."
};

var json = JsonSerializer.Serialize(original, options);
using var document = JsonDocument.Parse(json);
Assert.False(document.RootElement.TryGetProperty("reasoningEffort", out _));
}

[Fact]
public void ProviderConfig_CanSerializeHeaders_WithSdkOptions()
{
Expand Down
3 changes: 3 additions & 0 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,9 @@ type CustomAgentConfig struct {
// When set, the runtime will attempt to use this model for the agent,
// falling back to the parent session model if unavailable.
Model string `json:"model,omitempty"`
// ReasoningEffort is the reasoning effort level for this agent's model.
// When empty, no per-agent reasoning effort is sent to the runtime.
ReasoningEffort string `json:"reasoningEffort,omitempty"`
}

// DefaultAgentConfig configures the default agent (the built-in agent that handles turns when no custom agent is selected).
Expand Down
25 changes: 25 additions & 0 deletions go/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,28 @@ func TestCustomAgentConfig_JSONIncludesModel(t *testing.T) {
}
}

func TestCustomAgentConfig_JSONIncludesReasoningEffort(t *testing.T) {
cfg := CustomAgentConfig{
Name: "reasoning-agent",
Prompt: "Think carefully.",
ReasoningEffort: "high",
}

data, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("failed to marshal CustomAgentConfig: %v", err)
}

var decoded map[string]any
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal CustomAgentConfig: %v", err)
}

if decoded["reasoningEffort"] != "high" {
t.Errorf("expected reasoningEffort 'high', got %v", decoded["reasoningEffort"])
}
}

func TestCustomAgentConfig_JSONIncludesEmptyTools(t *testing.T) {
cfg := CustomAgentConfig{
Name: "no-tools-agent",
Expand Down Expand Up @@ -273,6 +295,9 @@ func TestCustomAgentConfig_JSONOmitsModelWhenEmpty(t *testing.T) {
if _, present := decoded["model"]; present {
t.Errorf("expected model to be omitted when empty, got %v", decoded["model"])
}
if _, present := decoded["reasoningEffort"]; present {
t.Errorf("expected reasoningEffort to be omitted when empty, got %v", decoded["reasoningEffort"])
}
}

func TestTool_JSONIncludesEmptyParameters(t *testing.T) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ public class CustomAgentConfig {
@JsonProperty("model")
private String model;

@JsonProperty("reasoningEffort")
private String reasoningEffort;

/**
* Gets the unique identifier name for this agent.
*
Expand Down Expand Up @@ -282,4 +285,27 @@ public CustomAgentConfig setModel(String model) {
this.model = model;
return this;
}

/**
* Gets the reasoning effort level for this agent's model.
*
* @return the reasoning effort level, or {@code null} if not set
*/
public String getReasoningEffort() {
return reasoningEffort;
}

/**
* Sets the reasoning effort level for this agent's model.
* <p>
* When omitted, no per-agent reasoning effort is sent to the runtime.
*
* @param reasoningEffort
* the reasoning effort level
* @return this config for method chaining
*/
public CustomAgentConfig setReasoningEffort(String reasoningEffort) {
this.reasoningEffort = reasoningEffort;
return this;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ void postToolUseHookInputSessionIdRoundTrip() {
assertEquals("session-xyz", input.getSessionId());
}

// ===== CustomAgentConfig model field =====
// ===== CustomAgentConfig model fields =====

@Test
void customAgentConfigModelGetterAndSetter() {
Expand Down Expand Up @@ -227,6 +227,36 @@ void customAgentConfigModelOmittedWhenNull() throws Exception {
assertFalse(json.contains("\"model\""));
}

@Test
void customAgentConfigReasoningEffortGetterAndFluentSetter() {
var cfg = new CustomAgentConfig();
assertNull(cfg.getReasoningEffort());

var result = cfg.setReasoningEffort("high");
assertSame(cfg, result);
assertEquals("high", cfg.getReasoningEffort());
}

@Test
void customAgentConfigReasoningEffortSerializationRoundTrip() throws Exception {
var mapper = JsonRpcClient.getObjectMapper();
var cfg = new CustomAgentConfig().setName("reasoning-agent").setReasoningEffort("high");

var json = mapper.writeValueAsString(cfg);
assertTrue(json.contains("\"reasoningEffort\":\"high\""));

var deserialized = mapper.readValue(json, CustomAgentConfig.class);
assertEquals("high", deserialized.getReasoningEffort());
}

@Test
void customAgentConfigReasoningEffortOmittedWhenNull() throws Exception {
var mapper = JsonRpcClient.getObjectMapper();
var json = mapper.writeValueAsString(new CustomAgentConfig().setName("default-agent"));

assertFalse(json.contains("\"reasoningEffort\""));
}

// ===== PermissionRequestResult setRules =====

@Test
Expand Down
5 changes: 5 additions & 0 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1632,6 +1632,11 @@ export interface CustomAgentConfig {
* falling back to the parent session model if unavailable.
*/
model?: string;
/**
* Reasoning effort level for this agent's model.
* When omitted, the SDK does not send a per-agent reasoning effort.
*/
reasoningEffort?: ReasoningEffort;
}

/**
Expand Down
10 changes: 8 additions & 2 deletions nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2216,9 +2216,10 @@ describe("CopilotClient", () => {
const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any;
expect(payload.agent).toBe("test-agent");
expect(payload.customAgents).toEqual([expect.objectContaining({ name: "test-agent" })]);
expect(payload.customAgents[0].reasoningEffort).toBeUndefined();
});

it("forwards custom agent model in session.create request", async () => {
it("forwards custom agent model and reasoning effort in session.create request", async () => {
const client = new CopilotClient();
await client.start();
onTestFinished(() => stopClient(client));
Expand All @@ -2231,13 +2232,18 @@ describe("CopilotClient", () => {
name: "model-agent",
prompt: "You are a model agent.",
model: "claude-haiku-4.5",
reasoningEffort: "high",
},
],
});

const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any;
expect(payload.customAgents).toEqual([
expect.objectContaining({ name: "model-agent", model: "claude-haiku-4.5" }),
expect.objectContaining({
name: "model-agent",
model: "claude-haiku-4.5",
reasoningEffort: "high",
}),
]);
});

Expand Down
2 changes: 2 additions & 0 deletions python/copilot/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3736,6 +3736,8 @@ def _convert_custom_agent_to_wire_format(
wire_agent["skills"] = agent["skills"]
if "model" in agent:
wire_agent["model"] = agent["model"]
if "reasoning_effort" in agent:
wire_agent["reasoningEffort"] = agent["reasoning_effort"]
return wire_agent

def _convert_default_agent_to_wire_format(
Expand Down
2 changes: 2 additions & 0 deletions python/copilot/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -1080,6 +1080,8 @@ class CustomAgentConfig(TypedDict, total=False):
skills: NotRequired[list[str]]
# Model identifier (e.g. "claude-haiku-4.5"); runtime falls back to parent model if unavailable
model: NotRequired[str]
# Reasoning effort for this agent's model; omit to leave it unspecified
reasoning_effort: NotRequired[ReasoningEffort]


class DefaultAgentConfig(TypedDict, total=False):
Expand Down
26 changes: 26 additions & 0 deletions python/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2245,6 +2245,32 @@ def test_model_field_is_omitted_when_absent(self):
wire = client._convert_custom_agent_to_wire_format(agent)
assert "model" not in wire

def test_reasoning_effort_is_forwarded_in_camel_case(self):
from copilot.client import CopilotClient
from copilot.session import CustomAgentConfig

client = CopilotClient.__new__(CopilotClient)
agent: CustomAgentConfig = {
"name": "reasoning-agent",
"prompt": "Think carefully.",
"reasoning_effort": "high",
}
wire = client._convert_custom_agent_to_wire_format(agent)
assert wire["reasoningEffort"] == "high"
assert "reasoning_effort" not in wire

def test_reasoning_effort_is_omitted_when_absent(self):
from copilot.client import CopilotClient
from copilot.session import CustomAgentConfig

client = CopilotClient.__new__(CopilotClient)
agent: CustomAgentConfig = {
"name": "default-agent",
"prompt": "Use runtime defaults.",
}
wire = client._convert_custom_agent_to_wire_format(agent)
assert "reasoningEffort" not in wire


class TestPostToolUseFailureHookDispatch:
"""Unit tests for the postToolUseFailure handler dispatch."""
Expand Down
33 changes: 33 additions & 0 deletions rust/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,11 @@ pub struct CustomAgentConfig {
/// falling back to the parent session model if unavailable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Reasoning effort level for this agent's model.
///
/// When unset, no per-agent reasoning effort is sent to the runtime.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
}

impl CustomAgentConfig {
Expand Down Expand Up @@ -695,6 +700,12 @@ impl CustomAgentConfig {
self.model = Some(model.into());
self
}

/// Set the reasoning effort level for this agent's model.
pub fn with_reasoning_effort(mut self, reasoning_effort: impl Into<String>) -> Self {
self.reasoning_effort = Some(reasoning_effort.into());
self
}
}

/// Configures the default (built-in) agent that handles turns when no
Expand Down Expand Up @@ -5469,6 +5480,28 @@ mod tests {
assert!(wire.get("model").is_none());
}

#[test]
fn custom_agent_config_builder_with_reasoning_effort() {
let agent =
CustomAgentConfig::new("reasoning-agent", "prompt").with_reasoning_effort("high");
assert_eq!(agent.reasoning_effort.as_deref(), Some("high"));
}

#[test]
fn custom_agent_config_serializes_reasoning_effort() {
let agent =
CustomAgentConfig::new("reasoning-agent", "prompt").with_reasoning_effort("high");
let wire = serde_json::to_value(&agent).unwrap();
assert_eq!(wire["reasoningEffort"], "high");
}

#[test]
fn custom_agent_config_omits_reasoning_effort_when_none() {
let agent = CustomAgentConfig::new("default-agent", "prompt");
let wire = serde_json::to_value(&agent).unwrap();
assert!(wire.get("reasoningEffort").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
Loading