feat(skill): unify skill activation as authorized tools

Expose skill tools (activate/register_skill/native exec) like native tools
instead of gating them behind the skill_authoring capability:
- toolmgr.get_all_tools drops include_skill_authoring; SkillToolLoader
  self-gates on sandbox + skill_mgr
- preproc drops the include_skill_authoring branch; pipeline-bound skills
  and the skills resource gate on skill_mgr presence

Persist activated skills into host.activated_skills conversation state so
they survive across runs (host writes at activate; last-write-wins); drop
the dead restore_activated_skills helper.

Prefill ToolResource.parameters host-side (tool_mgr.get_tool_schema) so
runners build LLM tools without per-tool get_tool_detail round-trips.

Align agent-runner-pluginization design docs to the all-tool model.
This commit is contained in:
huanghuoguoguo
2026-06-21 09:27:05 +08:00
parent cb9930c9e4
commit 5f65f962c5
14 changed files with 213 additions and 64 deletions
+49 -21
View File
@@ -91,27 +91,6 @@ def get_activated_skill_names(query: pipeline_query.Query) -> list[str]:
return normalize_skill_names(list(get_activated_skills(query).keys()))
def restore_activated_skills(
ap: app.Application,
query: pipeline_query.Query,
skill_names: typing.Any,
) -> list[str]:
"""Restore caller-provided activated skill names into Query variables.
Persistence and state scope ownership belong to higher-level flows. This
helper only rebuilds current Query state from pipeline-visible skills, so
removed or unbound skills stay unavailable to native exec/write/edit.
"""
restored: list[str] = []
for skill_name in normalize_skill_names(skill_names):
skill_data = get_visible_skill(ap, query, skill_name)
if skill_data is None:
continue
register_activated_skill(query, skill_data)
restored.append(skill_name)
return restored
def restore_activated_skills_from_state(
ap: app.Application,
query: pipeline_query.Query,
@@ -135,6 +114,55 @@ def restore_activated_skills_from_state(
return restored
async def persist_activated_skill(
ap: app.Application,
query: pipeline_query.Query,
skill_name: str,
) -> None:
"""Persist activated skill names into host-owned conversation state.
``activate`` runs host-side. This writes the run's current activated skill
names to the conversation-scope ``host.activated_skills`` snapshot so a later
run can restore them via ``restore_activated_skills_from_state``. Host writes
here and a runner ``state.updated`` to the same key follow last-write-wins.
Best-effort: a persistence failure must not fail the activation itself. No-op
when the call is not inside an authorized agent run, or when conversation
state is unavailable (state disabled / scope not enabled / no conversation).
"""
session = getattr(query, '_agent_run_session', None)
if not isinstance(session, dict):
return
state_context = session.get('state_context')
if not isinstance(state_context, dict):
return
scope_keys = state_context.get('scope_keys')
conversation_scope_key = scope_keys.get('conversation') if isinstance(scope_keys, dict) else None
if not conversation_scope_key:
return
try:
from ....agent.runner.persistent_state_store import get_persistent_state_store
store = get_persistent_state_store(ap.persistence_mgr.get_db_engine())
await store.state_set(
scope_key=conversation_scope_key,
state_key=ACTIVATED_SKILL_NAMES_STATE_KEY,
value=get_activated_skill_names(query),
runner_id=str(session.get('runner_id', '') or ''),
binding_identity=str(state_context.get('binding_identity', 'unknown') or 'unknown'),
scope='conversation',
context=state_context,
logger=getattr(ap, 'logger', None),
)
except Exception as e: # noqa: BLE001 - persistence is best-effort, must not break activation
logger = getattr(ap, 'logger', None)
if logger is not None:
logger.warning(f'Failed to persist activated skill "{skill_name}": {e}')
def parse_skill_mount_path(sandbox_path: str) -> tuple[str | None, str]:
normalized_path = str(sandbox_path or '/workspace').strip() or '/workspace'
if normalized_path == SKILL_MOUNT_PREFIX:
+22 -3
View File
@@ -59,14 +59,16 @@ class ToolManager:
self,
bound_plugins: list[str] | None = None,
bound_mcp_servers: list[str] | None = None,
include_skill_authoring: bool = False,
include_mcp_resource_tools: bool = True,
) -> list[resource_tool.LLMTool]:
all_functions: list[resource_tool.LLMTool] = []
all_functions.extend(await self.native_tool_loader.get_tools())
if include_skill_authoring:
all_functions.extend(await self.skill_tool_loader.get_tools())
# Skill tools (activate / register_skill) are exposed like native tools:
# the SkillToolLoader gates itself on sandbox + skill_mgr availability, so
# skill is just a group of authorized tools rather than a separate
# capability-gated surface.
all_functions.extend(await self.skill_tool_loader.get_tools())
all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins))
all_functions.extend(
await self.mcp_tool_loader.get_tools(
@@ -127,6 +129,23 @@ class ToolManager:
return None
async def get_tool_schema(self, name: str) -> tuple[str | None, dict | None]:
"""Return (description, parameters JSON schema) for a tool by name.
Used by the host to prefill ToolResource so a runner can build LLM tool
definitions without a separate get_tool_detail round-trip. Handles both
LLMTool (native/mcp/skill) and plugin ComponentManifest shapes. Returns
(None, None) when the tool is not found.
"""
tool = await self.get_tool_by_name(name)
if tool is None:
return None, None
if hasattr(tool, 'spec') and hasattr(tool, 'metadata'):
spec = getattr(tool, 'spec', None) or {}
return spec.get('llm_prompt'), (spec.get('parameters') or None)
description = getattr(tool, 'description', None) or getattr(tool, 'human_desc', None)
return description, (getattr(tool, 'parameters', None) or None)
async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list:
tools = []