Skip to content
Merged
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
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. When omitted, no override is sent and the backend chooses its default |

> [!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. When `reasoningEffort` is omitted, the SDK sends no per-agent override and the backend chooses its default. The parent session effort is not inherited, and 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
8 changes: 8 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2657,6 +2657,14 @@ 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 override is sent and the backend chooses its
/// default. The parent session effort is not inherited.
/// </summary>
[JsonPropertyName("reasoningEffort")]
public string? ReasoningEffort { get; set; }
Comment thread
roji marked this conversation as resolved.
}

/// <summary>
Expand Down
51 changes: 51 additions & 0 deletions dotnet/test/Unit/ClientSessionLifetimeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,57 @@ public async Task ResumeSessionAsync_Throws_When_Same_Client_Already_Tracks_Sess
AssertSessionCount(client, sessions: 1);
}

[Fact]
public async Task CreateSessionAsync_Serializes_CustomAgent_ReasoningEffort()
{
await using var server = await FakeCopilotServer.StartAsync();
await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) });
await client.StartAsync();

await using var session = await client.CreateSessionAsync(new SessionConfig
{
CustomAgents =
[
new CustomAgentConfig
{
Name = "reasoning-agent",
Prompt = "Think carefully.",
ReasoningEffort = "high"
}
],
OnPermissionRequest = PermissionHandler.ApproveAll
});

var request = Assert.Single(server.Requests, request => request.Method == "session.create");
var agent = Assert.Single(request.Params.GetProperty("customAgents").EnumerateArray());
Assert.Equal("high", agent.GetProperty("reasoningEffort").GetString());
}

[Fact]
public async Task CreateSessionAsync_Omits_CustomAgent_ReasoningEffort_When_Unset()
{
await using var server = await FakeCopilotServer.StartAsync();
await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) });
await client.StartAsync();

await using var session = await client.CreateSessionAsync(new SessionConfig
{
CustomAgents =
[
new CustomAgentConfig
{
Name = "default-agent",
Prompt = "Use runtime defaults."
}
],
OnPermissionRequest = PermissionHandler.ApproveAll
});

var request = Assert.Single(server.Requests, request => request.Method == "session.create");
var agent = Assert.Single(request.Params.GetProperty("customAgents").EnumerateArray());
Assert.False(agent.TryGetProperty("reasoningEffort", out _));
}

[Fact]
public async Task CreateSessionAsync_Registers_McpAuth_Interest_Only_When_Handler_Configured()
{
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
4 changes: 4 additions & 0 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,10 @@ 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 override is sent and the backend chooses its
// default. The parent session effort is not inherited.
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,28 @@ 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 override is sent and the backend chooses its
* default. The parent session effort is not inherited.
*
* @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
6 changes: 6 additions & 0 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1632,6 +1632,12 @@ export interface CustomAgentConfig {
* falling back to the parent session model if unavailable.
*/
model?: string;
/**
* Reasoning effort level for this agent's model.
* When omitted, no per-agent override is sent and the backend chooses its
* default. The parent session effort is not inherited.
*/
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
3 changes: 3 additions & 0 deletions python/copilot/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -1080,6 +1080,9 @@ 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. When omitted, no per-agent override
# is sent and the backend chooses its default; the parent effort is not inherited.
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
34 changes: 34 additions & 0 deletions rust/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,12 @@ 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 override is sent and the backend chooses its
/// default. The parent session effort is not inherited.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
}

impl CustomAgentConfig {
Expand Down Expand Up @@ -695,6 +701,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 +5481,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