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
9 changes: 6 additions & 3 deletions prompts/en/fragments/skills_channel.md.j2
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
## Available Skills

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.
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.

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."
**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.

Example: `spawn_worker(task="Generate a 30-second downtempo track with these lyrics: ...", suggested_skills=["generate_music"])`

You may suggest multiple skills if the task spans more than one: `suggested_skills=["github", "coding-agent"]`

<available_skills>
{%- for skill in skills %}
<skill>
<name>{{ skill.name }}</name>
<description>{{ skill.description }}</description>
<location>{{ skill.location }}</location>
</skill>
{%- endfor %}
</available_skills>
15 changes: 12 additions & 3 deletions prompts/en/fragments/skills_worker.md.j2
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
## Skill Instructions
## Available Skills

You are executing the **{{ skill_name }}** skill. Follow these instructions:
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.

{{ skill_content }}
Skills marked as **suggested** were recommended by the channel for this specific task. Read those first, then decide if any others apply.

<available_skills>
{%- for skill in skills %}
<skill{% if skill.suggested %} suggested="true"{% endif %}>
<name>{{ skill.name }}</name>
<description>{{ skill.description }}</description>
</skill>
{%- endfor %}
</available_skills>
20 changes: 11 additions & 9 deletions src/agent/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1425,7 +1425,7 @@ pub async fn spawn_worker_from_state(
state: &ChannelState,
task: impl Into<String>,
interactive: bool,
skill_name: Option<&str>,
suggested_skills: &[&str],
) -> std::result::Result<WorkerId, AgentError> {
check_worker_limit(state).await?;
let task = task.into();
Expand All @@ -1442,16 +1442,18 @@ pub async fn spawn_worker_from_state(
let browser_config = (**rc.browser_config.load()).clone();
let brave_search_key = (**rc.brave_search_key.load()).clone();

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

let worker = if interactive {
Expand Down
1 change: 1 addition & 0 deletions src/agent/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ impl Worker {
self.deps.runtime_config.workspace_dir.clone(),
self.deps.runtime_config.instance_dir.clone(),
mcp_tools,
self.deps.runtime_config.clone(),
);

let routing = self.deps.runtime_config.routing.load();
Expand Down
13 changes: 9 additions & 4 deletions src/prompts/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,13 +242,15 @@ impl PromptEngine {
)
}

/// Convenience method for rendering skills worker fragment.
pub fn render_skills_worker(&self, skill_name: &str, skill_content: &str) -> Result<String> {
/// Render the skills listing for a worker system prompt.
///
/// Workers see all available skills with suggestions from the channel flagged.
/// They read whichever skills they need via the read_skill tool.
pub fn render_skills_worker(&self, skills: Vec<SkillInfo>) -> Result<String> {
self.render(
"fragments/skills_worker",
context! {
skill_name => skill_name,
skill_content => skill_content,
skills => skills,
},
)
}
Expand Down Expand Up @@ -429,6 +431,9 @@ pub struct SkillInfo {
pub name: String,
pub description: String,
pub location: String,
/// Whether the spawning channel suggested this skill for the current task.
/// Workers should prioritise suggested skills but may read others too.
pub suggested: bool,
}

/// Information about a channel for template rendering.
Expand Down
63 changes: 42 additions & 21 deletions src/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,30 +132,42 @@ impl SkillSet {
name: s.name.clone(),
description: s.description.clone(),
location: s.file_path.display().to_string(),
suggested: false,
})
.collect();

prompt_engine.render_skills_channel(skill_infos)
}

/// Render the skills section for injection into a worker system prompt.
/// Render the skills listing for injection into a worker system prompt.
///
/// Workers get the full skill content so they can follow the instructions
/// directly without needing to read the file.
pub fn render_worker_prompt(
/// Workers see all available skills with any channel-suggested skills flagged.
/// They decide which skills are relevant and read them via the read_skill tool.
pub fn render_worker_skills(
&self,
skill_name: &str,
suggested: &[&str],
prompt_engine: &crate::prompts::PromptEngine,
) -> Option<String> {
let skill = self.get(skill_name)?;

match prompt_engine.render_skills_worker(&skill.name, &skill.content) {
Ok(rendered) => Some(rendered),
Err(error) => {
tracing::error!(%error, skill = %skill_name, "failed to render worker skill prompt");
None
}
) -> crate::error::Result<String> {
if self.skills.is_empty() {
return Ok(String::new());
}

let mut sorted_skills: Vec<&Skill> = self.skills.values().collect();
sorted_skills.sort_by(|a, b| a.name.cmp(&b.name));

let suggested_lower: Vec<String> = suggested.iter().map(|s| s.to_lowercase()).collect();

let skill_infos: Vec<crate::prompts::SkillInfo> = sorted_skills
.into_iter()
.map(|s| crate::prompts::SkillInfo {
suggested: suggested_lower.contains(&s.name.to_lowercase()),
name: s.name.clone(),
description: s.description.clone(),
location: s.file_path.display().to_string(),
})
.collect();

prompt_engine.render_skills_worker(skill_infos)
}

/// Remove a skill by name.
Expand Down Expand Up @@ -464,7 +476,7 @@ mod tests {
}

#[test]
fn test_skill_set_worker_prompt() {
fn test_skill_set_worker_skills() {
let mut set = SkillSet::default();
set.skills.insert(
"weather".into(),
Expand All @@ -479,12 +491,21 @@ mod tests {
);

let engine = crate::prompts::PromptEngine::new("en").unwrap();
let prompt = set.render_worker_prompt("weather", &engine).unwrap();
assert!(prompt.contains("## Skill Instructions"));
assert!(prompt.contains("**weather**"));
assert!(prompt.contains("# Weather"));
assert!(prompt.contains("Use curl."));

assert!(set.render_worker_prompt("nonexistent", &engine).is_none());
// Without suggestions
let prompt = set.render_worker_skills(&[], &engine).unwrap();
assert!(prompt.contains("<available_skills>"));
assert!(prompt.contains("<name>weather</name>"));
assert!(prompt.contains("<description>Get weather forecasts</description>"));
assert!(!prompt.contains("suggested=\"true\""));

// With suggestion
let prompt = set.render_worker_skills(&["weather"], &engine).unwrap();
assert!(prompt.contains("suggested=\"true\""));

// Empty set returns empty string
let empty_set = SkillSet::default();
let prompt = empty_set.render_worker_skills(&[], &engine).unwrap();
assert!(prompt.is_empty());
}
}
8 changes: 6 additions & 2 deletions src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub mod memory_delete;
pub mod memory_recall;
pub mod memory_save;
pub mod react;
pub mod read_skill;
pub mod reply;
pub mod route;
pub mod send_file;
Expand Down Expand Up @@ -66,6 +67,7 @@ pub use memory_save::{
AssociationInput, MemorySaveArgs, MemorySaveError, MemorySaveOutput, MemorySaveTool,
};
pub use react::{ReactArgs, ReactError, ReactOutput, ReactTool};
pub use read_skill::{ReadSkillArgs, ReadSkillError, ReadSkillOutput, ReadSkillTool};
pub use reply::{RepliedFlag, ReplyArgs, ReplyError, ReplyOutput, ReplyTool, new_replied_flag};
pub use route::{RouteArgs, RouteError, RouteOutput, RouteTool};
pub use send_file::{SendFileArgs, SendFileError, SendFileOutput, SendFileTool};
Expand All @@ -79,7 +81,7 @@ pub use spawn_worker::{SpawnWorkerArgs, SpawnWorkerError, SpawnWorkerOutput, Spa
pub use web_search::{SearchResult, WebSearchArgs, WebSearchError, WebSearchOutput, WebSearchTool};

use crate::agent::channel::ChannelState;
use crate::config::BrowserConfig;
use crate::config::{BrowserConfig, RuntimeConfig};
use crate::memory::MemorySearch;
use crate::{AgentId, ChannelId, OutboundResponse, ProcessEvent, WorkerId};
use rig::tool::Tool as _;
Expand Down Expand Up @@ -272,14 +274,16 @@ pub fn create_worker_tool_server(
workspace: PathBuf,
instance_dir: PathBuf,
mcp_tools: Vec<McpToolAdapter>,
runtime_config: Arc<RuntimeConfig>,
) -> ToolServerHandle {
let mut server = ToolServer::new()
.tool(ShellTool::new(instance_dir.clone(), workspace.clone()))
.tool(FileTool::new(workspace.clone()))
.tool(ExecTool::new(instance_dir, workspace))
.tool(SetStatusTool::new(
agent_id, worker_id, channel_id, event_tx,
));
))
.tool(ReadSkillTool::new(runtime_config));

if browser_config.enabled {
server = server.tool(BrowserTool::new(browser_config, screenshot_dir));
Expand Down
85 changes: 85 additions & 0 deletions src/tools/read_skill.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//! Read skill tool — lets workers read the full content of a named skill.
//!
//! Workers see a listing of available skills (name + description) in their
//! system prompt. When they decide a skill is relevant to their task, they
//! call this tool to get the full instructions. This keeps the system prompt
//! compact while still giving workers on-demand access to any skill.

use crate::config::RuntimeConfig;
use rig::completion::ToolDefinition;
use rig::tool::Tool;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::sync::Arc;

/// Tool that lets a worker read the full content of a named skill.
#[derive(Debug, Clone)]
pub struct ReadSkillTool {
runtime_config: Arc<RuntimeConfig>,
}

impl ReadSkillTool {
pub fn new(runtime_config: Arc<RuntimeConfig>) -> Self {
Self { runtime_config }
}
}

/// Error type for read_skill tool.
#[derive(Debug, thiserror::Error)]
#[error("read_skill failed: {0}")]
pub struct ReadSkillError(String);

/// Arguments for read_skill tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ReadSkillArgs {
/// Name of the skill to read. Must match a name from the <available_skills> listing.
pub name: String,
}

/// Output from read_skill tool.
#[derive(Debug, Serialize)]
pub struct ReadSkillOutput {
/// The full skill instructions.
pub content: String,
}

impl Tool for ReadSkillTool {
const NAME: &'static str = "read_skill";

type Error = ReadSkillError;
type Args = ReadSkillArgs;
type Output = ReadSkillOutput;

async fn definition(&self, _prompt: String) -> ToolDefinition {
ToolDefinition {
name: Self::NAME.to_string(),
description: "Read the full instructions for a skill by name. \
Call this before starting any task that matches a skill in <available_skills>. \
You may read multiple skills if the task requires more than one."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The skill name to read, exactly as it appears in <available_skills>."
}
},
"required": ["name"]
}),
}
}

async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
let skills = self.runtime_config.skills.load();
match skills.get(&args.name) {
Some(skill) => Ok(ReadSkillOutput {
content: skill.content.clone(),
}),
None => Err(ReadSkillError(format!(
"skill '{}' not found. Available skills are listed in <available_skills> in your system prompt.",
args.name
))),
}
}
}
20 changes: 13 additions & 7 deletions src/tools/spawn_worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,11 @@ pub struct SpawnWorkerArgs {
/// Whether this is an interactive worker (accepts follow-up messages).
#[serde(default)]
pub interactive: bool,
/// Optional skill name to load into the worker's context. The worker will
/// receive the full skill instructions in its system prompt.
/// Optional list of skill names to suggest to the worker. The worker sees
/// all available skills and can read any of them via read_skill, but
/// suggested skills are flagged as recommended for this task.
#[serde(default)]
pub skill: Option<String>,
pub suggested_skills: Vec<String>,
/// Worker type: "builtin" (default) runs a Rig agent loop with shell/file/exec
/// tools. "opencode" spawns an OpenCode subprocess with full coding agent
/// capabilities. Use "opencode" for complex coding tasks that benefit from
Expand Down Expand Up @@ -106,9 +107,10 @@ impl Tool for SpawnWorkerTool {
"default": false,
"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."
},
"skill": {
"type": "string",
"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>."
"suggested_skills": {
"type": "array",
"items": { "type": "string" },
"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."
}
});

Expand Down Expand Up @@ -158,7 +160,11 @@ impl Tool for SpawnWorkerTool {
&self.state,
&args.task,
args.interactive,
args.skill.as_deref(),
&args
.suggested_skills
.iter()
.map(String::as_str)
.collect::<Vec<_>>(),
)
.await
.map_err(|e| SpawnWorkerError(format!("{e}")))?
Expand Down
2 changes: 2 additions & 0 deletions tests/context_dump.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ async fn dump_worker_context() {
std::path::PathBuf::from("/tmp"),
std::path::PathBuf::from("/tmp"),
vec![],
deps.runtime_config.clone(),
);

let tool_defs = worker_tool_server
Expand Down Expand Up @@ -470,6 +471,7 @@ async fn dump_all_contexts() {
std::path::PathBuf::from("/tmp"),
std::path::PathBuf::from("/tmp"),
vec![],
deps.runtime_config.clone(),
);
let worker_tool_defs = worker_tool_server.get_tool_defs(None).await.unwrap();
let worker_tools_text = format_tool_defs(&worker_tool_defs);
Expand Down