This commit is contained in:
Timothy Jaeryang Baek
2026-07-06 21:05:25 -05:00
Unverified
parent e2f4f69702
commit 556db8aebf
7 changed files with 144 additions and 42 deletions
@@ -7,12 +7,17 @@
let loading = $state(true);
let saving = $state(false);
let enabled = $state(true);
let toolEnabled = $state(true);
let backgroundReview = $state(true);
let reviewInterval = $state(10);
onMount(async () => {
try {
const config = await getAdminConfig();
enabled = config['skills.enabled'] !== false && config['skills.enabled'] !== 'false';
toolEnabled =
config['skills.tool_enabled'] !== false && config['skills.tool_enabled'] !== 'false';
backgroundReview =
config['skills.background_review_enabled'] !== false &&
config['skills.background_review_enabled'] !== 'false';
@@ -27,6 +32,8 @@
saving = true;
try {
await updateConfig({
'skills.enabled': enabled,
'skills.tool_enabled': toolEnabled,
'skills.background_review_enabled': backgroundReview,
'skills.review_interval_turns': Math.max(1, Number(reviewInterval) || 10)
});
@@ -39,48 +46,72 @@
}
</script>
<div class="flex flex-col min-h-full">
<h2 class="text-sm font-medium text-gray-900 dark:text-white mb-4">Skills</h2>
<div class="flex flex-col h-full">
{#if loading}
<div class="flex justify-center py-8"><Spinner size={16} /></div>
{:else}
<div class="flex flex-col gap-2.5">
<label class="flex items-center justify-between cursor-pointer">
<span class="text-xs text-gray-600 dark:text-gray-400">Background review</span>
<ToggleSwitch
value={backgroundReview}
onchange={(v) => {
backgroundReview = v;
}}
/>
</label>
<p class="text-[0.6875rem] text-gray-400 dark:text-gray-600 -mt-1">
Let Computer create or update managed skills after successful reusable work.
</p>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5 -mr-1.5">
<h2 class="text-sm font-medium text-gray-900 dark:text-white mb-4">Skills</h2>
{#if backgroundReview}
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="skills-review-interval"
>Review interval</label
>
<div class="flex items-center gap-1.5 mt-1">
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2">Behavior</h3>
<div class="flex flex-col gap-2.5">
<label class="flex items-center justify-between cursor-pointer">
<span class="text-xs text-gray-600 dark:text-gray-400">Enable skills</span>
<ToggleSwitch
value={enabled}
onchange={(v) => {
enabled = v;
}}
/>
</label>
{#if enabled}
<label class="flex items-center justify-between cursor-pointer">
<span class="text-xs text-gray-600 dark:text-gray-400"
>Assistant can manage skills</span
>
<ToggleSwitch
value={toolEnabled}
onchange={(v) => {
toolEnabled = v;
}}
/>
</label>
<label class="flex items-center justify-between cursor-pointer">
<span class="text-xs text-gray-600 dark:text-gray-400">Background review</span>
<ToggleSwitch
value={backgroundReview}
onchange={(v) => {
backgroundReview = v;
}}
/>
</label>
{/if}
</div>
{#if enabled}
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2 mt-5">Limits</h3>
<div class="flex flex-col gap-2.5">
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="skills-review-interval">
Review every
</label>
<input
id="skills-review-interval"
type="number"
bind:value={reviewInterval}
min="1"
class="w-16 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors"
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none transition-colors"
/>
<span class="text-[0.6875rem] text-gray-400 dark:text-gray-600">turns</span>
</div>
</div>
{/if}
</div>
<div class="mt-5 pt-4 border-t border-gray-100 dark:border-white/8">
<div class="shrink-0 pt-3 flex justify-end">
<button
class="h-8 px-3 rounded-lg text-xs bg-gray-900 dark:bg-white text-white dark:text-black hover:bg-gray-700 dark:hover:bg-gray-200 transition-colors disabled:opacity-50"
class="text-[0.8125rem] text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors duration-100 disabled:opacity-50"
disabled={saving}
onclick={save}
>
@@ -66,7 +66,6 @@
'connections',
'agents',
'models',
'skills',
'messaging',
'gateway',
'audio',
@@ -74,7 +73,8 @@
'web',
'toolservers',
'subagents',
'memory'
'memory',
'skills'
];
const personalTabs: SettingsTab[] = $derived.by(() => {
@@ -95,7 +95,6 @@
{ id: 'connections', label: $t('admin.connections'), icon: 'plug' },
{ id: 'agents', label: $t('admin.agents'), icon: 'terminal' },
{ id: 'models', label: $t('admin.models'), icon: 'cube' },
{ id: 'skills', label: 'Skills', icon: 'spark' },
{ id: 'messaging', label: $t('admin.messaging'), icon: 'chat-bubble' },
{ id: 'gateway', label: $t('admin.gateway.tab'), icon: 'gateway' },
{ id: 'audio', label: $t('admin.audio.title'), icon: 'microphone' },
@@ -103,7 +102,8 @@
{ id: 'web', label: $t('admin.web'), icon: 'globe' },
{ id: 'toolservers', label: $t('admin.toolServers'), icon: 'plug' },
{ id: 'subagents', label: $t('admin.subagents'), icon: 'user' },
{ id: 'memory', label: $t('settings.memory'), icon: 'brain' }
{ id: 'memory', label: $t('settings.memory'), icon: 'brain' },
{ id: 'skills', label: 'Skills', icon: 'spark' }
]);
onMount(() => {
+4
View File
@@ -32,8 +32,12 @@ class SkillResponse(BaseModel):
@router.get("", response_model=list[SkillResponse])
async def list_skills(workspace: str = Query(..., description="Workspace path")):
"""List available skills (frontmatter only) for the $ mention picker."""
from cptr.models import Config
from cptr.utils.skills import discover_skills
if (await Config.get("skills.enabled")) in (False, "false", "0"):
return []
skills = await asyncio.to_thread(discover_skills, workspace)
return [
SkillResponse(
+33 -7
View File
@@ -14,7 +14,13 @@ import uuid
from cptr.events import EVENTS, publish_event
from cptr.env import CHAT_MAX_ITERATIONS, CHAT_TOOL_COMMAND_MAX_CHARS, CHAT_TOOL_MAX_CHARS
from cptr.utils.context import resolve_compact_token_threshold, should_compact
from cptr.utils.skills import bump_skill_view, discover_skills, load_skill, format_skill_content
from cptr.utils.skills import (
bump_skill_view,
discover_skills,
format_skill_content,
get_skill_settings,
load_skill,
)
from cptr.utils.summarize import summarize_messages
from cptr.models import Chat, ChatMessage, Config
from cptr.socket.main import emit_to_user
@@ -186,7 +192,12 @@ def _has_prior_real_chat_content(messages: list[dict], loaded_summary: str | Non
)
def _build_skill_create_gate_prompt() -> str:
def _build_skill_create_gate_prompt(reason: str = "empty_chat") -> str:
if reason == "disabled":
return (
"[/skills:create] Skill creation is disabled in admin settings. "
"Explain this briefly and do not try to create or update a skill."
)
return (
"[/skills:create] The user tried to create a skill before this chat had "
"any prior real content. Explain briefly that skill creation needs an "
@@ -195,7 +206,12 @@ def _build_skill_create_gate_prompt() -> str:
)
def _apply_skills_create_prompt(messages: list[dict], *, allowed: bool) -> bool:
def _apply_skills_create_prompt(
messages: list[dict],
*,
allowed: bool,
denial_reason: str = "empty_chat",
) -> bool:
for message in reversed(messages):
if message.get("role") != "user":
continue
@@ -206,7 +222,7 @@ def _apply_skills_create_prompt(messages: list[dict], *, allowed: bool) -> bool:
message["content"] = (
_build_skill_create_prompt(match.group(1) or "")
if allowed
else _build_skill_create_gate_prompt()
else _build_skill_create_gate_prompt(denial_reason)
)
return True
return False
@@ -1449,9 +1465,14 @@ async def run_chat_task(
chat_obj = await Chat.get_by_id(chat_id)
chat_params = (chat_obj.meta or {}).get("params", {}) if chat_obj else {}
messages, loaded_summary = await _load_message_history(chat_id, message_id)
skill_settings = await get_skill_settings()
skill_authoring_allowed = _has_prior_real_chat_content(messages, loaded_summary)
skill_create_denial_reason = "empty_chat"
if not skill_settings["enabled"] or not skill_settings["tool_enabled"]:
skill_authoring_allowed = False
skill_create_denial_reason = "disabled"
skill_create_requested = _apply_skills_create_prompt(
messages, allowed=skill_authoring_allowed
messages, allowed=skill_authoring_allowed, denial_reason=skill_create_denial_reason
)
memory_message, memory_files = _memory_recall_inputs(messages, regeneration_prompt)
system = await _load_system_prompt(
@@ -1720,9 +1741,14 @@ async def run_chat_task(
chat_models_config = {}
builtin_tools = resolve_builtin_tools_config(chat_models_config, model, configured_model)
messages, loaded_summary = await _load_message_history(chat_id, message_id)
skill_settings = await get_skill_settings()
skill_authoring_allowed = _has_prior_real_chat_content(messages, loaded_summary)
skill_create_denial_reason = "empty_chat"
if not skill_settings["enabled"] or not skill_settings["tool_enabled"]:
skill_authoring_allowed = False
skill_create_denial_reason = "disabled"
skill_create_requested = _apply_skills_create_prompt(
messages, allowed=skill_authoring_allowed
messages, allowed=skill_authoring_allowed, denial_reason=skill_create_denial_reason
)
memory_message, memory_files = _memory_recall_inputs(messages, regeneration_prompt)
system = await _load_system_prompt(
@@ -1742,7 +1768,7 @@ async def run_chat_task(
tools = [t for t in tools if t["name"] != "manage_skill"]
# Remove view_skill tool if no skills are available
skills = discover_skills(workspace)
skills = discover_skills(workspace) if skill_settings["enabled"] else []
if not skills:
tools = [t for t in tools if t["name"] != "view_skill"]
+13 -4
View File
@@ -208,7 +208,12 @@ def _render_system_template(template: str, variables: dict[str, str]) -> str:
return re.sub(r"\n{3,}", "\n\n", rendered).strip()
def _build_template_variables(workspace: str, model: str = "", memory: str = "") -> dict[str, str]:
def _build_template_variables(
workspace: str,
model: str = "",
memory: str = "",
skills_enabled: bool = True,
) -> dict[str, str]:
"""Build the dict of template variable values for the current context."""
ws_path = Path(workspace)
os_name = platform.system().replace("Darwin", "macOS")
@@ -225,8 +230,7 @@ def _build_template_variables(workspace: str, model: str = "", memory: str = "")
else:
instructions_block = ""
skills = discover_skills(workspace)
skills_block = build_catalog_xml(skills)
skills_block = build_catalog_xml(discover_skills(workspace)) if skills_enabled else ""
return {
"WORKSPACE_NAME": ws_path.name if ws_path.is_dir() else "",
@@ -310,5 +314,10 @@ async def load_system_prompt(
if memory and "{{MEMORY}}" not in template:
template = template.rstrip() + "\n\n{{MEMORY}}"
variables = _build_template_variables(workspace, model, memory)
try:
skills_enabled = (await Config.get("skills.enabled")) not in (False, "false", "0")
except Exception:
skills_enabled = True
variables = _build_template_variables(workspace, model, memory, skills_enabled)
return _render_system_template(template, variables)
+9 -1
View File
@@ -50,6 +50,8 @@ MANAGED_WORKSPACE_SKILL_DIR = ".cptr/skills"
MANAGED_GLOBAL_SKILL_DIR = Path.home() / ".cptr" / "skills"
ALLOWED_BUNDLE_DIRS = {"references", "templates", "scripts", "assets"}
DEFAULT_SKILL_SETTINGS: dict[str, Any] = {
"enabled": True,
"tool_enabled": True,
"background_review_enabled": True,
"review_interval_turns": 10,
}
@@ -712,6 +714,8 @@ async def get_skill_settings() -> dict[str, Any]:
settings["review_interval_turns"] = max(1, int(settings["review_interval_turns"]))
except (TypeError, ValueError):
settings["review_interval_turns"] = DEFAULT_SKILL_SETTINGS["review_interval_turns"]
settings["enabled"] = settings.get("enabled") not in (False, "false", "0")
settings["tool_enabled"] = settings.get("tool_enabled") not in (False, "false", "0")
settings["background_review_enabled"] = settings.get("background_review_enabled") not in (
False,
"false",
@@ -744,7 +748,11 @@ async def review_skills_after_turn(
):
return
settings = await get_skill_settings()
if not settings["background_review_enabled"]:
if (
not settings["enabled"]
or not settings["tool_enabled"]
or not settings["background_review_enabled"]
):
return
if not tool_names and not skill_create_requested and not loaded_skill_names:
return
+24
View File
@@ -1611,8 +1611,12 @@ async def view_skill(
"""Load the full instructions and resource listing for an available skill.
:param skill_name: The name of the skill to load (from the <available_skills> catalog).
"""
from cptr.models import Config
from cptr.utils.skills import bump_skill_view, load_skill, format_skill_content
if (await Config.get("skills.enabled")) in (False, "false", "0"):
return "Error: skills are disabled by the administrator."
# Deduplication: if already activated, return short notice
if skill_name in _activated_skills:
return f"Skill '{skill_name}' is already loaded in this session. Refer to the existing <skill_content> above."
@@ -1654,6 +1658,15 @@ async def manage_skill(
update_managed_skill,
write_managed_skill_file,
)
from cptr.models import Config
if (await Config.get("skills.enabled")) in (False, "false", "0"):
return json.dumps({"success": False, "error": "skills are disabled"}, ensure_ascii=False)
if (await Config.get("skills.tool_enabled")) in (False, "false", "0"):
return json.dumps(
{"success": False, "error": "skill management is disabled"},
ensure_ascii=False,
)
workspace = __context__.get("workspace", "")
try:
@@ -2852,6 +2865,17 @@ async def get_tool_list(builtin_tools: dict | None = None) -> list[dict]:
memory_enabled = (await Config.get("memory.enabled")) not in (False, "false", "0")
if not memory_enabled:
tools.pop("update_memory", None)
skills_enabled = (await Config.get("skills.enabled")) not in (False, "false", "0")
skills_tool_enabled = (await Config.get("skills.tool_enabled")) not in (
False,
"false",
"0",
)
if not skills_enabled:
tools.pop("view_skill", None)
tools.pop("manage_skill", None)
elif not skills_tool_enabled:
tools.pop("manage_skill", None)
if (await Config.get("browser.enabled")) in (True, "true", "1"):
tools.update(BROWSER_TOOLS)
if (await Config.get("subagents.enabled")) in (True, "true", "1"):