From 190028d5ab2e7f783179a4496320460d17f0f142 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sun, 21 Jun 2026 09:27:05 +0800 Subject: [PATCH] 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. --- .../AGENT_CONTEXT_PROTOCOL.md | 2 +- .../HOST_SDK_INFRASTRUCTURE.md | 8 ++- .../agent-runner-pluginization/PROTOCOL_V1.md | 17 ++++- .../SECURITY_HARDENING.md | 4 +- docs/agent-runner-pluginization/STATUS.md | 4 +- .../pkg/agent/runner/resource_builder.py | 20 ++++-- src/langbot/pkg/pipeline/preproc/preproc.py | 9 +-- .../pkg/provider/tools/loaders/skill.py | 70 +++++++++++++------ src/langbot/pkg/provider/tools/toolmgr.py | 25 ++++++- .../unit_tests/agent/test_resource_builder.py | 18 ++++- tests/unit_tests/provider/test_skill_tools.py | 65 ++++++++++++++++- .../provider/test_tool_manager_native.py | 14 ++-- tests/unit_tests/test_preproc.py | 16 +++-- 13 files changed, 210 insertions(+), 62 deletions(-) diff --git a/docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md b/docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md index 9a7b2f5d4..70f917870 100644 --- a/docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md +++ b/docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md @@ -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 保存。 diff --git a/docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md b/docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md index b8ba6fb7b..df5217204 100644 --- a/docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md +++ b/docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md @@ -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 转发进入 runner;runner 不应识别或硬编码执行环境 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 转发进入 runner;runner 不应识别或硬编码执行环境 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,保存原始事件、系统事件、工具调用、投递结果、错误。 diff --git a/docs/agent-runner-pluginization/PROTOCOL_V1.md b/docs/agent-runner-pluginization/PROTOCOL_V1.md index b7e410c68..7a5512eee 100644 --- a/docs/agent-runner-pluginization/PROTOCOL_V1.md +++ b/docs/agent-runner-pluginization/PROTOCOL_V1.md @@ -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。) diff --git a/docs/agent-runner-pluginization/SECURITY_HARDENING.md b/docs/agent-runner-pluginization/SECURITY_HARDENING.md index 43f74518c..3d577fe4a 100644 --- a/docs/agent-runner-pluginization/SECURITY_HARDENING.md +++ b/docs/agent-runner-pluginization/SECURITY_HARDENING.md @@ -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 | diff --git a/docs/agent-runner-pluginization/STATUS.md b/docs/agent-runner-pluginization/STATUS.md index ad9d8ea90..d9ea56e10 100644 --- a/docs/agent-runner-pluginization/STATUS.md +++ b/docs/agent-runner-pluginization/STATUS.md @@ -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 loop;queue 有上限;未 pull 的 claimed 输入在 run 结束时写 `steering.dropped` 审计终态。 | @@ -25,6 +26,7 @@ - `action.requested` 仍只作为 telemetry / reserved surface;platform 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 schema;runner(如 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 sandbox;external harness 的 OS/process/network quota、workspace GC、provider-native tool 权限由用户或部署环境承担。 diff --git a/src/langbot/pkg/agent/runner/resource_builder.py b/src/langbot/pkg/agent/runner/resource_builder.py index 1abc3cf1c..a2e8c3667 100644 --- a/src/langbot/pkg/agent/runner/resource_builder.py +++ b/src/langbot/pkg/agent/runner/resource_builder.py @@ -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 [] diff --git a/src/langbot/pkg/pipeline/preproc/preproc.py b/src/langbot/pkg/pipeline/preproc/preproc.py index 1ded09a00..8813e1a57 100644 --- a/src/langbot/pkg/pipeline/preproc/preproc.py +++ b/src/langbot/pkg/pipeline/preproc/preproc.py @@ -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) diff --git a/src/langbot/pkg/provider/tools/loaders/skill.py b/src/langbot/pkg/provider/tools/loaders/skill.py index 7de439caa..716e0cab8 100644 --- a/src/langbot/pkg/provider/tools/loaders/skill.py +++ b/src/langbot/pkg/provider/tools/loaders/skill.py @@ -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: diff --git a/src/langbot/pkg/provider/tools/toolmgr.py b/src/langbot/pkg/provider/tools/toolmgr.py index 38b08aa19..ad73cf567 100644 --- a/src/langbot/pkg/provider/tools/toolmgr.py +++ b/src/langbot/pkg/provider/tools/toolmgr.py @@ -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 = [] diff --git a/tests/unit_tests/agent/test_resource_builder.py b/tests/unit_tests/agent/test_resource_builder.py index e3fb9420b..dcb9d4ffb 100644 --- a/tests/unit_tests/agent/test_resource_builder.py +++ b/tests/unit_tests/agent/test_resource_builder.py @@ -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, }, ] diff --git a/tests/unit_tests/provider/test_skill_tools.py b/tests/unit_tests/provider/test_skill_tools.py index 9db7b945e..fa77ce2ed 100644 --- a/tests/unit_tests/provider/test_skill_tools.py +++ b/tests/unit_tests/provider/test_skill_tools.py @@ -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'] diff --git a/tests/unit_tests/provider/test_tool_manager_native.py b/tests/unit_tests/provider/test_tool_manager_native.py index 01e044e5a..3865c14c6 100644 --- a/tests/unit_tests/provider/test_tool_manager_native.py +++ b/tests/unit_tests/provider/test_tool_manager_native.py @@ -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 diff --git a/tests/unit_tests/test_preproc.py b/tests/unit_tests/test_preproc.py index f58d3f2c5..b34516c5f 100644 --- a/tests/unit_tests/test_preproc.py +++ b/tests/unit_tests/test_preproc.py @@ -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)