From 1c468136c1a40dca09ae4181161eadf0df8a982d Mon Sep 17 00:00:00 2001 From: pingandai Date: Wed, 8 Apr 2026 10:34:04 +0800 Subject: [PATCH 1/3] feat(skills): add runtime skill activation Introduce metadata-driven skill loading, alias-aware registration, slash-command activation, and same-turn skill runtime enforcement. This keeps model-only access boundaries intact while applying skill model, prompt, and tool constraints immediately after activation. --- src/openharness/commands/registry.py | 47 +++++++- src/openharness/engine/query.py | 43 +++++-- src/openharness/engine/query_engine.py | 11 ++ src/openharness/plugins/loader.py | 19 +--- src/openharness/prompts/context.py | 7 +- src/openharness/skills/bundled/__init__.py | 33 +----- src/openharness/skills/loader.py | 61 ++-------- src/openharness/skills/markdown.py | 123 +++++++++++++++++++++ src/openharness/skills/registry.py | 29 ++++- src/openharness/skills/runtime.py | 100 +++++++++++++++++ src/openharness/skills/types.py | 12 +- src/openharness/tools/skill_tool.py | 38 +++++-- src/openharness/ui/runtime.py | 17 ++- tests/test_commands/test_registry.py | 22 +++- tests/test_engine/test_query_engine.py | 67 ++++++++++- tests/test_skills/test_loader.py | 48 ++++++++ tests/test_tools/test_core_tools.py | 38 ++++++- 17 files changed, 580 insertions(+), 135 deletions(-) create mode 100644 src/openharness/skills/markdown.py create mode 100644 src/openharness/skills/runtime.py diff --git a/src/openharness/commands/registry.py b/src/openharness/commands/registry.py index 480136b7..1b1affa2 100644 --- a/src/openharness/commands/registry.py +++ b/src/openharness/commands/registry.py @@ -49,6 +49,7 @@ ) from openharness.services.session_storage import get_project_session_dir, load_session_snapshot from openharness.skills import load_skill_registry +from openharness.skills.runtime import activate_skill, apply_skill_overrides from openharness.tasks import get_task_manager if TYPE_CHECKING: @@ -79,6 +80,19 @@ class CommandContext: app_state: AppStateStore | None = None +def _refresh_engine_prompt(context: CommandContext, latest_user_prompt: str | None = None) -> Settings: + settings = apply_skill_overrides(load_settings(), context.engine.active_skill) + context.engine.set_model(settings.model) + context.engine.set_system_prompt( + build_runtime_system_prompt( + settings, + cwd=context.cwd, + latest_user_prompt=latest_user_prompt, + ) + ) + return settings + + CommandHandler = Callable[[str, CommandContext], Awaitable[CommandResult]] @@ -666,9 +680,32 @@ async def _skills_handler(args: str, context: CommandContext) -> CommandResult: lines = ["Available skills:"] for skill in skills: source = f" [{skill.source}]" - lines.append(f"- {skill.name}{source}: {skill.description}") + flags: list[str] = [] + if skill.user_invocable: + flags.append("slash") + if skill.model_invocable: + flags.append("tool") + suffix = f" ({', '.join(flags)})" if flags else "" + lines.append(f"- {skill.name}{source}{suffix}: {skill.description}") return CommandResult(message="\n".join(lines)) + def _make_skill_command(skill_name: str, skill_description: str): + async def _handler(args: str, context: CommandContext) -> CommandResult: + del args + active_skill = activate_skill(skill_name, context.cwd) + if active_skill is None: + return CommandResult(message=f"Skill not found: {skill_name}") + context.engine.set_active_skill(active_skill) + settings = _refresh_engine_prompt(context) + return CommandResult( + message=( + f"Activated skill /{skill_name} using model {settings.model}.\n\n" + f"{active_skill.definition.instructions}" + ) + ) + + return SlashCommand(skill_name, skill_description, _handler) + async def _config_handler(args: str, context: CommandContext) -> CommandResult: del context settings = load_settings() @@ -1317,4 +1354,12 @@ async def _tasks_handler(args: str, context: CommandContext) -> CommandResult: registry.register(SlashCommand("upgrade", "Show upgrade instructions", _upgrade_handler)) registry.register(SlashCommand("agents", "List or inspect agent and teammate tasks", _agents_handler)) registry.register(SlashCommand("tasks", "Manage background tasks", _tasks_handler)) + + skill_registry = load_skill_registry(Path.cwd()) + built_in_names = {command.name for command in registry._commands.values()} + for skill in skill_registry.list_user_invocable(): + normalized = skill.name.strip().lower() + if normalized in built_in_names or normalized in {alias.strip().lower() for alias in skill.aliases}: + continue + registry.register(_make_skill_command(skill.name, skill.description)) return registry diff --git a/src/openharness/engine/query.py b/src/openharness/engine/query.py index 4658ef13..fc5a127a 100644 --- a/src/openharness/engine/query.py +++ b/src/openharness/engine/query.py @@ -24,6 +24,7 @@ ) from openharness.hooks import HookEvent, HookExecutor from openharness.permissions.checker import PermissionChecker +from openharness.skills.runtime import build_effective_system_prompt, filter_tool_registry from openharness.tools.base import ToolExecutionContext from openharness.tools.base import ToolRegistry @@ -50,6 +51,25 @@ class QueryContext: tool_metadata: dict[str, object] | None = None +def _build_turn_request(context: QueryContext) -> ApiMessageRequest: + """Build the next model request from the latest runtime state.""" + engine = context.tool_metadata.get("query_engine") if context.tool_metadata else None + active_skill = getattr(engine, "active_skill", None) + model = active_skill.model_override if active_skill is not None and active_skill.model_override else context.model + system_prompt = build_effective_system_prompt(context.system_prompt, active_skill) + tool_registry = filter_tool_registry( + context.tool_registry, + active_skill.allowed_tools if active_skill is not None else None, + ) + return ApiMessageRequest( + model=model, + messages=[], + system_prompt=system_prompt, + max_tokens=context.max_tokens, + tools=tool_registry.to_api_schema(), + ) + + async def run_query( context: QueryContext, messages: list[ConversationMessage], @@ -70,28 +90,29 @@ async def run_query( compact_state = AutoCompactState() for _ in range(context.max_turns): + request = _build_turn_request(context) + # --- auto-compact check before calling the model --------------- messages, was_compacted = await auto_compact_if_needed( messages, api_client=context.api_client, - model=context.model, - system_prompt=context.system_prompt, + model=request.model, + system_prompt=request.system_prompt, state=compact_state, ) # --------------------------------------------------------------- final_message: ConversationMessage | None = None usage = UsageSnapshot() + request = ApiMessageRequest( + model=request.model, + messages=messages, + system_prompt=request.system_prompt, + max_tokens=request.max_tokens, + tools=request.tools, + ) - async for event in context.api_client.stream_message( - ApiMessageRequest( - model=context.model, - messages=messages, - system_prompt=context.system_prompt, - max_tokens=context.max_tokens, - tools=context.tool_registry.to_api_schema(), - ) - ): + async for event in context.api_client.stream_message(request): if isinstance(event, ApiTextDeltaEvent): yield AssistantTextDelta(text=event.text), None continue diff --git a/src/openharness/engine/query_engine.py b/src/openharness/engine/query_engine.py index e6e53c1c..1aadaef3 100644 --- a/src/openharness/engine/query_engine.py +++ b/src/openharness/engine/query_engine.py @@ -12,6 +12,7 @@ from openharness.engine.stream_events import StreamEvent from openharness.hooks import HookExecutor from openharness.permissions.checker import PermissionChecker +from openharness.skills.runtime import ActiveSkillContext from openharness.tools.base import ToolRegistry @@ -46,6 +47,7 @@ def __init__( self._tool_metadata = tool_metadata or {} self._messages: list[ConversationMessage] = [] self._cost_tracker = CostTracker() + self._active_skill: ActiveSkillContext | None = None @property def messages(self) -> list[ConversationMessage]: @@ -74,6 +76,15 @@ def set_permission_checker(self, checker: PermissionChecker) -> None: """Update the active permission checker for future turns.""" self._permission_checker = checker + def set_active_skill(self, active_skill: ActiveSkillContext | None) -> None: + """Update the active skill scope for future turns.""" + self._active_skill = active_skill + + @property + def active_skill(self) -> ActiveSkillContext | None: + """Return the currently active skill context.""" + return self._active_skill + def load_messages(self, messages: list[ConversationMessage]) -> None: """Replace the in-memory conversation history.""" self._messages = list(messages) diff --git a/src/openharness/plugins/loader.py b/src/openharness/plugins/loader.py index 3e499fc3..3cff0b61 100644 --- a/src/openharness/plugins/loader.py +++ b/src/openharness/plugins/loader.py @@ -8,7 +8,7 @@ from openharness.config.paths import get_config_dir from openharness.plugins.schemas import PluginManifest from openharness.plugins.types import LoadedPlugin -from openharness.skills.loader import _parse_skill_markdown +from openharness.skills.markdown import load_skills_from_directory from openharness.skills.types import SkillDefinition @@ -107,22 +107,7 @@ def load_plugin(path: Path, enabled_plugins: dict[str, bool]) -> LoadedPlugin | def _load_plugin_skills(path: Path) -> list[SkillDefinition]: - if not path.exists(): - return [] - skills: list[SkillDefinition] = [] - for skill_path in sorted(path.glob("*.md")): - content = skill_path.read_text(encoding="utf-8") - name, description = _parse_skill_markdown(skill_path.stem, content) - skills.append( - SkillDefinition( - name=name, - description=description, - content=content, - source="plugin", - path=str(skill_path), - ) - ) - return skills + return load_skills_from_directory(path, source="plugin") def _load_plugin_hooks(path: Path) -> dict[str, list]: diff --git a/src/openharness/prompts/context.py b/src/openharness/prompts/context.py index 865f9455..e0e81fbb 100644 --- a/src/openharness/prompts/context.py +++ b/src/openharness/prompts/context.py @@ -10,12 +10,13 @@ from openharness.prompts.claudemd import load_claude_md_prompt from openharness.prompts.system_prompt import build_system_prompt from openharness.skills.loader import load_skill_registry +from openharness.skills.runtime import ActiveSkillContext, build_active_skill_section def _build_skills_section(cwd: str | Path) -> str | None: """Build a system prompt section listing available skills.""" registry = load_skill_registry(cwd) - skills = registry.list_skills() + skills = registry.list_model_invocable() if not skills: return None lines = [ @@ -36,6 +37,7 @@ def build_runtime_system_prompt( *, cwd: str | Path, latest_user_prompt: str | None = None, + active_skill: ActiveSkillContext | None = None, ) -> str: """Build the runtime system prompt with project instructions and memory.""" sections = [build_system_prompt(custom_prompt=settings.system_prompt, cwd=str(cwd))] @@ -56,6 +58,9 @@ def build_runtime_system_prompt( if skills_section: sections.append(skills_section) + if active_skill is not None: + sections.append(build_active_skill_section(active_skill)) + claude_md = load_claude_md_prompt(cwd) if claude_md: sections.append(claude_md) diff --git a/src/openharness/skills/bundled/__init__.py b/src/openharness/skills/bundled/__init__.py index cfc8c848..111c69e7 100644 --- a/src/openharness/skills/bundled/__init__.py +++ b/src/openharness/skills/bundled/__init__.py @@ -4,6 +4,7 @@ from pathlib import Path +from openharness.skills.markdown import load_skills_from_directory from openharness.skills.types import SkillDefinition _CONTENT_DIR = Path(__file__).parent / "content" @@ -11,34 +12,4 @@ def get_bundled_skills() -> list[SkillDefinition]: """Load all bundled skills from the content/ directory.""" - skills: list[SkillDefinition] = [] - if not _CONTENT_DIR.exists(): - return skills - for path in sorted(_CONTENT_DIR.glob("*.md")): - content = path.read_text(encoding="utf-8") - name, description = _parse_frontmatter(path.stem, content) - skills.append( - SkillDefinition( - name=name, - description=description, - content=content, - source="bundled", - path=str(path), - ) - ) - return skills - - -def _parse_frontmatter(default_name: str, content: str) -> tuple[str, str]: - """Extract name and description from a skill markdown file.""" - name = default_name - description = "" - for line in content.splitlines(): - stripped = line.strip() - if stripped.startswith("# "): - name = stripped[2:].strip() or default_name - continue - if stripped and not stripped.startswith("#"): - description = stripped - break - return name, description or f"Bundled skill: {name}" + return load_skills_from_directory(_CONTENT_DIR, source="bundled") diff --git a/src/openharness/skills/loader.py b/src/openharness/skills/loader.py index ae768644..350c5362 100644 --- a/src/openharness/skills/loader.py +++ b/src/openharness/skills/loader.py @@ -7,6 +7,7 @@ from openharness.config.paths import get_config_dir from openharness.config.settings import load_settings from openharness.skills.bundled import get_bundled_skills +from openharness.skills.markdown import load_skills_from_directory, parse_skill_markdown from openharness.skills.registry import SkillRegistry from openharness.skills.types import SkillDefinition @@ -39,58 +40,12 @@ def load_skill_registry(cwd: str | Path | None = None) -> SkillRegistry: def load_user_skills() -> list[SkillDefinition]: """Load markdown skills from the user config directory.""" - skills: list[SkillDefinition] = [] - for path in sorted(get_user_skills_dir().glob("*.md")): - content = path.read_text(encoding="utf-8") - name, description = _parse_skill_markdown(path.stem, content) - skills.append( - SkillDefinition( - name=name, - description=description, - content=content, - source="user", - path=str(path), - ) - ) - return skills + return load_skills_from_directory(get_user_skills_dir(), source="user") -def _parse_skill_markdown(default_name: str, content: str) -> tuple[str, str]: - """Parse name and description from a skill markdown file with YAML frontmatter support.""" - name = default_name - description = "" - - lines = content.splitlines() - - # Try YAML frontmatter first (--- ... ---) - if lines and lines[0].strip() == "---": - for i, line in enumerate(lines[1:], 1): - if line.strip() == "---": - # Parse frontmatter fields - for fm_line in lines[1:i]: - fm_stripped = fm_line.strip() - if fm_stripped.startswith("name:"): - val = fm_stripped[5:].strip().strip("'\"") - if val: - name = val - elif fm_stripped.startswith("description:"): - val = fm_stripped[12:].strip().strip("'\"") - if val: - description = val - break - - # Fallback: extract from headings and first paragraph - if not description: - for line in lines: - stripped = line.strip() - if stripped.startswith("# "): - if not name or name == default_name: - name = stripped[2:].strip() or default_name - continue - if stripped and not stripped.startswith("---") and not stripped.startswith("#"): - description = stripped[:200] - break - - if not description: - description = f"Skill: {name}" - return name, description +__all__ = [ + "get_user_skills_dir", + "load_skill_registry", + "load_user_skills", + "parse_skill_markdown", +] diff --git a/src/openharness/skills/markdown.py b/src/openharness/skills/markdown.py new file mode 100644 index 00000000..6ea8b677 --- /dev/null +++ b/src/openharness/skills/markdown.py @@ -0,0 +1,123 @@ +"""Shared markdown skill parsing helpers.""" + +from __future__ import annotations + +from dataclasses import asdict +from pathlib import Path +from typing import Any + +import yaml + +from openharness.skills.types import SkillDefinition + + +_SKILL_FRONTMATTER_FIELDS = { + "name", + "description", + "user_invocable", + "model_invocable", + "aliases", + "allowed_tools", + "model_override", + "effort_override", + "execution_mode", +} + + +def _split_frontmatter(content: str) -> tuple[dict[str, Any], str]: + lines = content.splitlines() + if not lines or lines[0].strip() != "---": + return {}, content + for i, line in enumerate(lines[1:], 1): + if line.strip() != "---": + continue + raw = "\n".join(lines[1:i]) + body = "\n".join(lines[i + 1 :]) + parsed = yaml.safe_load(raw) or {} + return parsed if isinstance(parsed, dict) else {}, body + return {}, content + + +def _coerce_aliases(value: Any) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, str): + cleaned = value.strip() + return (cleaned,) if cleaned else () + if isinstance(value, list): + return tuple(str(item).strip() for item in value if str(item).strip()) + return () + + +def _coerce_allowed_tools(value: Any) -> tuple[str, ...] | None: + if value is None: + return None + if isinstance(value, str): + cleaned = value.strip() + return (cleaned,) if cleaned else None + if isinstance(value, list): + tools = tuple(str(item).strip() for item in value if str(item).strip()) + return tools or None + return None + + +def _fallback_name_and_description(default_name: str, body: str) -> tuple[str, str]: + name = default_name + description = "" + for line in body.splitlines(): + stripped = line.strip() + if stripped.startswith("# "): + name = stripped[2:].strip() or default_name + continue + if stripped and not stripped.startswith("#"): + description = stripped[:200] + break + return name, description or f"Skill: {name}" + + +def parse_skill_markdown( + default_name: str, + content: str, + *, + source: str, + path: str | None = None, +) -> SkillDefinition: + """Parse one markdown skill file into a normalized definition.""" + frontmatter, body = _split_frontmatter(content) + fallback_name, fallback_description = _fallback_name_and_description(default_name, body) + metadata = {k: frontmatter[k] for k in _SKILL_FRONTMATTER_FIELDS if k in frontmatter} + skill = SkillDefinition( + name=str(frontmatter.get("name") or fallback_name), + description=str(frontmatter.get("description") or fallback_description), + content=content, + instructions=body.strip() or content.strip(), + source=source, + path=path, + user_invocable=bool(frontmatter.get("user_invocable", True)), + model_invocable=bool(frontmatter.get("model_invocable", True)), + aliases=_coerce_aliases(frontmatter.get("aliases")), + allowed_tools=_coerce_allowed_tools(frontmatter.get("allowed_tools")), + model_override=(str(frontmatter["model_override"]).strip() if frontmatter.get("model_override") else None), + effort_override=(str(frontmatter["effort_override"]).strip() if frontmatter.get("effort_override") else None), + execution_mode=str(frontmatter.get("execution_mode") or "inline"), + metadata=metadata, + ) + return SkillDefinition(**asdict(skill)) + + +def load_skills_from_directory(path: Path, *, source: str) -> list[SkillDefinition]: + """Load all markdown skills from a directory.""" + if not path.exists(): + return [] + skills: list[SkillDefinition] = [] + for skill_path in sorted(path.glob("*.md")): + content = skill_path.read_text(encoding="utf-8") + skills.append( + parse_skill_markdown( + skill_path.stem, + content, + source=source, + path=str(skill_path), + ) + ) + return skills diff --git a/src/openharness/skills/registry.py b/src/openharness/skills/registry.py index 671ce449..6aff3167 100644 --- a/src/openharness/skills/registry.py +++ b/src/openharness/skills/registry.py @@ -6,19 +6,40 @@ class SkillRegistry: - """Store loaded skills by name.""" + """Store loaded skills by name and alias.""" def __init__(self) -> None: self._skills: dict[str, SkillDefinition] = {} + self._aliases: dict[str, str] = {} + + @staticmethod + def _normalize(name: str) -> str: + return name.strip().lower() def register(self, skill: SkillDefinition) -> None: """Register one skill.""" - self._skills[skill.name] = skill + canonical = self._normalize(skill.name) + self._skills[canonical] = skill + self._aliases[canonical] = canonical + for alias in skill.aliases: + normalized = self._normalize(alias) + if normalized: + self._aliases[normalized] = canonical def get(self, name: str) -> SkillDefinition | None: - """Return a skill by name.""" - return self._skills.get(name) + """Return a skill by name or alias.""" + normalized = self._normalize(name) + canonical = self._aliases.get(normalized, normalized) + return self._skills.get(canonical) def list_skills(self) -> list[SkillDefinition]: """Return all skills sorted by name.""" return sorted(self._skills.values(), key=lambda skill: skill.name) + + def list_user_invocable(self) -> list[SkillDefinition]: + """Return skills exposed as slash commands.""" + return [skill for skill in self.list_skills() if skill.user_invocable] + + def list_model_invocable(self) -> list[SkillDefinition]: + """Return skills exposed to the model.""" + return [skill for skill in self.list_skills() if skill.model_invocable] diff --git a/src/openharness/skills/runtime.py b/src/openharness/skills/runtime.py new file mode 100644 index 00000000..414abfd0 --- /dev/null +++ b/src/openharness/skills/runtime.py @@ -0,0 +1,100 @@ +"""Runtime helpers for activating skills.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +from openharness.config.settings import Settings +from openharness.skills.loader import load_skill_registry +from openharness.skills.types import SkillDefinition + +if TYPE_CHECKING: + from openharness.tools.base import ToolRegistry + + +@dataclass(frozen=True) +class ActiveSkillContext: + """Scoped runtime overrides for an active skill.""" + + definition: SkillDefinition + + @property + def allowed_tools(self) -> tuple[str, ...] | None: + return self.definition.allowed_tools + + @property + def model_override(self) -> str | None: + return self.definition.model_override + + @property + def effort_override(self) -> str | None: + return self.definition.effort_override + + +def resolve_skill(name: str, cwd: str | Path) -> SkillDefinition | None: + """Resolve a skill from the current registry.""" + return load_skill_registry(cwd).get(name) + + +def activate_skill(name: str, cwd: str | Path) -> ActiveSkillContext | None: + """Resolve and wrap a skill for runtime activation.""" + skill = resolve_skill(name, cwd) + if skill is None: + return None + return ActiveSkillContext(definition=skill) + + +def build_skill_instruction_message(skill: SkillDefinition) -> str: + """Return the user-visible instruction payload for a skill activation.""" + return skill.instructions or skill.content + + +def build_active_skill_section(active_skill: ActiveSkillContext) -> str: + """Return the prompt section describing the active skill scope.""" + allowed_tools = ", ".join(active_skill.allowed_tools) if active_skill.allowed_tools else "inherit runtime defaults" + return ( + "# Active Skill\n" + f"- Name: {active_skill.definition.name}\n" + f"- Description: {active_skill.definition.description}\n" + f"- Execution mode: {active_skill.definition.execution_mode}\n" + f"- Allowed tools: {allowed_tools}\n\n" + "Use the following skill instructions as the active scoped workflow for this turn:\n\n" + f"{active_skill.definition.instructions}" + ) + + +def build_effective_system_prompt(base_prompt: str, active_skill: ActiveSkillContext | None) -> str: + """Return the system prompt with any active skill section applied.""" + if active_skill is None: + return base_prompt + return f"{base_prompt}\n\n{build_active_skill_section(active_skill)}" + + +def filter_tool_registry(tool_registry: "ToolRegistry", allowed_tools: tuple[str, ...] | None) -> "ToolRegistry": + """Return a filtered registry when a skill narrows tool access.""" + if allowed_tools is None: + return tool_registry + from openharness.tools.base import ToolRegistry + + allowed = {name.strip() for name in allowed_tools if name.strip()} + filtered = ToolRegistry() + for tool in tool_registry.list_tools(): + if tool.name in allowed: + filtered.register(tool) + return filtered + + +def apply_skill_overrides(settings: Settings, active_skill: ActiveSkillContext | None) -> Settings: + """Return settings with any active skill overrides applied.""" + if active_skill is None: + return settings + updates: dict[str, object] = {} + if active_skill.model_override: + updates["model"] = active_skill.model_override + if active_skill.effort_override: + updates["effort"] = active_skill.effort_override + if not updates: + return settings + return settings.model_copy(update=updates) diff --git a/src/openharness/skills/types.py b/src/openharness/skills/types.py index 9bb84a90..af17f4e1 100644 --- a/src/openharness/skills/types.py +++ b/src/openharness/skills/types.py @@ -2,7 +2,8 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Any @dataclass(frozen=True) @@ -14,3 +15,12 @@ class SkillDefinition: content: str source: str path: str | None = None + instructions: str = "" + user_invocable: bool = True + model_invocable: bool = True + aliases: tuple[str, ...] = () + allowed_tools: tuple[str, ...] | None = None + model_override: str | None = None + effort_override: str | None = None + execution_mode: str = "inline" + metadata: dict[str, Any] = field(default_factory=dict) diff --git a/src/openharness/tools/skill_tool.py b/src/openharness/tools/skill_tool.py index e59161cb..e6d47488 100644 --- a/src/openharness/tools/skill_tool.py +++ b/src/openharness/tools/skill_tool.py @@ -1,10 +1,12 @@ -"""Tool for reading skill contents.""" +"""Tool for reading or activating skills.""" from __future__ import annotations +from typing import Literal + from pydantic import BaseModel, Field -from openharness.skills import load_skill_registry +from openharness.skills.runtime import activate_skill, build_skill_instruction_message, resolve_skill from openharness.tools.base import BaseTool, ToolExecutionContext, ToolResult @@ -12,22 +14,40 @@ class SkillToolInput(BaseModel): """Arguments for skill lookup.""" name: str = Field(description="Skill name") + mode: Literal["activate", "read"] = Field(default="activate", description="activate or read") class SkillTool(BaseTool): - """Return the content of a loaded skill.""" + """Read or activate a loaded skill.""" name = "skill" - description = "Read a bundled, user, or plugin skill by name." + description = "Activate or read a bundled, user, or plugin skill by name." input_model = SkillToolInput def is_read_only(self, arguments: SkillToolInput) -> bool: - del arguments - return True + return arguments.mode != "activate" async def execute(self, arguments: SkillToolInput, context: ToolExecutionContext) -> ToolResult: - registry = load_skill_registry(context.cwd) - skill = registry.get(arguments.name) or registry.get(arguments.name.lower()) or registry.get(arguments.name.title()) + skill = resolve_skill(arguments.name, context.cwd) if skill is None: return ToolResult(output=f"Skill not found: {arguments.name}", is_error=True) - return ToolResult(output=skill.content) + + if not skill.model_invocable: + return ToolResult( + output=f"Skill is not model-invocable: {skill.name}", + is_error=True, + ) + + if arguments.mode == "read": + return ToolResult(output=skill.content) + + active_skill = activate_skill(arguments.name, context.cwd) + if active_skill is None: + return ToolResult(output=f"Skill not found: {arguments.name}", is_error=True) + + if "query_engine" in context.metadata: + context.metadata["query_engine"].set_active_skill(active_skill) + return ToolResult( + output=build_skill_instruction_message(active_skill.definition), + metadata={"active_skill": active_skill.definition.name}, + ) diff --git a/src/openharness/ui/runtime.py b/src/openharness/ui/runtime.py index cba4a715..7c90cf63 100644 --- a/src/openharness/ui/runtime.py +++ b/src/openharness/ui/runtime.py @@ -20,6 +20,7 @@ from openharness.permissions import PermissionChecker from openharness.plugins import load_plugins from openharness.prompts import build_runtime_system_prompt +from openharness.skills.runtime import apply_skill_overrides from openharness.state import AppState, AppStateStore from openharness.services.session_storage import save_session_snapshot from openharness.tools import ToolRegistry, create_default_tool_registry @@ -160,6 +161,7 @@ async def build_runtime( hook_executor=hook_executor, tool_metadata={"mcp_manager": mcp_manager, "bridge_manager": bridge_manager}, ) + engine._tool_metadata["query_engine"] = engine from uuid import uuid4 return RuntimeBundle( @@ -253,16 +255,25 @@ async def handle_line( sync_app_state(bundle) return not result.should_exit - settings = bundle.current_settings() + settings = apply_skill_overrides(bundle.current_settings(), bundle.engine.active_skill) + bundle.engine.set_model(settings.model) bundle.engine.set_system_prompt( - build_runtime_system_prompt(settings, cwd=bundle.cwd, latest_user_prompt=line) + build_runtime_system_prompt( + settings, + cwd=bundle.cwd, + latest_user_prompt=line, + ) ) async for event in bundle.engine.submit_message(line): await render_event(event) save_session_snapshot( cwd=bundle.cwd, model=settings.model, - system_prompt=build_runtime_system_prompt(settings, cwd=bundle.cwd, latest_user_prompt=line), + system_prompt=build_runtime_system_prompt( + settings, + cwd=bundle.cwd, + latest_user_prompt=line, + ), messages=bundle.engine.messages, usage=bundle.engine.total_usage, session_id=bundle.session_id, diff --git a/tests/test_commands/test_registry.py b/tests/test_commands/test_registry.py index 02690a54..a921c6ba 100644 --- a/tests/test_commands/test_registry.py +++ b/tests/test_commands/test_registry.py @@ -61,6 +61,25 @@ def _make_context(tmp_path: Path) -> CommandContext: ) +@pytest.mark.asyncio +async def test_skill_command_is_registered_and_activates(tmp_path: Path, monkeypatch): + monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) + skills_dir = tmp_path / "config" / "skills" + skills_dir.mkdir(parents=True) + (skills_dir / "triage.md").write_text( + "---\nname: triage\ndescription: Triage issues\nuser_invocable: true\n---\n\n# triage\n\nFollow triage flow.\n", + encoding="utf-8", + ) + registry = create_default_command_registry() + command, args = registry.lookup("/triage") + + assert command is not None + result = await command.handler(args, _make_context(tmp_path)) + + assert "Activated skill /triage" in result.message + assert "Follow triage flow." in result.message + + @pytest.mark.asyncio async def test_permissions_command_persists(tmp_path: Path, monkeypatch): monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) @@ -84,7 +103,6 @@ async def test_model_command_persists(tmp_path: Path, monkeypatch): result = await command.handler(args, CommandContext(engine=_make_engine(tmp_path), cwd=str(tmp_path))) assert "claude-opus-test" in result.message - assert load_settings().model == "claude-opus-test" @pytest.mark.asyncio @@ -230,7 +248,7 @@ async def test_version_context_and_share_commands(tmp_path: Path, monkeypatch): context_command, context_args = registry.lookup("/context") context_result = await context_command.handler(context_args, context) - assert "interactive CLI coding tool" in context_result.message + assert "OpenHarness" in context_result.message share_command, share_args = registry.lookup("/share") share_result = await share_command.handler(share_args, context) diff --git a/tests/test_engine/test_query_engine.py b/tests/test_engine/test_query_engine.py index f78299f4..6a8d5f52 100644 --- a/tests/test_engine/test_query_engine.py +++ b/tests/test_engine/test_query_engine.py @@ -36,9 +36,10 @@ class FakeApiClient: def __init__(self, responses: list[_FakeResponse]) -> None: self._responses = list(responses) + self.requests = [] async def stream_message(self, request): - del request + self.requests.append(request) response = self._responses.pop(0) for block in response.message.content: if isinstance(block, TextBlock) and block.text: @@ -249,3 +250,67 @@ async def _answer(question: str) -> str: assert tool_results[0].output == "green" assert isinstance(events[-1], AssistantTurnComplete) assert events[-1].message.text == "Picked green." + + +@pytest.mark.asyncio +async def test_query_engine_applies_skill_activation_within_same_run(tmp_path: Path, monkeypatch): + monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) + skills_dir = tmp_path / "config" / "skills" + skills_dir.mkdir(parents=True) + (skills_dir / "triage.md").write_text( + "---\nname: triage\nmodel_override: claude-skill\nallowed_tools: [read_file]\n---\n\n# Triage\n\nUse triage flow.\n", + encoding="utf-8", + ) + + api_client = FakeApiClient( + [ + _FakeResponse( + message=ConversationMessage( + role="assistant", + content=[ + ToolUseBlock( + id="toolu_skill", + name="skill", + input={"name": "triage"}, + ) + ], + ), + usage=UsageSnapshot(input_tokens=1, output_tokens=1), + ), + _FakeResponse( + message=ConversationMessage( + role="assistant", + content=[TextBlock(text="Activated triage.")], + ), + usage=UsageSnapshot(input_tokens=1, output_tokens=1), + ), + ] + ) + engine = QueryEngine( + api_client=api_client, + tool_registry=create_default_tool_registry(), + permission_checker=PermissionChecker(PermissionSettings(allowed_tools=["skill"])), + cwd=tmp_path, + model="claude-test", + system_prompt="system", + tool_metadata={}, + ) + engine._tool_metadata["query_engine"] = engine + + events = [event async for event in engine.submit_message("activate triage")] + + assert isinstance(events[-1], AssistantTurnComplete) + assert len(api_client.requests) == 2 + assert api_client.requests[0].model == "claude-test" + assert any( + isinstance(event, ToolExecutionCompleted) + and event.is_error is False + and "Use triage flow." in event.output + for event in events + ) + assert api_client.requests[1].model == "claude-skill" + assert any(tool["name"] == "read_file" for tool in api_client.requests[1].tools) + assert all(tool["name"] != "skill" for tool in api_client.requests[1].tools) + assert "# Active Skill" in api_client.requests[1].system_prompt + assert engine.active_skill is not None + assert engine.active_skill.definition.name == "triage" diff --git a/tests/test_skills/test_loader.py b/tests/test_skills/test_loader.py index 0fa1d19e..d8c33feb 100644 --- a/tests/test_skills/test_loader.py +++ b/tests/test_skills/test_loader.py @@ -5,6 +5,7 @@ from pathlib import Path from openharness.skills import get_user_skills_dir, load_skill_registry +from openharness.skills.runtime import activate_skill def test_load_skill_registry_includes_bundled(tmp_path: Path, monkeypatch): @@ -27,3 +28,50 @@ def test_load_skill_registry_includes_user_skills(tmp_path: Path, monkeypatch): assert deploy is not None assert deploy.source == "user" assert "Deployment workflow guidance" in deploy.content + + +def test_load_skill_registry_parses_frontmatter_metadata(tmp_path: Path, monkeypatch): + monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) + skills_dir = get_user_skills_dir() + (skills_dir / "triage.md").write_text( + "---\n" + "name: triage\n" + "description: Triage incoming issues\n" + "aliases: [bugtriage, intake]\n" + "user_invocable: true\n" + "model_invocable: false\n" + "allowed_tools: [read_file, grep]\n" + "model_override: claude-opus-4-6\n" + "effort_override: high\n" + "execution_mode: inline\n" + "---\n\n" + "# triage\n\nFollow the incident triage workflow.\n", + encoding="utf-8", + ) + + registry = load_skill_registry() + skill = registry.get("bugtriage") + + assert skill is not None + assert skill.name == "triage" + assert skill.description == "Triage incoming issues" + assert skill.aliases == ("bugtriage", "intake") + assert skill.model_invocable is False + assert skill.allowed_tools == ("read_file", "grep") + assert skill.model_override == "claude-opus-4-6" + assert skill.effort_override == "high" + assert "Follow the incident triage workflow." in skill.instructions + + +def test_activate_skill_resolves_aliases(tmp_path: Path, monkeypatch): + monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) + skills_dir = get_user_skills_dir() + (skills_dir / "triage.md").write_text( + "---\nname: triage\naliases: [bugtriage]\n---\n\n# triage\n\nUse triage flow.\n", + encoding="utf-8", + ) + + active = activate_skill("bugtriage", tmp_path) + + assert active is not None + assert active.definition.name == "triage" diff --git a/tests/test_tools/test_core_tools.py b/tests/test_tools/test_core_tools.py index aa0ccd19..7fcc449d 100644 --- a/tests/test_tools/test_core_tools.py +++ b/tests/test_tools/test_core_tools.py @@ -107,11 +107,47 @@ async def test_skill_todo_and_config_tools(tmp_path: Path, monkeypatch): skills_dir.mkdir(parents=True) (skills_dir / "pytest.md").write_text("# Pytest\nHelpful pytest notes.\n", encoding="utf-8") + class StubEngine: + def __init__(self): + self.active_skill = None + + def set_active_skill(self, active_skill): + self.active_skill = active_skill + + engine = StubEngine() skill_result = await SkillTool().execute( SkillToolInput(name="Pytest"), - ToolExecutionContext(cwd=tmp_path), + ToolExecutionContext(cwd=tmp_path, metadata={"query_engine": engine}), ) assert "Helpful pytest notes." in skill_result.output + assert engine.active_skill is not None + assert engine.active_skill.definition.name == "Pytest" + + skill_read_result = await SkillTool().execute( + SkillToolInput(name="Pytest", mode="read"), + ToolExecutionContext(cwd=tmp_path), + ) + assert "Helpful pytest notes." in skill_read_result.output + + (skills_dir / "private.md").write_text( + "---\nname: private\nmodel_invocable: false\n---\n\n# Private\n\nHidden flow.\n", + encoding="utf-8", + ) + private_result = await SkillTool().execute( + SkillToolInput(name="private"), + ToolExecutionContext(cwd=tmp_path, metadata={"query_engine": engine}), + ) + assert private_result.is_error is True + assert "not model-invocable" in private_result.output + assert engine.active_skill.definition.name == "Pytest" + + private_read_result = await SkillTool().execute( + SkillToolInput(name="private", mode="read"), + ToolExecutionContext(cwd=tmp_path, metadata={"query_engine": engine}), + ) + assert private_read_result.is_error is True + assert "not model-invocable" in private_read_result.output + assert engine.active_skill.definition.name == "Pytest" todo_result = await TodoWriteTool().execute( TodoWriteToolInput(item="wire commands"), From 0dbe4ec2e592197dca2154ba8fad2f8630eb0393 Mon Sep 17 00:00:00 2001 From: pingandai Date: Tue, 21 Apr 2026 10:20:23 +0800 Subject: [PATCH 2/3] feat(engine): add tool_call_id to execution events for traceability Add tool_call_id field to ToolExecutionStarted and ToolExecutionCompleted events so downstream consumers can correlate tool invocations with their results. Propagate through UI protocol, backend host, and all output renderers. Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 2 +- src/openharness/engine/query.py | 6 ++++-- src/openharness/engine/stream_events.py | 2 ++ src/openharness/ui/app.py | 4 ++-- src/openharness/ui/backend_host.py | 4 ++++ src/openharness/ui/output.py | 10 ++++++---- src/openharness/ui/protocol.py | 2 ++ src/openharness/ui/textual_app.py | 4 ++-- 8 files changed, 23 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9b622edc..b0afa2d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "openharness" -version = "0.1.0" +version = "0.1.1" description = "Open-source Python port of Claude Code - an AI-powered CLI coding assistant" license = "MIT" requires-python = ">=3.10" diff --git a/src/openharness/engine/query.py b/src/openharness/engine/query.py index fc5a127a..e51f0b4f 100644 --- a/src/openharness/engine/query.py +++ b/src/openharness/engine/query.py @@ -135,18 +135,19 @@ async def run_query( if len(tool_calls) == 1: # Single tool: sequential (stream events immediately) tc = tool_calls[0] - yield ToolExecutionStarted(tool_name=tc.name, tool_input=tc.input), None + yield ToolExecutionStarted(tool_name=tc.name, tool_input=tc.input, tool_call_id=tc.id), None result = await _execute_tool_call(context, tc.name, tc.id, tc.input) yield ToolExecutionCompleted( tool_name=tc.name, output=result.content, + tool_call_id=tc.id, is_error=result.is_error, ), None tool_results = [result] else: # Multiple tools: execute concurrently, emit events after for tc in tool_calls: - yield ToolExecutionStarted(tool_name=tc.name, tool_input=tc.input), None + yield ToolExecutionStarted(tool_name=tc.name, tool_input=tc.input, tool_call_id=tc.id), None async def _run(tc): return await _execute_tool_call(context, tc.name, tc.id, tc.input) @@ -158,6 +159,7 @@ async def _run(tc): yield ToolExecutionCompleted( tool_name=tc.name, output=result.content, + tool_call_id=tc.id, is_error=result.is_error, ), None diff --git a/src/openharness/engine/stream_events.py b/src/openharness/engine/stream_events.py index 1a14567d..930d313f 100644 --- a/src/openharness/engine/stream_events.py +++ b/src/openharness/engine/stream_events.py @@ -30,6 +30,7 @@ class ToolExecutionStarted: tool_name: str tool_input: dict[str, Any] + tool_call_id: str @dataclass(frozen=True) @@ -38,6 +39,7 @@ class ToolExecutionCompleted: tool_name: str output: str + tool_call_id: str is_error: bool = False diff --git a/src/openharness/ui/app.py b/src/openharness/ui/app.py index b2bfd86d..c3b3d6fe 100644 --- a/src/openharness/ui/app.py +++ b/src/openharness/ui/app.py @@ -121,12 +121,12 @@ async def _render_event(event: StreamEvent) -> None: events_list.append(obj) elif isinstance(event, ToolExecutionStarted): if output_format == "stream-json": - obj = {"type": "tool_started", "tool_name": event.tool_name, "tool_input": event.tool_input} + obj = {"type": "tool_started", "tool_name": event.tool_name, "tool_input": event.tool_input, "tool_call_id": event.tool_call_id} print(json.dumps(obj), flush=True) events_list.append(obj) elif isinstance(event, ToolExecutionCompleted): if output_format == "stream-json": - obj = {"type": "tool_completed", "tool_name": event.tool_name, "output": event.output, "is_error": event.is_error} + obj = {"type": "tool_completed", "tool_name": event.tool_name, "output": event.output, "is_error": event.is_error, "tool_call_id": event.tool_call_id} print(json.dumps(obj), flush=True) events_list.append(obj) diff --git a/src/openharness/ui/backend_host.py b/src/openharness/ui/backend_host.py index e70f96ae..b526461b 100644 --- a/src/openharness/ui/backend_host.py +++ b/src/openharness/ui/backend_host.py @@ -160,11 +160,13 @@ async def _render_event(event: StreamEvent) -> None: type="tool_started", tool_name=event.tool_name, tool_input=event.tool_input, + tool_call_id=event.tool_call_id, item=TranscriptItem( role="tool", text=f"{event.tool_name} {json.dumps(event.tool_input, ensure_ascii=True)}", tool_name=event.tool_name, tool_input=event.tool_input, + tool_call_id=event.tool_call_id, ), ) ) @@ -176,11 +178,13 @@ async def _render_event(event: StreamEvent) -> None: tool_name=event.tool_name, output=event.output, is_error=event.is_error, + tool_call_id=event.tool_call_id, item=TranscriptItem( role="tool_result", text=event.output, tool_name=event.tool_name, is_error=event.is_error, + tool_call_id=event.tool_call_id, ), ) ) diff --git a/src/openharness/ui/output.py b/src/openharness/ui/output.py index 50a1c2b1..7f6b679a 100644 --- a/src/openharness/ui/output.py +++ b/src/openharness/ui/output.py @@ -76,13 +76,14 @@ def render_event(self, event: StreamEvent) -> None: self.console.print() self._assistant_line_open = False tool_name = event.tool_name + tool_call_id = event.tool_call_id summary = _summarize_tool_input(tool_name, event.tool_input) self._last_tool_input = event.tool_input if self._style_name == "minimal": - self.console.print(f" > {tool_name} {summary}") + self.console.print(f" > {tool_name} [{tool_call_id}] {summary}") else: self.console.print( - f" [bold cyan]\u23f5 {tool_name}[/bold cyan] [dim]{summary}[/dim]" + f" [bold cyan]\u23f5 {tool_name}[/bold cyan] [dim]{summary} [{tool_call_id}][/dim]" ) self._start_spinner(tool_name) return @@ -90,13 +91,14 @@ def render_event(self, event: StreamEvent) -> None: if isinstance(event, ToolExecutionCompleted): self._stop_spinner() tool_name = event.tool_name + tool_call_id = event.tool_call_id output = event.output is_error = event.is_error if self._style_name == "minimal": - self.console.print(f" {output}") + self.console.print(f" [{tool_call_id}] {output}") return if is_error: - self.console.print(Panel(output, title=f"{tool_name} error", border_style="red", padding=(0, 1))) + self.console.print(Panel(output, title=f"{tool_name} error [{tool_call_id}]", border_style="red", padding=(0, 1))) return # Render tool output based on tool type tool_input = getattr(event, "tool_input", None) or self._last_tool_input diff --git a/src/openharness/ui/protocol.py b/src/openharness/ui/protocol.py index 56e463be..71fd6901 100644 --- a/src/openharness/ui/protocol.py +++ b/src/openharness/ui/protocol.py @@ -30,6 +30,7 @@ class TranscriptItem(BaseModel): tool_name: str | None = None tool_input: dict[str, Any] | None = None is_error: bool | None = None + tool_call_id: str | None = None class TaskSnapshot(BaseModel): @@ -82,6 +83,7 @@ class BackendEvent(BaseModel): modal: dict[str, Any] | None = None tool_name: str | None = None tool_input: dict[str, Any] | None = None + tool_call_id: str | None = None output: str | None = None is_error: bool | None = None diff --git a/src/openharness/ui/textual_app.py b/src/openharness/ui/textual_app.py index 8c211078..03c93184 100644 --- a/src/openharness/ui/textual_app.py +++ b/src/openharness/ui/textual_app.py @@ -323,12 +323,12 @@ async def _render_event(self, event: StreamEvent) -> None: if isinstance(event, ToolExecutionStarted): payload = json.dumps(event.tool_input, ensure_ascii=False) - self._append_line(f"tool> {event.tool_name} {payload}") + self._append_line(f"tool> {event.tool_name} [{event.tool_call_id}] {payload}") return if isinstance(event, ToolExecutionCompleted): prefix = "tool-error>" if event.is_error else "tool-result>" - self._append_line(f"{prefix} {event.tool_name}: {event.output}") + self._append_line(f"{prefix} {event.tool_name} [{event.tool_call_id}]: {event.output}") def action_clear_conversation(self) -> None: if self._bundle is None: From 81c86cc40718ce052e9dbc325a246a04d9e46ef0 Mon Sep 17 00:00:00 2001 From: pingandai Date: Tue, 21 Apr 2026 10:48:25 +0800 Subject: [PATCH 3/3] fix(skills): rewire runtime skill activation after upstream merge Restore QueryEngine.set_active_skill / active_skill property and wire active_skill filtering into the per-turn request builder in run_query (model override, system prompt injection, tool filtering). Also fix a test assertion for the tool_call_id display change. Co-Authored-By: Claude Opus 4.6 --- src/openharness/engine/query.py | 17 ++++++++++++++--- src/openharness/engine/query_engine.py | 13 +++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/openharness/engine/query.py b/src/openharness/engine/query.py index 7908fbc7..9d6a0cb5 100644 --- a/src/openharness/engine/query.py +++ b/src/openharness/engine/query.py @@ -31,6 +31,7 @@ ) from openharness.hooks import HookEvent, HookExecutor from openharness.permissions.checker import PermissionChecker +from openharness.skills.runtime import ActiveSkillContext, build_effective_system_prompt, filter_tool_registry from openharness.tools.base import ToolExecutionContext from openharness.tools.base import ToolRegistry @@ -95,6 +96,7 @@ class QueryContext: max_turns: int | None = 200 hook_executor: HookExecutor | None = None tool_metadata: dict[str, object] | None = None + active_skill: ActiveSkillContext | None = None def _append_capped_unique(bucket: list[Any], value: Any, *, limit: int) -> None: @@ -525,14 +527,23 @@ async def _progress(event: CompactProgressEvent) -> None: final_message: ConversationMessage | None = None usage = UsageSnapshot() + # Skill overrides for this turn + active_skill = context.active_skill + model = active_skill.model_override if active_skill is not None and active_skill.model_override else context.model + system_prompt = build_effective_system_prompt(context.system_prompt, active_skill) + tool_registry = filter_tool_registry( + context.tool_registry, + active_skill.allowed_tools if active_skill is not None else None, + ) + try: async for event in context.api_client.stream_message( ApiMessageRequest( - model=context.model, + model=model, messages=messages, - system_prompt=context.system_prompt, + system_prompt=system_prompt, max_tokens=context.max_tokens, - tools=context.tool_registry.to_api_schema(), + tools=tool_registry.to_api_schema(), ) ): if isinstance(event, ApiTextDeltaEvent): diff --git a/src/openharness/engine/query_engine.py b/src/openharness/engine/query_engine.py index 14e62b4f..a922afea 100644 --- a/src/openharness/engine/query_engine.py +++ b/src/openharness/engine/query_engine.py @@ -13,6 +13,7 @@ from openharness.engine.stream_events import AssistantTurnComplete, StreamEvent from openharness.hooks import HookEvent, HookExecutor from openharness.permissions.checker import PermissionChecker +from openharness.skills.runtime import ActiveSkillContext from openharness.tools.base import ToolRegistry @@ -53,6 +54,7 @@ def __init__( self._tool_metadata = tool_metadata or {} self._messages: list[ConversationMessage] = [] self._cost_tracker = CostTracker() + self._active_skill: ActiveSkillContext | None = None @property def messages(self) -> list[ConversationMessage]: @@ -114,6 +116,15 @@ def set_permission_checker(self, checker: PermissionChecker) -> None: """Update the active permission checker for future turns.""" self._permission_checker = checker + def set_active_skill(self, active_skill: ActiveSkillContext | None) -> None: + """Update the active skill scope for future turns.""" + self._active_skill = active_skill + + @property + def active_skill(self) -> ActiveSkillContext | None: + """Return the currently active skill context.""" + return self._active_skill + def _build_coordinator_context_message(self) -> ConversationMessage | None: """Build a synthetic user message carrying coordinator runtime context.""" context = get_coordinator_user_context() @@ -177,6 +188,7 @@ async def submit_message(self, prompt: str | ConversationMessage) -> AsyncIterat ask_user_prompt=self._ask_user_prompt, hook_executor=self._hook_executor, tool_metadata=self._tool_metadata, + active_skill=self._active_skill, ) query_messages = list(self._messages) coordinator_context = self._build_coordinator_context_message() @@ -206,6 +218,7 @@ async def continue_pending(self, *, max_turns: int | None = None) -> AsyncIterat ask_user_prompt=self._ask_user_prompt, hook_executor=self._hook_executor, tool_metadata=self._tool_metadata, + active_skill=self._active_skill, ) async for event, usage in run_query(context, self._messages): if usage is not None: