use std::path::PathBuf;
use async_trait::async_trait;
use once_cell::sync::Lazy;
use serde::Deserialize;
use serde_json::{Value, json};
use theway_core::{
AgentTool, AgentToolError, AgentToolResult, AgentToolUpdate, PermissionClassification,
SkillSource, ToolExecutionMode,
};
use theway_llm_provider::{Tool, UserContentBlock};
use tokio_util::sync::CancellationToken;
use super::skill::SkillHarnessCell;
use crate::skill_overrides;
pub struct SetSkillStateTool {
harness: SkillHarnessCell,
base_dir: PathBuf,
}
impl SetSkillStateTool {
pub fn new(harness: SkillHarnessCell) -> Self {
Self::with_base_dir(harness, default_base_dir())
}
pub fn with_base_dir(harness: SkillHarnessCell, base_dir: PathBuf) -> Self {
Self { harness, base_dir }
}
}
pub(crate) fn default_base_dir() -> PathBuf {
theway_contract::config::base_dir()
}
#[derive(Debug, Deserialize)]
struct Input {
name: String,
#[serde(default)]
source: Option<String>,
enabled: bool,
#[serde(default)]
confirm: bool,
}
#[async_trait]
impl AgentTool for SetSkillStateTool {
fn definition(&self) -> &Tool {
&DEFINITION
}
fn label(&self) -> &str {
"set_skill_state"
}
fn execution_mode(&self) -> Option<ToolExecutionMode> {
Some(ToolExecutionMode::Sequential)
}
fn permission_classification(&self, prepared_args: &Value) -> PermissionClassification {
let enabled = prepared_args
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !enabled {
return PermissionClassification::Allow;
}
let name = prepared_args
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("<unknown>");
PermissionClassification::Prompt {
reason: format!("re-enable user-disabled skill `{name}`"),
}
}
async fn execute(
&self,
_id: &str,
params: Value,
_cancel: CancellationToken,
_on_update: Option<AgentToolUpdate>,
) -> Result<AgentToolResult, AgentToolError> {
let input: Input = serde_json::from_value(params)
.map_err(|e| AgentToolError::Message(format!("invalid arguments: {e}")))?;
let harness = self
.harness
.get()
.ok_or_else(|| AgentToolError::from("set_skill_state not yet initialized"))?;
let skills = harness.skills();
let Some(skill) = skills.iter().find(|s| s.name == input.name) else {
let mut names: Vec<&str> = skills
.iter()
.filter(|s| s.name.starts_with(&input.name) || s.name.contains(&input.name))
.map(|s| s.name.as_str())
.take(5)
.collect();
names.dedup();
let hint = if names.is_empty() {
String::new()
} else {
format!(" Did you mean: {}?", names.join(", "))
};
return Err(AgentToolError::Message(format!(
"no loaded skill named '{}'. Run /skills to list loaded skills.{hint}",
input.name
)));
};
let resolved_source = skill.source;
if let Some(req) = &input.source {
let req_src = parse_source(req)?;
if req_src != resolved_source {
return Err(AgentToolError::Message(format!(
"skill '{}' is active from source '{}', not '{}'. Omit `source` or pass \
'{}' (the active source).",
input.name,
resolved_source.label(),
req_src.label(),
resolved_source.label()
)));
}
}
let currently_enabled = !skill.disable_model_invocation;
let target_enabled = input.enabled;
if !input.confirm {
let noop = currently_enabled == target_enabled;
return Ok(AgentToolResult {
content: vec![UserContentBlock::text(format!(
"preview only — call again with `confirm: true` to apply. \
skill={} source={} currently={} target={}{}",
input.name,
resolved_source.label(),
enabled_word(currently_enabled),
enabled_word(target_enabled),
if noop { " (no change)" } else { "" }
))],
details: json!({
"phase": "preview",
"name": input.name,
"source": resolved_source.label(),
"currently_enabled": currently_enabled,
"target_enabled": target_enabled,
"no_change": noop,
}),
terminate: None,
});
}
skill_overrides::set_and_save(&self.base_dir, &input.name, resolved_source, target_enabled)
.await
.map_err(|e| AgentToolError::Message(format!("persist skill state: {e}")))?;
let reload = harness
.reload_skills_from_disk()
.await
.map_err(|e| AgentToolError::Message(format!("reload after state change: {e}")))?;
let effective_enabled = reload
.skills
.iter()
.find(|s| s.name == input.name && s.source == resolved_source)
.map(|s| !s.disable_model_invocation);
let audit = json!({
"op": "set_state",
"actor": "tool",
"name": input.name,
"source": resolved_source.label(),
"before_enabled": currently_enabled,
"after_enabled": target_enabled,
});
let audit_entry_id = match harness
.session()
.append_custom("skill_control_plane", Some(audit))
.await
{
Ok(id) => Some(id),
Err(e) => {
tracing::warn!(
skill = %input.name,
error = %e,
"skill_control_plane audit write failed; state change itself succeeded"
);
None
}
};
Ok(AgentToolResult {
content: vec![UserContentBlock::text(format!(
"{} skill '{}' (source: {}).",
if target_enabled {
"enabled"
} else {
"disabled"
},
input.name,
resolved_source.label()
))],
details: json!({
"phase": "applied",
"name": input.name,
"source": resolved_source.label(),
"enabled": target_enabled,
"effective_enabled_after_reload": effective_enabled,
"audit_entry_id": audit_entry_id,
}),
terminate: None,
})
}
}
fn parse_source(s: &str) -> Result<SkillSource, AgentToolError> {
match s.to_ascii_lowercase().as_str() {
"builtin" => Ok(SkillSource::Builtin),
"user" => Ok(SkillSource::User),
"project" => Ok(SkillSource::Project),
_ => Err(AgentToolError::from(
"invalid `source` (expected one of: builtin, user, project)",
)),
}
}
fn enabled_word(enabled: bool) -> &'static str {
if enabled { "enabled" } else { "disabled" }
}
static DEFINITION: Lazy<Tool> = Lazy::new(|| Tool {
name: "set_skill_state".into(),
description: "Enable or disable a loaded skill at runtime without editing its SKILL.md. \
The choice is recorded in a local overlay (~/.theway/skill-overrides.json) keyed by \
source+name and survives restarts. Works for any source — a builtin or project skill \
that can't be removed can still be disabled. Two-phase: first call previews (current \
vs target state); call again with `confirm: true` to apply. Disabling prevents the \
model from auto-invoking the skill via the skill tool; the skill still appears in \
the catalog. Re-enabling a previously-disabled skill is a privileged control-plane \
write and requires explicit user confirmation through the runtime prompt card before \
it takes effect (issue #110); disabling does not prompt."
.into(),
parameters: json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Exact skill name as shown in /skills."
},
"source": {
"type": "string",
"enum": ["builtin", "user", "project"],
"description": "Optional. The active source is resolved automatically; if given, must match it."
},
"enabled": {
"type": "boolean",
"description": "Target state. `false` disables (no user prompt). `true` re-enables and triggers a user confirmation prompt before the change applies."
},
"confirm": {
"type": "boolean",
"default": false,
"description": "When false (default) returns a preview; when true applies the change."
}
},
"required": ["name", "enabled"],
"additionalProperties": false
}),
});
#[cfg(test)]
tests_bridge_macro::tests_bridge!("tools/set_skill_state");
#[cfg(test)]
mod set_skill_state_extra {
tests_bridge_macro::tests_bridge!("tools/set_skill_state/extra");
}