mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-23 14:04:19 +00:00
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:
@@ -147,13 +147,22 @@ class AgentResourceBuilder:
|
||||
allowed_names = resource_policy.allowed_tool_names
|
||||
tool_operations = [operation for operation in ('detail', 'call') if operation in tool_perms]
|
||||
|
||||
# Prefill full tool schema (best-effort) so runners can build LLM tool
|
||||
# definitions without a per-tool get_tool_detail round-trip. Degrades to
|
||||
# None when no tool manager is available.
|
||||
get_tool_schema = getattr(getattr(self.ap, 'tool_mgr', None), 'get_tool_schema', None)
|
||||
if allowed_names:
|
||||
for tool_name in allowed_names:
|
||||
if get_tool_schema is not None:
|
||||
description, parameters = await get_tool_schema(tool_name)
|
||||
else:
|
||||
description, parameters = None, None
|
||||
tools.append({
|
||||
'tool_name': tool_name,
|
||||
'tool_type': None,
|
||||
'description': None,
|
||||
'description': description,
|
||||
'operations': tool_operations,
|
||||
'parameters': parameters,
|
||||
})
|
||||
|
||||
return tools
|
||||
@@ -203,10 +212,13 @@ class AgentResourceBuilder:
|
||||
resource_policy: typing.Any,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> list[SkillResource]:
|
||||
"""Build pipeline-visible skill resource facts."""
|
||||
if not config_schema.supports_skill_authoring(descriptor):
|
||||
return []
|
||||
"""Build pipeline-visible skill resource facts.
|
||||
|
||||
Skills are exposed as authorized tools (activate / register_skill / native
|
||||
exec), so skill facts are surfaced to every run that has a skill manager,
|
||||
not gated by the ``skill_authoring`` capability. The capability is now a
|
||||
semantic declaration only.
|
||||
"""
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
if skill_mgr is None:
|
||||
return []
|
||||
|
||||
@@ -181,10 +181,6 @@ class PreProcessor(stage.PipelineStage):
|
||||
|
||||
uses_host_models = config_schema.uses_host_models(descriptor)
|
||||
uses_host_tools = config_schema.uses_host_tools(descriptor)
|
||||
include_skill_authoring = (
|
||||
config_schema.supports_skill_authoring(descriptor)
|
||||
and getattr(self.ap, 'skill_service', None) is not None
|
||||
)
|
||||
llm_model = None
|
||||
if uses_host_models:
|
||||
primary_uuid, fallback_uuids = config_schema.extract_model_selection(descriptor, runner_config)
|
||||
@@ -242,7 +238,6 @@ class PreProcessor(stage.PipelineStage):
|
||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
include_skill_authoring=include_skill_authoring,
|
||||
)
|
||||
|
||||
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
||||
@@ -255,13 +250,11 @@ class PreProcessor(stage.PipelineStage):
|
||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
include_skill_authoring=include_skill_authoring,
|
||||
)
|
||||
elif uses_host_tools:
|
||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
include_skill_authoring=include_skill_authoring,
|
||||
)
|
||||
|
||||
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
||||
@@ -367,7 +360,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
query.prompt.messages = event_ctx.event.default_prompt
|
||||
query.messages = event_ctx.event.prompt
|
||||
|
||||
if include_skill_authoring and getattr(self.ap, 'skill_mgr', None) is not None:
|
||||
if getattr(self.ap, 'skill_mgr', None) is not None:
|
||||
pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid)
|
||||
extensions_prefs = (pipeline_data or {}).get('extensions_preferences', {})
|
||||
enable_all_skills = extensions_prefs.get('enable_all_skills', True)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -58,13 +58,15 @@ class ToolManager:
|
||||
self,
|
||||
bound_plugins: list[str] | None = None,
|
||||
bound_mcp_servers: list[str] | None = None,
|
||||
include_skill_authoring: bool = False,
|
||||
) -> 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(bound_mcp_servers))
|
||||
|
||||
@@ -84,6 +86,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 = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user