mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-22 13:34:24 +00:00
feat(skill): unify skill activation as authorized tools
Expose skill tools (activate/register_skill/native exec) like native tools instead of gating them behind the skill_authoring capability: - toolmgr.get_all_tools drops include_skill_authoring; SkillToolLoader self-gates on sandbox + skill_mgr - preproc drops the include_skill_authoring branch; pipeline-bound skills and the skills resource gate on skill_mgr presence Persist activated skills into host.activated_skills conversation state so they survive across runs (host writes at activate; last-write-wins); drop the dead restore_activated_skills helper. Prefill ToolResource.parameters host-side (tool_mgr.get_tool_schema) so runners build LLM tools without per-tool get_tool_detail round-trips. Align agent-runner-pluginization design docs to the all-tool model.
This commit is contained in:
@@ -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 转发进入 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,保存原始事件、系统事件、工具调用、投递结果、错误。
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 权限由用户或部署环境承担。
|
||||
|
||||
@@ -147,13 +147,22 @@ class AgentResourceBuilder:
|
||||
allowed_names = resource_policy.allowed_tool_names
|
||||
tool_operations = [operation for operation in ('detail', 'call') if operation in tool_perms]
|
||||
|
||||
# Prefill full tool schema (best-effort) so runners can build LLM tool
|
||||
# definitions without a per-tool get_tool_detail round-trip. Degrades to
|
||||
# None when no tool manager is available.
|
||||
get_tool_schema = getattr(getattr(self.ap, 'tool_mgr', None), 'get_tool_schema', None)
|
||||
if allowed_names:
|
||||
for tool_name in allowed_names:
|
||||
if get_tool_schema is not None:
|
||||
description, parameters = await get_tool_schema(tool_name)
|
||||
else:
|
||||
description, parameters = None, None
|
||||
tools.append({
|
||||
'tool_name': tool_name,
|
||||
'tool_type': None,
|
||||
'description': None,
|
||||
'description': description,
|
||||
'operations': tool_operations,
|
||||
'parameters': parameters,
|
||||
})
|
||||
|
||||
return tools
|
||||
@@ -203,10 +212,13 @@ class AgentResourceBuilder:
|
||||
resource_policy: typing.Any,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> list[SkillResource]:
|
||||
"""Build pipeline-visible skill resource facts."""
|
||||
if not config_schema.supports_skill_authoring(descriptor):
|
||||
return []
|
||||
"""Build pipeline-visible skill resource facts.
|
||||
|
||||
Skills are exposed as authorized tools (activate / register_skill / native
|
||||
exec), so skill facts are surfaced to every run that has a skill manager,
|
||||
not gated by the ``skill_authoring`` capability. The capability is now a
|
||||
semantic declaration only.
|
||||
"""
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
if skill_mgr is None:
|
||||
return []
|
||||
|
||||
@@ -181,10 +181,6 @@ class PreProcessor(stage.PipelineStage):
|
||||
|
||||
uses_host_models = config_schema.uses_host_models(descriptor)
|
||||
uses_host_tools = config_schema.uses_host_tools(descriptor)
|
||||
include_skill_authoring = (
|
||||
config_schema.supports_skill_authoring(descriptor)
|
||||
and getattr(self.ap, 'skill_service', None) is not None
|
||||
)
|
||||
llm_model = None
|
||||
if uses_host_models:
|
||||
primary_uuid, fallback_uuids = config_schema.extract_model_selection(descriptor, runner_config)
|
||||
@@ -242,7 +238,6 @@ class PreProcessor(stage.PipelineStage):
|
||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
include_skill_authoring=include_skill_authoring,
|
||||
)
|
||||
|
||||
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
||||
@@ -255,13 +250,11 @@ class PreProcessor(stage.PipelineStage):
|
||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
include_skill_authoring=include_skill_authoring,
|
||||
)
|
||||
elif uses_host_tools:
|
||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
include_skill_authoring=include_skill_authoring,
|
||||
)
|
||||
|
||||
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
||||
@@ -367,7 +360,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
query.prompt.messages = event_ctx.event.default_prompt
|
||||
query.messages = event_ctx.event.prompt
|
||||
|
||||
if include_skill_authoring and getattr(self.ap, 'skill_mgr', None) is not None:
|
||||
if getattr(self.ap, 'skill_mgr', None) is not None:
|
||||
pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid)
|
||||
extensions_prefs = (pipeline_data or {}).get('extensions_preferences', {})
|
||||
enable_all_skills = extensions_prefs.get('enable_all_skills', True)
|
||||
|
||||
@@ -91,27 +91,6 @@ def get_activated_skill_names(query: pipeline_query.Query) -> list[str]:
|
||||
return normalize_skill_names(list(get_activated_skills(query).keys()))
|
||||
|
||||
|
||||
def restore_activated_skills(
|
||||
ap: app.Application,
|
||||
query: pipeline_query.Query,
|
||||
skill_names: typing.Any,
|
||||
) -> list[str]:
|
||||
"""Restore caller-provided activated skill names into Query variables.
|
||||
|
||||
Persistence and state scope ownership belong to higher-level flows. This
|
||||
helper only rebuilds current Query state from pipeline-visible skills, so
|
||||
removed or unbound skills stay unavailable to native exec/write/edit.
|
||||
"""
|
||||
restored: list[str] = []
|
||||
for skill_name in normalize_skill_names(skill_names):
|
||||
skill_data = get_visible_skill(ap, query, skill_name)
|
||||
if skill_data is None:
|
||||
continue
|
||||
register_activated_skill(query, skill_data)
|
||||
restored.append(skill_name)
|
||||
return restored
|
||||
|
||||
|
||||
def restore_activated_skills_from_state(
|
||||
ap: app.Application,
|
||||
query: pipeline_query.Query,
|
||||
@@ -135,6 +114,55 @@ def restore_activated_skills_from_state(
|
||||
return restored
|
||||
|
||||
|
||||
async def persist_activated_skill(
|
||||
ap: app.Application,
|
||||
query: pipeline_query.Query,
|
||||
skill_name: str,
|
||||
) -> None:
|
||||
"""Persist activated skill names into host-owned conversation state.
|
||||
|
||||
``activate`` runs host-side. This writes the run's current activated skill
|
||||
names to the conversation-scope ``host.activated_skills`` snapshot so a later
|
||||
run can restore them via ``restore_activated_skills_from_state``. Host writes
|
||||
here and a runner ``state.updated`` to the same key follow last-write-wins.
|
||||
|
||||
Best-effort: a persistence failure must not fail the activation itself. No-op
|
||||
when the call is not inside an authorized agent run, or when conversation
|
||||
state is unavailable (state disabled / scope not enabled / no conversation).
|
||||
"""
|
||||
session = getattr(query, '_agent_run_session', None)
|
||||
if not isinstance(session, dict):
|
||||
return
|
||||
|
||||
state_context = session.get('state_context')
|
||||
if not isinstance(state_context, dict):
|
||||
return
|
||||
|
||||
scope_keys = state_context.get('scope_keys')
|
||||
conversation_scope_key = scope_keys.get('conversation') if isinstance(scope_keys, dict) else None
|
||||
if not conversation_scope_key:
|
||||
return
|
||||
|
||||
try:
|
||||
from ....agent.runner.persistent_state_store import get_persistent_state_store
|
||||
|
||||
store = get_persistent_state_store(ap.persistence_mgr.get_db_engine())
|
||||
await store.state_set(
|
||||
scope_key=conversation_scope_key,
|
||||
state_key=ACTIVATED_SKILL_NAMES_STATE_KEY,
|
||||
value=get_activated_skill_names(query),
|
||||
runner_id=str(session.get('runner_id', '') or ''),
|
||||
binding_identity=str(state_context.get('binding_identity', 'unknown') or 'unknown'),
|
||||
scope='conversation',
|
||||
context=state_context,
|
||||
logger=getattr(ap, 'logger', None),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - persistence is best-effort, must not break activation
|
||||
logger = getattr(ap, 'logger', None)
|
||||
if logger is not None:
|
||||
logger.warning(f'Failed to persist activated skill "{skill_name}": {e}')
|
||||
|
||||
|
||||
def parse_skill_mount_path(sandbox_path: str) -> tuple[str | None, str]:
|
||||
normalized_path = str(sandbox_path or '/workspace').strip() or '/workspace'
|
||||
if normalized_path == SKILL_MOUNT_PREFIX:
|
||||
|
||||
@@ -58,13 +58,15 @@ class ToolManager:
|
||||
self,
|
||||
bound_plugins: list[str] | None = None,
|
||||
bound_mcp_servers: list[str] | None = None,
|
||||
include_skill_authoring: bool = False,
|
||||
) -> list[resource_tool.LLMTool]:
|
||||
all_functions: list[resource_tool.LLMTool] = []
|
||||
|
||||
all_functions.extend(await self.native_tool_loader.get_tools())
|
||||
if include_skill_authoring:
|
||||
all_functions.extend(await self.skill_tool_loader.get_tools())
|
||||
# Skill tools (activate / register_skill) are exposed like native tools:
|
||||
# the SkillToolLoader gates itself on sandbox + skill_mgr availability, so
|
||||
# skill is just a group of authorized tools rather than a separate
|
||||
# capability-gated surface.
|
||||
all_functions.extend(await self.skill_tool_loader.get_tools())
|
||||
all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins))
|
||||
all_functions.extend(await self.mcp_tool_loader.get_tools(bound_mcp_servers))
|
||||
|
||||
@@ -84,6 +86,23 @@ class ToolManager:
|
||||
|
||||
return None
|
||||
|
||||
async def get_tool_schema(self, name: str) -> tuple[str | None, dict | None]:
|
||||
"""Return (description, parameters JSON schema) for a tool by name.
|
||||
|
||||
Used by the host to prefill ToolResource so a runner can build LLM tool
|
||||
definitions without a separate get_tool_detail round-trip. Handles both
|
||||
LLMTool (native/mcp/skill) and plugin ComponentManifest shapes. Returns
|
||||
(None, None) when the tool is not found.
|
||||
"""
|
||||
tool = await self.get_tool_by_name(name)
|
||||
if tool is None:
|
||||
return None, None
|
||||
if hasattr(tool, 'spec') and hasattr(tool, 'metadata'):
|
||||
spec = getattr(tool, 'spec', None) or {}
|
||||
return spec.get('llm_prompt'), (spec.get('parameters') or None)
|
||||
description = getattr(tool, 'description', None) or getattr(tool, 'human_desc', None)
|
||||
return description, (getattr(tool, 'parameters', None) or None)
|
||||
|
||||
async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list:
|
||||
tools = []
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user