mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-17 18:06:06 +00:00
feat(agent-runner): expose skill resources through host context
This commit is contained in:
@@ -90,7 +90,6 @@ class AgentRunnerCapabilities(BaseModel):
|
|||||||
knowledge_retrieval: bool = False
|
knowledge_retrieval: bool = False
|
||||||
multimodal_input: bool = False
|
multimodal_input: bool = False
|
||||||
skill_authoring: bool = False
|
skill_authoring: bool = False
|
||||||
skill_injection: bool = False
|
|
||||||
event_context: bool = True
|
event_context: bool = True
|
||||||
platform_api: bool = False
|
platform_api: bool = False
|
||||||
interrupt: bool = False
|
interrupt: bool = False
|
||||||
@@ -104,8 +103,7 @@ class AgentRunnerCapabilities(BaseModel):
|
|||||||
- `tool_calling`: runner 可能调用 Host tool API。
|
- `tool_calling`: runner 可能调用 Host tool API。
|
||||||
- `knowledge_retrieval`: runner 可能调用 Host knowledge API。
|
- `knowledge_retrieval`: runner 可能调用 Host knowledge API。
|
||||||
- `multimodal_input`: runner 可以处理非纯文本 input / artifact。
|
- `multimodal_input`: runner 可以处理非纯文本 input / artifact。
|
||||||
- `skill_authoring`: runner 需要 Host 提供的 skill authoring tools。
|
- `skill_authoring`: runner 需要 Host 提供 skill facts 以及 skill authoring tools,例如 `activate` / `register_skill`。
|
||||||
- `skill_injection`: runner 需要 Host 在 effective prompt 中注入 skill index。
|
|
||||||
- `event_context`: runner 理解 event-first 输入。
|
- `event_context`: runner 理解 event-first 输入。
|
||||||
- `platform_api`: runner 可能请求平台动作。
|
- `platform_api`: runner 可能请求平台动作。
|
||||||
- `interrupt`: runner 支持取消或中断。
|
- `interrupt`: runner 支持取消或中断。
|
||||||
@@ -353,15 +351,23 @@ State 是可选 host-owned snapshot。Runner 也可以完全自管状态。
|
|||||||
## 6. Resources
|
## 6. Resources
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
class SkillResource(BaseModel):
|
||||||
|
skill_name: str
|
||||||
|
display_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
class AgentResources(BaseModel):
|
class AgentResources(BaseModel):
|
||||||
models: list[ModelResource] = []
|
models: list[ModelResource] = []
|
||||||
tools: list[ToolResource] = []
|
tools: list[ToolResource] = []
|
||||||
knowledge_bases: list[KnowledgeBaseResource] = []
|
knowledge_bases: list[KnowledgeBaseResource] = []
|
||||||
|
skills: list[SkillResource] = []
|
||||||
files: list[FileResource] = []
|
files: list[FileResource] = []
|
||||||
storage: StorageResource = StorageResource()
|
storage: StorageResource = StorageResource()
|
||||||
platform_capabilities: dict[str, Any] = {}
|
platform_capabilities: dict[str, Any] = {}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`skills` 只包含本次 run 中 pipeline-visible 的 skill facts,例如 `skill_name`、`display_name` 和 `description`。Host 不把这些 facts 追加到 system prompt,也不把它们编排进工具描述;runner 可以自行决定是否放入 model prompt、转换成 MCP surface,或只在自己的策略层使用。
|
||||||
|
|
||||||
资源列表是本次 run 的授权结果。History / Event / Artifact 访问通过 permissions、`ctx.context.available_apis` 和 Host 侧 run session 校验控制,不作为可枚举 resource list 暴露。Runner 只能通过 `AgentRunAPIProxy` 访问这些能力。
|
资源列表是本次 run 的授权结果。History / Event / Artifact 访问通过 permissions、`ctx.context.available_apis` 和 Host 侧 run session 校验控制,不作为可枚举 resource list 暴露。Runner 只能通过 `AgentRunAPIProxy` 访问这些能力。
|
||||||
|
|
||||||
## 7. Result Stream
|
## 7. Result Stream
|
||||||
|
|||||||
@@ -71,13 +71,6 @@ def supports_skill_authoring(descriptor: AgentRunnerDescriptor | None) -> bool:
|
|||||||
return bool(descriptor.capabilities.get('skill_authoring', False))
|
return bool(descriptor.capabilities.get('skill_authoring', False))
|
||||||
|
|
||||||
|
|
||||||
def supports_skill_injection(descriptor: AgentRunnerDescriptor | None) -> bool:
|
|
||||||
"""Return whether the runner wants the Host skill index in the effective prompt."""
|
|
||||||
if descriptor is None:
|
|
||||||
return False
|
|
||||||
return bool(descriptor.capabilities.get('skill_injection', False))
|
|
||||||
|
|
||||||
|
|
||||||
def extract_prompt_config(
|
def extract_prompt_config(
|
||||||
descriptor: AgentRunnerDescriptor | None,
|
descriptor: AgentRunnerDescriptor | None,
|
||||||
runner_config: dict[str, typing.Any],
|
runner_config: dict[str, typing.Any],
|
||||||
|
|||||||
@@ -84,6 +84,14 @@ class KnowledgeBaseResource(typing.TypedDict):
|
|||||||
kb_type: str | None
|
kb_type: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class SkillResource(typing.TypedDict):
|
||||||
|
"""Skill resource payload."""
|
||||||
|
|
||||||
|
skill_name: str
|
||||||
|
display_name: str | None
|
||||||
|
description: str | None
|
||||||
|
|
||||||
|
|
||||||
class FileResource(typing.TypedDict):
|
class FileResource(typing.TypedDict):
|
||||||
"""File resource payload."""
|
"""File resource payload."""
|
||||||
|
|
||||||
@@ -106,6 +114,7 @@ class AgentResources(typing.TypedDict):
|
|||||||
models: list[ModelResource]
|
models: list[ModelResource]
|
||||||
tools: list[ToolResource]
|
tools: list[ToolResource]
|
||||||
knowledge_bases: list[KnowledgeBaseResource]
|
knowledge_bases: list[KnowledgeBaseResource]
|
||||||
|
skills: list[SkillResource]
|
||||||
files: list[FileResource]
|
files: list[FileResource]
|
||||||
storage: StorageResource
|
storage: StorageResource
|
||||||
platform_capabilities: dict[str, typing.Any]
|
platform_capabilities: dict[str, typing.Any]
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ class ResourcePolicy(pydantic.BaseModel):
|
|||||||
allowed_kb_uuids: list[str] | None = None
|
allowed_kb_uuids: list[str] | None = None
|
||||||
"""Additional knowledge base UUID grants. None means no additional KB grants."""
|
"""Additional knowledge base UUID grants. None means no additional KB grants."""
|
||||||
|
|
||||||
|
allowed_skill_names: list[str] | None = None
|
||||||
|
"""Allowed skill names. None means all currently visible skills are allowed."""
|
||||||
|
|
||||||
allow_plugin_storage: bool = True
|
allow_plugin_storage: bool = True
|
||||||
"""Whether plugin storage is allowed."""
|
"""Whether plugin storage is allowed."""
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from .result_normalizer import AgentResultNormalizer
|
|||||||
from .run_journal import AgentRunJournal, MAX_ARTIFACT_INLINE_BYTES as _MAX_ARTIFACT_INLINE_BYTES
|
from .run_journal import AgentRunJournal, MAX_ARTIFACT_INLINE_BYTES as _MAX_ARTIFACT_INLINE_BYTES
|
||||||
from .session_registry import AgentRunSessionRegistry, get_session_registry
|
from .session_registry import AgentRunSessionRegistry, get_session_registry
|
||||||
from .state_scope import build_state_context
|
from .state_scope import build_state_context
|
||||||
|
from ...provider.tools.loaders import skill as skill_loader
|
||||||
|
|
||||||
|
|
||||||
MAX_ARTIFACT_INLINE_BYTES = _MAX_ARTIFACT_INLINE_BYTES
|
MAX_ARTIFACT_INLINE_BYTES = _MAX_ARTIFACT_INLINE_BYTES
|
||||||
@@ -86,6 +87,13 @@ class AgentRunOrchestrator:
|
|||||||
|
|
||||||
session_query_id = None
|
session_query_id = None
|
||||||
if adapter_context:
|
if adapter_context:
|
||||||
|
query = adapter_context.get('_query')
|
||||||
|
if query is not None:
|
||||||
|
skill_loader.restore_activated_skills_from_state(
|
||||||
|
self.ap,
|
||||||
|
query,
|
||||||
|
context.get('state', {}),
|
||||||
|
)
|
||||||
session_query_id = adapter_context.get('query_id')
|
session_query_id = adapter_context.get('query_id')
|
||||||
if 'params' in adapter_context:
|
if 'params' in adapter_context:
|
||||||
context['adapter']['extra']['params'] = adapter_context['params']
|
context['adapter']['extra']['params'] = adapter_context['params']
|
||||||
@@ -175,11 +183,13 @@ class AgentRunOrchestrator:
|
|||||||
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
|
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
|
||||||
"""Run an AgentRunner from the current Pipeline Query entry point."""
|
"""Run an AgentRunner from the current Pipeline Query entry point."""
|
||||||
plan = self.query_bridge.build_plan(query)
|
plan = self.query_bridge.build_plan(query)
|
||||||
|
adapter_context = dict(plan.adapter_context)
|
||||||
|
adapter_context['_query'] = query
|
||||||
async for result in self.run(
|
async for result in self.run(
|
||||||
plan.event,
|
plan.event,
|
||||||
plan.binding,
|
plan.binding,
|
||||||
bound_plugins=plan.bound_plugins,
|
bound_plugins=plan.bound_plugins,
|
||||||
adapter_context=plan.adapter_context,
|
adapter_context=adapter_context,
|
||||||
):
|
):
|
||||||
yield result
|
yield result
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ class QueryEntryAdapter:
|
|||||||
allowed_model_uuids=cls._extract_allowed_models(query),
|
allowed_model_uuids=cls._extract_allowed_models(query),
|
||||||
allowed_tool_names=cls._extract_allowed_tools(query),
|
allowed_tool_names=cls._extract_allowed_tools(query),
|
||||||
allowed_kb_uuids=cls._extract_allowed_kbs(query),
|
allowed_kb_uuids=cls._extract_allowed_kbs(query),
|
||||||
|
allowed_skill_names=cls._extract_allowed_skills(query),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build state policy
|
# Build state policy
|
||||||
@@ -583,3 +584,19 @@ class QueryEntryAdapter:
|
|||||||
if kb_uuids:
|
if kb_uuids:
|
||||||
return kb_uuids
|
return kb_uuids
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _extract_allowed_skills(
|
||||||
|
cls,
|
||||||
|
query: pipeline_query.Query,
|
||||||
|
) -> list[str] | None:
|
||||||
|
"""Extract pipeline-visible skill names from query."""
|
||||||
|
variables = getattr(query, 'variables', None)
|
||||||
|
if not variables or '_pipeline_bound_skills' not in variables:
|
||||||
|
return None
|
||||||
|
bound_skills = variables.get('_pipeline_bound_skills')
|
||||||
|
if bound_skills is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(bound_skills, list):
|
||||||
|
return []
|
||||||
|
return [str(skill_name) for skill_name in bound_skills if skill_name]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from .context_builder import (
|
|||||||
ModelResource,
|
ModelResource,
|
||||||
ToolResource,
|
ToolResource,
|
||||||
KnowledgeBaseResource,
|
KnowledgeBaseResource,
|
||||||
|
SkillResource,
|
||||||
StorageResource,
|
StorageResource,
|
||||||
)
|
)
|
||||||
from . import config_schema
|
from . import config_schema
|
||||||
@@ -36,6 +37,7 @@ class AgentResourceBuilder:
|
|||||||
- ModelResource: model_id, model_type, provider
|
- ModelResource: model_id, model_type, provider
|
||||||
- ToolResource: tool_name, tool_type, description
|
- ToolResource: tool_name, tool_type, description
|
||||||
- KnowledgeBaseResource: kb_id, kb_name, kb_type
|
- KnowledgeBaseResource: kb_id, kb_name, kb_type
|
||||||
|
- SkillResource: skill_name, display_name, description
|
||||||
- StorageResource: plugin_storage, workspace_storage
|
- StorageResource: plugin_storage, workspace_storage
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -81,12 +83,16 @@ class AgentResourceBuilder:
|
|||||||
knowledge_bases = await self._build_knowledge_bases_from_binding(
|
knowledge_bases = await self._build_knowledge_bases_from_binding(
|
||||||
manifest_perms, resource_policy, descriptor, runner_config
|
manifest_perms, resource_policy, descriptor, runner_config
|
||||||
)
|
)
|
||||||
|
skills = self._build_skills_from_binding(
|
||||||
|
resource_policy, descriptor
|
||||||
|
)
|
||||||
storage = self._build_storage_from_binding(manifest_perms, binding)
|
storage = self._build_storage_from_binding(manifest_perms, binding)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'models': models,
|
'models': models,
|
||||||
'tools': tools,
|
'tools': tools,
|
||||||
'knowledge_bases': knowledge_bases,
|
'knowledge_bases': knowledge_bases,
|
||||||
|
'skills': skills,
|
||||||
'files': [], # Files are populated at runtime
|
'files': [], # Files are populated at runtime
|
||||||
'storage': storage,
|
'storage': storage,
|
||||||
'platform_capabilities': {}, # Reserved for EBA
|
'platform_capabilities': {}, # Reserved for EBA
|
||||||
@@ -193,6 +199,36 @@ class AgentResourceBuilder:
|
|||||||
|
|
||||||
return kb_resources
|
return kb_resources
|
||||||
|
|
||||||
|
def _build_skills_from_binding(
|
||||||
|
self,
|
||||||
|
resource_policy: typing.Any,
|
||||||
|
descriptor: AgentRunnerDescriptor,
|
||||||
|
) -> list[SkillResource]:
|
||||||
|
"""Build pipeline-visible skill resource facts."""
|
||||||
|
if not config_schema.supports_skill_authoring(descriptor):
|
||||||
|
return []
|
||||||
|
|
||||||
|
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||||
|
if skill_mgr is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
loaded_skills = getattr(skill_mgr, 'skills', {}) or {}
|
||||||
|
allowed_names = resource_policy.allowed_skill_names
|
||||||
|
if allowed_names is None:
|
||||||
|
names = sorted(loaded_skills.keys())
|
||||||
|
else:
|
||||||
|
names = sorted(name for name in allowed_names if name in loaded_skills)
|
||||||
|
|
||||||
|
skills: list[SkillResource] = []
|
||||||
|
for skill_name in names:
|
||||||
|
skill_data = loaded_skills.get(skill_name) or {}
|
||||||
|
skills.append({
|
||||||
|
'skill_name': skill_name,
|
||||||
|
'display_name': skill_data.get('display_name') or skill_data.get('name') or skill_name,
|
||||||
|
'description': skill_data.get('description') or None,
|
||||||
|
})
|
||||||
|
return skills
|
||||||
|
|
||||||
def _build_storage_from_binding(
|
def _build_storage_from_binding(
|
||||||
self,
|
self,
|
||||||
manifest_perms: dict[str, list[str]],
|
manifest_perms: dict[str, list[str]],
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ class AgentRunSessionRegistry:
|
|||||||
'model': {m.get('model_id') for m in resources.get('models', [])},
|
'model': {m.get('model_id') for m in resources.get('models', [])},
|
||||||
'tool': {t.get('tool_name') for t in resources.get('tools', [])},
|
'tool': {t.get('tool_name') for t in resources.get('tools', [])},
|
||||||
'knowledge_base': {kb.get('kb_id') for kb in resources.get('knowledge_bases', [])},
|
'knowledge_base': {kb.get('kb_id') for kb in resources.get('knowledge_bases', [])},
|
||||||
|
'skill': {s.get('skill_name') for s in resources.get('skills', [])},
|
||||||
'file': {f.get('file_id') for f in resources.get('files', [])},
|
'file': {f.get('file_id') for f in resources.get('files', [])},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +198,7 @@ class AgentRunSessionRegistry:
|
|||||||
authorized_ids = authorization['authorized_ids']
|
authorized_ids = authorization['authorized_ids']
|
||||||
resources = authorization['resources']
|
resources = authorization['resources']
|
||||||
|
|
||||||
if resource_type in ('model', 'tool', 'knowledge_base', 'file'):
|
if resource_type in ('model', 'tool', 'knowledge_base', 'skill', 'file'):
|
||||||
return resource_id in authorized_ids.get(resource_type, set())
|
return resource_id in authorized_ids.get(resource_type, set())
|
||||||
|
|
||||||
if resource_type == 'storage':
|
if resource_type == 'storage':
|
||||||
|
|||||||
@@ -171,7 +171,6 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
config_schema.supports_skill_authoring(descriptor)
|
config_schema.supports_skill_authoring(descriptor)
|
||||||
and getattr(self.ap, 'skill_service', None) is not None
|
and getattr(self.ap, 'skill_service', None) is not None
|
||||||
)
|
)
|
||||||
inject_skill_context = config_schema.supports_skill_injection(descriptor)
|
|
||||||
llm_model = None
|
llm_model = None
|
||||||
if uses_host_models:
|
if uses_host_models:
|
||||||
primary_uuid, fallback_uuids = config_schema.extract_model_selection(descriptor, runner_config)
|
primary_uuid, fallback_uuids = config_schema.extract_model_selection(descriptor, runner_config)
|
||||||
@@ -350,19 +349,7 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
query.prompt.messages = event_ctx.event.default_prompt
|
query.prompt.messages = event_ctx.event.default_prompt
|
||||||
query.messages = event_ctx.event.prompt
|
query.messages = event_ctx.event.prompt
|
||||||
|
|
||||||
# =========== Skill awareness for capable runners ===========
|
if include_skill_authoring and getattr(self.ap, 'skill_mgr', None) is not None:
|
||||||
# The actual activation goes through the ``activate`` Tool Call so the
|
|
||||||
# LLM doesn't see full SKILL.md instructions until it commits to a
|
|
||||||
# skill (Claude Code's progressive disclosure). But the LLM still has
|
|
||||||
# to KNOW which skills exist to make that choice, so we:
|
|
||||||
# 1. resolve the pipeline's bound skills and stash them in
|
|
||||||
# ``query.variables['_pipeline_bound_skills']`` for downstream
|
|
||||||
# visibility checks (skill loader, native exec workdir);
|
|
||||||
# 2. inject a short ``Available Skills`` index (name + description
|
|
||||||
# only) into the system prompt. The contributor's original PR
|
|
||||||
# relied on this injection; without it the LLM never discovers
|
|
||||||
# the skills are there and just calls native tools instead.
|
|
||||||
if inject_skill_context and self.ap.skill_mgr:
|
|
||||||
pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid)
|
pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid)
|
||||||
extensions_prefs = (pipeline_data or {}).get('extensions_preferences', {})
|
extensions_prefs = (pipeline_data or {}).get('extensions_preferences', {})
|
||||||
enable_all_skills = extensions_prefs.get('enable_all_skills', True)
|
enable_all_skills = extensions_prefs.get('enable_all_skills', True)
|
||||||
@@ -374,43 +361,4 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
|
|
||||||
query.variables['_pipeline_bound_skills'] = bound_skills
|
query.variables['_pipeline_bound_skills'] = bound_skills
|
||||||
|
|
||||||
skill_addition = self.ap.skill_mgr.build_skill_aware_prompt_addition(
|
|
||||||
bound_skills=bound_skills,
|
|
||||||
)
|
|
||||||
if skill_addition:
|
|
||||||
# Append to the first system message; create one if the
|
|
||||||
# prompt has none. Handles both plain-string and
|
|
||||||
# content-element (list) message bodies.
|
|
||||||
if query.prompt.messages and query.prompt.messages[0].role == 'system':
|
|
||||||
head = query.prompt.messages[0]
|
|
||||||
if isinstance(head.content, str):
|
|
||||||
head.content = head.content + skill_addition
|
|
||||||
elif isinstance(head.content, list):
|
|
||||||
appended = False
|
|
||||||
for ce in head.content:
|
|
||||||
if getattr(ce, 'type', None) == 'text':
|
|
||||||
ce.text = (ce.text or '') + skill_addition
|
|
||||||
appended = True
|
|
||||||
break
|
|
||||||
if not appended:
|
|
||||||
head.content.append(provider_message.ContentElement(type='text', text=skill_addition))
|
|
||||||
else:
|
|
||||||
query.prompt.messages.insert(
|
|
||||||
0,
|
|
||||||
provider_message.Message(role='system', content=skill_addition.strip()),
|
|
||||||
)
|
|
||||||
self.ap.logger.debug(
|
|
||||||
f'Skill index injected into system prompt: '
|
|
||||||
f'pipeline={query.pipeline_uuid} '
|
|
||||||
f'bound_skills={bound_skills or "all"} '
|
|
||||||
f'loaded_skills={len(self.ap.skill_mgr.skills)}'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.ap.logger.debug(
|
|
||||||
f'No skills available for prompt injection: '
|
|
||||||
f'pipeline={query.pipeline_uuid} '
|
|
||||||
f'loaded_skills={len(self.ap.skill_mgr.skills)} '
|
|
||||||
f'bound_skills={bound_skills}'
|
|
||||||
)
|
|
||||||
|
|
||||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ if typing.TYPE_CHECKING:
|
|||||||
from langbot_plugin.api.entities.events import pipeline_query
|
from langbot_plugin.api.entities.events import pipeline_query
|
||||||
|
|
||||||
ACTIVATED_SKILLS_KEY = '_activated_skills'
|
ACTIVATED_SKILLS_KEY = '_activated_skills'
|
||||||
|
ACTIVATED_SKILL_NAMES_STATE_KEY = 'host.activated_skills'
|
||||||
PIPELINE_BOUND_SKILLS_KEY = '_pipeline_bound_skills'
|
PIPELINE_BOUND_SKILLS_KEY = '_pipeline_bound_skills'
|
||||||
SKILL_MOUNT_PREFIX = '/workspace/.skills'
|
SKILL_MOUNT_PREFIX = '/workspace/.skills'
|
||||||
_SKILL_MOUNT_PATTERN = re.compile(r'/workspace/\.skills/([A-Za-z0-9_-]+)')
|
_SKILL_MOUNT_PATTERN = re.compile(r'/workspace/\.skills/([A-Za-z0-9_-]+)')
|
||||||
@@ -72,6 +73,116 @@ def register_activated_skill(query: pipeline_query.Query, skill_data: dict) -> N
|
|||||||
activated[skill_name] = skill_data
|
activated[skill_name] = skill_data
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_skill_names(value: typing.Any) -> list[str]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
names: list[str] = []
|
||||||
|
for item in value:
|
||||||
|
skill_name = str(item or '').strip()
|
||||||
|
if skill_name and skill_name not in names:
|
||||||
|
names.append(skill_name)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def restore_activated_skills_from_state(
|
||||||
|
ap: app.Application,
|
||||||
|
query: pipeline_query.Query,
|
||||||
|
state: dict[str, dict[str, typing.Any]],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Restore persisted activated skill names into Query variables.
|
||||||
|
|
||||||
|
The state value stores names only. Full skill metadata is rebuilt from the
|
||||||
|
current pipeline-visible skill cache so removed or unbound skills remain
|
||||||
|
unavailable to native exec/write/edit.
|
||||||
|
"""
|
||||||
|
conversation_state = state.get('conversation', {}) if isinstance(state, dict) else {}
|
||||||
|
skill_names = _normalize_skill_names(conversation_state.get(ACTIVATED_SKILL_NAMES_STATE_KEY))
|
||||||
|
restored: list[str] = []
|
||||||
|
for skill_name in 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 _get_agent_run_authorization(query: pipeline_query.Query) -> dict[str, typing.Any] | None:
|
||||||
|
session = getattr(query, '_agent_run_session', None)
|
||||||
|
if not isinstance(session, dict):
|
||||||
|
return None
|
||||||
|
authorization = session.get('authorization')
|
||||||
|
return authorization if isinstance(authorization, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_conversation_state_target(query: pipeline_query.Query) -> tuple[str, str, str, dict[str, typing.Any]] | None:
|
||||||
|
session = getattr(query, '_agent_run_session', None)
|
||||||
|
if not isinstance(session, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
authorization = _get_agent_run_authorization(query)
|
||||||
|
if authorization is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
state_policy = authorization.get('state_policy') or {}
|
||||||
|
if not state_policy.get('enable_state', True):
|
||||||
|
return None
|
||||||
|
state_scopes = state_policy.get('state_scopes', ['conversation', 'actor'])
|
||||||
|
if 'conversation' not in state_scopes:
|
||||||
|
return None
|
||||||
|
|
||||||
|
state_context = authorization.get('state_context') or {}
|
||||||
|
scope_keys = state_context.get('scope_keys') or {}
|
||||||
|
scope_key = scope_keys.get('conversation')
|
||||||
|
if not scope_key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
runner_id = str(session.get('runner_id') or 'unknown')
|
||||||
|
binding_identity = str(state_context.get('binding_identity') or 'unknown')
|
||||||
|
return scope_key, runner_id, binding_identity, state_context
|
||||||
|
|
||||||
|
|
||||||
|
async def persist_activated_skill(ap: app.Application, query: pipeline_query.Query, skill_name: str) -> bool:
|
||||||
|
"""Persist activated skill names for the current AgentRunner conversation.
|
||||||
|
|
||||||
|
Returns False when the call is outside an AgentRunner run or state policy
|
||||||
|
does not expose a conversation scope. The in-memory Query activation still
|
||||||
|
remains valid for the current turn.
|
||||||
|
"""
|
||||||
|
target = _get_conversation_state_target(query)
|
||||||
|
if target is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
persistence_mgr = getattr(ap, 'persistence_mgr', None)
|
||||||
|
if persistence_mgr is None or not hasattr(persistence_mgr, 'get_db_engine'):
|
||||||
|
return False
|
||||||
|
|
||||||
|
from ....agent.runner.persistent_state_store import get_persistent_state_store
|
||||||
|
|
||||||
|
scope_key, runner_id, binding_identity, state_context = target
|
||||||
|
store = get_persistent_state_store(persistence_mgr.get_db_engine())
|
||||||
|
existing_names = _normalize_skill_names(await store.state_get(scope_key, ACTIVATED_SKILL_NAMES_STATE_KEY))
|
||||||
|
if skill_name not in existing_names:
|
||||||
|
existing_names.append(skill_name)
|
||||||
|
|
||||||
|
success, error = await store.state_set(
|
||||||
|
scope_key=scope_key,
|
||||||
|
state_key=ACTIVATED_SKILL_NAMES_STATE_KEY,
|
||||||
|
value=existing_names,
|
||||||
|
runner_id=runner_id,
|
||||||
|
binding_identity=binding_identity,
|
||||||
|
scope='conversation',
|
||||||
|
context=state_context,
|
||||||
|
logger=getattr(ap, 'logger', None),
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
logger = getattr(ap, 'logger', None)
|
||||||
|
if logger is not None:
|
||||||
|
logger.warning(f'Failed to persist activated skill "{skill_name}": {error}')
|
||||||
|
return success
|
||||||
|
|
||||||
|
|
||||||
def parse_skill_mount_path(sandbox_path: str) -> tuple[str | None, str]:
|
def parse_skill_mount_path(sandbox_path: str) -> tuple[str | None, str]:
|
||||||
normalized_path = str(sandbox_path or '/workspace').strip() or '/workspace'
|
normalized_path = str(sandbox_path or '/workspace').strip() or '/workspace'
|
||||||
if normalized_path == SKILL_MOUNT_PREFIX:
|
if normalized_path == SKILL_MOUNT_PREFIX:
|
||||||
|
|||||||
@@ -82,17 +82,17 @@ class SkillToolLoader(loader.ToolLoader):
|
|||||||
if not skill_name:
|
if not skill_name:
|
||||||
raise ValueError('skill_name is required')
|
raise ValueError('skill_name is required')
|
||||||
|
|
||||||
skill_mgr = self.ap.skill_mgr
|
from . import skill as skill_loader
|
||||||
skill_data = skill_mgr.get_skill_by_name(skill_name)
|
|
||||||
|
skill_data = skill_loader.get_visible_skill(self.ap, query, skill_name)
|
||||||
if skill_data is None:
|
if skill_data is None:
|
||||||
visible_skills = getattr(skill_mgr, 'skills', {})
|
visible_skills = skill_loader.get_visible_skills(self.ap, query)
|
||||||
available_names = ', '.join(sorted(visible_skills.keys())) or 'none'
|
available_names = ', '.join(sorted(visible_skills.keys())) or 'none'
|
||||||
raise ValueError(f'Skill "{skill_name}" not found. Available skills: {available_names}')
|
raise ValueError(f'Skill "{skill_name}" not found. Available skills: {available_names}')
|
||||||
|
|
||||||
# Register activated skill for sandbox mount path resolution
|
# Register activated skill for sandbox mount path resolution
|
||||||
from . import skill as skill_loader
|
|
||||||
|
|
||||||
skill_loader.register_activated_skill(query, skill_data)
|
skill_loader.register_activated_skill(query, skill_data)
|
||||||
|
await skill_loader.persist_activated_skill(self.ap, query, skill_name)
|
||||||
|
|
||||||
# Return SKILL.md content as Tool Result (injects into context)
|
# Return SKILL.md content as Tool Result (injects into context)
|
||||||
instructions = skill_data.get('instructions', '')
|
instructions = skill_data.get('instructions', '')
|
||||||
@@ -191,13 +191,13 @@ class SkillToolLoader(loader.ToolLoader):
|
|||||||
return resource_tool.LLMTool(
|
return resource_tool.LLMTool(
|
||||||
name=ACTIVATE_SKILL_TOOL_NAME,
|
name=ACTIVATE_SKILL_TOOL_NAME,
|
||||||
human_desc='Activate a skill',
|
human_desc='Activate a skill',
|
||||||
description=self._build_activate_tool_description(),
|
description='Activate a pipeline-visible skill by name and return its instructions as a tool result.',
|
||||||
parameters={
|
parameters={
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'properties': {
|
'properties': {
|
||||||
'skill_name': {
|
'skill_name': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'description': 'The skill name to activate (no arguments). E.g., "pdf" or "data-analysis"',
|
'description': 'The skill name to activate.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'required': ['skill_name'],
|
'required': ['skill_name'],
|
||||||
@@ -245,50 +245,3 @@ class SkillToolLoader(loader.ToolLoader):
|
|||||||
},
|
},
|
||||||
func=lambda parameters: parameters,
|
func=lambda parameters: parameters,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _build_activate_tool_description(self) -> str:
|
|
||||||
"""Build tool description with embedded available_skills list."""
|
|
||||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
|
||||||
if skill_mgr is None:
|
|
||||||
return 'Activate a skill. No skills are currently available.'
|
|
||||||
|
|
||||||
skills = getattr(skill_mgr, 'skills', {})
|
|
||||||
if not skills:
|
|
||||||
return 'Activate a skill. No skills are currently available.'
|
|
||||||
|
|
||||||
# Build <available_skills> section
|
|
||||||
available_skills_lines = ['<available_skills>']
|
|
||||||
for skill_name, skill_data in sorted(skills.items()):
|
|
||||||
description = skill_data.get('description', '')
|
|
||||||
available_skills_lines.append('<skill>')
|
|
||||||
available_skills_lines.append(f'<name>{skill_name}</name>')
|
|
||||||
available_skills_lines.append(f'<description>{description}</description>')
|
|
||||||
available_skills_lines.append('</skill>')
|
|
||||||
available_skills_lines.append('</available_skills>')
|
|
||||||
|
|
||||||
available_skills_block = '\n'.join(available_skills_lines)
|
|
||||||
|
|
||||||
return f"""Activate a skill within the main conversation.
|
|
||||||
|
|
||||||
<skills_instructions>
|
|
||||||
When users ask you to perform tasks, check if any of the available skills
|
|
||||||
below can help complete the task more effectively. Skills provide specialized
|
|
||||||
capabilities and domain knowledge.
|
|
||||||
|
|
||||||
How to use skills:
|
|
||||||
- Invoke skills using this tool with the skill name only (no arguments)
|
|
||||||
- When you invoke a skill, you will see <command-message>
|
|
||||||
The skill is activated
|
|
||||||
</command-message>
|
|
||||||
- The skill's instructions will be provided in the tool result
|
|
||||||
- Examples:
|
|
||||||
- skill_name: "pdf" - invoke the pdf skill
|
|
||||||
- skill_name: "data-analysis" - invoke the data-analysis skill
|
|
||||||
|
|
||||||
Important:
|
|
||||||
- Only use skills listed in <available_skills> below
|
|
||||||
- Do not invoke a skill that is already running
|
|
||||||
- To create a new skill: prepare it in /workspace, then use register_skill tool
|
|
||||||
</skills_instructions>
|
|
||||||
|
|
||||||
{available_skills_block}"""
|
|
||||||
|
|||||||
@@ -86,50 +86,3 @@ class SkillManager:
|
|||||||
def get_skill_by_name(self, name: str) -> dict | None:
|
def get_skill_by_name(self, name: str) -> dict | None:
|
||||||
"""Get skill data by name."""
|
"""Get skill data by name."""
|
||||||
return self.skills.get(name)
|
return self.skills.get(name)
|
||||||
|
|
||||||
def get_skill_index(self, bound_skills: list[str] | None = None) -> str:
|
|
||||||
"""Render the pipeline-visible skills as a short ``name: description``
|
|
||||||
index suitable for the system prompt.
|
|
||||||
|
|
||||||
``bound_skills`` follows the same convention as
|
|
||||||
``query.variables['_pipeline_bound_skills']``: ``None`` means every
|
|
||||||
loaded skill is exposed; an explicit list filters to that subset.
|
|
||||||
Returns an empty string when no skills are visible.
|
|
||||||
"""
|
|
||||||
lines: list[str] = []
|
|
||||||
for skill in self.skills.values():
|
|
||||||
name = skill.get('name')
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
if bound_skills is not None and name not in bound_skills:
|
|
||||||
continue
|
|
||||||
display = skill.get('display_name') or name
|
|
||||||
description = (skill.get('description') or '').strip().replace('\n', ' ')
|
|
||||||
lines.append(f'- {name} ({display}): {description}')
|
|
||||||
|
|
||||||
if not lines:
|
|
||||||
return ''
|
|
||||||
return 'Available Skills:\n' + '\n'.join(lines)
|
|
||||||
|
|
||||||
def build_skill_aware_prompt_addition(self, bound_skills: list[str] | None = None) -> str:
|
|
||||||
"""Build the system-prompt addendum that makes the LLM aware of the
|
|
||||||
pipeline-visible skills.
|
|
||||||
|
|
||||||
Only metadata (name + description) is injected — the full SKILL.md is
|
|
||||||
loaded later via the ``activate`` Tool Call, protecting KV cache and
|
|
||||||
matching Claude Code's progressive disclosure pattern. Returns an
|
|
||||||
empty string when no skills are visible (no prompt change at all).
|
|
||||||
"""
|
|
||||||
skill_index = self.get_skill_index(bound_skills)
|
|
||||||
if not skill_index:
|
|
||||||
return ''
|
|
||||||
return (
|
|
||||||
'\n\n'
|
|
||||||
f'{skill_index}\n\n'
|
|
||||||
"When the user's request clearly matches one or more skills "
|
|
||||||
'based on their descriptions above, call the `activate` tool with '
|
|
||||||
'the skill name to load its full instructions. Only the name and '
|
|
||||||
'description are visible here; the actual instructions arrive as '
|
|
||||||
'the tool result. If no skill is a clear match, respond normally '
|
|
||||||
'without activating any skill.'
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -122,11 +122,9 @@ class FakeApp:
|
|||||||
return cmd_mgr
|
return cmd_mgr
|
||||||
|
|
||||||
def _create_mock_skill_mgr(self):
|
def _create_mock_skill_mgr(self):
|
||||||
"""Mock SkillManager that returns no skill index addition by default."""
|
"""Mock SkillManager with no loaded skills by default."""
|
||||||
skill_mgr = Mock()
|
skill_mgr = Mock()
|
||||||
skill_mgr.skills = {}
|
skill_mgr.skills = {}
|
||||||
skill_mgr.build_skill_aware_prompt_addition = Mock(return_value='')
|
|
||||||
skill_mgr.get_skill_index = Mock(return_value=[])
|
|
||||||
return skill_mgr
|
return skill_mgr
|
||||||
|
|
||||||
def _create_mock_pipeline_service(self):
|
def _create_mock_pipeline_service(self):
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ def make_resources(
|
|||||||
models: list[dict] | None = None,
|
models: list[dict] | None = None,
|
||||||
tools: list[dict] | None = None,
|
tools: list[dict] | None = None,
|
||||||
knowledge_bases: list[dict] | None = None,
|
knowledge_bases: list[dict] | None = None,
|
||||||
|
skills: list[dict] | None = None,
|
||||||
storage: dict | None = None,
|
storage: dict | None = None,
|
||||||
files: list[dict] | None = None,
|
files: list[dict] | None = None,
|
||||||
) -> dict[str, typing.Any]:
|
) -> dict[str, typing.Any]:
|
||||||
@@ -17,6 +18,7 @@ def make_resources(
|
|||||||
models: List of model dicts with 'model_id' key
|
models: List of model dicts with 'model_id' key
|
||||||
tools: List of tool dicts with 'tool_name' key
|
tools: List of tool dicts with 'tool_name' key
|
||||||
knowledge_bases: List of KB dicts with 'kb_id' key
|
knowledge_bases: List of KB dicts with 'kb_id' key
|
||||||
|
skills: List of skill dicts with 'skill_name' key
|
||||||
storage: Storage permissions dict
|
storage: Storage permissions dict
|
||||||
files: List of file dicts with 'file_id' key
|
files: List of file dicts with 'file_id' key
|
||||||
|
|
||||||
@@ -27,6 +29,7 @@ def make_resources(
|
|||||||
'models': models or [],
|
'models': models or [],
|
||||||
'tools': tools or [],
|
'tools': tools or [],
|
||||||
'knowledge_bases': knowledge_bases or [],
|
'knowledge_bases': knowledge_bases or [],
|
||||||
|
'skills': skills or [],
|
||||||
'files': files or [],
|
'files': files or [],
|
||||||
'storage': storage or {'plugin_storage': False, 'workspace_storage': False},
|
'storage': storage or {'plugin_storage': False, 'workspace_storage': False},
|
||||||
'platform_capabilities': {},
|
'platform_capabilities': {},
|
||||||
@@ -71,6 +74,7 @@ def make_session(
|
|||||||
'model': {m.get('model_id') for m in res.get('models', [])},
|
'model': {m.get('model_id') for m in res.get('models', [])},
|
||||||
'tool': {t.get('tool_name') for t in res.get('tools', [])},
|
'tool': {t.get('tool_name') for t in res.get('tools', [])},
|
||||||
'knowledge_base': {kb.get('kb_id') for kb in res.get('knowledge_bases', [])},
|
'knowledge_base': {kb.get('kb_id') for kb in res.get('knowledge_bases', [])},
|
||||||
|
'skill': {s.get('skill_name') for s in res.get('skills', [])},
|
||||||
'file': {f.get('file_id') for f in res.get('files', [])},
|
'file': {f.get('file_id') for f in res.get('files', [])},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ class TestContextValidation:
|
|||||||
'models': [],
|
'models': [],
|
||||||
'tools': [],
|
'tools': [],
|
||||||
'knowledge_bases': [],
|
'knowledge_bases': [],
|
||||||
|
'skills': [],
|
||||||
'files': [],
|
'files': [],
|
||||||
'storage': {'plugin_storage': True, 'workspace_storage': True},
|
'storage': {'plugin_storage': True, 'workspace_storage': True},
|
||||||
'platform_capabilities': {},
|
'platform_capabilities': {},
|
||||||
|
|||||||
@@ -124,6 +124,20 @@ class FakeApplication:
|
|||||||
self.rag_mgr = types.SimpleNamespace(
|
self.rag_mgr = types.SimpleNamespace(
|
||||||
get_knowledge_base_by_uuid=AsyncMock(return_value=FakeKnowledgeBase("kb_001"))
|
get_knowledge_base_by_uuid=AsyncMock(return_value=FakeKnowledgeBase("kb_001"))
|
||||||
)
|
)
|
||||||
|
self.skill_mgr = types.SimpleNamespace(
|
||||||
|
skills={
|
||||||
|
"demo": {
|
||||||
|
"name": "demo",
|
||||||
|
"display_name": "Demo Skill",
|
||||||
|
"description": "Helps with demo tasks.",
|
||||||
|
},
|
||||||
|
"hidden": {
|
||||||
|
"name": "hidden",
|
||||||
|
"display_name": "Hidden Skill",
|
||||||
|
"description": "Not bound to this pipeline.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FakeConversation:
|
class FakeConversation:
|
||||||
@@ -140,7 +154,12 @@ def make_descriptor() -> AgentRunnerDescriptor:
|
|||||||
plugin_name="local-agent",
|
plugin_name="local-agent",
|
||||||
runner_name="default",
|
runner_name="default",
|
||||||
protocol_version="1",
|
protocol_version="1",
|
||||||
capabilities={"streaming": True, "tool_calling": True, "knowledge_retrieval": True},
|
capabilities={
|
||||||
|
"streaming": True,
|
||||||
|
"tool_calling": True,
|
||||||
|
"knowledge_retrieval": True,
|
||||||
|
"skill_authoring": True,
|
||||||
|
},
|
||||||
config_schema=[
|
config_schema=[
|
||||||
{"name": "model", "type": "model-fallback-selector"},
|
{"name": "model", "type": "model-fallback-selector"},
|
||||||
{"name": "knowledge-bases", "type": "knowledge-base-multi-selector", "default": []},
|
{"name": "knowledge-bases", "type": "knowledge-base-multi-selector", "default": []},
|
||||||
@@ -211,6 +230,7 @@ def make_query():
|
|||||||
variables={
|
variables={
|
||||||
"_pipeline_bound_plugins": ["langbot/local-agent"],
|
"_pipeline_bound_plugins": ["langbot/local-agent"],
|
||||||
"_fallback_model_uuids": ["model_fallback"],
|
"_fallback_model_uuids": ["model_fallback"],
|
||||||
|
"_pipeline_bound_skills": ["demo"],
|
||||||
"public_param": "visible",
|
"public_param": "visible",
|
||||||
},
|
},
|
||||||
use_llm_model_uuid="model_primary",
|
use_llm_model_uuid="model_primary",
|
||||||
@@ -323,12 +343,20 @@ async def test_orchestrator_runs_fake_plugin_with_authorized_context(clean_agent
|
|||||||
assert {m["model_id"] for m in resources["models"]} == {"model_primary", "model_fallback"}
|
assert {m["model_id"] for m in resources["models"]} == {"model_primary", "model_fallback"}
|
||||||
assert resources["tools"][0]["tool_name"] == "langbot/test-tool/search"
|
assert resources["tools"][0]["tool_name"] == "langbot/test-tool/search"
|
||||||
assert resources["knowledge_bases"][0]["kb_id"] == "kb_001"
|
assert resources["knowledge_bases"][0]["kb_id"] == "kb_001"
|
||||||
|
assert resources["skills"] == [
|
||||||
|
{
|
||||||
|
"skill_name": "demo",
|
||||||
|
"display_name": "Demo Skill",
|
||||||
|
"description": "Helps with demo tasks.",
|
||||||
|
}
|
||||||
|
]
|
||||||
assert resources["storage"]["plugin_storage"] is True
|
assert resources["storage"]["plugin_storage"] is True
|
||||||
|
|
||||||
session_during_run = plugin_connector.sessions_during_run[0]
|
session_during_run = plugin_connector.sessions_during_run[0]
|
||||||
assert session_during_run is not None
|
assert session_during_run is not None
|
||||||
assert session_during_run["plugin_identity"] == "langbot/local-agent"
|
assert session_during_run["plugin_identity"] == "langbot/local-agent"
|
||||||
assert session_during_run["authorization"]["authorized_ids"]["tool"] == {"langbot/test-tool/search"}
|
assert session_during_run["authorization"]["authorized_ids"]["tool"] == {"langbot/test-tool/search"}
|
||||||
|
assert session_during_run["authorization"]["authorized_ids"]["skill"] == {"demo"}
|
||||||
assert await get_session_registry().get(context["run_id"]) is None
|
assert await get_session_registry().get(context["run_id"]) is None
|
||||||
|
|
||||||
|
|
||||||
@@ -766,6 +794,50 @@ class TestQueryEntryAdapterHostCapabilities:
|
|||||||
snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor)
|
snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor)
|
||||||
assert snapshot["conversation"]["external.test_key"] == "test_value"
|
assert snapshot["conversation"]["external.test_key"] == "test_value"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_from_query_restores_activated_skills_from_state(self, clean_agent_state):
|
||||||
|
"""Persisted activated skill names are restored into the next Query run."""
|
||||||
|
from langbot.pkg.agent.runner.persistent_state_store import get_persistent_state_store
|
||||||
|
from langbot.pkg.provider.tools.loaders.skill import (
|
||||||
|
ACTIVATED_SKILL_NAMES_STATE_KEY,
|
||||||
|
ACTIVATED_SKILLS_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
|
db_engine = clean_agent_state
|
||||||
|
descriptor = make_descriptor()
|
||||||
|
plugin_connector = FakePluginConnector(
|
||||||
|
results=[
|
||||||
|
{
|
||||||
|
"type": "message.completed",
|
||||||
|
"data": {"message": {"role": "assistant", "content": "restored"}},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
ap = FakeApplication(plugin_connector, db_engine)
|
||||||
|
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
|
||||||
|
query = make_query()
|
||||||
|
|
||||||
|
persistent_store = get_persistent_state_store(db_engine)
|
||||||
|
event = QueryEntryAdapter.query_to_event(query)
|
||||||
|
agent_config = QueryEntryAdapter.config_to_agent_config(query, RUNNER_ID)
|
||||||
|
binding = AgentBindingResolver().resolve_one(event, [agent_config])
|
||||||
|
success, error = await persistent_store.apply_update_from_event(
|
||||||
|
event,
|
||||||
|
binding,
|
||||||
|
descriptor,
|
||||||
|
"conversation",
|
||||||
|
ACTIVATED_SKILL_NAMES_STATE_KEY,
|
||||||
|
["demo"],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert success is True
|
||||||
|
assert error is None
|
||||||
|
|
||||||
|
messages = [message async for message in orchestrator.run_from_query(query)]
|
||||||
|
|
||||||
|
assert len(messages) == 1
|
||||||
|
assert query.variables[ACTIVATED_SKILLS_KEY]["demo"]["name"] == "demo"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_event_log_and_transcript_written(self, clean_agent_state):
|
async def test_event_log_and_transcript_written(self, clean_agent_state):
|
||||||
"""EventLog and Transcript are written via Pipeline path."""
|
"""EventLog and Transcript are written via Pipeline path."""
|
||||||
|
|||||||
@@ -307,6 +307,21 @@ class TestIsResourceAllowed:
|
|||||||
|
|
||||||
assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is False
|
assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is False
|
||||||
|
|
||||||
|
def test_skill_allowed(self):
|
||||||
|
"""Skill in resources should be allowed."""
|
||||||
|
registry = AgentRunSessionRegistry()
|
||||||
|
resources = make_resources(
|
||||||
|
skills=[
|
||||||
|
{'skill_name': 'demo', 'display_name': 'Demo'},
|
||||||
|
{'skill_name': 'writer', 'display_name': 'Writer'},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session = make_session(resources=resources)
|
||||||
|
|
||||||
|
assert registry.is_resource_allowed(session, 'skill', 'demo') is True
|
||||||
|
assert registry.is_resource_allowed(session, 'skill', 'writer') is True
|
||||||
|
assert registry.is_resource_allowed(session, 'skill', 'hidden') is False
|
||||||
|
|
||||||
def test_storage_plugin_allowed(self):
|
def test_storage_plugin_allowed(self):
|
||||||
"""Plugin storage permission should be checked."""
|
"""Plugin storage permission should be checked."""
|
||||||
registry = AgentRunSessionRegistry()
|
registry = AgentRunSessionRegistry()
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ def make_host_model_runner_descriptor(
|
|||||||
tool_calling: bool = True,
|
tool_calling: bool = True,
|
||||||
knowledge_retrieval: bool = True,
|
knowledge_retrieval: bool = True,
|
||||||
skill_authoring: bool = False,
|
skill_authoring: bool = False,
|
||||||
skill_injection: bool = False,
|
|
||||||
):
|
):
|
||||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||||
|
|
||||||
@@ -72,7 +71,6 @@ def make_host_model_runner_descriptor(
|
|||||||
'knowledge_retrieval': knowledge_retrieval,
|
'knowledge_retrieval': knowledge_retrieval,
|
||||||
'multimodal_input': multimodal_input,
|
'multimodal_input': multimodal_input,
|
||||||
'skill_authoring': skill_authoring,
|
'skill_authoring': skill_authoring,
|
||||||
'skill_injection': skill_injection,
|
|
||||||
},
|
},
|
||||||
permissions={
|
permissions={
|
||||||
'models': ['list', 'invoke', 'stream'],
|
'models': ['list', 'invoke', 'stream'],
|
||||||
|
|||||||
@@ -274,6 +274,130 @@ class TestSkillToolLoader:
|
|||||||
SimpleNamespace(variables={}),
|
SimpleNamespace(variables={}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_activate_rejects_pipeline_hidden_skill(self):
|
||||||
|
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||||
|
ACTIVATE_SKILL_TOOL_NAME,
|
||||||
|
SkillToolLoader,
|
||||||
|
)
|
||||||
|
from langbot.pkg.provider.tools.loaders.skill import (
|
||||||
|
ACTIVATED_SKILLS_KEY,
|
||||||
|
PIPELINE_BOUND_SKILLS_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
|
demo = _make_skill_data(name='demo', package_root='/data/skills/demo', instructions='Demo instructions')
|
||||||
|
hidden = _make_skill_data(
|
||||||
|
name='hidden',
|
||||||
|
package_root='/data/skills/hidden',
|
||||||
|
instructions='Hidden instructions',
|
||||||
|
)
|
||||||
|
ap = _make_ap()
|
||||||
|
ap.skill_mgr = SimpleNamespace(
|
||||||
|
skills={'demo': demo, 'hidden': hidden},
|
||||||
|
)
|
||||||
|
|
||||||
|
loader = SkillToolLoader(ap)
|
||||||
|
query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']})
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='Available skills: demo'):
|
||||||
|
await loader.invoke_tool(ACTIVATE_SKILL_TOOL_NAME, {'skill_name': 'hidden'}, query)
|
||||||
|
|
||||||
|
result = await loader.invoke_tool(ACTIVATE_SKILL_TOOL_NAME, {'skill_name': 'demo'}, query)
|
||||||
|
|
||||||
|
assert result['activated'] is True
|
||||||
|
assert result['skill_name'] == 'demo'
|
||||||
|
assert set(query.variables[ACTIVATED_SKILLS_KEY].keys()) == {'demo'}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_activate_persists_and_restores_for_next_query_exec(self, tmp_path):
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
from langbot.pkg.agent.runner.persistent_state_store import (
|
||||||
|
get_persistent_state_store,
|
||||||
|
reset_persistent_state_store,
|
||||||
|
)
|
||||||
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
|
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||||
|
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||||
|
ACTIVATE_SKILL_TOOL_NAME,
|
||||||
|
SkillToolLoader,
|
||||||
|
)
|
||||||
|
from langbot.pkg.provider.tools.loaders.skill import (
|
||||||
|
ACTIVATED_SKILL_NAMES_STATE_KEY,
|
||||||
|
ACTIVATED_SKILLS_KEY,
|
||||||
|
PIPELINE_BOUND_SKILLS_KEY,
|
||||||
|
restore_activated_skills_from_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
reset_persistent_state_store()
|
||||||
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "state.db"}')
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
try:
|
||||||
|
skill = _make_skill_data(name='demo', package_root=str(tmp_path), instructions='Demo instructions')
|
||||||
|
ap = _make_ap()
|
||||||
|
ap.persistence_mgr.get_db_engine = Mock(return_value=engine)
|
||||||
|
ap.box_service = SimpleNamespace(
|
||||||
|
available=True,
|
||||||
|
default_workspace=str(tmp_path),
|
||||||
|
execute_tool=AsyncMock(return_value={'ok': True}),
|
||||||
|
)
|
||||||
|
ap.skill_mgr = SimpleNamespace(
|
||||||
|
skills={'demo': skill},
|
||||||
|
refresh_skill_from_disk=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
scope_key = 'conversation:plugin:langbot/local-agent/default:binding_001:conv_001'
|
||||||
|
query1 = SimpleNamespace(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']})
|
||||||
|
object.__setattr__(
|
||||||
|
query1,
|
||||||
|
'_agent_run_session',
|
||||||
|
{
|
||||||
|
'runner_id': 'plugin:langbot/local-agent/default',
|
||||||
|
'authorization': {
|
||||||
|
'state_policy': {'enable_state': True, 'state_scopes': ['conversation']},
|
||||||
|
'state_context': {
|
||||||
|
'scope_keys': {'conversation': scope_key},
|
||||||
|
'binding_identity': 'binding_001',
|
||||||
|
'conversation_id': 'conv_001',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await SkillToolLoader(ap).invoke_tool(ACTIVATE_SKILL_TOOL_NAME, {'skill_name': 'demo'}, query1)
|
||||||
|
|
||||||
|
store = get_persistent_state_store(engine)
|
||||||
|
persisted_names = await store.state_get(scope_key, ACTIVATED_SKILL_NAMES_STATE_KEY)
|
||||||
|
assert persisted_names == ['demo']
|
||||||
|
|
||||||
|
query2 = SimpleNamespace(query_id='q2', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']})
|
||||||
|
restored = restore_activated_skills_from_state(
|
||||||
|
ap,
|
||||||
|
query2,
|
||||||
|
{'conversation': {ACTIVATED_SKILL_NAMES_STATE_KEY: persisted_names}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert restored == ['demo']
|
||||||
|
assert set(query2.variables[ACTIVATED_SKILLS_KEY]) == {'demo'}
|
||||||
|
|
||||||
|
result = await NativeToolLoader(ap).invoke_tool(
|
||||||
|
'exec',
|
||||||
|
{
|
||||||
|
'command': 'python /workspace/.skills/demo/scripts/run.py',
|
||||||
|
'workdir': '/workspace/.skills/demo',
|
||||||
|
},
|
||||||
|
query2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['ok'] is True
|
||||||
|
ap.box_service.execute_tool.assert_awaited_once()
|
||||||
|
ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with('demo')
|
||||||
|
finally:
|
||||||
|
reset_persistent_state_store()
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_register_skill_scans_directory_and_creates_skill(self):
|
async def test_register_skill_scans_directory_and_creates_skill(self):
|
||||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||||
@@ -461,6 +585,35 @@ class TestNativeToolLoaderSkillPaths:
|
|||||||
assert tool_parameters['workdir'] == '/workspace/.skills/demo'
|
assert tool_parameters['workdir'] == '/workspace/.skills/demo'
|
||||||
ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with('demo')
|
ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with('demo')
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exec_requires_skill_activation_even_when_skill_visible(self):
|
||||||
|
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||||
|
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
ap = _make_ap()
|
||||||
|
ap.box_service = SimpleNamespace(
|
||||||
|
available=True,
|
||||||
|
default_workspace=tmpdir,
|
||||||
|
execute_tool=AsyncMock(return_value={'ok': True}),
|
||||||
|
)
|
||||||
|
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)})
|
||||||
|
loader = NativeToolLoader(ap)
|
||||||
|
|
||||||
|
query = SimpleNamespace(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']})
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='must be activated before exec'):
|
||||||
|
await loader.invoke_tool(
|
||||||
|
'exec',
|
||||||
|
{
|
||||||
|
'command': 'python /workspace/.skills/demo/scripts/run.py',
|
||||||
|
'workdir': '/workspace',
|
||||||
|
},
|
||||||
|
query,
|
||||||
|
)
|
||||||
|
|
||||||
|
ap.box_service.execute_tool.assert_not_awaited()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_write_requires_skill_activation(self):
|
async def test_write_requires_skill_activation(self):
|
||||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ class _FakeRunnerDescriptor:
|
|||||||
'knowledge_retrieval': True,
|
'knowledge_retrieval': True,
|
||||||
'multimodal_input': True,
|
'multimodal_input': True,
|
||||||
'skill_authoring': True,
|
'skill_authoring': True,
|
||||||
'skill_injection': True,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def supports_tool_calling(self):
|
def supports_tool_calling(self):
|
||||||
@@ -122,7 +121,6 @@ def _make_app(*, skill_service) -> SimpleNamespace:
|
|||||||
),
|
),
|
||||||
agent_runner_registry=SimpleNamespace(get=AsyncMock(return_value=_FakeRunnerDescriptor())),
|
agent_runner_registry=SimpleNamespace(get=AsyncMock(return_value=_FakeRunnerDescriptor())),
|
||||||
skill_mgr=SimpleNamespace(
|
skill_mgr=SimpleNamespace(
|
||||||
build_skill_aware_prompt_addition=Mock(return_value=''),
|
|
||||||
skills={},
|
skills={},
|
||||||
),
|
),
|
||||||
skill_service=skill_service,
|
skill_service=skill_service,
|
||||||
@@ -195,30 +193,24 @@ async def test_preproc_disables_skill_authoring_tools_when_skill_service_missing
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_preproc_injects_skill_index_into_system_prompt():
|
async def test_preproc_records_all_visible_skills_without_prompt_injection():
|
||||||
"""The Tool Call activation pattern still needs the LLM to know which
|
|
||||||
skills exist. PreProcessor must append the SkillManager's index
|
|
||||||
addendum to the first system message."""
|
|
||||||
preproc_module, entities_module = _import_preproc_modules()
|
preproc_module, entities_module = _import_preproc_modules()
|
||||||
|
|
||||||
app = _make_app(skill_service=SimpleNamespace())
|
app = _make_app(skill_service=SimpleNamespace())
|
||||||
addendum = '\n\nAvailable Skills:\n- demo (demo): Demo skill.\n\nCall activate ...'
|
|
||||||
app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value=addendum)
|
|
||||||
|
|
||||||
query = _make_query()
|
query = _make_query()
|
||||||
result = await stage_process_capture(preproc_module, app, query)
|
result = await stage_process_capture(preproc_module, app, query)
|
||||||
|
|
||||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||||
app.skill_mgr.build_skill_aware_prompt_addition.assert_called_once_with(bound_skills=None)
|
app.pipeline_service.get_pipeline.assert_awaited_once_with('pipe-1')
|
||||||
|
assert query.variables.get('_pipeline_bound_skills') is None
|
||||||
head = query.prompt.messages[0]
|
head = query.prompt.messages[0]
|
||||||
assert head.role == 'system'
|
assert head.role == 'system'
|
||||||
assert head.content.endswith(addendum)
|
assert head.content == 'system prompt'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_preproc_respects_pipeline_bound_skills_subset():
|
async def test_preproc_respects_pipeline_bound_skills_subset():
|
||||||
"""When ``enable_all_skills`` is false the bound list is passed through
|
|
||||||
so the addendum only mentions skills allowed for this pipeline."""
|
|
||||||
preproc_module, entities_module = _import_preproc_modules()
|
preproc_module, entities_module = _import_preproc_modules()
|
||||||
|
|
||||||
app = _make_app(skill_service=SimpleNamespace())
|
app = _make_app(skill_service=SimpleNamespace())
|
||||||
@@ -230,31 +222,28 @@ async def test_preproc_respects_pipeline_bound_skills_subset():
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value='')
|
|
||||||
|
|
||||||
query = _make_query()
|
query = _make_query()
|
||||||
result = await stage_process_capture(preproc_module, app, query)
|
result = await stage_process_capture(preproc_module, app, query)
|
||||||
|
|
||||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||||
app.skill_mgr.build_skill_aware_prompt_addition.assert_called_once_with(bound_skills=['only-this'])
|
|
||||||
assert query.variables.get('_pipeline_bound_skills') == ['only-this']
|
assert query.variables.get('_pipeline_bound_skills') == ['only-this']
|
||||||
|
assert query.prompt.messages[0].content == 'system prompt'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_preproc_skips_injection_when_addendum_is_empty():
|
async def test_preproc_does_not_load_skill_preferences_without_skill_authoring_service():
|
||||||
"""No visible skills → system prompt is left untouched (no
|
|
||||||
``Available Skills`` block appended)."""
|
|
||||||
preproc_module, entities_module = _import_preproc_modules()
|
preproc_module, entities_module = _import_preproc_modules()
|
||||||
|
|
||||||
app = _make_app(skill_service=SimpleNamespace())
|
app = _make_app(skill_service=None)
|
||||||
app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value='')
|
|
||||||
|
|
||||||
query = _make_query()
|
query = _make_query()
|
||||||
result = await stage_process_capture(preproc_module, app, query)
|
result = await stage_process_capture(preproc_module, app, query)
|
||||||
|
|
||||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||||
if query.prompt and query.prompt.messages:
|
app.pipeline_service.get_pipeline.assert_not_awaited()
|
||||||
assert 'Available Skills' not in (query.prompt.messages[0].content or '')
|
assert '_pipeline_bound_skills' not in query.variables
|
||||||
|
assert query.prompt.messages[0].content == 'system prompt'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user