Skip to content

Commit 0bd0a1f

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 7f5b7fb commit 0bd0a1f

29 files changed

+493
-281
lines changed
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
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+
Example: `spawn_worker(task="Generate a 30-second downtempo track.", suggested_skills=["generate_music"])`
6+
7+
You may suggest multiple skills if the task spans more than one: `suggested_skills=["github", "coding-agent"]`
68

79
<available_skills>
810
{%- for skill in skills %}
911
<skill>
1012
<name>{{ skill.name }}</name>
1113
<description>{{ skill.description }}</description>
12-
<location>{{ skill.location }}</location>
1314
</skill>
1415
{%- endfor %}
1516
</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: 53 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -282,10 +282,10 @@ impl Channel {
282282
_ = tokio::time::sleep(sleep_duration), if next_deadline.is_some() => {
283283
let now = tokio::time::Instant::now();
284284
// Check coalesce deadline
285-
if self.coalesce_deadline.is_some_and(|d| d <= now) {
286-
if let Err(error) = self.flush_coalesce_buffer().await {
287-
tracing::error!(%error, channel_id = %self.id, "error flushing coalesce buffer on deadline");
288-
}
285+
if self.coalesce_deadline.is_some_and(|d| d <= now)
286+
&& let Err(error) = self.flush_coalesce_buffer().await
287+
{
288+
tracing::error!(%error, channel_id = %self.id, "error flushing coalesce buffer on deadline");
289289
}
290290
// Check retrigger deadline
291291
if self.retrigger_deadline.is_some_and(|d| d <= now) {
@@ -391,7 +391,10 @@ impl Channel {
391391

392392
if messages.len() == 1 {
393393
// Single message - process normally
394-
let message = messages.into_iter().next().ok_or_else(|| anyhow::anyhow!("empty iterator after length check"))?;
394+
let message = messages
395+
.into_iter()
396+
.next()
397+
.ok_or_else(|| anyhow::anyhow!("empty iterator after length check"))?;
395398
self.handle_message(message).await
396399
} else {
397400
// Multiple messages - batch them
@@ -462,10 +465,11 @@ impl Channel {
462465
.get("telegram_chat_type")
463466
.and_then(|v| v.as_str())
464467
});
465-
self.conversation_context = Some(
466-
prompt_engine
467-
.render_conversation_context(&first.source, server_name, channel_name)?,
468-
);
468+
self.conversation_context = Some(prompt_engine.render_conversation_context(
469+
&first.source,
470+
server_name,
471+
channel_name,
472+
)?);
469473
}
470474

471475
// Persist each message to conversation log (individual audit trail)
@@ -605,8 +609,11 @@ impl Channel {
605609
let browser_enabled = rc.browser_config.load().enabled;
606610
let web_search_enabled = rc.brave_search_key.load().is_some();
607611
let opencode_enabled = rc.opencode.load().enabled;
608-
let worker_capabilities =
609-
prompt_engine.render_worker_capabilities(browser_enabled, web_search_enabled, opencode_enabled)?;
612+
let worker_capabilities = prompt_engine.render_worker_capabilities(
613+
browser_enabled,
614+
web_search_enabled,
615+
opencode_enabled,
616+
)?;
610617

611618
let status_text = {
612619
let status = self.state.status_block.read().await;
@@ -712,10 +719,11 @@ impl Channel {
712719
.get("telegram_chat_type")
713720
.and_then(|v| v.as_str())
714721
});
715-
self.conversation_context = Some(
716-
prompt_engine
717-
.render_conversation_context(&message.source, server_name, channel_name)?,
718-
);
722+
self.conversation_context = Some(prompt_engine.render_conversation_context(
723+
&message.source,
724+
server_name,
725+
channel_name,
726+
)?);
719727
}
720728

721729
let system_prompt = self.build_system_prompt().await?;
@@ -802,8 +810,11 @@ impl Channel {
802810
let browser_enabled = rc.browser_config.load().enabled;
803811
let web_search_enabled = rc.brave_search_key.load().is_some();
804812
let opencode_enabled = rc.opencode.load().enabled;
805-
let worker_capabilities = prompt_engine
806-
.render_worker_capabilities(browser_enabled, web_search_enabled, opencode_enabled)?;
813+
let worker_capabilities = prompt_engine.render_worker_capabilities(
814+
browser_enabled,
815+
web_search_enabled,
816+
opencode_enabled,
817+
)?;
807818

808819
let status_text = {
809820
let status = self.state.status_block.read().await;
@@ -814,17 +825,16 @@ impl Channel {
814825

815826
let empty_to_none = |s: String| if s.is_empty() { None } else { Some(s) };
816827

817-
prompt_engine
818-
.render_channel_prompt(
819-
empty_to_none(identity_context),
820-
empty_to_none(memory_bulletin.to_string()),
821-
empty_to_none(skills_prompt),
822-
worker_capabilities,
823-
self.conversation_context.clone(),
824-
empty_to_none(status_text),
825-
None, // coalesce_hint - only set for batched messages
826-
available_channels,
827-
)
828+
prompt_engine.render_channel_prompt(
829+
empty_to_none(identity_context),
830+
empty_to_none(memory_bulletin.to_string()),
831+
empty_to_none(skills_prompt),
832+
worker_capabilities,
833+
self.conversation_context.clone(),
834+
empty_to_none(status_text),
835+
None, // coalesce_hint - only set for batched messages
836+
available_channels,
837+
)
828838
}
829839

830840
/// Register per-turn tools, run the LLM agentic loop, and clean up.
@@ -1147,8 +1157,10 @@ impl Channel {
11471157
for (key, value) in retrigger_metadata {
11481158
self.pending_retrigger_metadata.insert(key, value);
11491159
}
1150-
self.retrigger_deadline =
1151-
Some(tokio::time::Instant::now() + std::time::Duration::from_millis(RETRIGGER_DEBOUNCE_MS));
1160+
self.retrigger_deadline = Some(
1161+
tokio::time::Instant::now()
1162+
+ std::time::Duration::from_millis(RETRIGGER_DEBOUNCE_MS),
1163+
);
11521164
}
11531165
}
11541166

@@ -1413,7 +1425,7 @@ pub async fn spawn_worker_from_state(
14131425
state: &ChannelState,
14141426
task: impl Into<String>,
14151427
interactive: bool,
1416-
skill_name: Option<&str>,
1428+
suggested_skills: &[&str],
14171429
) -> std::result::Result<WorkerId, AgentError> {
14181430
check_worker_limit(state).await?;
14191431
let task = task.into();
@@ -1430,16 +1442,18 @@ pub async fn spawn_worker_from_state(
14301442
let browser_config = (**rc.browser_config.load()).clone();
14311443
let brave_search_key = (**rc.brave_search_key.load()).clone();
14321444

1433-
// Build the worker system prompt, optionally prepending skill instructions
1434-
let system_prompt = if let Some(name) = skill_name {
1435-
if let Some(skill_prompt) = skills.render_worker_prompt(name, &prompt_engine) {
1436-
format!("{}\n\n{}", worker_system_prompt, skill_prompt)
1437-
} else {
1438-
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");
14391455
worker_system_prompt
14401456
}
1441-
} else {
1442-
worker_system_prompt
14431457
};
14441458

14451459
let worker = if interactive {

src/agent/cortex_chat.rs

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ impl<M: CompletionModel> PromptHook<M> for CortexChatHook {
7575
) -> ToolCallHookAction {
7676
self.send(CortexChatEvent::ToolStarted {
7777
tool: tool_name.to_string(),
78-
}).await;
78+
})
79+
.await;
7980
ToolCallHookAction::Continue
8081
}
8182

@@ -95,7 +96,8 @@ impl<M: CompletionModel> PromptHook<M> for CortexChatHook {
9596
self.send(CortexChatEvent::ToolCompleted {
9697
tool: tool_name.to_string(),
9798
result_preview: preview,
98-
}).await;
99+
})
100+
.await;
99101
HookAction::Continue
100102
}
101103

@@ -295,26 +297,33 @@ impl CortexChatSession {
295297
let _ = store
296298
.save_message(&thread_id, "assistant", &response, channel_ref)
297299
.await;
298-
let _ = event_tx.send(CortexChatEvent::Done {
299-
full_text: response,
300-
}).await;
300+
let _ = event_tx
301+
.send(CortexChatEvent::Done {
302+
full_text: response,
303+
})
304+
.await;
301305
}
302306
Err(error) => {
303307
let error_text = format!("Cortex chat error: {error}");
304308
let _ = store
305309
.save_message(&thread_id, "assistant", &error_text, channel_ref)
306310
.await;
307-
let _ = event_tx.send(CortexChatEvent::Error {
308-
message: error_text,
309-
}).await;
311+
let _ = event_tx
312+
.send(CortexChatEvent::Error {
313+
message: error_text,
314+
})
315+
.await;
310316
}
311317
}
312318
});
313319

314320
Ok(event_rx)
315321
}
316322

317-
async fn build_system_prompt(&self, channel_context_id: Option<&str>) -> crate::error::Result<String> {
323+
async fn build_system_prompt(
324+
&self,
325+
channel_context_id: Option<&str>,
326+
) -> crate::error::Result<String> {
318327
let runtime_config = &self.deps.runtime_config;
319328
let prompt_engine = runtime_config.prompts.load();
320329

@@ -324,8 +333,11 @@ impl CortexChatSession {
324333
let browser_enabled = runtime_config.browser_config.load().enabled;
325334
let web_search_enabled = runtime_config.brave_search_key.load().is_some();
326335
let opencode_enabled = runtime_config.opencode.load().enabled;
327-
let worker_capabilities =
328-
prompt_engine.render_worker_capabilities(browser_enabled, web_search_enabled, opencode_enabled)?;
336+
let worker_capabilities = prompt_engine.render_worker_capabilities(
337+
browser_enabled,
338+
web_search_enabled,
339+
opencode_enabled,
340+
)?;
329341

330342
// Load channel transcript if a channel context is active
331343
let channel_transcript = if let Some(channel_id) = channel_context_id {

src/agent/worker.rs

Lines changed: 6 additions & 3 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();
@@ -264,7 +265,10 @@ impl Worker {
264265
None
265266
}
266267
})
267-
.unwrap_or_else(|| "Worker reached maximum segments without a final response.".to_string());
268+
.unwrap_or_else(|| {
269+
"Worker reached maximum segments without a final response."
270+
.to_string()
271+
});
268272
}
269273

270274
self.maybe_compact_history(&mut history).await;
@@ -358,8 +362,7 @@ impl Worker {
358362
self.hook.send_status("compacting (overflow recovery)");
359363
self.force_compact_history(&mut history).await;
360364
let prompt_engine = self.deps.runtime_config.prompts.load();
361-
let overflow_msg =
362-
prompt_engine.render_system_worker_overflow()?;
365+
let overflow_msg = prompt_engine.render_system_worker_overflow()?;
363366
follow_up_prompt = format!("{follow_up}\n\n{overflow_msg}");
364367
}
365368
Err(error) => {

src/api/bindings.rs

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -377,12 +377,10 @@ pub(super) async fn create_binding(
377377
Some(existing) => existing.clone(),
378378
None => {
379379
drop(perms_guard);
380-
let Some(discord_config) = new_config
381-
.messaging
382-
.discord
383-
.as_ref()
384-
else {
385-
tracing::error!("discord config missing despite token being provided");
380+
let Some(discord_config) = new_config.messaging.discord.as_ref() else {
381+
tracing::error!(
382+
"discord config missing despite token being provided"
383+
);
386384
return Err(StatusCode::INTERNAL_SERVER_ERROR);
387385
};
388386
let perms = crate::config::DiscordPermissions::from_config(
@@ -409,12 +407,10 @@ pub(super) async fn create_binding(
409407
Some(existing) => existing.clone(),
410408
None => {
411409
drop(perms_guard);
412-
let Some(slack_config) = new_config
413-
.messaging
414-
.slack
415-
.as_ref()
416-
else {
417-
tracing::error!("slack config missing despite tokens being provided");
410+
let Some(slack_config) = new_config.messaging.slack.as_ref() else {
411+
tracing::error!(
412+
"slack config missing despite tokens being provided"
413+
);
418414
return Err(StatusCode::INTERNAL_SERVER_ERROR);
419415
};
420416
let perms = crate::config::SlackPermissions::from_config(
@@ -453,11 +449,7 @@ pub(super) async fn create_binding(
453449

454450
if let Some(token) = new_telegram_token {
455451
let telegram_perms = {
456-
let Some(telegram_config) = new_config
457-
.messaging
458-
.telegram
459-
.as_ref()
460-
else {
452+
let Some(telegram_config) = new_config.messaging.telegram.as_ref() else {
461453
tracing::error!("telegram config missing despite token being provided");
462454
return Err(StatusCode::INTERNAL_SERVER_ERROR);
463455
};
@@ -475,11 +467,7 @@ pub(super) async fn create_binding(
475467
}
476468

477469
if let Some((username, oauth_token)) = new_twitch_creds {
478-
let Some(twitch_config) = new_config
479-
.messaging
480-
.twitch
481-
.as_ref()
482-
else {
470+
let Some(twitch_config) = new_config.messaging.twitch.as_ref() else {
483471
tracing::error!("twitch config missing despite credentials being provided");
484472
return Err(StatusCode::INTERNAL_SERVER_ERROR);
485473
};

src/api/server.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ use axum::middleware::{self, Next};
1515
use axum::response::{Html, IntoResponse, Response};
1616
use axum::routing::{delete, get, post, put};
1717
use rust_embed::Embed;
18-
use tower_http::cors::CorsLayer;
1918
use serde_json::json;
19+
use tower_http::cors::CorsLayer;
2020

2121
use std::net::SocketAddr;
2222
use std::sync::Arc;
@@ -45,11 +45,7 @@ pub async fn start_http_server(
4545
axum::http::Method::DELETE,
4646
axum::http::Method::OPTIONS,
4747
])
48-
.allow_headers([
49-
header::CONTENT_TYPE,
50-
header::AUTHORIZATION,
51-
header::ACCEPT,
52-
]);
48+
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION, header::ACCEPT]);
5349

5450
let api_routes = Router::new()
5551
.route("/health", get(system::health))

0 commit comments

Comments
 (0)