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
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ thiserror = "2.0"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.9"

# LLM / Rig framework
rig = { version = "0.33", package = "rig-core", features = ["derive"] }
Expand Down
364 changes: 364 additions & 0 deletions docs/design-docs/skill-lifecycle.md

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions migrations/20260808000001_skill_usage.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- Per-skill provenance and usage tracking.
--
-- Skills on disk with no row get one seeded on first sight with
-- created_at = now, so a newly noticed skill's staleness clock starts at
-- discovery rather than at epoch. Only skills with created_by = 'agent'
-- are ever auto-curated; 'user' and 'installed' skills are outside curator
-- jurisdiction unless explicitly adopted.
CREATE TABLE skill_usage (
skill_name TEXT PRIMARY KEY, -- lowercased canonical name
created_by TEXT NOT NULL, -- 'user' | 'agent' | 'installed'
origin_conversation_id TEXT, -- set when created_by = 'agent'
state TEXT NOT NULL DEFAULT 'active', -- 'active' | 'stale' | 'archived'
pinned INTEGER NOT NULL DEFAULT 0,
read_count INTEGER NOT NULL DEFAULT 0,
patch_count INTEGER NOT NULL DEFAULT 0,
last_read_at TEXT,
last_patched_at TEXT,
created_at TEXT NOT NULL,
archived_at TEXT
);
3 changes: 3 additions & 0 deletions prompts/en/branch.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ Forget a memory by ID. Use this when the user wants something removed, or when y
### spacebot_docs
Read embedded Spacebot docs, including `AGENTS.md`, `CHANGELOG.md`, and product docs from `docs/content/`. Use `action: "list"` to discover IDs, then `action: "read"` for the specific document.

### read_skill
Load the full instructions for a skill listed in `<available_skills>`. Read a skill before reasoning about work it covers, and pass skill names to spawned workers as `suggested_skills` rather than inlining their content.

### spawn_worker
If the user wants something done now and it needs execution tools (shell, file), spawn a worker. Give it a specific task description with enough context to work independently. The worker won't have the conversation history — it only knows what you tell it. If the user is describing something for later rather than requesting immediate action, save a **todo** memory instead.

Expand Down
14 changes: 14 additions & 0 deletions prompts/en/fragments/skills_branch.md.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
## Available Skills

These skills are procedures this agent knows. When one is relevant to your reasoning, call `read_skill` to load its full instructions.

When you spawn a worker for a task that matches a skill, pass the skill names as `suggested_skills` instead of inlining the skill's content into the task description — the worker reads the skills it needs itself.

<available_skills>
{%- for skill in skills %}
<skill>
<name>{{ skill.name }}</name>
<description>{{ skill.description }}</description>
</skill>
{%- endfor %}
</available_skills>
8 changes: 8 additions & 0 deletions src/agent/channel_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,14 @@ pub async fn spawn_branch_from_state(
&rc.workspace_dir.display().to_string(),
wiki_enabled,
)
.and_then(|prompt| {
let skills_prompt = rc.skills.load().render_branch_skills(&prompt_engine)?;
Ok(if skills_prompt.is_empty() {
prompt
} else {
format!("{prompt}\n\n{skills_prompt}")
})
})
.and_then(|prompt| {
prompt_engine.maybe_append_tool_use_enforcement(
prompt,
Expand Down
14 changes: 14 additions & 0 deletions src/api/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,20 @@ pub async fn create_agent_internal(
skills,
));
runtime_config.set_settings(settings_store.clone());
let skill_usage_store =
std::sync::Arc::new(crate::skills::SkillUsageStore::new(db.sqlite.clone()));
runtime_config.set_skill_usage(skill_usage_store.clone());
{
let skill_names: Vec<String> = runtime_config
.skills
.load()
.iter()
.map(|s| s.name.to_lowercase())
.collect();
if let Err(error) = skill_usage_store.seed(&skill_names).await {
tracing::warn!(%error, "failed to seed skill usage rows");
}
}

let llm_manager = {
let guard = state.llm_manager.read().await;
Expand Down
47 changes: 47 additions & 0 deletions src/api/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,36 @@ pub(super) struct RegistrySkillContentResponse {
content: Option<String>,
}

/// Deterministically reload skills into live runtime configs after a skill
/// mutation, rather than relying on the file watcher, and record installed
/// provenance for any newly installed skills.
///
/// `agent_id = None` reloads every running agent (instance-level change).
async fn reload_after_skill_change(state: &ApiState, agent_id: Option<&str>, installed: &[String]) {
let configs = state.runtime_configs.load();
let instance_skills_dir = state.instance_dir.load().join("skills");

for (id, runtime_config) in configs.iter() {
if let Some(target) = agent_id
&& target != id
{
continue;
}

let workspace_skills_dir = runtime_config.workspace_dir.join("skills");
let skills =
crate::skills::SkillSet::load(&instance_skills_dir, &workspace_skills_dir).await;
runtime_config.reload_skills(skills);

if !installed.is_empty()
&& let Some(store) = runtime_config.skill_usage.load().as_ref()
&& let Err(error) = store.record_installed(installed).await
{
tracing::warn!(%error, agent_id = %id, "failed to record installed skills");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

/// List installed skills for an agent.
#[utoipa::path(
get,
Expand Down Expand Up @@ -255,6 +285,9 @@ pub(super) async fn install_skill(
StatusCode::INTERNAL_SERVER_ERROR
})?;

let reload_target = (!req.instance).then_some(req.agent_id.as_str());
reload_after_skill_change(&state, reload_target, &installed).await;

state.send_event(ApiEvent::ConfigReloaded);

Ok(Json(InstallSkillResponse { installed }))
Expand Down Expand Up @@ -301,6 +334,17 @@ pub(super) async fn remove_skill(
}
})?;

if removed_path.is_some() {
reload_after_skill_change(&state, Some(&req.agent_id), &[]).await;

if let Some(runtime_config) = state.runtime_configs.load().get(&req.agent_id)
&& let Some(store) = runtime_config.skill_usage.load().as_ref()
&& let Err(error) = store.remove(&req.name).await
{
tracing::warn!(%error, skill = %req.name, "failed to remove skill usage row");
}
}

state.send_event(ApiEvent::ConfigReloaded);

tracing::info!(
Expand Down Expand Up @@ -454,6 +498,9 @@ pub(super) async fn upload_skill(
}

if !all_installed.is_empty() {
// Uploads are user-provided, not registry installs — seeding during
// reload records them with 'user' provenance.
reload_after_skill_change(&state, Some(&query.agent_id), &[]).await;
state.send_event(ApiEvent::ConfigReloaded);
}

Expand Down
23 changes: 23 additions & 0 deletions src/config/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ pub struct RuntimeConfig {
pub cron_scheduler: ArcSwap<Option<Arc<crate::cron::Scheduler>>>,
/// Settings store for agent-specific configuration.
pub settings: ArcSwap<Option<Arc<crate::settings::SettingsStore>>>,
/// Skill provenance and usage tracking, set after agent initialization.
pub skill_usage: ArcSwap<Option<Arc<crate::skills::SkillUsageStore>>>,
/// Prompt snapshot store for debugging prompt construction.
pub prompt_snapshots: ArcSwap<Option<Arc<crate::agent::prompt_snapshot::PromptSnapshotStore>>>,
/// Secrets store for encrypted credential storage.
Expand Down Expand Up @@ -155,6 +157,7 @@ impl RuntimeConfig {
cron_store: ArcSwap::from_pointee(None),
cron_scheduler: ArcSwap::from_pointee(None),
settings: ArcSwap::from_pointee(None),
skill_usage: ArcSwap::from_pointee(None),
prompt_snapshots: ArcSwap::from_pointee(None),
secrets: ArcSwap::from_pointee(None),
sandbox: Arc::new(ArcSwap::from_pointee(agent_config.sandbox.clone())),
Expand Down Expand Up @@ -186,6 +189,11 @@ impl RuntimeConfig {
self.settings.store(Arc::new(Some(settings)));
}

/// Set the skill usage store after initialization.
pub fn set_skill_usage(&self, store: Arc<crate::skills::SkillUsageStore>) {
self.skill_usage.store(Arc::new(Some(store)));
}

/// Set the secrets store after initialization.
pub fn set_secrets(&self, secrets: Arc<crate::secrets::store::SecretsStore>) {
self.secrets.store(Arc::new(Some(secrets)));
Expand Down Expand Up @@ -322,9 +330,24 @@ impl RuntimeConfig {
}

/// Reload skills from disk.
///
/// Seeds usage rows for skills seen for the first time, so their
/// staleness clock starts at discovery.
pub fn reload_skills(&self, skills: crate::skills::SkillSet) {
let names: Vec<String> = skills.iter().map(|s| s.name.to_lowercase()).collect();
self.skills.store(Arc::new(skills));
tracing::info!("skills reloaded");

if let Some(store) = self.skill_usage.load().as_ref()
&& let Ok(handle) = tokio::runtime::Handle::try_current()
{
let store = store.clone();
handle.spawn(async move {
if let Err(error) = store.seed(&names).await {
tracing::warn!(%error, "failed to seed skill usage rows");
}
});
}
}
}

Expand Down
35 changes: 18 additions & 17 deletions src/config/watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,25 +90,26 @@ pub fn spawn_file_watcher(
tracing::warn!(%error, path = %config_path.display(), "failed to watch config file");
}

// Watch instance-level skills directory
let instance_skills_dir = instance_dir.join("skills");
if instance_skills_dir.is_dir()
&& let Err(error) = watcher.watch(&instance_skills_dir, RecursiveMode::Recursive)
{
tracing::warn!(%error, path = %instance_skills_dir.display(), "failed to watch instance skills dir");
// Watch skills directories. Roots are created before watching so a
// dir that doesn't exist yet at startup is still covered, and kept
// for prefix-matching changed paths against actual skills roots.
let mut skill_roots: Vec<PathBuf> = Vec::new();
skill_roots.push(instance_dir.join("skills"));
for (_, workspace, _, _, _) in &agents {
skill_roots.push(workspace.join("skills"));
}
for root in &skill_roots {
if let Err(error) = std::fs::create_dir_all(root) {
tracing::warn!(%error, path = %root.display(), "failed to create skills dir");
continue;
}
if let Err(error) = watcher.watch(root, RecursiveMode::Recursive) {
tracing::warn!(%error, path = %root.display(), "failed to watch skills dir");
}
}

// Watch per-agent directories
for (_, workspace, identity_dir, _, _) in &agents {
// Watch workspace/skills for skill file changes
{
let path = workspace.join("skills");
if path.is_dir()
&& let Err(error) = watcher.watch(&path, RecursiveMode::Recursive)
{
tracing::warn!(%error, path = %path.display(), "failed to watch agent skills dir");
}
}
for (_, _, identity_dir, _, _) in &agents {
// Watch the agent root (identity_dir) for SOUL.md/IDENTITY.md/ROLE.md changes.
// Identity files live outside the workspace, in the agent root directory.
if let Err(error) = watcher.watch(identity_dir, RecursiveMode::NonRecursive) {
Expand Down Expand Up @@ -157,7 +158,7 @@ pub fn spawn_file_watcher(
});
let skills_changed = changed_paths
.iter()
.any(|p| p.to_string_lossy().contains("skills"));
.any(|p| skill_roots.iter().any(|root| p.starts_with(root)));

// Skip entirely if nothing relevant changed
if !config_changed && !identity_changed && !skills_changed {
Expand Down
13 changes: 13 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2957,6 +2957,19 @@ async fn initialize_agents(
));

runtime_config.set_settings(settings_store.clone());
let skill_usage_store = Arc::new(spacebot::skills::SkillUsageStore::new(db.sqlite.clone()));
runtime_config.set_skill_usage(skill_usage_store.clone());
{
let skill_names: Vec<String> = runtime_config
.skills
.load()
.iter()
.map(|s| s.name.to_lowercase())
.collect();
if let Err(error) = skill_usage_store.seed(&skill_names).await {
tracing::warn!(%error, agent = %agent_config.id, "failed to seed skill usage rows");
}
}
runtime_config
.prompt_snapshots
.store(Arc::new(prompt_snapshot_store.clone()));
Expand Down
17 changes: 17 additions & 0 deletions src/prompts/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ impl PromptEngine {
"fragments/skills_worker",
crate::prompts::text::get("fragments/skills_worker"),
)?;
env.add_template(
"fragments/skills_branch",
crate::prompts::text::get("fragments/skills_branch"),
)?;
env.add_template(
"fragments/available_channels",
crate::prompts::text::get("fragments/available_channels"),
Expand Down Expand Up @@ -296,6 +300,19 @@ impl PromptEngine {
)
}

/// Render the skills listing for a branch system prompt.
///
/// Branches read skills directly via `read_skill` or pass names to
/// spawned workers as `suggested_skills`.
pub fn render_skills_branch(&self, skills: Vec<SkillInfo>) -> Result<String> {
self.render(
"fragments/skills_branch",
context! {
skills => skills,
},
)
}

/// Render the worker system prompt with filesystem context and optional tool
/// secret names.
#[allow(clippy::too_many_arguments)]
Expand Down
3 changes: 3 additions & 0 deletions src/prompts/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ fn lookup(lang: &str, key: &str) -> &'static str {
("en", "fragments/skills_worker") => {
include_str!("../../prompts/en/fragments/skills_worker.md.j2")
}
("en", "fragments/skills_branch") => {
include_str!("../../prompts/en/fragments/skills_branch.md.j2")
}
("en", "fragments/available_channels") => {
include_str!("../../prompts/en/fragments/available_channels.md.j2")
}
Expand Down
Loading
Loading