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 cede35b31b
commit 190028d5ab
13 changed files with 210 additions and 62 deletions
@@ -109,7 +109,7 @@ Claude Code、Codex、Kimi Code 这类 runtime 通常已有自己的 session、
- `agent-context.json`:结构化 JSON,包含 `run_id``event``actor``subject``input``delivery``resources``context``state``runtime`
- `LANGBOT_CONTEXT.md`:人类可读摘要。
- `resources`:只包含本次 run 授权后的资源句柄和能力摘要,不暴露 Host 内部私有对象、secret 或资源内容。
- `skills`LangBot skills 不是直接投影给 harness native tool loop 的文件能力;已授权 skill 应由 Host / sandbox 封装成 scoped tools,再通过 `ctx.resources.tools``AgentRunAPIProxy` 或 SDK-owned MCP bridge 暴露
- `skills`LangBot skills 不是直接投影给 harness native tool loop 的文件能力,而是**一组被授权的 tool**。发现走 `list_skills`(或 `langbot_list_assets` 增加 skills 一类),激活/注册走 `activate` / `register_skill`,包内操作走 native exec/read/write,统一通过 `ctx.resources.tools``AgentRunAPIProxy` 或 SDK-owned MCP bridge 暴露。Host 不向 prompt 注入 skill 索引(无 progressive-disclosure 注入);harness 通过调用发现工具主动查询 skill 清单。`agent-context.json``skills` 字段仅作发现工具的数据来源与可选 `suggested_skill_prompt` 的输入
- `MCP config`:只投影 per-run、scoped 的 SDK-owned bridge 或外部 MCP 连接配置;LangBot 资源访问必须回到 SDK runtime / Host API,不允许 harness 通过自带 MCP/native tool 直接读 Host 内部资源。
- `state pointers`:外部 session id、working directory、checkpoint 等小型 JSON 状态通过 Host state API 保存。
@@ -214,16 +214,20 @@ run session、caller plugin identity、resource id、scope、payload size、rate
limit 和 deadline。Handler 不应重新执行授权裁剪,否则 build-time 与 runtime
授权逻辑会漂移。
SDK 侧本地校验只用于开发体验,host 侧 run authorization snapshot 才是安全边界。`spec.capabilities` 只帮助 Host 判断 runner 是否需要 tool / knowledge / skill 等资源投影,不能替代 permissions 或 binding policy。
SDK 侧本地校验只用于开发体验,host 侧 run authorization snapshot 才是安全边界。`spec.capabilities` 只帮助 Host 判断 runner 是否需要 tool / knowledge 等资源投影,不能替代 permissions 或 binding policy。skill 不由独立 capability 决定是否投影——它通过统一 tool 授权(`resource_policy.allowed_tool_names`)消费,`skill_authoring` 仅作为「一键授权这组 skill tool + sandbox」的便捷开关。
资源裁剪应通用,不写死 local-agent。selector 与资源的映射示例:`model-fallback-selector` → primary/fallback LLM、`llm-model-selector` → LLM、`rerank-model-selector` → rerank 模型、`knowledge-base-multi-selector` → 知识库;新增 selector 时在 resource builder 中统一扩展。
执行/文件/skill/MCP 等能力的接入方向:先由 Host / sandbox 封装成普通 scoped tool,再通过 `ctx.resources.tools` 和 SDK runtime 转发进入 runnerrunner 不应识别或硬编码执行环境 provider。外部 harness 的 native tools 不能直接访问 LangBot 资源
构造 `ctx.resources.tools` 时,Host 一次塞齐每个工具的完整 schema(`ToolResource.parameters`),runner 不需再逐个 `get_tool_detail` 拉取,减少 N 次往返
执行/文件/skill/MCP 等能力的接入方向:先由 Host / sandbox 封装成普通 scoped tool,再通过 `ctx.resources.tools` 和 SDK runtime 转发进入 runnerrunner 不应识别或硬编码执行环境 provider。外部 harness 的 native tools 不能直接访问 LangBot 资源。skill 的整个生命周期都走统一 tool:发现走 `list_skills` / `langbot_list_assets`,激活/注册走 `activate` / `register_skill`,包内操作走 native exec/read/write——runner 不需要独立的 skill 渲染或门控。
### 4.6 State / Storage
LangBot 可提供 host-owned state 让 runner 寄宿状态(conversation / actor / subject / runner / binding / workspace state),但**不是强制**。Host 只需提供:授权开关、scope key、get/set/list/delete API(见 PROTOCOL_V1 §8)、持久化 backend、审计和清理策略。外部 agent runtime 可维护自己的 session 和 memory。进程内 state store 只能作为过渡实现,不能作为正式生产语义。
部分 host-owned state 由 Host 自身直接写:例如 `activate` tool 在 Host 侧执行时,把已激活 skill 写入 conversation scope 的 `host.activated_skills`。host 直接写与 runner `state.updated` 写到同一 key 时按 **last-write-wins** 合并,runner 可覆盖。
### 4.7 EventLog / Transcript / Sandbox Files(事实源)
- `EventLog`: durable append-only,保存原始事件、系统事件、工具调用、投递结果、错误。
+14 -3
View File
@@ -111,7 +111,7 @@ class AgentRunnerCapabilities(BaseModel):
- `tool_calling`: runner 可能调用 Host tool API。
- `knowledge_retrieval`: runner 可能调用 Host knowledge API。
- `multimodal_input`: runner 可以处理非纯文本 input / attachment。
- `skill_authoring`: runner 需要 Host 提供 skill facts 以及 skill authoring tools,例如 `activate` / `register_skill`
- `skill_authoring`:(降级为便捷开关,非访问硬前提)声明该 runner 期望使用 LangBot skill 工具链。skill 本身通过**统一 tool 授权**获得——发现走 `list_skills` / `langbot_list_assets`,激活/注册走 `activate` / `register_skill`,操作走 native exec/read/write,全部计入 `resource_policy.allowed_tool_names`。该 capability 仅作为「一键授权这组 skill tool + sandbox」的便捷开关,不再单独决定 skill 是否可用
- `interrupt`: runner 支持取消或中断。
- `steering`: runner 支持在 turn 边界通过 Host pull API 消费同 conversation 在途追加消息。
@@ -354,6 +354,13 @@ State 是可选 host-owned snapshot。Runner 也可以完全自管状态。
## 6. Resources
```python
class ToolResource(BaseModel):
tool_name: str
tool_type: str | None = None
description: str | None = None
parameters: dict[str, Any] | None = None # 完整 JSON schema,由 Host 一次塞齐
operations: list[Literal["detail", "call"]] = []
class SkillResource(BaseModel):
skill_name: str
display_name: str | None = None
@@ -368,7 +375,9 @@ class AgentResources(BaseModel):
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,或只在自己的策略层使用
`tools` 携带每个授权工具的完整 schema`parameters`),由 Host 在构造 `ctx.resources` 时一次塞齐,runner 不需再逐个调用 `get_tool_detail` 拉取,减少 N 次往返
`skills` 是本次 run 中 pipeline-visible 的 skill facts`skill_name``display_name``description`)。**skill 通过统一 tool 形式消费,不是独立资源类别**:发现走 `list_skills` tool(或 `langbot_list_assets` 增加 skills 一类),激活走 `activate`,操作走 native exec/read/write。Host **不**把 skill 索引注入 system prompt,也不做 progressive-disclosure 注入;LLM 通过调用发现工具主动查询 skill 清单。Host **可选**在 ctx 提供预渲染的 `suggested_skill_prompt`(首轮延迟优化,runner 可忽略 / override),但它不是访问前提。`skills` 字段本身仅作为发现工具的数据来源与该可选预渲染的输入。
资源列表是本次 run 的授权结果。History / Event / State / Storage 访问通过 `ctx.context.available_apis` 和 Host 侧 run session 校验控制,不作为可枚举 resource list 暴露。Runner 只能通过 `AgentRunAPIProxy` 访问这些能力。当前事件的文件和工具大结果优先进入授权 sandbox/workspace,由 runner 通过 read/write/exec 类工具按需读取。
@@ -457,6 +466,8 @@ Runner 生成的大文件、工具输出和临时产物不通过 result event
Host 必须校验 `state.updated` 的 scope、key、value 大小和 JSON 可序列化性。本分支 `action.requested` 仍只记录 telemetry。
除 runner 经 `state.updated` 写之外,Host 自身也可直接写部分 host-owned state。例如 `activate` tool 在 Host 侧执行时,直接把已激活 skill 写入 conversation scope 的 `host.activated_skills` 快照。当 host 直接写与 runner `state.updated` 写到同一 key 时,按 **last-write-wins** 合并——runner 可以覆盖 host 写的快照。
### 7.4 Stream delivery semantics
- Host 按 Runtime stream 顺序消费 result。当前 v1 不定义跨连接 replay,也不承诺 at-least-once;从 Host 视角,收到的 result 最多应用一次。
@@ -722,4 +733,4 @@ entry adapter 只是迁移桥。它负责:
- `AgentBinding` 是否需要进入 SDK 文档作为只读诊断信息,还是完全 Host 内部。
- State 与 Storage 的边界是否需要更强类型。
- platform action 的审批模型如何表达。
- Host 侧 scoped MCP / skill / workspace projection 是否需要从 runner config 上移为一等 resource projection API。
- Host 侧 scoped MCP / workspace projection 是否需要从 runner config 上移为一等 resource projection API。skill 一项已收敛:skill 全 tool 化,作为被授权 tool 暴露,不再是独立 projection。)
@@ -74,6 +74,8 @@ Claude Code、Codex、OpenCode、Kimi Code、Gemini CLI 等外部工具继续使
当前实现方向是正确的:`AgentRunSessionRegistry` 保存 run-scoped snapshot`plugin/handler.py` 对模型、工具、知识库、history、state、storage 等 action 做运行期校验,sandbox/workspace 文件访问由 scoped tool 边界控制。
**Skill 读写门控(不可弱化)**pipeline-visible 的 skill 一次性以 `rw` 挂进同一 sandbox,mount 层不区分「可见」与「已激活」;写类 native 操作(write/edit/exec)只放行 activated skill,读类放行 visible + activated——这层区分等同资产授权语义,必须保留。skill 全 tool 化后尤其注意:「都是 tool」不等于「只控资产授权即可」,native 层的 visible/activated 门控不能砍。可弱化的只是 realpath 越界字符串检查(有 chroot/namespace 兜底)。
### MCP / Asset Gateway Boundary
LangBot MCP / asset gateway 只暴露当前 run 授权的工具面:
@@ -158,7 +160,7 @@ LangBot 需要提供基本可控性:
| Permission boundary | 必须保护 LangBot 资源;不约束外部 CLI native 能力。 | Required |
| Secret handling | 基础不投影、基础 masking、run token 短期化。 | Basic required |
| MCP policy | run-scoped token + scoped tool surface;无复杂审批。 | Required |
| Skill access policy | 通过 Host 授权资源暴露harness-native skill 文件不作为 LangBot 安全边界。 | Basic required |
| Skill access policy | skill 通过 Host 授权 tool 暴露(发现 / activate / register / native exec 走统一 tool 授权);**native 层 visible(只读)vs activated(可写)门控不可弱化**——所有 pipeline-visible skill 以 `rw` 挂进同一 sandbox,读写区分全靠 native 层harness-native skill 文件不作为 LangBot 安全边界。 | Required |
| Process isolation | 由 Docker/K8s/用户机器负责。 | Out of scope |
| State lifecycle | scope 隔离、JSON size limit、基础 cleanup primitive。 | Basic required |
| Audit | 记录运行事实和拒绝原因。 | Audit-lite |
+3 -1
View File
@@ -2,7 +2,7 @@
本文档是 `docs/agent-runner-pluginization/` 的状态事实源。协议 schema 仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准;测试步骤以 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) 为准;安全发布门槛以 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) 为准。
状态快照日期:2026-06-16
状态快照日期:2026-06-20
## 实现状态
@@ -15,6 +15,7 @@
| Result payload validation | Done | Wire 保持 `{type, data}`Host 对投递/副作用类 payload 严格校验,tool-call telemetry 宽松,未知 type 忽略并 warning。 |
| Old built-in runners | Done | 旧 `src/langbot/pkg/provider/runners/*``RequestRunner` 路径已从本分支删除。 |
| Official runner manifests | Done | `local-agent`、ACP / Claude Code / Codex 外部 harness runner、外部服务 runner 已重新声明真实生效的 LangBot resource permissions。 |
| Skill 链路 | Broken → Redesigning | 分支上 skill 激活链端到端悬空:`activate` 调用未定义的 `persist_activated_skill`(运行即 `AttributeError`)、`host.activated_skills` 只读不写、skill awareness 既未注入也未被 runner 消费。已拍板改为 **skill 全 tool 化**:发现走 `list_skills` / `langbot_list_assets` 加 skills 一类,`activate` / `register_skill` 走统一 tool 授权,`skill_authoring` capability 降级为便捷开关,host 直接写 `host.activated_skills`last-write-wins)。 |
| Runtime Control Plane v2 foundation | Partial | Host-owned `AgentRun` / `AgentRunEvent` ledger、orchestrator 自动建账、result event persistence、run get/list/event page/cancel/append/finalize actions 已落地;`agent_run:admin` / `runtime:admin` 控制权限、最小 runtime register/heartbeat/list/reconcile 和 run claim/renew/release 原语已落地。完整 Agent Platform 产品形态、daemon supervisor、任务唤醒/长轮询/WebSocket、分布式 runtime 管控仍未完成。 |
| Security boundary | Done | 当前口径降级为轻量边界:LangBot 保护自身持有资源;external harness 的 OS / process / network / workspace 风险由用户或部署环境承担;managed sandbox 不是当前承诺。 |
| Steering control path | Done | claim 异常不再逃逸 consumer loopqueue 有上限;未 pull 的 claimed 输入在 run 结束时写 `steering.dropped` 审计终态。 |
@@ -25,6 +26,7 @@
- `action.requested` 仍只作为 telemetry / reserved surfaceplatform action executor 不在本分支执行。
- EventGateway / EventRouter 完整实现由外部 EBA 分支联调;本分支只提供 event-first host envelope / binding / run 入口。
- State 与 storage 的长期类型边界仍可继续收窄;当前合同只要求 JSON-safe state 与受控 storage API。
- `ToolResource` 当前只带 `tool_name` / `tool_type` / `description` / `operations`,不含 `parameters` full schemarunner(如 local-agent `build_llm_tools`)需逐个 `get_tool_detail` 往返。拟在 `ToolResource` 增补 `parameters`,由 Host 在构造 `ctx.resources` 时一次塞齐。
- EventLog / Transcript 已提供显式 cleanup primitive;长期 retention 默认值、TTL 调度接入和 sandbox/workspace 文件清理仍是运维收尾项,应在 Runtime Control Plane 产品化前补齐。
- External harness 的 native shell / filesystem / CLI / MCP 权限不受 manifest permissions 约束;manifest permissions 只约束 LangBot 持有的资源访问。
- LangBot 当前不承诺 managed sandboxexternal harness 的 OS/process/network quota、workspace GC、provider-native tool 权限由用户或部署环境承担。
@@ -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 []
+1 -8
View File
@@ -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)
+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
@@ -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 = []
@@ -97,6 +97,9 @@ def app():
mock_app.model_mgr = Mock()
mock_app.rag_mgr = Mock()
mock_app.rag_mgr.get_knowledge_base_by_uuid = AsyncMock(return_value=None)
mock_app.skill_mgr = None
mock_app.tool_mgr = Mock()
mock_app.tool_mgr.get_tool_schema = AsyncMock(return_value=(None, None))
return mock_app
@@ -278,7 +281,16 @@ async def test_build_models_deduplicates_query_and_config_models(app):
@pytest.mark.asyncio
async def test_build_tools_authorizes_query_declared_tools(app):
"""Tools discovered by Pipeline preprocessing become run-scoped authorized resources."""
"""Tools discovered by Pipeline preprocessing become run-scoped authorized
resources, with full parameters schema prefilled by the host."""
app.tool_mgr.get_tool_schema = AsyncMock(
side_effect=lambda name: {
'qa_plugin_echo': (
'Echo test tool',
{'type': 'object', 'properties': {'text': {'type': 'string'}}},
),
}.get(name, (None, None))
)
descriptor = make_descriptor(
capabilities={'tool_calling': True},
)
@@ -296,14 +308,16 @@ async def test_build_tools_authorizes_query_declared_tools(app):
{
'tool_name': 'qa_plugin_echo',
'tool_type': None,
'description': None,
'description': 'Echo test tool',
'operations': ['detail', 'call'],
'parameters': {'type': 'object', 'properties': {'text': {'type': 'string'}}},
},
{
'tool_name': 'qa_mcp_echo',
'tool_type': None,
'description': None,
'operations': ['detail', 'call'],
'parameters': None,
},
]
+62 -3
View File
@@ -176,6 +176,63 @@ class TestSkillActivationHelper:
assert register_activated_skill(ap, query, 'primary') is False
class TestPersistActivatedSkill:
"""Host-side persistence of activated skills into conversation state (S-01/S-02)."""
@pytest.mark.asyncio
async def test_persist_writes_conversation_state(self):
from unittest.mock import patch
from langbot.pkg.provider.tools.loaders.skill import (
persist_activated_skill,
ACTIVATED_SKILLS_KEY,
ACTIVATED_SKILL_NAMES_STATE_KEY,
)
ap = _make_ap()
ap.persistence_mgr.get_db_engine = Mock(return_value=Mock())
query = SimpleNamespace(variables={ACTIVATED_SKILLS_KEY: {'pdf': {'name': 'pdf'}}})
query._agent_run_session = {
'runner_id': 'plugin:langbot/local-agent/default',
'state_context': {
'scope_keys': {'conversation': 'conv-scope-key'},
'binding_identity': 'binding-1',
'conversation_id': 'c1',
},
}
store = SimpleNamespace(state_set=AsyncMock(return_value=(True, None)))
with patch(
'langbot.pkg.agent.runner.persistent_state_store.get_persistent_state_store',
return_value=store,
):
await persist_activated_skill(ap, query, 'pdf')
store.state_set.assert_awaited_once()
kwargs = store.state_set.await_args.kwargs
assert kwargs['scope_key'] == 'conv-scope-key'
assert kwargs['state_key'] == ACTIVATED_SKILL_NAMES_STATE_KEY
assert kwargs['value'] == ['pdf']
assert kwargs['scope'] == 'conversation'
assert kwargs['runner_id'] == 'plugin:langbot/local-agent/default'
assert kwargs['binding_identity'] == 'binding-1'
@pytest.mark.asyncio
async def test_persist_noop_without_run_session(self):
from unittest.mock import patch
from langbot.pkg.provider.tools.loaders.skill import persist_activated_skill
ap = _make_ap()
query = SimpleNamespace(variables={'_activated_skills': {'pdf': {'name': 'pdf'}}})
with patch(
'langbot.pkg.agent.runner.persistent_state_store.get_persistent_state_store',
) as mock_factory:
await persist_activated_skill(ap, query, 'pdf')
mock_factory.assert_not_called()
class TestSkillPathHelpers:
def test_get_visible_skills_filters_by_bound_names(self):
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY, get_visible_skills
@@ -193,12 +250,13 @@ class TestSkillPathHelpers:
assert list(result.keys()) == ['visible']
def test_restore_activated_skills_uses_caller_provided_names_and_visibility(self):
def test_restore_activated_skills_from_state_filters_by_visibility(self):
from langbot.pkg.provider.tools.loaders.skill import (
ACTIVATED_SKILLS_KEY,
ACTIVATED_SKILL_NAMES_STATE_KEY,
PIPELINE_BOUND_SKILLS_KEY,
get_activated_skill_names,
restore_activated_skills,
restore_activated_skills_from_state,
)
ap = _make_ap()
@@ -209,8 +267,9 @@ class TestSkillPathHelpers:
}
)
query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['visible']})
state = {'conversation': {ACTIVATED_SKILL_NAMES_STATE_KEY: ['visible', 'hidden', 'visible', '']}}
restored = restore_activated_skills(ap, query, ['visible', 'hidden', 'visible', ''])
restored = restore_activated_skills_from_state(ap, query, state)
assert restored == ['visible']
assert list(query.variables[ACTIVATED_SKILLS_KEY].keys()) == ['visible']
@@ -43,7 +43,8 @@ def make_tool(name: str) -> resource_tool.LLMTool:
@pytest.mark.asyncio
async def test_tool_manager_omits_skill_authoring_tools_by_default():
async def test_tool_manager_includes_skill_tools_by_default():
"""Skill tools are exposed like native tools; the SkillToolLoader self-gates."""
manager = ToolManager(SimpleNamespace())
manager.native_tool_loader = StubLoader([make_tool('exec')])
manager.skill_tool_loader = StubLoader([make_tool('activate')])
@@ -52,20 +53,21 @@ async def test_tool_manager_omits_skill_authoring_tools_by_default():
tools = await manager.get_all_tools()
assert [tool.name for tool in tools] == ['exec', 'plugin_tool', 'mcp_tool']
assert [tool.name for tool in tools] == ['exec', 'activate', 'plugin_tool', 'mcp_tool']
@pytest.mark.asyncio
async def test_tool_manager_includes_skill_authoring_tools_when_requested():
async def test_tool_manager_omits_skill_tools_when_loader_unavailable():
"""When the SkillToolLoader gate is closed (no sandbox / skill_mgr) it returns no tools."""
manager = ToolManager(SimpleNamespace())
manager.native_tool_loader = StubLoader([make_tool('exec')])
manager.skill_tool_loader = StubLoader([make_tool('activate')])
manager.skill_tool_loader = StubLoader([])
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
tools = await manager.get_all_tools(include_skill_authoring=True)
tools = await manager.get_all_tools()
assert [tool.name for tool in tools] == ['exec', 'activate', 'plugin_tool', 'mcp_tool']
assert [tool.name for tool in tools] == ['exec', 'plugin_tool', 'mcp_tool']
@pytest.mark.asyncio
+9 -7
View File
@@ -150,7 +150,7 @@ def _import_preproc_modules():
@pytest.mark.asyncio
async def test_preproc_enables_skill_authoring_tools_when_skill_service_available():
async def test_preproc_loads_host_tools_for_runner():
preproc_module, entities_module = _import_preproc_modules()
app = _make_app(skill_service=SimpleNamespace())
@@ -159,7 +159,7 @@ async def test_preproc_enables_skill_authoring_tools_when_skill_service_availabl
result = await stage.process(_make_query(), 'PreProcessor')
assert result.result_type == entities_module.ResultType.CONTINUE
app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None, include_skill_authoring=True)
app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None)
@pytest.mark.asyncio
@@ -180,12 +180,13 @@ async def test_preproc_puts_host_skill_tools_into_query_scope():
result = await stage.process(query, 'PreProcessor')
assert result.result_type == entities_module.ResultType.CONTINUE
app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None, include_skill_authoring=True)
app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None)
assert [tool.name for tool in query.use_funcs] == ['activate', 'register_skill']
@pytest.mark.asyncio
async def test_preproc_disables_skill_authoring_tools_when_skill_service_missing():
async def test_preproc_loads_host_tools_regardless_of_skill_service():
"""Skill tooling no longer gates on skill_service at the preproc layer."""
preproc_module, entities_module = _import_preproc_modules()
app = _make_app(skill_service=None)
@@ -194,7 +195,7 @@ async def test_preproc_disables_skill_authoring_tools_when_skill_service_missing
result = await stage.process(_make_query(), 'PreProcessor')
assert result.result_type == entities_module.ResultType.CONTINUE
app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None, include_skill_authoring=False)
app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None)
@pytest.mark.asyncio
@@ -237,10 +238,11 @@ async def test_preproc_respects_pipeline_bound_skills_subset():
@pytest.mark.asyncio
async def test_preproc_does_not_load_skill_preferences_without_skill_authoring_service():
async def test_preproc_does_not_load_skill_preferences_without_skill_mgr():
preproc_module, entities_module = _import_preproc_modules()
app = _make_app(skill_service=None)
app = _make_app(skill_service=SimpleNamespace())
app.skill_mgr = None # no skill manager -> skill tooling unavailable
query = _make_query()
result = await stage_process_capture(preproc_module, app, query)