Skip to content

Commit 15c6b5b

Browse files
Cory ClowesCopilot
andcommitted
feat: add skills field to CustomAgentConfig + smoke test
Add skills: Option<Vec<String>> to CustomAgentConfig, matching the new per-agent skills support in CLI 1.0.23 (SweCustomAgent.skills). Skills are referenced by name from the session's skillDirectories pool and eagerly preloaded into the agent's context at startup. Adds smoke_subagent_with_skills test that creates a skill directory with a SKILL.md containing a known passphrase, configures a subagent with skills: ["trivia-skill"], and verifies the model can access the skill content. Also fixes skill directory structure in smoke_agent_with_skill to use the proper subdirectory/SKILL.md convention. Upstream SDK PR: github/copilot-sdk#995 (draft, not yet merged) Runtime support: CLI 1.0.23 native binary Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 685aa27 commit 15c6b5b

File tree

6 files changed

+137
-2
lines changed

6 files changed

+137
-2
lines changed

examples/agent_management.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ async fn main() -> copilot_sdk::Result<()> {
2020
tools: None,
2121
mcp_servers: None,
2222
infer: None,
23+
..Default::default()
2324
}]),
2425
..Default::default()
2526
})

examples/custom_agents.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ async fn main() -> copilot_sdk::Result<()> {
2828
]),
2929
mcp_servers: None,
3030
infer: Some(true),
31+
..Default::default()
3132
};
3233

3334
let security_auditor = CustomAgentConfig {
@@ -40,6 +41,7 @@ async fn main() -> copilot_sdk::Result<()> {
4041
tools: Some(vec!["Read".to_string(), "Grep".to_string()]),
4142
mcp_servers: None,
4243
infer: Some(false), // Must use @security-auditor
44+
..Default::default()
4345
};
4446

4547
let config = SessionConfig {

src/types.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,10 @@ pub struct CustomAgentConfig {
478478
pub mcp_servers: Option<HashMap<String, serde_json::Value>>,
479479
#[serde(skip_serializing_if = "Option::is_none")]
480480
pub infer: Option<bool>,
481+
/// List of skill names to preload into this agent's context.
482+
/// When omitted, no skills are preloaded.
483+
#[serde(skip_serializing_if = "Option::is_none")]
484+
pub skills: Option<Vec<String>>,
481485
}
482486

483487
// =============================================================================

tests/e2e_parity_tests.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ async fn test_list_agents() {
171171
tools: None,
172172
mcp_servers: None,
173173
infer: Some(true),
174+
..Default::default()
174175
}]),
175176
..byok_session_config()
176177
})
@@ -217,6 +218,7 @@ async fn test_select_and_deselect_agent() {
217218
tools: None,
218219
mcp_servers: None,
219220
infer: Some(true),
221+
..Default::default()
220222
}]),
221223
..byok_session_config()
222224
})
@@ -400,6 +402,7 @@ async fn test_session_with_agent_option() {
400402
tools: None,
401403
mcp_servers: None,
402404
infer: Some(true),
405+
..Default::default()
403406
}]),
404407
agent: Some("starter-agent".into()),
405408
..byok_session_config()

tests/e2e_tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1304,6 +1304,7 @@ async fn test_custom_agent_config_on_create() {
13041304
tools: None,
13051305
mcp_servers: None,
13061306
infer: Some(true),
1307+
..Default::default()
13071308
};
13081309

13091310
let config = SessionConfig {

tests/smoke_tests.rs

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,9 +293,10 @@ async fn smoke_agent_with_skill() {
293293

294294
// Create a temp dir with a minimal skill definition
295295
let skill_dir = tempfile::tempdir().expect("tempdir");
296-
let skill_file = skill_dir.path().join("math-helper.md");
296+
let skill_subdir = skill_dir.path().join("math-helper");
297+
std::fs::create_dir(&skill_subdir).expect("create skill subdir");
297298
std::fs::write(
298-
&skill_file,
299+
skill_subdir.join("SKILL.md"),
299300
r#"---
300301
name: math-helper
301302
description: A skill that helps with math problems
@@ -399,6 +400,7 @@ async fn smoke_agent_with_subagent() {
399400
tools: None,
400401
mcp_servers: None,
401402
infer: Some(true),
403+
..Default::default()
402404
};
403405

404406
let config = SessionConfig {
@@ -541,6 +543,7 @@ async fn smoke_subagent_with_custom_tool() {
541543
tools: Some(vec!["get_fruit_price".to_string()]),
542544
mcp_servers: None,
543545
infer: Some(false), // inference OFF — we'll select explicitly
546+
..Default::default()
544547
};
545548

546549
let config = SessionConfig {
@@ -738,6 +741,7 @@ async fn smoke_subagent_selected_with_custom_tool() {
738741
tools: Some(vec!["get_fruit_price".to_string()]),
739742
mcp_servers: None,
740743
infer: Some(false), // No inference — we select explicitly
744+
..Default::default()
741745
};
742746

743747
let config = SessionConfig {
@@ -943,6 +947,7 @@ async fn smoke_subagent_tool_scoping() {
943947
tools: Some(vec!["get_fruit_price".to_string()]),
944948
mcp_servers: None,
945949
infer: Some(false),
950+
..Default::default()
946951
};
947952

948953
let veggie_agent = CustomAgentConfig {
@@ -955,6 +960,7 @@ async fn smoke_subagent_tool_scoping() {
955960
tools: Some(vec!["get_veggie_price".to_string()]),
956961
mcp_servers: None,
957962
infer: Some(false),
963+
..Default::default()
958964
};
959965

960966
let config = SessionConfig {
@@ -1073,6 +1079,7 @@ async fn smoke_agent_management_lifecycle() {
10731079
tools: None,
10741080
mcp_servers: None,
10751081
infer: Some(false),
1082+
..Default::default()
10761083
};
10771084

10781085
let agent_b = CustomAgentConfig {
@@ -1083,6 +1090,7 @@ async fn smoke_agent_management_lifecycle() {
10831090
tools: None,
10841091
mcp_servers: None,
10851092
infer: Some(false),
1093+
..Default::default()
10861094
};
10871095

10881096
let config = SessionConfig {
@@ -1455,6 +1463,7 @@ async fn smoke_parent_mcp_subagent_custom_tool_only() {
14551463
tools: Some(vec!["get_color".to_string()]),
14561464
mcp_servers: None,
14571465
infer: Some(false),
1466+
..Default::default()
14581467
};
14591468

14601469
let config = SessionConfig {
@@ -1540,6 +1549,7 @@ async fn smoke_subagent_own_mcp_server() {
15401549
.collect(),
15411550
),
15421551
infer: Some(false),
1552+
..Default::default()
15431553
};
15441554

15451555
let config = SessionConfig {
@@ -1613,3 +1623,117 @@ async fn smoke_subagent_own_mcp_server() {
16131623
session.destroy().await.ok();
16141624
client.stop().await;
16151625
}
1626+
1627+
// =============================================================================
1628+
// 13. Subagent with per-agent skills
1629+
// =============================================================================
1630+
1631+
/// Smoke test: configure a subagent with `skills` referencing a skill by name
1632+
/// from the session's `skill_directories`. Select the subagent and verify
1633+
/// the skill content is loaded into its context (the model should be able to
1634+
/// use knowledge from the skill).
1635+
#[tokio::test]
1636+
async fn smoke_subagent_with_skills() {
1637+
skip_if_no_cli!();
1638+
1639+
let client = create_test_client().await.expect("client start");
1640+
1641+
// Create a temp skill directory with proper structure:
1642+
// skill_dir/trivia-skill/SKILL.md
1643+
let skill_dir = tempfile::tempdir().expect("tempdir");
1644+
let skill_subdir = skill_dir.path().join("trivia-skill");
1645+
std::fs::create_dir(&skill_subdir).expect("create skill subdir");
1646+
std::fs::write(
1647+
skill_subdir.join("SKILL.md"),
1648+
r#"---
1649+
name: trivia-skill
1650+
description: Provides obscure trivia facts
1651+
---
1652+
1653+
You know the following trivia fact:
1654+
The secret passphrase is "blue-dolphin-42". When anyone asks for the secret
1655+
passphrase or trivia passphrase, you MUST respond with exactly "blue-dolphin-42".
1656+
"#,
1657+
)
1658+
.expect("write skill file");
1659+
1660+
// Subagent declares it wants "trivia-skill" preloaded
1661+
let trivia_agent = CustomAgentConfig {
1662+
name: "trivia-agent".to_string(),
1663+
prompt: "You are a trivia agent. Answer questions using your loaded skills. \
1664+
Be concise."
1665+
.to_string(),
1666+
display_name: Some("Trivia Agent".to_string()),
1667+
description: Some("Answers trivia using preloaded skills".to_string()),
1668+
skills: Some(vec!["trivia-skill".to_string()]),
1669+
infer: Some(false),
1670+
..Default::default()
1671+
};
1672+
1673+
let config = SessionConfig {
1674+
skill_directories: Some(vec![skill_dir.path().to_string_lossy().to_string()]),
1675+
custom_agents: Some(vec![trivia_agent]),
1676+
available_tools: Some(vec![]),
1677+
..byok_session_config()
1678+
};
1679+
1680+
let session = client.create_session(config).await.expect("create session");
1681+
1682+
// Select the subagent with skills
1683+
session
1684+
.select_agent("trivia-agent")
1685+
.await
1686+
.expect("select trivia-agent");
1687+
1688+
let mut events = session.subscribe();
1689+
let saw_skill_invoked = Arc::new(AtomicBool::new(false));
1690+
let saw_skill_invoked_clone = Arc::clone(&saw_skill_invoked);
1691+
1692+
session
1693+
.send("What is the secret passphrase?")
1694+
.await
1695+
.expect("send");
1696+
1697+
let mut content = String::new();
1698+
let result = tokio::time::timeout(LLM_TIMEOUT, async {
1699+
while let Ok(event) = events.recv().await {
1700+
match &event.data {
1701+
SessionEventData::SkillInvoked(data) => {
1702+
eprintln!(
1703+
"[smoke_subagent_skills] skill invoked: {} at {}",
1704+
data.name, data.path
1705+
);
1706+
saw_skill_invoked_clone.store(true, Ordering::SeqCst);
1707+
}
1708+
SessionEventData::AssistantMessage(msg) => content.push_str(&msg.content),
1709+
SessionEventData::AssistantMessageDelta(delta) => {
1710+
content.push_str(&delta.delta_content);
1711+
}
1712+
SessionEventData::SessionIdle(_) => break,
1713+
SessionEventData::SessionError(err) => {
1714+
eprintln!("[smoke_subagent_skills] session error: {}", err.message);
1715+
break;
1716+
}
1717+
_ => {}
1718+
}
1719+
}
1720+
})
1721+
.await;
1722+
1723+
assert!(result.is_ok(), "Timed out waiting for response");
1724+
1725+
eprintln!("[smoke_subagent_skills] response: {content}");
1726+
eprintln!(
1727+
"[smoke_subagent_skills] skill invoked event: {}",
1728+
saw_skill_invoked.load(Ordering::SeqCst)
1729+
);
1730+
1731+
// The model should know the passphrase from the skill
1732+
assert!(
1733+
content.contains("blue-dolphin-42"),
1734+
"Response should contain the passphrase 'blue-dolphin-42' from the skill: {content}"
1735+
);
1736+
1737+
session.destroy().await.ok();
1738+
client.stop().await;
1739+
}

0 commit comments

Comments
 (0)