Skip to content

Commit d0eb531

Browse files
patnikoCopilot
andauthored
feat: add model field to CustomAgentConfig across all SDKs (#1309)
* feat(nodejs): add model field to CustomAgentConfig Add optional `model` property to the Node/TypeScript CustomAgentConfig interface. When set, the runtime will attempt to use the specified model for the agent, falling back to the parent session model if unavailable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(python): add model field to CustomAgentConfig Add optional `model` key to the Python CustomAgentConfig TypedDict and wire it through `_convert_custom_agent_to_wire_format` so the runtime receives it in the session.create / session.resume payloads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(go): add Model field to CustomAgentConfig Add optional `Model` field to the Go CustomAgentConfig struct. The field serializes as `"model"` and is omitted when empty. When set, the runtime will attempt to use the specified model for the agent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(dotnet): add Model property to CustomAgentConfig Add optional `Model` property to the .NET CustomAgentConfig class. The property serializes as `"model"` and is omitted when null. When set, the runtime will attempt to use the specified model for the agent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(rust): add model field to CustomAgentConfig Add optional `model` field to the Rust CustomAgentConfig struct with a `with_model` builder method. Serializes as `"model"` (camelCase rename is a no-op for single-word fields) and is skipped when None. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(nodejs): verify model field is forwarded in session.create payload Add a test case that creates a custom agent with a model property and asserts it appears in the session.create RPC payload. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(python): verify model field in CustomAgentConfig wire conversion Add unit tests asserting that the model key is correctly forwarded to the camelCase wire payload, and omitted when not set. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(go): verify CustomAgentConfig model JSON serialization Add tests asserting that the model field round-trips through JSON when set and is omitted from the payload when empty. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(dotnet): verify CustomAgentConfig.Model is preserved through Clone Update the SessionConfig clone test to set Model on a CustomAgentConfig and assert it survives the clone operation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(rust): verify CustomAgentConfig model builder and serialization Add unit tests for the with_model() builder, JSON serialization with model set, and confirming model is omitted from wire when None. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 38f38ff commit d0eb531

10 files changed

Lines changed: 161 additions & 1 deletion

File tree

dotnet/src/Types.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1933,6 +1933,14 @@ public class CustomAgentConfig
19331933
/// </summary>
19341934
[JsonPropertyName("skills")]
19351935
public IList<string>? Skills { get; set; }
1936+
1937+
/// <summary>
1938+
/// Model identifier for this agent (e.g. "claude-haiku-4.5").
1939+
/// When set, the runtime will attempt to use this model for the agent,
1940+
/// falling back to the parent session model if unavailable.
1941+
/// </summary>
1942+
[JsonPropertyName("model")]
1943+
public string? Model { get; set; }
19361944
}
19371945

19381946
/// <summary>

dotnet/test/Unit/CloneTests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ public void SessionConfig_Clone_CopiesAllProperties()
9595
EnableSessionTelemetry = false,
9696
IncludeSubAgentStreamingEvents = false,
9797
McpServers = new Dictionary<string, McpServerConfig> { ["server1"] = new McpStdioServerConfig { Command = "echo" } },
98-
CustomAgents = [new CustomAgentConfig { Name = "agent1" }],
98+
CustomAgents = [new CustomAgentConfig { Name = "agent1", Model = "claude-haiku-4.5" }],
9999
Agent = "agent1",
100100
DefaultAgent = new DefaultAgentConfig { ExcludedTools = ["hidden-tool"] },
101101
SkillDirectories = ["/skills"],
@@ -120,6 +120,7 @@ public void SessionConfig_Clone_CopiesAllProperties()
120120
Assert.Equal(original.IncludeSubAgentStreamingEvents, clone.IncludeSubAgentStreamingEvents);
121121
Assert.Equal(original.McpServers.Count, clone.McpServers!.Count);
122122
Assert.Equal(original.CustomAgents.Count, clone.CustomAgents!.Count);
123+
Assert.Equal(original.CustomAgents[0].Model, clone.CustomAgents[0].Model);
123124
Assert.Equal(original.Agent, clone.Agent);
124125
Assert.Equal(original.DefaultAgent!.ExcludedTools, clone.DefaultAgent!.ExcludedTools);
125126
Assert.Equal(original.SkillDirectories, clone.SkillDirectories);

go/types.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,10 @@ type CustomAgentConfig struct {
532532
Infer *bool `json:"infer,omitempty"`
533533
// Skills is the list of skill names to preload into this agent's context at startup (opt-in; omit for none)
534534
Skills []string `json:"skills,omitempty"`
535+
// Model is the model identifier for this agent (e.g. "claude-haiku-4.5").
536+
// When set, the runtime will attempt to use this model for the agent,
537+
// falling back to the parent session model if unavailable.
538+
Model string `json:"model,omitempty"`
535539
}
536540

537541
// DefaultAgentConfig configures the default agent (the built-in agent that handles turns when no custom agent is selected).

go/types_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,49 @@ func TestProviderConfig_JSONOmitsUnsetTokenFields(t *testing.T) {
216216
}
217217
}
218218
}
219+
220+
func TestCustomAgentConfig_JSONIncludesModel(t *testing.T) {
221+
cfg := CustomAgentConfig{
222+
Name: "model-agent",
223+
Prompt: "You are a model agent.",
224+
Model: "claude-haiku-4.5",
225+
}
226+
227+
data, err := json.Marshal(cfg)
228+
if err != nil {
229+
t.Fatalf("failed to marshal CustomAgentConfig: %v", err)
230+
}
231+
232+
var decoded map[string]any
233+
if err := json.Unmarshal(data, &decoded); err != nil {
234+
t.Fatalf("failed to unmarshal CustomAgentConfig: %v", err)
235+
}
236+
237+
if decoded["model"] != "claude-haiku-4.5" {
238+
t.Errorf("expected model 'claude-haiku-4.5', got %v", decoded["model"])
239+
}
240+
if decoded["name"] != "model-agent" {
241+
t.Errorf("expected name 'model-agent', got %v", decoded["name"])
242+
}
243+
}
244+
245+
func TestCustomAgentConfig_JSONOmitsModelWhenEmpty(t *testing.T) {
246+
cfg := CustomAgentConfig{
247+
Name: "no-model-agent",
248+
Prompt: "You are an agent without a model.",
249+
}
250+
251+
data, err := json.Marshal(cfg)
252+
if err != nil {
253+
t.Fatalf("failed to marshal CustomAgentConfig: %v", err)
254+
}
255+
256+
var decoded map[string]any
257+
if err := json.Unmarshal(data, &decoded); err != nil {
258+
t.Fatalf("failed to unmarshal CustomAgentConfig: %v", err)
259+
}
260+
261+
if _, present := decoded["model"]; present {
262+
t.Errorf("expected model to be omitted when empty, got %v", decoded["model"])
263+
}
264+
}

nodejs/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1213,6 +1213,12 @@ export interface CustomAgentConfig {
12131213
* When omitted, no skills are injected (opt-in model).
12141214
*/
12151215
skills?: string[];
1216+
/**
1217+
* Model identifier for this agent (e.g. "claude-haiku-4.5").
1218+
* When set, the runtime will attempt to use this model for the agent,
1219+
* falling back to the parent session model if unavailable.
1220+
*/
1221+
model?: string;
12161222
}
12171223

12181224
/**

nodejs/test/client.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,6 +868,29 @@ describe("CopilotClient", () => {
868868
expect(payload.customAgents).toEqual([expect.objectContaining({ name: "test-agent" })]);
869869
});
870870

871+
it("forwards custom agent model in session.create request", async () => {
872+
const client = new CopilotClient();
873+
await client.start();
874+
onTestFinished(() => client.forceStop());
875+
876+
const spy = vi.spyOn((client as any).connection!, "sendRequest");
877+
await client.createSession({
878+
onPermissionRequest: approveAll,
879+
customAgents: [
880+
{
881+
name: "model-agent",
882+
prompt: "You are a model agent.",
883+
model: "claude-haiku-4.5",
884+
},
885+
],
886+
});
887+
888+
const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any;
889+
expect(payload.customAgents).toEqual([
890+
expect.objectContaining({ name: "model-agent", model: "claude-haiku-4.5" }),
891+
]);
892+
});
893+
871894
it("forwards agent in session.resume request", async () => {
872895
const client = new CopilotClient();
873896
await client.start();

python/copilot/client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2512,6 +2512,8 @@ def _convert_custom_agent_to_wire_format(
25122512
wire_agent["infer"] = agent["infer"]
25132513
if "skills" in agent:
25142514
wire_agent["skills"] = agent["skills"]
2515+
if "model" in agent:
2516+
wire_agent["model"] = agent["model"]
25152517
return wire_agent
25162518

25172519
def _convert_default_agent_to_wire_format(

python/copilot/session.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,8 @@ class CustomAgentConfig(TypedDict, total=False):
818818
infer: NotRequired[bool] # Whether agent is available for model inference
819819
# Skill names to preload into this agent's context at startup (opt-in; omit for none)
820820
skills: NotRequired[list[str]]
821+
# Model identifier (e.g. "claude-haiku-4.5"); runtime falls back to parent model if unavailable
822+
model: NotRequired[str]
821823

822824

823825
class DefaultAgentConfig(TypedDict, total=False):

python/test_client.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -982,3 +982,34 @@ async def test_aexit_calls_disconnect(self):
982982
with patch.object(session, "disconnect", new_callable=AsyncMock) as mock_disconnect:
983983
await session.__aexit__(None, None, None)
984984
mock_disconnect.assert_awaited_once()
985+
986+
987+
class TestCustomAgentWireFormat:
988+
def test_model_field_is_forwarded_in_wire_format(self):
989+
"""The model key in CustomAgentConfig should appear as 'model' in the wire payload."""
990+
from copilot.client import CopilotClient
991+
from copilot.session import CustomAgentConfig
992+
993+
client = CopilotClient.__new__(CopilotClient)
994+
agent: CustomAgentConfig = {
995+
"name": "model-agent",
996+
"prompt": "You are a model agent.",
997+
"model": "claude-haiku-4.5",
998+
}
999+
wire = client._convert_custom_agent_to_wire_format(agent)
1000+
assert wire["model"] == "claude-haiku-4.5"
1001+
assert wire["name"] == "model-agent"
1002+
assert wire["prompt"] == "You are a model agent."
1003+
1004+
def test_model_field_is_omitted_when_absent(self):
1005+
"""When model is not set, it should not appear in the wire payload."""
1006+
from copilot.client import CopilotClient
1007+
from copilot.session import CustomAgentConfig
1008+
1009+
client = CopilotClient.__new__(CopilotClient)
1010+
agent: CustomAgentConfig = {
1011+
"name": "no-model-agent",
1012+
"prompt": "You are an agent without a model.",
1013+
}
1014+
wire = client._convert_custom_agent_to_wire_format(agent)
1015+
assert "model" not in wire

rust/src/types.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,12 @@ pub struct CustomAgentConfig {
526526
/// Skill names to preload into this agent's context at startup.
527527
#[serde(default, skip_serializing_if = "Option::is_none")]
528528
pub skills: Option<Vec<String>>,
529+
/// Model identifier for this agent (e.g. `"claude-haiku-4.5"`).
530+
///
531+
/// When set, the runtime will attempt to use this model for the agent,
532+
/// falling back to the parent session model if unavailable.
533+
#[serde(default, skip_serializing_if = "Option::is_none")]
534+
pub model: Option<String>,
529535
}
530536

531537
impl CustomAgentConfig {
@@ -587,6 +593,12 @@ impl CustomAgentConfig {
587593
self.skills = Some(skills.into_iter().map(Into::into).collect());
588594
self
589595
}
596+
597+
/// Set the model identifier for this agent.
598+
pub fn with_model(mut self, model: impl Into<String>) -> Self {
599+
self.model = Some(model.into());
600+
self
601+
}
590602
}
591603

592604
/// Configures the default (built-in) agent that handles turns when no
@@ -3196,6 +3208,31 @@ mod tests {
31963208
assert!(tool.skip_permission);
31973209
}
31983210

3211+
#[test]
3212+
fn custom_agent_config_builder_with_model() {
3213+
let agent = CustomAgentConfig::new("my-agent", "You are helpful.")
3214+
.with_model("claude-haiku-4.5")
3215+
.with_display_name("My Agent");
3216+
assert_eq!(agent.name, "my-agent");
3217+
assert_eq!(agent.model.as_deref(), Some("claude-haiku-4.5"));
3218+
assert_eq!(agent.display_name.as_deref(), Some("My Agent"));
3219+
}
3220+
3221+
#[test]
3222+
fn custom_agent_config_serializes_model() {
3223+
let agent = CustomAgentConfig::new("model-agent", "prompt").with_model("claude-haiku-4.5");
3224+
let wire = serde_json::to_value(&agent).unwrap();
3225+
assert_eq!(wire["model"], "claude-haiku-4.5");
3226+
assert_eq!(wire["name"], "model-agent");
3227+
}
3228+
3229+
#[test]
3230+
fn custom_agent_config_omits_model_when_none() {
3231+
let agent = CustomAgentConfig::new("no-model-agent", "prompt");
3232+
let wire = serde_json::to_value(&agent).unwrap();
3233+
assert!(wire.get("model").is_none());
3234+
}
3235+
31993236
#[test]
32003237
fn tool_with_parameters_handles_non_object_value() {
32013238
let tool = Tool::new("noop").with_parameters(json!(null));

0 commit comments

Comments
 (0)