Skip to content
Open
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
7 changes: 7 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2796,6 +2796,13 @@ public sealed class CustomAgentConfig
/// </summary>
[JsonPropertyName("reasoningEffort")]
public string? ReasoningEffort { get; set; }

/// <summary>
/// Large tool output handling for this agent.
/// </summary>
/// <remarks>When omitted, no agent-specific large output override is sent.</remarks>
[JsonPropertyName("largeOutput")]
public LargeToolOutputConfig? LargeOutput { get; set; }
}

/// <summary>
Expand Down
25 changes: 25 additions & 0 deletions dotnet/test/Unit/SerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,31 @@ public void SessionRequests_CanSerializePluginDirectoriesAndLargeOutput_WithSdkO
Assert.Equal("/tmp/large-output", resumeLargeOutput.GetProperty("outputDir").GetString());
}

[Fact]
public void CustomAgentConfig_CanSerializeLargeOutput_WithSdkOptions()
{
var options = GetSerializerOptions();
var agent = new CustomAgentConfig
{
Name = "large-output-agent",
Prompt = "Handle large outputs.",
LargeOutput = new LargeToolOutputConfig
{
Enabled = false,
MaxSizeBytes = 2048,
OutputDirectory = "/tmp/agent-large-output",
},
};

var json = JsonSerializer.Serialize(agent, options);
using var document = JsonDocument.Parse(json);
var largeOutput = document.RootElement.GetProperty("largeOutput");

Assert.False(largeOutput.GetProperty("enabled").GetBoolean());
Assert.Equal(2048, largeOutput.GetProperty("maxSizeBytes").GetInt64());
Assert.Equal("/tmp/agent-large-output", largeOutput.GetProperty("outputDir").GetString());
}

[Fact]
public void SessionRequests_CanSerializeMemory_WithSdkOptions()
{
Expand Down
3 changes: 3 additions & 0 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,9 @@ type CustomAgentConfig struct {
// When empty, the runtime resolves model configuration, then inherits the
// parent effort only for the same model.
ReasoningEffort string `json:"reasoningEffort,omitempty"`
// LargeOutput configures large tool output handling for this agent. When
// nil, no agent-specific large output override is sent.
LargeOutput *LargeToolOutputConfig `json:"largeOutput,omitempty"`
}

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

func TestCustomAgentConfig_JSONIncludesLargeOutput(t *testing.T) {
enabled := false
maxSizeBytes := int64(2048)
cfg := CustomAgentConfig{
Name: "large-output-agent",
Prompt: "Handle large outputs.",
LargeOutput: &LargeToolOutputConfig{
Enabled: &enabled,
MaxSizeBytes: &maxSizeBytes,
OutputDirectory: "/tmp/agent-large-output",
},
}

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)
}

largeOutput, ok := decoded["largeOutput"].(map[string]any)
if !ok {
t.Fatalf("expected largeOutput object, got %v", decoded["largeOutput"])
}
if largeOutput["enabled"] != false {
t.Errorf("expected enabled false, got %v", largeOutput["enabled"])
}
if largeOutput["maxSizeBytes"] != float64(2048) {
t.Errorf("expected maxSizeBytes 2048, got %v", largeOutput["maxSizeBytes"])
}
if largeOutput["outputDir"] != "/tmp/agent-large-output" {
t.Errorf("expected outputDir '/tmp/agent-large-output', got %v", largeOutput["outputDir"])
}
}

func TestCustomAgentConfig_JSONIncludesEmptyTools(t *testing.T) {
cfg := CustomAgentConfig{
Name: "no-tools-agent",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ public class CustomAgentConfig {
@JsonProperty("reasoningEffort")
private String reasoningEffort;

@JsonProperty("largeOutput")
private LargeToolOutputConfig largeOutput;

/**
* Gets the unique identifier name for this agent.
*
Expand Down Expand Up @@ -309,4 +312,27 @@ public CustomAgentConfig setReasoningEffort(String reasoningEffort) {
this.reasoningEffort = reasoningEffort;
return this;
}

/**
* Gets the large tool output handling configuration for this agent.
*
* @return the large output configuration, or {@code null} if not set
*/
public LargeToolOutputConfig getLargeOutput() {
return largeOutput;
}

/**
* Sets the large tool output handling configuration for this agent.
* <p>
* When omitted, no agent-specific large output override is sent.
*
* @param largeOutput
* the large output configuration
* @return this config for method chaining
*/
public CustomAgentConfig setLargeOutput(LargeToolOutputConfig largeOutput) {
this.largeOutput = largeOutput;
return this;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,24 @@ void customAgentConfigReasoningEffortOmittedWhenNull() throws Exception {
assertFalse(json.contains("\"reasoningEffort\""));
}

@Test
void customAgentConfigLargeOutputSerializationRoundTrip() throws Exception {
var mapper = JsonRpcClient.getObjectMapper();
var cfg = new CustomAgentConfig().setName("large-output-agent")
.setLargeOutput(new LargeToolOutputConfig().setEnabled(false).setMaxSizeBytes(2048L)
.setOutputDirectory("/tmp/agent-large-output"));

var json = mapper.writeValueAsString(cfg);
assertTrue(json.contains("\"largeOutput\""));
assertTrue(json.contains("\"outputDir\":\"/tmp/agent-large-output\""));

var deserialized = mapper.readValue(json, CustomAgentConfig.class);
assertNotNull(deserialized.getLargeOutput());
assertEquals(false, deserialized.getLargeOutput().getEnabled());
assertEquals(2048L, deserialized.getLargeOutput().getMaxSizeBytes());
assertEquals("/tmp/agent-large-output", deserialized.getLargeOutput().getOutputDirectory());
}

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

@Test
Expand Down
9 changes: 6 additions & 3 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,9 +248,12 @@ function toWireMcpServers(
function toWireCustomAgents(agents: CustomAgentConfig[] | undefined): unknown[] | undefined {
if (!agents) return undefined;
return agents.map((agent) => {
if (!agent.mcpServers) return agent;
const { mcpServers, ...rest } = agent;
return { ...rest, mcpServers: toWireMcpServers(mcpServers) };
const { mcpServers, largeOutput, ...rest } = agent;
return {
...rest,
...(mcpServers ? { mcpServers: toWireMcpServers(mcpServers) } : {}),
...(largeOutput ? { largeOutput: toWireLargeOutput(largeOutput) } : {}),
};
});
}

Expand Down
5 changes: 5 additions & 0 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1791,6 +1791,11 @@ export interface CustomAgentConfig {
* then inherits the parent effort only if this agent uses the same model.
*/
reasoningEffort?: ReasoningEffort;
/**
* Large tool output handling for this agent.
* When unset, no agent-specific large output override is sent.
*/
largeOutput?: LargeToolOutputConfig;
}

/**
Expand Down
34 changes: 34 additions & 0 deletions nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2646,6 +2646,40 @@ describe("CopilotClient", () => {
]);
});

it("forwards custom agent large output in session.create request", async () => {
const client = new CopilotClient();
await client.start();
onTestFinished(() => stopClient(client));

const spy = vi.spyOn((client as any).connection!, "sendRequest");
await client.createSession({
onPermissionRequest: approveAll,
customAgents: [
{
name: "large-output-agent",
prompt: "You are a large output agent.",
largeOutput: {
enabled: false,
maxSizeBytes: 2048,
outputDirectory: "/tmp/agent-large-output",
},
},
],
});

const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any;
expect(payload.customAgents).toEqual([
expect.objectContaining({
name: "large-output-agent",
largeOutput: {
enabled: false,
maxSizeBytes: 2048,
outputDir: "/tmp/agent-large-output",
},
}),
]);
});

it("forwards agent in session.resume request", async () => {
const client = new CopilotClient();
await client.start();
Expand Down
2 changes: 2 additions & 0 deletions python/copilot/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4035,6 +4035,8 @@ def _convert_custom_agent_to_wire_format(
wire_agent["model"] = agent["model"]
if "reasoning_effort" in agent:
wire_agent["reasoningEffort"] = agent["reasoning_effort"]
if "large_output" in agent:
wire_agent["largeOutput"] = _large_output_to_wire(agent["large_output"])
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 @@ -1161,6 +1161,9 @@ class CustomAgentConfig(TypedDict, total=False):
# Reasoning effort for this agent's model. When omitted, the runtime resolves
# model configuration, then inherits the parent effort only for the same model.
reasoning_effort: NotRequired[ReasoningEffort]
# Large output handling for this agent. When omitted, no agent-specific
# large output override is sent.
large_output: NotRequired[LargeToolOutputConfig]


class DefaultAgentConfig(TypedDict, total=False):
Expand Down
43 changes: 43 additions & 0 deletions python/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import os
from datetime import UTC, datetime
from tempfile import TemporaryDirectory
from typing import Any
from unittest.mock import AsyncMock, Mock, patch

import pytest
Expand Down Expand Up @@ -1166,6 +1167,48 @@ async def mock_request(method, params, **kwargs):
finally:
await client.force_stop()

@pytest.mark.asyncio
async def test_custom_agent_large_output_uses_wire_output_dir(self) -> None:
client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH))
await client.start()
try:
captured: dict[str, Any] = {}

async def mock_request(method: str, params: dict[str, Any], **kwargs: Any) -> Any:
captured[method] = params
if method == "session.create":
result = {"sessionId": params.get("sessionId") or "session-1"}
callback = kwargs.get("on_response_inline")
if callback is not None:
callback(result)
return result
return {}

client._client.request = mock_request

await client.create_session(
on_permission_request=PermissionHandler.approve_all,
custom_agents=[
{
"name": "large-output-agent",
"prompt": "You are a large output agent.",
"large_output": {
"enabled": False,
"max_size_bytes": 2048,
"output_directory": "/tmp/agent-large-output",
},
}
],
)

assert captured["session.create"]["customAgents"][0]["largeOutput"] == {
"enabled": False,
"maxSizeBytes": 2048,
"outputDir": "/tmp/agent-large-output",
}
finally:
await client.force_stop()

@pytest.mark.asyncio
async def test_create_and_resume_session_forward_memory(self):
client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH))
Expand Down
25 changes: 25 additions & 0 deletions rust/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,11 @@ pub struct CustomAgentConfig {
/// parent effort only for the same model.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
/// Large tool output handling for this agent.
///
/// When unset, no agent-specific large output override is sent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub large_output: Option<LargeToolOutputConfig>,
}

impl CustomAgentConfig {
Expand Down Expand Up @@ -741,6 +746,12 @@ impl CustomAgentConfig {
self.reasoning_effort = Some(reasoning_effort.into());
self
}

/// Set the large tool output handling policy for this agent.
pub fn with_large_output(mut self, config: LargeToolOutputConfig) -> Self {
self.large_output = Some(config);
self
}
}

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

#[test]
fn custom_agent_config_serializes_large_output() {
let agent = CustomAgentConfig::new("large-output-agent", "prompt").with_large_output(
LargeToolOutputConfig::new()
.with_enabled(false)
.with_max_size_bytes(2048)
.with_output_directory("/tmp/agent-large-output"),
);
let wire = serde_json::to_value(&agent).unwrap();
assert_eq!(wire["largeOutput"]["enabled"], false);
assert_eq!(wire["largeOutput"]["maxSizeBytes"], 2048);
assert_eq!(wire["largeOutput"]["outputDir"], "/tmp/agent-large-output");
}

#[test]
#[should_panic(expected = "tool parameter schema must be a JSON object")]
fn tool_with_parameters_panics_on_non_object_value() {
Expand Down