Skip to content

Commit 6d4a68e

Browse files
committed
feat(skills): workers discover skills on demand via read_skill tool
Previously workers received a single skill's full content injected into their system prompt at spawn time. This had two problems: - Workers were locked into one skill chosen by the channel upfront - Workers couldn't access skills they turned out to need mid-task New approach (aligned with the design docs): - Workers see the full skills listing (name + description) in their system prompt, same as the channel - Channel suggests relevant skills via suggested_skills=[...] on spawn_worker — these are flagged in the listing but not mandatory - Workers call the new read_skill tool to fetch the full content of any skill they decide is relevant — including ones not suggested - read_skill reads from the in-memory SkillSet (not the file tool), so the workspace boundary is never involved Changes: - src/tools/read_skill.rs: new ReadSkillTool, scoped to SkillSet - src/tools.rs: register ReadSkillTool in create_worker_tool_server - src/skills.rs: replace render_worker_prompt with render_worker_skills, takes suggested names and builds listing with suggested flag - src/prompts/engine.rs: render_skills_worker now takes Vec<SkillInfo> with suggested field; SkillInfo gains suggested: bool - src/agent/channel.rs: spawn_worker_from_state takes suggested_skills &[&str] instead of skill_name Option<&str> - src/tools/spawn_worker.rs: skill: Option<String> -> suggested_skills: Vec<String> - prompts/en/fragments/skills_worker.md.j2: rewritten — listing with suggestions flagged, instruction to call read_skill before starting - prompts/en/fragments/skills_channel.md.j2: updated example to use suggested_skills=[...]
1 parent 94db204 commit 6d4a68e

File tree

10 files changed

+187
-49
lines changed

10 files changed

+187
-49
lines changed
Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
## Available Skills
22

3-
You have access to the following skills. Skills contain specialized instructions for specific tasks. When a user's request matches a skill, spawn a worker to handle it and include the skill name in the task description so the worker knows which skill to follow.
3+
You have access to the following skills. When a user's request matches one or more skills, spawn a worker and pass the relevant skill names as `suggested_skills`. The worker will read the skills it needs automatically.
44

5-
To use a skill, spawn a worker with a task like: "Use the [skill-name] skill to [task]. Read the skill instructions at [path] first."
5+
**Do not include implementation details, tool invocations, or protocol instructions in the task description.** Describe *what* to do, not *how*. The worker reads the skill for the how.
6+
7+
Example: `spawn_worker(task="Generate a 30-second downtempo track with these lyrics: ...", suggested_skills=["generate_music"])`
8+
9+
You may suggest multiple skills if the task spans more than one: `suggested_skills=["github", "coding-agent"]`
610

711
<available_skills>
812
{%- for skill in skills %}
913
<skill>
1014
<name>{{ skill.name }}</name>
1115
<description>{{ skill.description }}</description>
12-
<location>{{ skill.location }}</location>
1316
</skill>
1417
{%- endfor %}
1518
</available_skills>
Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1-
## Skill Instructions
1+
## Available Skills
22

3-
You are executing the **{{ skill_name }}** skill. Follow these instructions:
3+
You have access to the following skills. Before starting your task, scan the list and call `read_skill` for any skill that is relevant — you may read more than one.
44

5-
{{ skill_content }}
5+
Skills marked as **suggested** were recommended by the channel for this specific task. Read those first, then decide if any others apply.
6+
7+
<available_skills>
8+
{%- for skill in skills %}
9+
<skill{% if skill.suggested %} suggested="true"{% endif %}>
10+
<name>{{ skill.name }}</name>
11+
<description>{{ skill.description }}</description>
12+
</skill>
13+
{%- endfor %}
14+
</available_skills>

src/agent/channel.rs

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1425,7 +1425,7 @@ pub async fn spawn_worker_from_state(
14251425
state: &ChannelState,
14261426
task: impl Into<String>,
14271427
interactive: bool,
1428-
skill_name: Option<&str>,
1428+
suggested_skills: &[&str],
14291429
) -> std::result::Result<WorkerId, AgentError> {
14301430
check_worker_limit(state).await?;
14311431
let task = task.into();
@@ -1442,16 +1442,18 @@ pub async fn spawn_worker_from_state(
14421442
let browser_config = (**rc.browser_config.load()).clone();
14431443
let brave_search_key = (**rc.brave_search_key.load()).clone();
14441444

1445-
// Build the worker system prompt, optionally prepending skill instructions
1446-
let system_prompt = if let Some(name) = skill_name {
1447-
if let Some(skill_prompt) = skills.render_worker_prompt(name, &prompt_engine) {
1448-
format!("{}\n\n{}", worker_system_prompt, skill_prompt)
1449-
} else {
1450-
tracing::warn!(skill = %name, "skill not found, spawning worker without skill context");
1445+
// Append skills listing to worker system prompt. Suggested skills are
1446+
// flagged so the worker knows the channel's intent, but it can read any
1447+
// skill it decides is relevant via the read_skill tool.
1448+
let system_prompt = match skills.render_worker_skills(suggested_skills, &prompt_engine) {
1449+
Ok(skills_prompt) if !skills_prompt.is_empty() => {
1450+
format!("{worker_system_prompt}\n\n{skills_prompt}")
1451+
}
1452+
Ok(_) => worker_system_prompt,
1453+
Err(error) => {
1454+
tracing::warn!(%error, "failed to render worker skills listing, spawning without skills context");
14511455
worker_system_prompt
14521456
}
1453-
} else {
1454-
worker_system_prompt
14551457
};
14561458

14571459
let worker = if interactive {

src/agent/worker.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ impl Worker {
204204
self.deps.runtime_config.workspace_dir.clone(),
205205
self.deps.runtime_config.instance_dir.clone(),
206206
mcp_tools,
207+
self.deps.runtime_config.clone(),
207208
);
208209

209210
let routing = self.deps.runtime_config.routing.load();

src/prompts/engine.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,13 +242,15 @@ impl PromptEngine {
242242
)
243243
}
244244

245-
/// Convenience method for rendering skills worker fragment.
246-
pub fn render_skills_worker(&self, skill_name: &str, skill_content: &str) -> Result<String> {
245+
/// Render the skills listing for a worker system prompt.
246+
///
247+
/// Workers see all available skills with suggestions from the channel flagged.
248+
/// They read whichever skills they need via the read_skill tool.
249+
pub fn render_skills_worker(&self, skills: Vec<SkillInfo>) -> Result<String> {
247250
self.render(
248251
"fragments/skills_worker",
249252
context! {
250-
skill_name => skill_name,
251-
skill_content => skill_content,
253+
skills => skills,
252254
},
253255
)
254256
}
@@ -429,6 +431,9 @@ pub struct SkillInfo {
429431
pub name: String,
430432
pub description: String,
431433
pub location: String,
434+
/// Whether the spawning channel suggested this skill for the current task.
435+
/// Workers should prioritise suggested skills but may read others too.
436+
pub suggested: bool,
432437
}
433438

434439
/// Information about a channel for template rendering.

src/skills.rs

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -132,30 +132,42 @@ impl SkillSet {
132132
name: s.name.clone(),
133133
description: s.description.clone(),
134134
location: s.file_path.display().to_string(),
135+
suggested: false,
135136
})
136137
.collect();
137138

138139
prompt_engine.render_skills_channel(skill_infos)
139140
}
140141

141-
/// Render the skills section for injection into a worker system prompt.
142+
/// Render the skills listing for injection into a worker system prompt.
142143
///
143-
/// Workers get the full skill content so they can follow the instructions
144-
/// directly without needing to read the file.
145-
pub fn render_worker_prompt(
144+
/// Workers see all available skills with any channel-suggested skills flagged.
145+
/// They decide which skills are relevant and read them via the read_skill tool.
146+
pub fn render_worker_skills(
146147
&self,
147-
skill_name: &str,
148+
suggested: &[&str],
148149
prompt_engine: &crate::prompts::PromptEngine,
149-
) -> Option<String> {
150-
let skill = self.get(skill_name)?;
151-
152-
match prompt_engine.render_skills_worker(&skill.name, &skill.content) {
153-
Ok(rendered) => Some(rendered),
154-
Err(error) => {
155-
tracing::error!(%error, skill = %skill_name, "failed to render worker skill prompt");
156-
None
157-
}
150+
) -> crate::error::Result<String> {
151+
if self.skills.is_empty() {
152+
return Ok(String::new());
158153
}
154+
155+
let mut sorted_skills: Vec<&Skill> = self.skills.values().collect();
156+
sorted_skills.sort_by(|a, b| a.name.cmp(&b.name));
157+
158+
let suggested_lower: Vec<String> = suggested.iter().map(|s| s.to_lowercase()).collect();
159+
160+
let skill_infos: Vec<crate::prompts::SkillInfo> = sorted_skills
161+
.into_iter()
162+
.map(|s| crate::prompts::SkillInfo {
163+
suggested: suggested_lower.contains(&s.name.to_lowercase()),
164+
name: s.name.clone(),
165+
description: s.description.clone(),
166+
location: s.file_path.display().to_string(),
167+
})
168+
.collect();
169+
170+
prompt_engine.render_skills_worker(skill_infos)
159171
}
160172

161173
/// Remove a skill by name.
@@ -464,7 +476,7 @@ mod tests {
464476
}
465477

466478
#[test]
467-
fn test_skill_set_worker_prompt() {
479+
fn test_skill_set_worker_skills() {
468480
let mut set = SkillSet::default();
469481
set.skills.insert(
470482
"weather".into(),
@@ -479,12 +491,21 @@ mod tests {
479491
);
480492

481493
let engine = crate::prompts::PromptEngine::new("en").unwrap();
482-
let prompt = set.render_worker_prompt("weather", &engine).unwrap();
483-
assert!(prompt.contains("## Skill Instructions"));
484-
assert!(prompt.contains("**weather**"));
485-
assert!(prompt.contains("# Weather"));
486-
assert!(prompt.contains("Use curl."));
487494

488-
assert!(set.render_worker_prompt("nonexistent", &engine).is_none());
495+
// Without suggestions
496+
let prompt = set.render_worker_skills(&[], &engine).unwrap();
497+
assert!(prompt.contains("<available_skills>"));
498+
assert!(prompt.contains("<name>weather</name>"));
499+
assert!(prompt.contains("<description>Get weather forecasts</description>"));
500+
assert!(!prompt.contains("suggested=\"true\""));
501+
502+
// With suggestion
503+
let prompt = set.render_worker_skills(&["weather"], &engine).unwrap();
504+
assert!(prompt.contains("suggested=\"true\""));
505+
506+
// Empty set returns empty string
507+
let empty_set = SkillSet::default();
508+
let prompt = empty_set.render_worker_skills(&[], &engine).unwrap();
509+
assert!(prompt.is_empty());
489510
}
490511
}

src/tools.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ pub mod memory_delete;
3333
pub mod memory_recall;
3434
pub mod memory_save;
3535
pub mod react;
36+
pub mod read_skill;
3637
pub mod reply;
3738
pub mod route;
3839
pub mod send_file;
@@ -66,6 +67,7 @@ pub use memory_save::{
6667
AssociationInput, MemorySaveArgs, MemorySaveError, MemorySaveOutput, MemorySaveTool,
6768
};
6869
pub use react::{ReactArgs, ReactError, ReactOutput, ReactTool};
70+
pub use read_skill::{ReadSkillArgs, ReadSkillError, ReadSkillOutput, ReadSkillTool};
6971
pub use reply::{RepliedFlag, ReplyArgs, ReplyError, ReplyOutput, ReplyTool, new_replied_flag};
7072
pub use route::{RouteArgs, RouteError, RouteOutput, RouteTool};
7173
pub use send_file::{SendFileArgs, SendFileError, SendFileOutput, SendFileTool};
@@ -79,7 +81,7 @@ pub use spawn_worker::{SpawnWorkerArgs, SpawnWorkerError, SpawnWorkerOutput, Spa
7981
pub use web_search::{SearchResult, WebSearchArgs, WebSearchError, WebSearchOutput, WebSearchTool};
8082

8183
use crate::agent::channel::ChannelState;
82-
use crate::config::BrowserConfig;
84+
use crate::config::{BrowserConfig, RuntimeConfig};
8385
use crate::memory::MemorySearch;
8486
use crate::{AgentId, ChannelId, OutboundResponse, ProcessEvent, WorkerId};
8587
use rig::tool::Tool as _;
@@ -272,14 +274,16 @@ pub fn create_worker_tool_server(
272274
workspace: PathBuf,
273275
instance_dir: PathBuf,
274276
mcp_tools: Vec<McpToolAdapter>,
277+
runtime_config: Arc<RuntimeConfig>,
275278
) -> ToolServerHandle {
276279
let mut server = ToolServer::new()
277280
.tool(ShellTool::new(instance_dir.clone(), workspace.clone()))
278281
.tool(FileTool::new(workspace.clone()))
279282
.tool(ExecTool::new(instance_dir, workspace))
280283
.tool(SetStatusTool::new(
281284
agent_id, worker_id, channel_id, event_tx,
282-
));
285+
))
286+
.tool(ReadSkillTool::new(runtime_config));
283287

284288
if browser_config.enabled {
285289
server = server.tool(BrowserTool::new(browser_config, screenshot_dir));

src/tools/read_skill.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
//! Read skill tool — lets workers read the full content of a named skill.
2+
//!
3+
//! Workers see a listing of available skills (name + description) in their
4+
//! system prompt. When they decide a skill is relevant to their task, they
5+
//! call this tool to get the full instructions. This keeps the system prompt
6+
//! compact while still giving workers on-demand access to any skill.
7+
8+
use crate::config::RuntimeConfig;
9+
use rig::completion::ToolDefinition;
10+
use rig::tool::Tool;
11+
use schemars::JsonSchema;
12+
use serde::{Deserialize, Serialize};
13+
use std::sync::Arc;
14+
15+
/// Tool that lets a worker read the full content of a named skill.
16+
#[derive(Debug, Clone)]
17+
pub struct ReadSkillTool {
18+
runtime_config: Arc<RuntimeConfig>,
19+
}
20+
21+
impl ReadSkillTool {
22+
pub fn new(runtime_config: Arc<RuntimeConfig>) -> Self {
23+
Self { runtime_config }
24+
}
25+
}
26+
27+
/// Error type for read_skill tool.
28+
#[derive(Debug, thiserror::Error)]
29+
#[error("read_skill failed: {0}")]
30+
pub struct ReadSkillError(String);
31+
32+
/// Arguments for read_skill tool.
33+
#[derive(Debug, Deserialize, JsonSchema)]
34+
pub struct ReadSkillArgs {
35+
/// Name of the skill to read. Must match a name from the <available_skills> listing.
36+
pub name: String,
37+
}
38+
39+
/// Output from read_skill tool.
40+
#[derive(Debug, Serialize)]
41+
pub struct ReadSkillOutput {
42+
/// The full skill instructions.
43+
pub content: String,
44+
}
45+
46+
impl Tool for ReadSkillTool {
47+
const NAME: &'static str = "read_skill";
48+
49+
type Error = ReadSkillError;
50+
type Args = ReadSkillArgs;
51+
type Output = ReadSkillOutput;
52+
53+
async fn definition(&self, _prompt: String) -> ToolDefinition {
54+
ToolDefinition {
55+
name: Self::NAME.to_string(),
56+
description: "Read the full instructions for a skill by name. \
57+
Call this before starting any task that matches a skill in <available_skills>. \
58+
You may read multiple skills if the task requires more than one."
59+
.to_string(),
60+
parameters: serde_json::json!({
61+
"type": "object",
62+
"properties": {
63+
"name": {
64+
"type": "string",
65+
"description": "The skill name to read, exactly as it appears in <available_skills>."
66+
}
67+
},
68+
"required": ["name"]
69+
}),
70+
}
71+
}
72+
73+
async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
74+
let skills = self.runtime_config.skills.load();
75+
match skills.get(&args.name) {
76+
Some(skill) => Ok(ReadSkillOutput {
77+
content: skill.content.clone(),
78+
}),
79+
None => Err(ReadSkillError(format!(
80+
"skill '{}' not found. Available skills are listed in <available_skills> in your system prompt.",
81+
args.name
82+
))),
83+
}
84+
}
85+
}

src/tools/spawn_worker.rs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,11 @@ pub struct SpawnWorkerArgs {
3535
/// Whether this is an interactive worker (accepts follow-up messages).
3636
#[serde(default)]
3737
pub interactive: bool,
38-
/// Optional skill name to load into the worker's context. The worker will
39-
/// receive the full skill instructions in its system prompt.
38+
/// Optional list of skill names to suggest to the worker. The worker sees
39+
/// all available skills and can read any of them via read_skill, but
40+
/// suggested skills are flagged as recommended for this task.
4041
#[serde(default)]
41-
pub skill: Option<String>,
42+
pub suggested_skills: Vec<String>,
4243
/// Worker type: "builtin" (default) runs a Rig agent loop with shell/file/exec
4344
/// tools. "opencode" spawns an OpenCode subprocess with full coding agent
4445
/// capabilities. Use "opencode" for complex coding tasks that benefit from
@@ -106,9 +107,10 @@ impl Tool for SpawnWorkerTool {
106107
"default": false,
107108
"description": "If true, the worker stays alive and accepts follow-up messages via route_to_worker. If false (default), the worker runs once and returns."
108109
},
109-
"skill": {
110-
"type": "string",
111-
"description": "Name of a skill to load into the worker. The worker receives the full skill instructions in its system prompt. Only use skill names from <available_skills>."
110+
"suggested_skills": {
111+
"type": "array",
112+
"items": { "type": "string" },
113+
"description": "Skill names from <available_skills> that are likely relevant to this task. The worker sees all skills and decides what to read, but suggested skills are flagged as recommended."
112114
}
113115
});
114116

@@ -158,7 +160,11 @@ impl Tool for SpawnWorkerTool {
158160
&self.state,
159161
&args.task,
160162
args.interactive,
161-
args.skill.as_deref(),
163+
&args
164+
.suggested_skills
165+
.iter()
166+
.map(String::as_str)
167+
.collect::<Vec<_>>(),
162168
)
163169
.await
164170
.map_err(|e| SpawnWorkerError(format!("{e}")))?

tests/context_dump.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ async fn dump_worker_context() {
321321
std::path::PathBuf::from("/tmp"),
322322
std::path::PathBuf::from("/tmp"),
323323
vec![],
324+
deps.runtime_config.clone(),
324325
);
325326

326327
let tool_defs = worker_tool_server
@@ -470,6 +471,7 @@ async fn dump_all_contexts() {
470471
std::path::PathBuf::from("/tmp"),
471472
std::path::PathBuf::from("/tmp"),
472473
vec![],
474+
deps.runtime_config.clone(),
473475
);
474476
let worker_tool_defs = worker_tool_server.get_tool_defs(None).await.unwrap();
475477
let worker_tools_text = format_tool_defs(&worker_tool_defs);

0 commit comments

Comments
 (0)