@@ -54,9 +69,23 @@ export default function PluginPagesPage() {
const author = parts[0];
const pluginName = parts[1];
- // Use the asset path from the page manifest, not the page ID
- const assetPath = page?.path ?? parts.slice(2).join('/');
- const pageId = parts.slice(2).join('/');
+ if (!page) {
+ if (lookupCompleteForId === id) {
+ return (
+
+ {t('pluginPages.invalidPage')}
+
+ );
+ }
+ return (
+
+ Loading...
+
+ );
+ }
+
+ const assetPath = page.path;
+ const pageId = page.pageId;
return (
{
+ const url = httpClient.getPluginAssetURL(author, pluginName, pagePath);
+ const separator = url.includes('?') ? '&' : '?';
+ return `${url}${separator}_lb_page_v=${Date.now()}`;
+ }, [author, pluginName, pagePath]);
// Send context (theme + language) to iframe
// Use '*' as targetOrigin because sandboxed iframe has opaque (null) origin
diff --git a/web/src/app/infra/entities/form/dynamic.ts b/web/src/app/infra/entities/form/dynamic.ts
index 44fed3acf..5ea3d7e73 100644
--- a/web/src/app/infra/entities/form/dynamic.ts
+++ b/web/src/app/infra/entities/form/dynamic.ts
@@ -70,6 +70,11 @@ export enum DynamicFormItemType {
WEBHOOK_URL = 'webhook-url',
EMBED_CODE = 'embed-code',
QR_CODE_LOGIN = 'qr-code-login',
+ // Plugin manifest type aliases for compatibility
+ SELECT_LLM_MODEL = 'select-llm-model',
+ SELECT_KNOWLEDGE_BASES = 'select-knowledge-bases',
+ NUMBER = 'number',
+ JSON = 'json',
}
export interface IFileConfig {
diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx
index a3afd07c4..955e008b4 100644
--- a/web/src/app/wizard/page.tsx
+++ b/web/src/app/wizard/page.tsx
@@ -86,7 +86,6 @@ export default function WizardPage() {
const [selectedAdapter, setSelectedAdapter] = useState(null);
const [selectedRunner, setSelectedRunner] = useState(null);
const [botName, setBotName] = useState('');
-
const [botDescription, _setBotDescription] = useState('');
const [adapterConfig, setAdapterConfig] = useState>(
{},
@@ -202,7 +201,7 @@ export default function WizardPage() {
const runnerOptions = useMemo(() => {
if (!runnerStage) return [];
- const runnerField = runnerStage.config.find((c) => c.name === 'runner');
+ const runnerField = runnerStage.config.find((c) => c.name === 'id');
return runnerField?.options ?? [];
}, [runnerStage]);
@@ -257,9 +256,11 @@ export default function WizardPage() {
const handleSelectRunner = useCallback(
(runner: string) => {
setSelectedRunner(runner);
+ const configStage = aiConfigTab?.stages.find((s) => s.name === runner);
+ setRunnerConfig(configStage ? getDefaultValues(configStage.config) : {});
saveProgress({ step: 2, selected_runner: runner });
},
- [saveProgress],
+ [aiConfigTab, saveProgress],
);
// ---- Navigation helpers ----
@@ -427,14 +428,36 @@ export default function WizardPage() {
// (includes trigger, safety, ai, output sections).
// Then merge only the AI section with the wizard's runner config.
const createdPipeline = await httpClient.getPipeline(pipelineResp.uuid);
- const fullConfig = createdPipeline.pipeline.config;
+ const fullConfig = createdPipeline.pipeline.config as unknown as Record<
+ string,
+ unknown
+ >;
+ const fullAiConfig =
+ fullConfig.ai && typeof fullConfig.ai === 'object'
+ ? (fullConfig.ai as Record)
+ : {};
+ const existingRunner =
+ fullAiConfig.runner && typeof fullAiConfig.runner === 'object'
+ ? (fullAiConfig.runner as Record)
+ : {};
+ const existingRunnerConfigs =
+ fullAiConfig.runner_config &&
+ typeof fullAiConfig.runner_config === 'object'
+ ? (fullAiConfig.runner_config as Record)
+ : {};
const mergedConfig = {
...fullConfig,
ai: {
- ...fullConfig.ai,
- runner: { runner: selectedRunner },
- [selectedRunner]: runnerConfig,
+ ...fullAiConfig,
+ runner: {
+ ...existingRunner,
+ id: selectedRunner,
+ },
+ runner_config: {
+ ...existingRunnerConfigs,
+ [selectedRunner]: runnerConfig,
+ },
},
};
@@ -1113,26 +1136,27 @@ function StepAIEngine({
})}
{/* Space promotion banner */}
- {selected === 'local-agent' && isLocalAccount && (
-
-
-
-
-
- {t('wizard.spaceBanner.message')}
-
-
+ {selected === 'plugin:langbot/local-agent/default' &&
+ isLocalAccount && (
+
+
+
+
+
+ {t('wizard.spaceBanner.message')}
+
+
+
-
- )}
+ )}
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 2/8] 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)
From e5a51884424ebb4f56c4f65b3e8118c8d0b035a7 Mon Sep 17 00:00:00 2001
From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com>
Date: Sun, 21 Jun 2026 23:46:22 +0800
Subject: [PATCH 3/8] test(qa): skill all-tool acceptance matrix + mcp-gateway
discovery case
- references/skill-all-tool-acceptance.md: acceptance matrix for the skill
all-tool model (runner x lifecycle x backend), case status, exit criteria,
and the #2271 known issue (pre-existing box nested-mount, not this branch)
- cases/skill-discovery-via-mcp-gateway.yaml: schema-valid case proving an
external harness discovers skills via langbot_list_assets (the new 'skills'
asset class); marked blocked-env until remote claude-code is responsive
---
.../skill-discovery-via-mcp-gateway.yaml | 58 +++++++++++++++++++
.../references/skill-all-tool-acceptance.md | 55 ++++++++++++++++++
2 files changed, 113 insertions(+)
create mode 100644 skills/skills/langbot-testing/cases/skill-discovery-via-mcp-gateway.yaml
create mode 100644 skills/skills/langbot-testing/references/skill-all-tool-acceptance.md
diff --git a/skills/skills/langbot-testing/cases/skill-discovery-via-mcp-gateway.yaml b/skills/skills/langbot-testing/cases/skill-discovery-via-mcp-gateway.yaml
new file mode 100644
index 000000000..93e13f1d9
--- /dev/null
+++ b/skills/skills/langbot-testing/cases/skill-discovery-via-mcp-gateway.yaml
@@ -0,0 +1,58 @@
+id: skill-discovery-via-mcp-gateway
+title: "External harness discovers LangBot skills via langbot_list_assets (all-tool model)"
+mode: agent-browser
+area: sandbox
+type: regression
+priority: p2
+risk: medium
+ci_eligible: false
+tags:
+ - skills
+ - mcp-gateway
+ - acp-agent-runner
+ - all-tool-model
+ - tools
+skills:
+ - langbot-env-setup
+ - langbot-testing
+env:
+ - LANGBOT_FRONTEND_URL
+ - LANGBOT_BACKEND_URL
+ - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL
+ - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME
+preconditions:
+ - "An external-harness runner pipeline (e.g. ACP remote claude-code) is configured with langbot-assets-enabled=true so the LangBot MCP gateway is exposed to the harness."
+ - "The remote harness (claude-code) is reachable and responsive (claude -p returns within the runner timeout)."
+ - "At least one pipeline-visible skill exists in the Box skill store (otherwise the count is 0, which is still a valid pass for the discovery surface)."
+automation: scripts/e2e/pipeline-debug-chat.mjs
+automation_env:
+ - LANGBOT_FRONTEND_URL
+ - LANGBOT_BROWSER_PROFILE
+ - LANGBOT_CHROMIUM_EXECUTABLE
+ - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL
+ - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME
+automation_pipeline_url_env: LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL
+automation_pipeline_name_env: LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME
+automation_prompt: "You have LangBot tools available via an MCP server (tools prefixed langbot_). Call langbot_list_assets with asset_types = [\"skills\",\"tools\"]. Then reply with one single line: the literal token PROBEDONE, a space, the number of skills you found, a space, and the number of tools you found."
+automation_expected_text: "PROBEDONE"
+automation_response_timeout_ms: "540000"
+steps:
+ - "Open LANGBOT_FRONTEND_URL and navigate to the external-harness (ACP) pipeline."
+ - "Open Debug Chat with langbot-assets-enabled on the runner."
+ - "Send the automation_prompt asking the harness to call langbot_list_assets with asset_types [skills, tools]."
+ - "Capture the final reply, backend logs, and the MCP gateway call trace."
+checks:
+ - "UI: final reply contains PROBEDONE followed by a skill count and a tool count."
+ - "Logs: backend shows the harness invoked langbot_list_assets and the response included a 'skills' asset class (this is the all-tool-model discovery surface added on this branch)."
+ - "Behavior parity: a local-agent runner reaches the same skills via use_funcs / activate; the external harness reaches them via langbot_list_assets + langbot_call_tool."
+evidence_required:
+ - ui
+ - screenshot
+ - backend_log
+expected_failures:
+ - "runner.timeout when the remote claude-code harness is unauthenticated or slow to start — this is an environment issue, not a discovery-surface regression."
+diagnostics:
+ - "If runner.timeout: ssh into the harness host and confirm `claude -p 'hi'` returns quickly; the ACP runner cannot complete until the harness responds."
+ - "Activated-skill OPERATE on docker+shared-fs is tracked separately by issue #2271 and is out of scope for this discovery case."
+troubleshooting:
+ - sandbox-native-tools-unavailable
diff --git a/skills/skills/langbot-testing/references/skill-all-tool-acceptance.md b/skills/skills/langbot-testing/references/skill-all-tool-acceptance.md
new file mode 100644
index 000000000..bc9ae7046
--- /dev/null
+++ b/skills/skills/langbot-testing/references/skill-all-tool-acceptance.md
@@ -0,0 +1,55 @@
+# Acceptance matrix — skill all-tool model
+
+Acceptance criteria for the branch that unifies LangBot skills as **authorized
+tools** (`feat/agent-runner-plugin`). Skills are no longer gated behind the
+`skill_authoring` capability; `activate` / `register_skill` / native `exec` are
+exposed like native tools, gated only on **sandbox + skill_mgr**. Discovery is
+tool-driven (`langbot_list_assets` gains a `skills` asset class for external
+harnesses). Host persists activated skills to `host.activated_skills`
+(last-write-wins) and prefills `ToolResource.parameters` so runners skip
+per-tool `get_tool_detail`.
+
+## What changed (scope under test)
+
+| Layer | Change |
+| --- | --- |
+| host | `toolmgr.get_all_tools` drops `include_skill_authoring`; `SkillToolLoader` self-gates on sandbox+skill_mgr |
+| host | `preproc` drops the `include_skill_authoring` branch; bound-skills + skills resource gate on `skill_mgr` |
+| host | `resource_builder` stops gating skills on `skill_authoring`; fills `ToolResource.parameters` via `tool_mgr.get_tool_schema` |
+| host | `persist_activated_skill` writes `host.activated_skills` (conversation scope) |
+| sdk | `ToolResource.parameters` (full JSON schema); `langbot_list_assets` `skills` asset class |
+| local-agent | `build_llm_tools` prefers `ctx.resources.tools.parameters`, falls back to `get_tool_detail`; `DEFAULT_MAX_TOOL_ITERATIONS` 20→100 |
+
+## Dimensions
+
+- **Runner**: `local-agent` (in-process logic, direct Run API, skill tools in `use_funcs`) · `acp-agent-runner` (external harness, remote-ssh claude-code, MCP gateway) · `claude-code-agent` (external harness, claude-code CLI, MCP gateway — *no pipeline yet*).
+- **Lifecycle**: discover → activate → operate (native exec under the activated mount path) → register.
+- **Backend**: docker · nsjail · e2b.
+
+## Cases & status
+
+| Case | Asserts | Runner(s) | Status |
+| --- | --- | --- | --- |
+| `skill-tool-exposure-no-capability` | skill tools offered to a tool-calling runner **without** `skill_authoring`; gated only on sandbox+skill_mgr | local-agent | **covered (unit)** — `test_tool_manager_native.py`, `test_preproc.py` |
+| `skill-activation-persistence` | activated skill survives a new run in the same conversation (`host.activated_skills` restore) | local-agent | **covered (unit)** — `test_skill_tools.py` |
+| `toolresource-parameters-prefill` | runner builds LLM tools from `ctx.resources.tools.parameters` without per-tool `get_tool_detail` | local-agent | **covered (unit)** — `test_run_assembly.py::test_build_llm_tools_uses_prefilled_schema_without_fetch` |
+| `regression-existing-runner-behavior` | existing local-agent cases (basic/rag/tool-call/steering/multimodal) unchanged | local-agent | **covered (unit)** — full host/sdk/local-agent suites green, 0 new failures |
+| `sandbox-skill-authoring-e2e` | create → register → activate → exec-from-activated-path → `E2E_OK` | local-agent | **partial** — authorization chain passes (agent calls exec/register/activate, skill registered 0→1); **OPERATE step blocked by [#2271](https://github.com/langbot-app/LangBot/issues/2271)** on docker+shared-fs |
+| `skill-discovery-via-mcp-gateway` | external harness calls `langbot_list_assets(['skills'])` and receives pipeline-visible skills | acp / claude-code | **blocked (env)** — remote claude-code unresponsive (`runner.timeout`); link is alive (runner started, reached execution) |
+| `skill-activation-cross-runner-parity` | local-agent and external harness both reach `activate` via their paths (`use_funcs` vs `langbot_call_tool`) | local-agent + acp | **blocked (env)** |
+
+## Known issues
+
+- [#2271](https://github.com/langbot-app/LangBot/issues/2271) — activated `/workspace/.skills/` missing `scripts/`/`data/` on docker backend (nested bind mount). **Pre-existing** (Feat/sandbox #2072), not introduced by this branch (the mount/register chain is byte-identical to `origin/master` across host loader, `box/service.py`, SDK box backend, SDK box runtime). This branch only **exposed** the path end-to-end for the first time. Blocks the OPERATE step on docker+shared-fs.
+
+## Exit criteria
+
+1. Unit matrix green across host/sdk/local-agent, 0 new failures. **(DONE)**
+2. `skill-tool-exposure-no-capability` + `skill-activation-persistence` + `toolresource-parameters-prefill` covered by unit. **(DONE)**
+3. `sandbox-skill-authoring-e2e` OPERATE step passes on at least one backend once #2271 is fixed (or a backend that avoids nested mounts), proving real end-to-end skill use. **(BLOCKED on #2271)**
+4. `skill-discovery-via-mcp-gateway` + `skill-activation-cross-runner-parity` pass on acp once remote claude-code is responsive. **(BLOCKED on env)**
+
+## How to run
+
+- **Unit**: LangBot `make test`; SDK `uv run pytest`; local-agent `uv run pytest tests/`.
+- **Browser e2e**: per-pipeline Debug Chat; canonical skill prompt pattern in [`sandbox-skill-authoring.md`](./sandbox-skill-authoring.md). Automatable cases use the `automation_*` fields + `scripts/e2e/pipeline-debug-chat.mjs`.
From 73be17b02c2085b1059d85d2988bb4561abc4a81 Mon Sep 17 00:00:00 2001
From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com>
Date: Mon, 22 Jun 2026 08:16:35 +0800
Subject: [PATCH 4/8] test(qa): record claude-code-agent skill discovery PASS +
acp transport finding
- claude-code-agent (new pipeline, remote-ssh->101): langbot_list_assets returns
skills=1 tools=15 in 24s -> all-tool 'skills' asset class is discoverable
end-to-end by an external harness on the unmodified branch
- document the runner transport difference: claude-code uses a stdio bridge
(works on remote-ssh out of the box), acp uses an HTTP proxy (needs
langbot-assets-gateway-public-url on remote-ssh). This is a runner-plugin
detail, not a host all-tool-branch issue
---
.../references/skill-all-tool-acceptance.md | 18 ++++++++++++++----
1 file changed, 14 insertions(+), 4 deletions(-)
diff --git a/skills/skills/langbot-testing/references/skill-all-tool-acceptance.md b/skills/skills/langbot-testing/references/skill-all-tool-acceptance.md
index bc9ae7046..d5c728659 100644
--- a/skills/skills/langbot-testing/references/skill-all-tool-acceptance.md
+++ b/skills/skills/langbot-testing/references/skill-all-tool-acceptance.md
@@ -22,7 +22,16 @@ per-tool `get_tool_detail`.
## Dimensions
-- **Runner**: `local-agent` (in-process logic, direct Run API, skill tools in `use_funcs`) · `acp-agent-runner` (external harness, remote-ssh claude-code, MCP gateway) · `claude-code-agent` (external harness, claude-code CLI, MCP gateway — *no pipeline yet*).
+- **Runner**: `local-agent` (in-process logic, direct Run API, skill tools in `use_funcs`) · `acp-agent-runner` (external harness, remote-ssh claude-code over ACP, MCP gateway via **HTTP proxy**) · `claude-code-agent` (external harness, claude-code CLI, MCP gateway via **stdio bridge** — pipeline `28fd37ac`, remote-ssh→101).
+
+### Runner transport difference (important for remote-ssh)
+
+Both external runners receive the same host-generated gateway `AgentMCPServerConfig`, but inject it differently:
+
+- **claude-code-agent → stdio bridge.** The mcp config is shipped to the remote host base64-over-SSH-stdin and consumed via `--mcp-config`; the gateway entry is a `command/args` (stdio) MCP server whose process tunnels back to the host over the SSH stdio pipe. **No extra config needed on remote-ssh** — works out of the box.
+- **acp-agent-runner → HTTP proxy.** The gateway is a localhost HTTP MCP proxy passed via ACP `session/new {mcpServers}`. On `remote-ssh` the remote claude must HTTP-reach the host, so you **must** set `langbot-assets-gateway-public-url` (or `mcp-public-url`) to a host URL the remote can reach. Without it the remote `mcpServers` entry points at the *remote's* localhost → `langbot_*` tools never enter claude's tool list.
+
+This is a **runner-plugin transport detail, not a host all-tool-branch issue** — proven by claude-code-agent discovering skills end-to-end with the unmodified branch.
- **Lifecycle**: discover → activate → operate (native exec under the activated mount path) → register.
- **Backend**: docker · nsjail · e2b.
@@ -35,8 +44,8 @@ per-tool `get_tool_detail`.
| `toolresource-parameters-prefill` | runner builds LLM tools from `ctx.resources.tools.parameters` without per-tool `get_tool_detail` | local-agent | **covered (unit)** — `test_run_assembly.py::test_build_llm_tools_uses_prefilled_schema_without_fetch` |
| `regression-existing-runner-behavior` | existing local-agent cases (basic/rag/tool-call/steering/multimodal) unchanged | local-agent | **covered (unit)** — full host/sdk/local-agent suites green, 0 new failures |
| `sandbox-skill-authoring-e2e` | create → register → activate → exec-from-activated-path → `E2E_OK` | local-agent | **partial** — authorization chain passes (agent calls exec/register/activate, skill registered 0→1); **OPERATE step blocked by [#2271](https://github.com/langbot-app/LangBot/issues/2271)** on docker+shared-fs |
-| `skill-discovery-via-mcp-gateway` | external harness calls `langbot_list_assets(['skills'])` and receives pipeline-visible skills | acp / claude-code | **blocked (env)** — remote claude-code unresponsive (`runner.timeout`); link is alive (runner started, reached execution) |
-| `skill-activation-cross-runner-parity` | local-agent and external harness both reach `activate` via their paths (`use_funcs` vs `langbot_call_tool`) | local-agent + acp | **blocked (env)** |
+| `skill-discovery-via-mcp-gateway` | external harness calls `langbot_list_assets(['skills'])` and receives pipeline-visible skills | claude-code / acp | **PASS (claude-code-agent)** — pipeline `28fd37ac`, remote-ssh→101: `PROBEDONE skills=1 tools=15` in 24s, proving the all-tool `skills` asset class is discoverable end-to-end by an external harness. **acp blocked (config)** — needs `langbot-assets-gateway-public-url` in remote-ssh (HTTP-proxy transport); without it claude reports langbot tools "not available in my direct tool list" → `PROBEDONE 0 0` |
+| `skill-activation-cross-runner-parity` | local-agent and external harness both reach skills via their paths (`use_funcs` vs `langbot_call_tool`) | local-agent + claude-code | **PARTIAL** — local-agent (use_funcs) ✓ and claude-code-agent (langbot_list_assets via stdio gateway) ✓ both discover skills; acp parity pending public-url config |
## Known issues
@@ -47,7 +56,8 @@ per-tool `get_tool_detail`.
1. Unit matrix green across host/sdk/local-agent, 0 new failures. **(DONE)**
2. `skill-tool-exposure-no-capability` + `skill-activation-persistence` + `toolresource-parameters-prefill` covered by unit. **(DONE)**
3. `sandbox-skill-authoring-e2e` OPERATE step passes on at least one backend once #2271 is fixed (or a backend that avoids nested mounts), proving real end-to-end skill use. **(BLOCKED on #2271)**
-4. `skill-discovery-via-mcp-gateway` + `skill-activation-cross-runner-parity` pass on acp once remote claude-code is responsive. **(BLOCKED on env)**
+4. `skill-discovery-via-mcp-gateway` passes on an external harness. **(DONE — claude-code-agent: skills=1 tools=15, 24s)**
+5. `skill-activation-cross-runner-parity` passes on acp once `langbot-assets-gateway-public-url` is configured for the remote-ssh HTTP-proxy transport. **(PENDING acp config)**
## How to run
From 96f5b5e365f66a9d51477d00808d8ecbe5fe653b Mon Sep 17 00:00:00 2001
From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com>
Date: Mon, 22 Jun 2026 09:08:29 +0800
Subject: [PATCH 5/8] =?UTF-8?q?test(qa):=20correct=20acp=20parity=20verdic?=
=?UTF-8?q?t=20=E2=80=94=20passes=20on=20clean=20runtime,=20no=20public-ur?=
=?UTF-8?q?l?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Prior matrix recorded acp as blocked needing langbot-assets-gateway-public-url
(PROBEDONE 0 0 / timeout). That was an environment artifact: a duplicate
LangBot-master/ backend contending on box ws-control-port 5410 plus a wedged
plugin runtime (host emit_event / list_agent_runners timing out). On a clean
single-instance runtime acp discovers skills via the SDK SSH reverse tunnel
with no public-url: PROBEDONE 1 17 (8-24s), parity with claude-code (1 15).
---
.../references/skill-all-tool-acceptance.md | 18 ++++++++++--------
1 file changed, 10 insertions(+), 8 deletions(-)
diff --git a/skills/skills/langbot-testing/references/skill-all-tool-acceptance.md b/skills/skills/langbot-testing/references/skill-all-tool-acceptance.md
index d5c728659..d443f7b54 100644
--- a/skills/skills/langbot-testing/references/skill-all-tool-acceptance.md
+++ b/skills/skills/langbot-testing/references/skill-all-tool-acceptance.md
@@ -24,14 +24,16 @@ per-tool `get_tool_detail`.
- **Runner**: `local-agent` (in-process logic, direct Run API, skill tools in `use_funcs`) · `acp-agent-runner` (external harness, remote-ssh claude-code over ACP, MCP gateway via **HTTP proxy**) · `claude-code-agent` (external harness, claude-code CLI, MCP gateway via **stdio bridge** — pipeline `28fd37ac`, remote-ssh→101).
-### Runner transport difference (important for remote-ssh)
+### Runner transport difference (both work out-of-the-box on remote-ssh)
-Both external runners receive the same host-generated gateway `AgentMCPServerConfig`, but inject it differently:
+Both external runners receive the same host-generated gateway `AgentMCPServerConfig`, but inject it differently — and **both are made remote-reachable automatically; neither requires `public-url` on remote-ssh**:
-- **claude-code-agent → stdio bridge.** The mcp config is shipped to the remote host base64-over-SSH-stdin and consumed via `--mcp-config`; the gateway entry is a `command/args` (stdio) MCP server whose process tunnels back to the host over the SSH stdio pipe. **No extra config needed on remote-ssh** — works out of the box.
-- **acp-agent-runner → HTTP proxy.** The gateway is a localhost HTTP MCP proxy passed via ACP `session/new {mcpServers}`. On `remote-ssh` the remote claude must HTTP-reach the host, so you **must** set `langbot-assets-gateway-public-url` (or `mcp-public-url`) to a host URL the remote can reach. Without it the remote `mcpServers` entry points at the *remote's* localhost → `langbot_*` tools never enter claude's tool list.
+- **claude-code-agent → stdio bridge.** The mcp config is shipped to the remote host base64-over-SSH-stdin and consumed via `--mcp-config`; the gateway entry is a `command/args` (stdio) MCP server whose process tunnels back to the host over the SSH stdio pipe.
+- **acp-agent-runner → HTTP proxy + SSH reverse tunnel.** The gateway is a localhost HTTP MCP proxy passed via ACP `session/new {mcpServers}`. On `remote-ssh` with no `public-url`, the SDK's `AgentRunMCPAccess` (`mcp_access.py` `_remote_reverse_tunnel`: location==remote-ssh and empty public_url) emits an `ssh -R 127.0.0.1::127.0.0.1:` reverse tunnel — consumed by acp `default.py:521` (`ssh_args.extend(access.reverse_tunnel.ssh_args())`) — and points `server_config.public_url` at the host-local `http_mcp_endpoint`. The remote claude hits `127.0.0.1:` which tunnels back to the host bridge. **`langbot-assets-gateway-public-url` is an optional alternative for topologies where the reverse tunnel can't be used — not a requirement.**
-This is a **runner-plugin transport detail, not a host all-tool-branch issue** — proven by claude-code-agent discovering skills end-to-end with the unmodified branch.
+This is a **runner-plugin transport detail, not a host all-tool-branch issue** — proven by **both** runners discovering skills end-to-end with the unmodified branch (see cases below).
+
+> **Correction (2026-06-22).** An earlier revision of this doc claimed acp was "blocked" on remote-ssh and *required* `langbot-assets-gateway-public-url`, based on a run that returned `PROBEDONE 0 0` / timeout. That was an **environment artifact, not an acp defect**: a duplicate backend instance (a second checkout `LangBot-master/` whose box runtime contended for the same `--ws-control-port 5410`) plus a wedged plugin runtime (host `emit_event` / `list_agent_runners` action calls timing out with `ActionCallTimeoutError`). Re-run on a clean single-instance runtime, **acp passes via the reverse tunnel with no `public-url`** (`PROBEDONE 1 17`, 8–24s).
- **Lifecycle**: discover → activate → operate (native exec under the activated mount path) → register.
- **Backend**: docker · nsjail · e2b.
@@ -44,8 +46,8 @@ This is a **runner-plugin transport detail, not a host all-tool-branch issue**
| `toolresource-parameters-prefill` | runner builds LLM tools from `ctx.resources.tools.parameters` without per-tool `get_tool_detail` | local-agent | **covered (unit)** — `test_run_assembly.py::test_build_llm_tools_uses_prefilled_schema_without_fetch` |
| `regression-existing-runner-behavior` | existing local-agent cases (basic/rag/tool-call/steering/multimodal) unchanged | local-agent | **covered (unit)** — full host/sdk/local-agent suites green, 0 new failures |
| `sandbox-skill-authoring-e2e` | create → register → activate → exec-from-activated-path → `E2E_OK` | local-agent | **partial** — authorization chain passes (agent calls exec/register/activate, skill registered 0→1); **OPERATE step blocked by [#2271](https://github.com/langbot-app/LangBot/issues/2271)** on docker+shared-fs |
-| `skill-discovery-via-mcp-gateway` | external harness calls `langbot_list_assets(['skills'])` and receives pipeline-visible skills | claude-code / acp | **PASS (claude-code-agent)** — pipeline `28fd37ac`, remote-ssh→101: `PROBEDONE skills=1 tools=15` in 24s, proving the all-tool `skills` asset class is discoverable end-to-end by an external harness. **acp blocked (config)** — needs `langbot-assets-gateway-public-url` in remote-ssh (HTTP-proxy transport); without it claude reports langbot tools "not available in my direct tool list" → `PROBEDONE 0 0` |
-| `skill-activation-cross-runner-parity` | local-agent and external harness both reach skills via their paths (`use_funcs` vs `langbot_call_tool`) | local-agent + claude-code | **PARTIAL** — local-agent (use_funcs) ✓ and claude-code-agent (langbot_list_assets via stdio gateway) ✓ both discover skills; acp parity pending public-url config |
+| `skill-discovery-via-mcp-gateway` | external harness calls `langbot_list_assets(['skills'])` and receives pipeline-visible skills | claude-code / acp | **PASS (both)** — clean single-instance runtime, remote-ssh→101. claude-code-agent (pipeline `28fd37ac`, stdio bridge): `PROBEDONE skills=1 tools=15`. acp-agent-runner (pipeline `b00794d2`, HTTP proxy + SSH reverse tunnel, **no public-url**): `PROBEDONE skills=1 tools=17`, 8–24s. Both prove the all-tool `skills` asset class is discoverable end-to-end by an external harness. |
+| `skill-activation-cross-runner-parity` | local-agent and external harness both reach skills via their paths (`use_funcs` vs `langbot_call_tool`) | local-agent + claude-code + acp | **PASS** — local-agent (use_funcs) ✓, claude-code-agent (stdio gateway, `skills=1 tools=15`) ✓, and acp-agent-runner (HTTP-proxy gateway over reverse tunnel, `skills=1 tools=17`) ✓ all discover skills. `skills` count matches (1==1); the `tools` count (17 vs 15) is claude's self-reported tally and not yet checked against the authoritative gateway count — most likely model-counting variance, not an asset difference. |
## Known issues
@@ -57,7 +59,7 @@ This is a **runner-plugin transport detail, not a host all-tool-branch issue**
2. `skill-tool-exposure-no-capability` + `skill-activation-persistence` + `toolresource-parameters-prefill` covered by unit. **(DONE)**
3. `sandbox-skill-authoring-e2e` OPERATE step passes on at least one backend once #2271 is fixed (or a backend that avoids nested mounts), proving real end-to-end skill use. **(BLOCKED on #2271)**
4. `skill-discovery-via-mcp-gateway` passes on an external harness. **(DONE — claude-code-agent: skills=1 tools=15, 24s)**
-5. `skill-activation-cross-runner-parity` passes on acp once `langbot-assets-gateway-public-url` is configured for the remote-ssh HTTP-proxy transport. **(PENDING acp config)**
+5. `skill-activation-cross-runner-parity` passes on acp. **(DONE — acp: skills=1 tools=17, 8s, via SSH reverse tunnel with no public-url; clean single-instance runtime)**
## How to run
From 4b34d4cffda4f2afe525551a1e90c8b0defdd4d1 Mon Sep 17 00:00:00 2001
From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com>
Date: Mon, 22 Jun 2026 11:24:03 +0800
Subject: [PATCH 6/8] test(qa): sandbox-skill-authoring OPERATE passes on
nsjail + docker (#2271 fixed)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- nsjail: full create→exec→register→activate→exec-from-activated-path chain
returns exit 0; activated mount runs scripts/use.py (reads data/input.json)
and writes activated_writeback.txt through to the host skill store.
- docker: same chain now passes after langbot-plugin-sdk#87 (recreate sandbox
container when extra_mounts change). Corrected #2271 root cause from
'docker masks nested bind mount' to container-reuse: extra_mounts was not in
the box session compatibility check, so docker reused a running container and
could not append the activated skill's bind mount.
- Exit criterion 3 (real end-to-end skill use) now DONE; all 5 criteria met.
- Documents the nsjail stale-docker-artifact environment gotcha.
---
.../references/skill-all-tool-acceptance.md | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/skills/skills/langbot-testing/references/skill-all-tool-acceptance.md b/skills/skills/langbot-testing/references/skill-all-tool-acceptance.md
index d443f7b54..a72c1db70 100644
--- a/skills/skills/langbot-testing/references/skill-all-tool-acceptance.md
+++ b/skills/skills/langbot-testing/references/skill-all-tool-acceptance.md
@@ -45,19 +45,20 @@ This is a **runner-plugin transport detail, not a host all-tool-branch issue**
| `skill-activation-persistence` | activated skill survives a new run in the same conversation (`host.activated_skills` restore) | local-agent | **covered (unit)** — `test_skill_tools.py` |
| `toolresource-parameters-prefill` | runner builds LLM tools from `ctx.resources.tools.parameters` without per-tool `get_tool_detail` | local-agent | **covered (unit)** — `test_run_assembly.py::test_build_llm_tools_uses_prefilled_schema_without_fetch` |
| `regression-existing-runner-behavior` | existing local-agent cases (basic/rag/tool-call/steering/multimodal) unchanged | local-agent | **covered (unit)** — full host/sdk/local-agent suites green, 0 new failures |
-| `sandbox-skill-authoring-e2e` | create → register → activate → exec-from-activated-path → `E2E_OK` | local-agent | **partial** — authorization chain passes (agent calls exec/register/activate, skill registered 0→1); **OPERATE step blocked by [#2271](https://github.com/langbot-app/LangBot/issues/2271)** on docker+shared-fs |
+| `sandbox-skill-authoring-e2e` | create → register → activate → exec-from-activated-path → `E2E_OK` | local-agent | **PASS (nsjail + docker)** — full chain green via local-agent Debug Chat (pipeline `3e645b04`): create+run in `/workspace` → `exit 0` `SANDBOX_COMPLEX_SKILL_OK sum=10 product=24`; register+activate; exec in `/workspace/.skills/` runs `scripts/use.py` (reads `data/input.json`) and writes `activated_writeback.txt` → `exit 0`, both markers, file written through to host skill store. Verified on **nsjail** first, then on **docker** after the #2271 fix ([langbot-plugin-sdk#87](https://github.com/langbot-app/langbot-plugin-sdk/pull/87)). |
| `skill-discovery-via-mcp-gateway` | external harness calls `langbot_list_assets(['skills'])` and receives pipeline-visible skills | claude-code / acp | **PASS (both)** — clean single-instance runtime, remote-ssh→101. claude-code-agent (pipeline `28fd37ac`, stdio bridge): `PROBEDONE skills=1 tools=15`. acp-agent-runner (pipeline `b00794d2`, HTTP proxy + SSH reverse tunnel, **no public-url**): `PROBEDONE skills=1 tools=17`, 8–24s. Both prove the all-tool `skills` asset class is discoverable end-to-end by an external harness. |
| `skill-activation-cross-runner-parity` | local-agent and external harness both reach skills via their paths (`use_funcs` vs `langbot_call_tool`) | local-agent + claude-code + acp | **PASS** — local-agent (use_funcs) ✓, claude-code-agent (stdio gateway, `skills=1 tools=15`) ✓, and acp-agent-runner (HTTP-proxy gateway over reverse tunnel, `skills=1 tools=17`) ✓ all discover skills. `skills` count matches (1==1); the `tools` count (17 vs 15) is claude's self-reported tally and not yet checked against the authoritative gateway count — most likely model-counting variance, not an asset difference. |
## Known issues
-- [#2271](https://github.com/langbot-app/LangBot/issues/2271) — activated `/workspace/.skills/` missing `scripts/`/`data/` on docker backend (nested bind mount). **Pre-existing** (Feat/sandbox #2072), not introduced by this branch (the mount/register chain is byte-identical to `origin/master` across host loader, `box/service.py`, SDK box backend, SDK box runtime). This branch only **exposed** the path end-to-end for the first time. Blocks the OPERATE step on docker+shared-fs.
+- [#2271](https://github.com/langbot-app/LangBot/issues/2271) — activated `/workspace/.skills/` `scripts/`/`data/` missing on the docker backend. **FIXED** by [langbot-plugin-sdk#87](https://github.com/langbot-app/langbot-plugin-sdk/pull/87) (`fix(box): recreate sandbox container when extra_mounts change`), rebased into this branch. **Corrected root cause:** not "docker masks the nested bind mount" (disproven) — the real bug is **container reuse**: `extra_mounts` was not part of the box session compatibility check, so when a skill is activated mid-conversation docker reused the already-running container and could not append the new bind mount; the activated skill therefore appeared empty. The fix records a mount signature on the session and recreates the container when the mount set changes (idempotent, no data loss). Pre-existing (Feat/sandbox #2072), reproduced on pure `origin/master` + the built-in local-agent runner, so not introduced by this branch — this branch only exposed the path end-to-end for the first time. After the fix, the OPERATE step passes on **both** docker and nsjail (see exit criterion 3). Merging needs a new SDK release + a `langbot-plugin` pin bump in LangBot's `pyproject.toml` to reach a released LangBot.
+- **nsjail + stale docker workspace artifacts (environment, not a code bug).** If a prior docker run left root-owned dirs under the workspace (e.g. `data/box/default/.skills/`, created root-owned because docker runs as root), nsjail — which runs as the invoking user — cannot create the nested skill mount target under that root-owned dir and `runChild()` fails with `Launching child process failed`, poisoning **every** exec in the session (the exact symptom documented in `box/service.py::build_skill_extra_mounts`). Fix: remove the root-owned leftovers (`sudo rm -rf data/box/default/.skills data/box/default/`) before running nsjail e2e. New nsjail runs create user-owned artifacts, so this is a one-time cleanup after switching off docker.
## Exit criteria
1. Unit matrix green across host/sdk/local-agent, 0 new failures. **(DONE)**
2. `skill-tool-exposure-no-capability` + `skill-activation-persistence` + `toolresource-parameters-prefill` covered by unit. **(DONE)**
-3. `sandbox-skill-authoring-e2e` OPERATE step passes on at least one backend once #2271 is fixed (or a backend that avoids nested mounts), proving real end-to-end skill use. **(BLOCKED on #2271)**
+3. `sandbox-skill-authoring-e2e` OPERATE step passes on a real backend, proving end-to-end skill use. **(DONE — nsjail + docker)** — full create→exec→register→activate→exec-from-`/workspace/.skills/` chain returns `exit 0`; the activated mount runs `scripts/use.py` (reads `data/input.json` → `SANDBOX_COMPLEX_SKILL_OK sum=10 product=24`) and writes `activated_writeback.txt` through to the host skill store. Verified on nsjail, then on docker after the #2271 fix ([langbot-plugin-sdk#87](https://github.com/langbot-app/langbot-plugin-sdk/pull/87)).
4. `skill-discovery-via-mcp-gateway` passes on an external harness. **(DONE — claude-code-agent: skills=1 tools=15, 24s)**
5. `skill-activation-cross-runner-parity` passes on acp. **(DONE — acp: skills=1 tools=17, 8s, via SSH reverse tunnel with no public-url; clean single-instance runtime)**
From c7d4885bfc182ff1656f06f6f9cf73325f2cda82 Mon Sep 17 00:00:00 2001
From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com>
Date: Mon, 22 Jun 2026 13:08:34 +0800
Subject: [PATCH 7/8] refactor(plugin): split agent-runner action handlers out
of handler.py
Extract the AgentRunner Protocol v1 host-side surface from the giant
RuntimeConnectionHandler.__init__ into sibling modules using a registration-
function pattern (behavior-preserving; @h.action == @self.action):
- agent_run_support.py: shared constants + authorization/scope/projection helpers
- agent_pull_actions.py: register(h) for history/event pull APIs
- agent_runner_actions.py: register(h) for run/runtime/stats/claim lifecycle
- agent_state_actions.py: register(h) for steering/state APIs
__init__ now calls the three register(self) functions. handler.py keeps the
pre-existing plugin/llm/vector/knowledge handlers, get_prompt/call_tool/
get_tool_detail (coupled to retained helpers), shared helpers, and outbound
methods; it re-imports _validate_agent_run_session so external imports keep
working. handler.py: 4066 -> 1871 lines.
test_state_api_auth.py: repoint get_session_registry patch targets to
agent_run_support (the lookup moved modules). 385 agent unit tests pass; ruff clean.
---
src/langbot/pkg/plugin/agent_pull_actions.py | 293 +++
src/langbot/pkg/plugin/agent_run_support.py | 488 ++++
.../pkg/plugin/agent_runner_actions.py | 1195 +++++++++
src/langbot/pkg/plugin/agent_state_actions.py | 316 +++
src/langbot/pkg/plugin/handler.py | 2209 +----------------
tests/unit_tests/agent/test_state_api_auth.py | 20 +-
6 files changed, 2309 insertions(+), 2212 deletions(-)
create mode 100644 src/langbot/pkg/plugin/agent_pull_actions.py
create mode 100644 src/langbot/pkg/plugin/agent_run_support.py
create mode 100644 src/langbot/pkg/plugin/agent_runner_actions.py
create mode 100644 src/langbot/pkg/plugin/agent_state_actions.py
diff --git a/src/langbot/pkg/plugin/agent_pull_actions.py b/src/langbot/pkg/plugin/agent_pull_actions.py
new file mode 100644
index 000000000..e1e3a1714
--- /dev/null
+++ b/src/langbot/pkg/plugin/agent_pull_actions.py
@@ -0,0 +1,293 @@
+"""Agent-runner pull actions (history / event)."""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+from langbot_plugin.runtime.io import handler
+from langbot_plugin.entities.io.actions.enums import (
+ PluginToRuntimeAction,
+)
+
+
+from .agent_run_support import (
+ _get_run_authorization,
+ _validate_agent_run_session,
+ _resolve_run_conversation,
+ _run_scope_filters,
+ _event_matches_run_scope,
+ _project_event_record_for_api,
+)
+
+
+def register(h):
+ @h.action(PluginToRuntimeAction.HISTORY_PAGE)
+ async def history_page(data: dict[str, Any]) -> handler.ActionResponse:
+ """Page through transcript history for a conversation.
+
+ Requires run_id authorization. Only allows access to current run's conversation.
+ """
+ run_id = data.get('run_id')
+ conversation_id = data.get('conversation_id')
+ before_cursor = data.get('before_cursor')
+ after_cursor = data.get('after_cursor')
+ limit = data.get('limit', 50)
+ direction = data.get('direction', 'backward')
+ include_attachments = data.get('include_attachments', False)
+ caller_plugin_identity = data.get('caller_plugin_identity')
+
+ if not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'History page',
+ api_capability='history_page',
+ )
+ if error:
+ return error
+
+ conversation_id, scope_error = _resolve_run_conversation(
+ session,
+ conversation_id,
+ 'History page',
+ )
+ if scope_error:
+ return scope_error
+
+ if not conversation_id:
+ return handler.ActionResponse.success(
+ data={
+ 'items': [],
+ 'next_cursor': None,
+ 'prev_cursor': None,
+ 'has_more': False,
+ }
+ )
+
+ # Parse cursors
+ before_seq = int(before_cursor) if before_cursor else None
+ after_seq = int(after_cursor) if after_cursor else None
+
+ # Query transcript
+ from ..agent.runner.transcript_store import TranscriptStore
+
+ store = TranscriptStore(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ items, next_seq, prev_seq, has_more = await store.page_transcript(
+ conversation_id=conversation_id,
+ before_seq=before_seq,
+ after_seq=after_seq,
+ limit=limit,
+ direction=direction,
+ include_attachments=include_attachments,
+ **_run_scope_filters(session),
+ )
+
+ return handler.ActionResponse.success(
+ data={
+ 'items': items,
+ 'next_cursor': str(next_seq) if next_seq else None,
+ 'prev_cursor': str(prev_seq) if prev_seq else None,
+ 'has_more': has_more,
+ }
+ )
+ except Exception as e:
+ h.ap.logger.error(f'HISTORY_PAGE error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'History page error: {e}')
+
+ @h.action(PluginToRuntimeAction.HISTORY_SEARCH)
+ async def history_search(data: dict[str, Any]) -> handler.ActionResponse:
+ """Search transcript history.
+
+ Requires run_id authorization. Only searches current run's conversation.
+ Basic implementation using LIKE filtering.
+ """
+ run_id = data.get('run_id')
+ query_text = data.get('query', '')
+ filters = data.get('filters') or {}
+ top_k = data.get('top_k', 10)
+ caller_plugin_identity = data.get('caller_plugin_identity')
+
+ if not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'History search',
+ api_capability='history_search',
+ )
+ if error:
+ return error
+
+ requested_conversation_id = filters.get('conversation_id')
+ conversation_id, scope_error = _resolve_run_conversation(
+ session,
+ requested_conversation_id,
+ 'History search',
+ )
+ if scope_error:
+ return scope_error
+
+ if not conversation_id:
+ return handler.ActionResponse.success(
+ data={
+ 'items': [],
+ 'total_count': 0,
+ 'query': query_text,
+ }
+ )
+
+ # Search transcript
+ from ..agent.runner.transcript_store import TranscriptStore
+
+ store = TranscriptStore(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ safe_filters = {k: v for k, v in filters.items() if k != 'conversation_id'}
+ items = await store.search_transcript(
+ conversation_id=conversation_id,
+ query_text=query_text,
+ filters=safe_filters,
+ top_k=top_k,
+ **_run_scope_filters(session),
+ )
+
+ return handler.ActionResponse.success(
+ data={
+ 'items': items,
+ 'total_count': len(items),
+ 'query': query_text,
+ }
+ )
+ except Exception as e:
+ h.ap.logger.error(f'HISTORY_SEARCH error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'History search error: {e}')
+
+ @h.action(PluginToRuntimeAction.EVENT_GET)
+ async def event_get(data: dict[str, Any]) -> handler.ActionResponse:
+ """Get a single event record by ID.
+
+ Requires run_id authorization. Only allows access to events in current run's conversation.
+ """
+ run_id = data.get('run_id')
+ event_id = data.get('event_id')
+ caller_plugin_identity = data.get('caller_plugin_identity')
+
+ if not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+
+ if not event_id:
+ return handler.ActionResponse.error(message='event_id is required')
+
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'Event get',
+ api_capability='event_get',
+ )
+ if error:
+ return error
+
+ # Get event
+ from ..agent.runner.event_log_store import EventLogStore
+
+ store = EventLogStore(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ event = await store.get_event(event_id)
+ if not event:
+ return handler.ActionResponse.error(message=f'Event {event_id} not found')
+
+ # Validate event is in the same conversation as the run, or was created by the same run.
+ session_conversation_id = _get_run_authorization(session).get('conversation_id')
+ event_run_id = event.get('run_id')
+ if event_run_id and event_run_id == run_id:
+ return handler.ActionResponse.success(data=_project_event_record_for_api(event))
+ if not session_conversation_id or not _event_matches_run_scope(session, event):
+ return handler.ActionResponse.error(message=f'Event {event_id} is not accessible by this run')
+
+ return handler.ActionResponse.success(data=_project_event_record_for_api(event))
+ except Exception as e:
+ h.ap.logger.error(f'EVENT_GET error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'Event get error: {e}')
+
+ @h.action(PluginToRuntimeAction.EVENT_PAGE)
+ async def event_page(data: dict[str, Any]) -> handler.ActionResponse:
+ """Page through event records.
+
+ Requires run_id authorization. Only allows access to current run's conversation.
+ """
+ run_id = data.get('run_id')
+ conversation_id = data.get('conversation_id')
+ event_types = data.get('event_types')
+ before_cursor = data.get('before_cursor')
+ limit = data.get('limit', 50)
+ caller_plugin_identity = data.get('caller_plugin_identity')
+
+ if not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'Event page',
+ api_capability='event_page',
+ )
+ if error:
+ return error
+
+ conversation_id, scope_error = _resolve_run_conversation(
+ session,
+ conversation_id,
+ 'Event page',
+ )
+ if scope_error:
+ return scope_error
+
+ if not conversation_id:
+ return handler.ActionResponse.success(
+ data={
+ 'items': [],
+ 'next_cursor': None,
+ 'prev_cursor': None,
+ 'has_more': False,
+ }
+ )
+
+ # Parse cursor
+ before_seq = int(before_cursor) if before_cursor else None
+
+ # Query events
+ from ..agent.runner.event_log_store import EventLogStore
+
+ store = EventLogStore(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ items, next_seq, has_more = await store.page_events(
+ conversation_id=conversation_id,
+ event_types=event_types,
+ before_seq=before_seq,
+ limit=limit,
+ **_run_scope_filters(session),
+ )
+
+ return handler.ActionResponse.success(
+ data={
+ 'items': [_project_event_record_for_api(item) for item in items],
+ 'next_cursor': str(next_seq) if next_seq else None,
+ 'prev_cursor': None,
+ 'has_more': has_more,
+ }
+ )
+ except Exception as e:
+ h.ap.logger.error(f'EVENT_PAGE error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'Event page error: {e}')
diff --git a/src/langbot/pkg/plugin/agent_run_support.py b/src/langbot/pkg/plugin/agent_run_support.py
new file mode 100644
index 000000000..d19a08b55
--- /dev/null
+++ b/src/langbot/pkg/plugin/agent_run_support.py
@@ -0,0 +1,488 @@
+"""Agent-runner protocol support: shared constants and authorization/scope/projection helpers extracted from handler.py."""
+
+from __future__ import annotations
+
+from typing import Any, Union
+import json
+import time
+
+import sqlalchemy
+
+from langbot_plugin.runtime.io import handler
+from langbot_plugin.entities.io.actions.enums import (
+ PluginToRuntimeAction,
+)
+
+
+from ..core import app
+from ..agent.runner.session_registry import get_session_registry
+from ..agent.runner.result_normalizer import MAX_RESULT_SIZE_BYTES, STRICT_RESULT_PAYLOADS
+
+
+class _RuntimeActionName:
+ def __init__(self, value: str):
+ self.value = value
+
+
+AGENT_RUN_ADMIN_PERMISSION = 'agent_run:admin'
+RUNTIME_ADMIN_PERMISSION = 'runtime:admin'
+AGENT_RUNNER_ADMIN_PERMISSION = 'agent_runner:admin'
+LEDGER_ONLY_SIDE_EFFECTING_RESULT_TYPES = {
+ 'message.delta',
+ 'message.completed',
+ 'state.updated',
+ 'run.completed',
+ 'run.failed',
+}
+
+
+def _plugin_runtime_action(name: str, value: str) -> Any:
+ return getattr(PluginToRuntimeAction, name, _RuntimeActionName(value))
+
+
+def _normalize_permission_set(value: Any) -> set[str]:
+ if isinstance(value, str):
+ return {permission.strip() for permission in value.split(',') if permission.strip()}
+ if isinstance(value, list):
+ return {str(item).strip() for item in value if str(item).strip()}
+ if isinstance(value, dict):
+ return {str(item).strip() for item, enabled in value.items() if enabled and str(item).strip()}
+ return set()
+
+
+def _iter_agent_runner_admin_plugin_configs(ap: app.Application) -> list[dict[str, Any]]:
+ instance_config = getattr(ap, 'instance_config', None)
+ config_data = getattr(instance_config, 'data', {}) if instance_config is not None else {}
+ if not isinstance(config_data, dict):
+ return []
+ agent_runner_config = config_data.get('agent_runner', {})
+ if not isinstance(agent_runner_config, dict):
+ return []
+ raw_admin_plugins = agent_runner_config.get('admin_plugins', [])
+ if isinstance(raw_admin_plugins, dict):
+ items: list[dict[str, Any]] = []
+ for identity, entry in raw_admin_plugins.items():
+ if isinstance(entry, dict):
+ merged = dict(entry)
+ merged.setdefault('identity', identity)
+ items.append(merged)
+ else:
+ items.append({'identity': identity, 'permissions': entry})
+ return items
+ if isinstance(raw_admin_plugins, list):
+ return [item for item in raw_admin_plugins if isinstance(item, dict)]
+ return []
+
+
+def _agent_runner_admin_permissions(ap: app.Application, plugin_identity: str | None) -> set[str]:
+ if not isinstance(plugin_identity, str) or not plugin_identity.strip():
+ return set()
+ normalized_identity = plugin_identity.strip()
+ permissions: set[str] = set()
+ for entry in _iter_agent_runner_admin_plugin_configs(ap):
+ if entry.get('enabled', True) is False:
+ continue
+ identity = entry.get('identity') or entry.get('plugin_identity') or entry.get('plugin') or entry.get('id')
+ if identity != normalized_identity:
+ continue
+ permissions.update(_normalize_permission_set(entry.get('permissions')))
+ permissions.update(_normalize_permission_set(entry.get('scopes')))
+ return permissions
+
+
+def _has_agent_runner_admin_permission(
+ ap: app.Application,
+ plugin_identity: str | None,
+ permission: str,
+) -> bool:
+ permissions = _agent_runner_admin_permissions(ap, plugin_identity)
+ if not permissions:
+ return False
+ domain = permission.split(':', 1)[0]
+ return bool(
+ permission in permissions
+ or f'{domain}:*' in permissions
+ or AGENT_RUNNER_ADMIN_PERMISSION in permissions
+ or '*' in permissions
+ )
+
+
+def _deadline_seconds_from_payload(data: dict[str, Any], default: int = 60) -> int:
+ deadline_at = data.get('heartbeat_deadline_at')
+ if deadline_at is not None:
+ try:
+ return max(int(float(deadline_at) - time.time()), 1)
+ except (TypeError, ValueError):
+ pass
+ try:
+ return max(int(data.get('heartbeat_ttl_seconds') or default), 1)
+ except (TypeError, ValueError):
+ return default
+
+
+def _get_run_authorization(session: dict[str, Any]) -> dict[str, Any]:
+ """Return the run-scoped authorization snapshot."""
+ return session['authorization']
+
+
+def _run_matches_run_scope(session: dict[str, Any], run: dict[str, Any]) -> bool:
+ authorization = _get_run_authorization(session)
+ session_run_id = session.get('run_id')
+ if run.get('run_id') == session_run_id:
+ return True
+ session_runner_id = session.get('runner_id') or authorization.get('runner_id')
+ if not session_runner_id or run.get('runner_id') != session_runner_id:
+ return False
+ if not authorization.get('conversation_id'):
+ return False
+ if run.get('conversation_id') != authorization.get('conversation_id'):
+ return False
+ if authorization.get('bot_id') is not None and authorization.get('bot_id') != run.get('bot_id'):
+ return False
+ if authorization.get('workspace_id') is not None and authorization.get('workspace_id') != run.get('workspace_id'):
+ return False
+ if authorization.get('thread_id') != run.get('thread_id'):
+ return False
+ return True
+
+
+def _authorize_target_run(
+ session: dict[str, Any],
+ run: dict[str, Any],
+) -> handler.ActionResponse | None:
+ """Authorize non-admin target-run access against scope and runner owner."""
+ if _run_matches_run_scope(session, run):
+ return None
+ return handler.ActionResponse.error(message=f'Run {run.get("run_id")} is not accessible by this run')
+
+
+def _validate_ledger_only_result_payload(
+ *,
+ ap: app.Application,
+ runner_id: str | None,
+ event_type: str,
+ data: dict[str, Any],
+) -> str | None:
+ """Validate result payloads that can be safely stored without side effects."""
+ try:
+ result_json = json.dumps({'type': event_type, 'data': data})
+ except (TypeError, ValueError) as exc:
+ return f'event data must be JSON serializable: {exc}'
+ if len(result_json) > MAX_RESULT_SIZE_BYTES:
+ return f'event payload exceeds {MAX_RESULT_SIZE_BYTES} bytes'
+
+ payload_model = STRICT_RESULT_PAYLOADS.get(event_type)
+ if payload_model is None:
+ return f'unknown result type: {event_type}'
+ try:
+ payload_model.model_validate(data)
+ except Exception as exc:
+ return f'invalid {event_type} payload: {exc}'
+
+ if event_type in LEDGER_ONLY_SIDE_EFFECTING_RESULT_TYPES:
+ if runner_id:
+ ap.logger.warning(
+ f'Runner {runner_id} attempted ledger-only append for side-effecting result type {event_type}'
+ )
+ return f'{event_type} must be emitted through the canonical runner result path'
+ return None
+
+
+async def _require_runtime_write_ownership(
+ *,
+ store: Any,
+ session: dict[str, Any],
+ run: dict[str, Any],
+ data: dict[str, Any],
+ api_name: str,
+) -> handler.ActionResponse | None:
+ """Require current-run ownership or an active runtime claim for run writes."""
+ if run.get('run_id') == session.get('run_id') and run.get('status') != 'claimed':
+ return None
+
+ runtime_id = data.get('runtime_id')
+ claim_token = data.get('claim_token')
+ if not runtime_id or not claim_token:
+ return handler.ActionResponse.error(
+ message=f'{api_name} requires active claim ownership for target run {run.get("run_id")}'
+ )
+
+ if not await store.validate_active_claim(
+ run_id=str(run.get('run_id')),
+ runtime_id=str(runtime_id),
+ claim_token=str(claim_token),
+ ):
+ return handler.ActionResponse.error(
+ message=f'{api_name} claim ownership is not active for target run {run.get("run_id")}'
+ )
+
+ return None
+
+
+def _resolve_state_scope(
+ session: dict[str, Any],
+ scope: str,
+) -> tuple[dict[str, Any] | None, str | None, handler.ActionResponse | None]:
+ """Resolve state policy/context for an authorized run scope."""
+ authorization = _get_run_authorization(session)
+ state_policy = authorization['state_policy']
+
+ if not state_policy.get('enable_state', True):
+ return None, None, handler.ActionResponse.error(message='State access is disabled by binding policy')
+
+ state_scopes = state_policy.get('state_scopes', ['conversation', 'actor'])
+ if scope not in state_scopes:
+ return None, None, handler.ActionResponse.error(message=f'Scope "{scope}" is not enabled by binding policy')
+
+ state_context = authorization['state_context']
+ scope_key = state_context.get('scope_keys', {}).get(scope)
+ if not scope_key:
+ return None, None, handler.ActionResponse.error(message=f'Scope key not available for scope "{scope}"')
+
+ return state_context, scope_key, None
+
+
+async def _validate_agent_run_session(
+ run_id: str,
+ caller_plugin_identity: str | None,
+ ap: app.Application,
+ api_name: str,
+ api_capability: str | None = None,
+ allow_persistent_authorization: bool = False,
+ admin_permission: str | None = None,
+) -> Union[tuple[None, handler.ActionResponse], tuple[Any, None]]:
+ """Validate an AgentRunner pull API run session and run-scoped API access."""
+ if (
+ not run_id
+ and admin_permission
+ and _has_agent_runner_admin_permission(
+ ap,
+ caller_plugin_identity,
+ admin_permission,
+ )
+ ):
+ return {
+ 'run_id': run_id,
+ 'runner_id': None,
+ 'query_id': None,
+ 'plugin_identity': caller_plugin_identity,
+ 'authorization': {},
+ 'status': {},
+ 'steering_queue': [],
+ }, None
+
+ session_registry = get_session_registry()
+ session = await session_registry.get(run_id)
+ if not session:
+ if allow_persistent_authorization:
+ session = await _load_persistent_agent_run_session(run_id, ap, api_name)
+ if not session:
+ return None, handler.ActionResponse.error(message=f'Run session {run_id} not found or expired')
+
+ session_plugin_identity = session.get('plugin_identity')
+ if not isinstance(session_plugin_identity, str) or not session_plugin_identity.strip():
+ ap.logger.warning(f'{api_name}: run_id {run_id} has no plugin_identity')
+ return None, handler.ActionResponse.error(message=f'Run session {run_id} has no plugin_identity')
+ if not caller_plugin_identity:
+ return None, handler.ActionResponse.error(message=f'caller_plugin_identity is required for run_id {run_id}')
+ if caller_plugin_identity != session_plugin_identity:
+ ap.logger.warning(
+ f'{api_name}: caller_plugin_identity {caller_plugin_identity} '
+ f'does not match session plugin_identity {session_plugin_identity}'
+ )
+ return None, handler.ActionResponse.error(message=f'Plugin identity mismatch for run_id {run_id}')
+
+ if api_capability:
+ available_apis = _get_run_authorization(session).get('available_apis', {})
+ has_admin_permission = bool(admin_permission) and _has_agent_runner_admin_permission(
+ ap,
+ caller_plugin_identity,
+ admin_permission,
+ )
+ if not available_apis.get(api_capability, False) and not has_admin_permission:
+ return None, handler.ActionResponse.error(message=f'{api_name} access not authorized')
+
+ return session, None
+
+
+async def _load_persistent_agent_run_session(
+ run_id: str,
+ ap: app.Application,
+ api_name: str,
+) -> dict[str, Any] | None:
+ """Load an expired run session from the AgentRun authorization snapshot."""
+ try:
+ from sqlalchemy.ext.asyncio import AsyncSession
+ from sqlalchemy.orm import sessionmaker
+
+ from ..entity.persistence.agent_run import AgentRun
+
+ engine = ap.persistence_mgr.get_db_engine()
+ session_factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+ async with session_factory() as db_session:
+ result = await db_session.execute(sqlalchemy.select(AgentRun).where(AgentRun.run_id == run_id))
+ run = result.scalars().first()
+ except Exception as e:
+ ap.logger.error(f'{api_name}: failed to load persistent authorization for run_id {run_id}: {e}', exc_info=True)
+ return None
+
+ if run is None:
+ return None
+
+ try:
+ authorization = json.loads(run.authorization_json) if run.authorization_json else {}
+ except (TypeError, ValueError) as e:
+ ap.logger.warning(f'{api_name}: run_id {run_id} has invalid authorization_json: {e}')
+ return None
+
+ if not isinstance(authorization, dict):
+ ap.logger.warning(f'{api_name}: run_id {run_id} authorization_json is not an object')
+ return None
+
+ return {
+ 'run_id': run.run_id,
+ 'runner_id': authorization.get('runner_id') or run.runner_id,
+ 'query_id': None,
+ 'plugin_identity': authorization.get('plugin_identity'),
+ 'authorization': authorization,
+ 'status': {},
+ 'steering_queue': [],
+ }
+
+
+def _resolve_run_conversation(
+ session: dict[str, Any],
+ requested_conversation_id: str | None,
+ api_name: str,
+) -> tuple[str | None, handler.ActionResponse | None]:
+ """Resolve and enforce current-run conversation scope."""
+ session_conversation_id = _get_run_authorization(session).get('conversation_id')
+
+ if requested_conversation_id:
+ if not session_conversation_id:
+ return None, handler.ActionResponse.error(message=f'{api_name} is not available without a run conversation')
+ if requested_conversation_id != session_conversation_id:
+ return None, handler.ActionResponse.error(
+ message=f'Conversation {requested_conversation_id} is not accessible by this run'
+ )
+ return requested_conversation_id, None
+
+ return session_conversation_id, None
+
+
+def _run_scope_filters(session: dict[str, Any]) -> dict[str, Any]:
+ authorization = _get_run_authorization(session)
+ return {
+ 'bot_id': authorization.get('bot_id'),
+ 'workspace_id': authorization.get('workspace_id'),
+ 'thread_id': authorization.get('thread_id'),
+ 'strict_thread': True,
+ }
+
+
+def _run_ledger_scope_filters(session: dict[str, Any]) -> dict[str, Any]:
+ authorization = _get_run_authorization(session)
+ filters = _run_scope_filters(session)
+ filters['runner_id'] = session.get('runner_id') or authorization.get('runner_id')
+ return filters
+
+
+def _event_matches_run_scope(session: dict[str, Any], event: dict[str, Any]) -> bool:
+ authorization = _get_run_authorization(session)
+ if authorization.get('conversation_id') != event.get('conversation_id'):
+ return False
+ if authorization.get('bot_id') is not None and authorization.get('bot_id') != event.get('bot_id'):
+ return False
+ if authorization.get('workspace_id') is not None and authorization.get('workspace_id') != event.get('workspace_id'):
+ return False
+ if authorization.get('thread_id') != event.get('thread_id'):
+ return False
+ return True
+
+
+def _project_event_record_for_api(event: dict[str, Any]) -> dict[str, Any]:
+ """Project EventLogStore rows onto the SDK AgentEventRecord DTO."""
+ seq = event.get('seq') or event.get('id')
+ return {
+ 'event_id': event.get('event_id'),
+ 'event_type': event.get('event_type'),
+ 'event_time': event.get('event_time'),
+ 'source': event.get('source'),
+ 'bot_id': event.get('bot_id'),
+ 'workspace_id': event.get('workspace_id'),
+ 'conversation_id': event.get('conversation_id'),
+ 'thread_id': event.get('thread_id'),
+ 'actor_type': event.get('actor_type'),
+ 'actor_id': event.get('actor_id'),
+ 'actor_name': event.get('actor_name'),
+ 'subject_type': event.get('subject_type'),
+ 'subject_id': event.get('subject_id'),
+ 'input_summary': event.get('input_summary'),
+ 'input_ref': event.get('input_ref'),
+ 'raw_ref': event.get('raw_ref'),
+ 'seq': seq,
+ 'cursor': event.get('cursor') or (str(seq) if seq is not None else None),
+ 'created_at': event.get('created_at'),
+ 'metadata': event.get('metadata') or {},
+ }
+
+
+def _project_runner_descriptor_for_api(descriptor: Any) -> dict[str, Any]:
+ """Project an AgentRunnerDescriptor-like object onto a JSON dict."""
+ if isinstance(descriptor, dict):
+ return dict(descriptor)
+ if hasattr(descriptor, 'model_dump'):
+ return descriptor.model_dump(mode='json')
+ return {
+ 'id': getattr(descriptor, 'id', None),
+ 'source': getattr(descriptor, 'source', None),
+ 'label': getattr(descriptor, 'label', {}),
+ 'description': getattr(descriptor, 'description', None),
+ 'plugin_author': getattr(descriptor, 'plugin_author', None),
+ 'plugin_name': getattr(descriptor, 'plugin_name', None),
+ 'runner_name': getattr(descriptor, 'runner_name', None),
+ 'plugin_version': getattr(descriptor, 'plugin_version', None),
+ 'config_schema': getattr(descriptor, 'config_schema', []),
+ 'capabilities': getattr(descriptor, 'capabilities', {}),
+ 'permissions': getattr(descriptor, 'permissions', {}),
+ 'raw_manifest': getattr(descriptor, 'raw_manifest', {}),
+ }
+
+
+async def _record_agent_runner_admin_action(
+ ap: app.Application,
+ store: Any,
+ *,
+ action: str,
+ caller_plugin_identity: str | None,
+ permission: str,
+ durable_run_id: str | None = None,
+ target_runtime_id: str | None = None,
+ detail: dict[str, Any] | None = None,
+) -> None:
+ """Record a small audit trail for privileged AgentRunner operations."""
+ audit_data: dict[str, Any] = {
+ 'action': action,
+ 'caller_plugin_identity': caller_plugin_identity,
+ 'permission': permission,
+ }
+ if durable_run_id:
+ audit_data['target_run_id'] = durable_run_id
+ if target_runtime_id:
+ audit_data['target_runtime_id'] = target_runtime_id
+ if detail:
+ audit_data['detail'] = detail
+
+ ap.logger.info('Agent runner admin action: %s', audit_data)
+ if not durable_run_id or store is None or not hasattr(store, 'append_audit_event'):
+ return
+
+ try:
+ await store.append_audit_event(
+ run_id=str(durable_run_id),
+ event_type=f'admin.{action}',
+ data=audit_data,
+ metadata={'permission': permission},
+ )
+ except Exception as exc:
+ ap.logger.warning(f'Failed to record AgentRunner admin audit event: {exc}', exc_info=True)
diff --git a/src/langbot/pkg/plugin/agent_runner_actions.py b/src/langbot/pkg/plugin/agent_runner_actions.py
new file mode 100644
index 000000000..40e72e194
--- /dev/null
+++ b/src/langbot/pkg/plugin/agent_runner_actions.py
@@ -0,0 +1,1195 @@
+"""Agent-runner run / runtime / stats / claim actions."""
+
+from __future__ import annotations
+
+from typing import Any
+import time
+
+
+from langbot_plugin.runtime.io import handler
+
+
+from ..agent.runner.run_ledger_store import TERMINAL_STATUSES
+
+from .agent_run_support import (
+ AGENT_RUN_ADMIN_PERMISSION,
+ RUNTIME_ADMIN_PERMISSION,
+ _plugin_runtime_action,
+ _has_agent_runner_admin_permission,
+ _deadline_seconds_from_payload,
+ _get_run_authorization,
+ _authorize_target_run,
+ _validate_ledger_only_result_payload,
+ _require_runtime_write_ownership,
+ _validate_agent_run_session,
+ _resolve_run_conversation,
+ _run_scope_filters,
+ _run_ledger_scope_filters,
+ _project_runner_descriptor_for_api,
+ _record_agent_runner_admin_action,
+)
+
+
+def register(h):
+ @h.action(_plugin_runtime_action('RUN_GET', 'run_get'))
+ async def run_get(data: dict[str, Any]) -> handler.ActionResponse:
+ """Get one Host-owned run record visible to the current run."""
+ run_id = data.get('run_id')
+ target_run_id = data.get('target_run_id') or run_id
+ caller_plugin_identity = data.get('caller_plugin_identity')
+ is_admin = _has_agent_runner_admin_permission(
+ h.ap,
+ caller_plugin_identity,
+ AGENT_RUN_ADMIN_PERMISSION,
+ )
+
+ if not is_admin and not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+ if not target_run_id:
+ return handler.ActionResponse.error(message='target_run_id is required')
+
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'Run get',
+ api_capability='run_get',
+ allow_persistent_authorization=True,
+ admin_permission=AGENT_RUN_ADMIN_PERMISSION,
+ )
+ if error:
+ return error
+
+ from ..agent.runner.run_ledger_store import RunLedgerStore
+
+ store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ run = await store.get_run(str(target_run_id))
+ if not run:
+ return handler.ActionResponse.error(message=f'Run {target_run_id} not found')
+ if not is_admin:
+ auth_error = _authorize_target_run(session, run)
+ if auth_error:
+ return auth_error
+ if is_admin:
+ await _record_agent_runner_admin_action(
+ h.ap,
+ store,
+ action='run_get',
+ caller_plugin_identity=caller_plugin_identity,
+ permission=AGENT_RUN_ADMIN_PERMISSION,
+ detail={'target_run_id': str(target_run_id)},
+ )
+ return handler.ActionResponse.success(data=run)
+ except Exception as e:
+ h.ap.logger.error(f'RUN_GET error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'Run get error: {e}')
+
+ @h.action(_plugin_runtime_action('RUN_LIST', 'run_list'))
+ async def run_list(data: dict[str, Any]) -> handler.ActionResponse:
+ """List Host-owned runs visible to the current run conversation."""
+ run_id = data.get('run_id')
+ conversation_id = data.get('conversation_id')
+ statuses = data.get('statuses')
+ before_cursor = data.get('before_cursor')
+ limit = data.get('limit', 50)
+ caller_plugin_identity = data.get('caller_plugin_identity')
+ is_admin = _has_agent_runner_admin_permission(
+ h.ap,
+ caller_plugin_identity,
+ AGENT_RUN_ADMIN_PERMISSION,
+ )
+
+ if not is_admin and not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+
+ scope_filters: dict[str, Any] = {}
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'Run list',
+ api_capability='run_list',
+ allow_persistent_authorization=True,
+ admin_permission=AGENT_RUN_ADMIN_PERMISSION,
+ )
+ if error:
+ return error
+
+ if not is_admin:
+ conversation_id, scope_error = _resolve_run_conversation(
+ session,
+ conversation_id,
+ 'Run list',
+ )
+ if scope_error:
+ return scope_error
+ scope_filters = _run_ledger_scope_filters(session)
+
+ if not is_admin and not conversation_id:
+ return handler.ActionResponse.success(
+ data={
+ 'items': [],
+ 'next_cursor': None,
+ 'prev_cursor': None,
+ 'has_more': False,
+ 'total_count': 0,
+ }
+ )
+
+ if statuses is not None and not isinstance(statuses, list):
+ return handler.ActionResponse.error(message='statuses must be a list')
+ try:
+ before_id = int(before_cursor) if before_cursor else None
+ except (TypeError, ValueError):
+ return handler.ActionResponse.error(message='before_cursor must be an integer cursor')
+
+ from ..agent.runner.run_ledger_store import RunLedgerStore
+
+ store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ items, next_cursor, has_more, total_count = await store.list_runs(
+ conversation_id=conversation_id,
+ statuses=[str(status) for status in statuses] if statuses else None,
+ before_id=before_id,
+ limit=limit,
+ **scope_filters,
+ )
+ if is_admin:
+ await _record_agent_runner_admin_action(
+ h.ap,
+ store,
+ action='run_list',
+ caller_plugin_identity=caller_plugin_identity,
+ permission=AGENT_RUN_ADMIN_PERMISSION,
+ detail={
+ 'statuses': [str(status) for status in statuses] if statuses else None,
+ 'limit': limit,
+ },
+ )
+ return handler.ActionResponse.success(
+ data={
+ 'items': items,
+ 'next_cursor': str(next_cursor) if next_cursor else None,
+ 'prev_cursor': None,
+ 'has_more': has_more,
+ 'total_count': total_count,
+ }
+ )
+ except Exception as e:
+ h.ap.logger.error(f'RUN_LIST error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'Run list error: {e}')
+
+ @h.action(_plugin_runtime_action('RUNNER_LIST', 'runner_list'))
+ async def runner_list(data: dict[str, Any]) -> handler.ActionResponse:
+ """List Host-discovered AgentRunner descriptors."""
+ run_id = data.get('run_id')
+ caller_plugin_identity = data.get('caller_plugin_identity')
+ is_admin = _has_agent_runner_admin_permission(
+ h.ap,
+ caller_plugin_identity,
+ AGENT_RUN_ADMIN_PERMISSION,
+ )
+
+ if not is_admin:
+ return handler.ActionResponse.error(message='Runner list access not authorized')
+
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'Runner list',
+ api_capability='runner_list',
+ allow_persistent_authorization=True,
+ admin_permission=AGENT_RUN_ADMIN_PERMISSION,
+ )
+ if error:
+ return error
+
+ include_plugins = data.get('include_plugins')
+ if include_plugins is not None and not isinstance(include_plugins, list):
+ return handler.ActionResponse.error(message='include_plugins must be a list')
+
+ registry = getattr(h.ap, 'agent_runner_registry', None)
+ if registry is None:
+ return handler.ActionResponse.success(data={'items': []})
+
+ try:
+ runners = await registry.list_runners(
+ bound_plugins=[str(item) for item in include_plugins] if include_plugins else None,
+ use_cache=bool(data.get('use_cache', True)),
+ )
+ items = [_project_runner_descriptor_for_api(item) for item in runners]
+ if is_admin:
+ await _record_agent_runner_admin_action(
+ h.ap,
+ None,
+ action='runner_list',
+ caller_plugin_identity=caller_plugin_identity,
+ permission=AGENT_RUN_ADMIN_PERMISSION,
+ detail={
+ 'include_plugins': [str(item) for item in include_plugins] if include_plugins else None,
+ 'count': len(items),
+ },
+ )
+ return handler.ActionResponse.success(data={'items': items})
+ except Exception as e:
+ h.ap.logger.error(f'RUNNER_LIST error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'Runner list error: {e}')
+
+ @h.action(_plugin_runtime_action('RUN_EVENTS_PAGE', 'run_events_page'))
+ async def run_events_page(data: dict[str, Any]) -> handler.ActionResponse:
+ """Page result events for one Host-owned run visible to current run."""
+ run_id = data.get('run_id')
+ target_run_id = data.get('target_run_id') or run_id
+ before_cursor = data.get('before_cursor')
+ after_cursor = data.get('after_cursor')
+ limit = data.get('limit', 50)
+ direction = data.get('direction', 'forward')
+ caller_plugin_identity = data.get('caller_plugin_identity')
+ is_admin = _has_agent_runner_admin_permission(
+ h.ap,
+ caller_plugin_identity,
+ AGENT_RUN_ADMIN_PERMISSION,
+ )
+
+ if not is_admin and not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+ if not target_run_id:
+ return handler.ActionResponse.error(message='target_run_id is required')
+
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'Run events page',
+ api_capability='run_events_page',
+ allow_persistent_authorization=True,
+ admin_permission=AGENT_RUN_ADMIN_PERMISSION,
+ )
+ if error:
+ return error
+
+ try:
+ before_sequence = int(before_cursor) if before_cursor else None
+ after_sequence = int(after_cursor) if after_cursor else None
+ except (TypeError, ValueError):
+ return handler.ActionResponse.error(message='run event cursors must be integer sequences')
+
+ from ..agent.runner.run_ledger_store import RunLedgerStore
+
+ store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ run = await store.get_run(str(target_run_id))
+ if not run:
+ return handler.ActionResponse.error(message=f'Run {target_run_id} not found')
+ if not is_admin:
+ auth_error = _authorize_target_run(session, run)
+ if auth_error:
+ return auth_error
+
+ items, next_cursor, prev_cursor, has_more = await store.page_run_events(
+ run_id=str(target_run_id),
+ before_sequence=before_sequence,
+ after_sequence=after_sequence,
+ limit=limit,
+ direction=str(direction or 'forward'),
+ )
+ if is_admin:
+ await _record_agent_runner_admin_action(
+ h.ap,
+ store,
+ action='run_events_page',
+ caller_plugin_identity=caller_plugin_identity,
+ permission=AGENT_RUN_ADMIN_PERMISSION,
+ detail={'target_run_id': str(target_run_id), 'limit': limit},
+ )
+ return handler.ActionResponse.success(
+ data={
+ 'items': items,
+ 'next_cursor': str(next_cursor) if next_cursor else None,
+ 'prev_cursor': str(prev_cursor) if prev_cursor else None,
+ 'has_more': has_more,
+ }
+ )
+ except Exception as e:
+ h.ap.logger.error(f'RUN_EVENTS_PAGE error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'Run events page error: {e}')
+
+ @h.action(_plugin_runtime_action('RUN_CANCEL', 'run_cancel'))
+ async def run_cancel(data: dict[str, Any]) -> handler.ActionResponse:
+ """Request cancellation for one Host-owned run visible to the current run."""
+ run_id = data.get('run_id')
+ target_run_id = data.get('target_run_id') or run_id
+ caller_plugin_identity = data.get('caller_plugin_identity')
+ is_admin = _has_agent_runner_admin_permission(
+ h.ap,
+ caller_plugin_identity,
+ AGENT_RUN_ADMIN_PERMISSION,
+ )
+
+ if not is_admin and not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+ if not target_run_id:
+ return handler.ActionResponse.error(message='target_run_id is required')
+
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'Run cancel',
+ api_capability='run_cancel',
+ allow_persistent_authorization=True,
+ admin_permission=AGENT_RUN_ADMIN_PERMISSION,
+ )
+ if error:
+ return error
+
+ from ..agent.runner.run_ledger_store import RunLedgerStore
+
+ store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ run = await store.get_run(str(target_run_id))
+ if not run:
+ return handler.ActionResponse.error(message=f'Run {target_run_id} not found')
+ if not is_admin:
+ auth_error = _authorize_target_run(session, run)
+ if auth_error:
+ return auth_error
+
+ updated = await store.request_cancel(
+ run_id=str(target_run_id),
+ status_reason=data.get('status_reason') or data.get('reason'),
+ )
+ if not updated:
+ return handler.ActionResponse.error(message=f'Run {target_run_id} not found')
+ if is_admin:
+ await _record_agent_runner_admin_action(
+ h.ap,
+ store,
+ action='run_cancel',
+ caller_plugin_identity=caller_plugin_identity,
+ permission=AGENT_RUN_ADMIN_PERMISSION,
+ durable_run_id=str(target_run_id),
+ detail={'status_reason': data.get('status_reason') or data.get('reason')},
+ )
+ return handler.ActionResponse.success(data=updated)
+ except Exception as e:
+ h.ap.logger.error(f'RUN_CANCEL error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'Run cancel error: {e}')
+
+ @h.action(_plugin_runtime_action('RUN_APPEND_RESULT', 'run_append_result'))
+ async def run_append_result(data: dict[str, Any]) -> handler.ActionResponse:
+ """Append one result event for a Host-owned run visible to the current run."""
+ run_id = data.get('run_id')
+ target_run_id = data.get('target_run_id') or run_id
+ caller_plugin_identity = data.get('caller_plugin_identity')
+ result = data.get('result') if isinstance(data.get('result'), dict) else {}
+ is_admin = _has_agent_runner_admin_permission(
+ h.ap,
+ caller_plugin_identity,
+ AGENT_RUN_ADMIN_PERMISSION,
+ )
+
+ if not is_admin and not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+ if not target_run_id:
+ return handler.ActionResponse.error(message='target_run_id is required')
+
+ try:
+ sequence = int(data.get('sequence') or result.get('sequence'))
+ except (TypeError, ValueError):
+ return handler.ActionResponse.error(message='sequence is required and must be an integer')
+
+ event_type = data.get('event_type') or data.get('type') or result.get('type')
+ if not event_type:
+ return handler.ActionResponse.error(message='event_type is required')
+
+ event_data = data.get('data') if isinstance(data.get('data'), dict) else result.get('data')
+ usage = data.get('usage') if isinstance(data.get('usage'), dict) else result.get('usage')
+ metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else None
+
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'Run append result',
+ api_capability='run_append_result',
+ allow_persistent_authorization=True,
+ admin_permission=AGENT_RUN_ADMIN_PERMISSION,
+ )
+ if error:
+ return error
+
+ from ..agent.runner.run_ledger_store import RunLedgerStore
+
+ store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ run = await store.get_run(str(target_run_id))
+ if not run:
+ return handler.ActionResponse.error(message=f'Run {target_run_id} not found')
+ if not is_admin:
+ auth_error = _authorize_target_run(session, run)
+ if auth_error:
+ return auth_error
+ if run.get('status') in TERMINAL_STATUSES:
+ return handler.ActionResponse.error(
+ message=f'Run append result is not allowed for terminal run {target_run_id}'
+ )
+ claim_error = await _require_runtime_write_ownership(
+ store=store,
+ session=session,
+ run=run,
+ data=data,
+ api_name='Run append result',
+ )
+ if claim_error:
+ return claim_error
+
+ event_payload = event_data if isinstance(event_data, dict) else {}
+ payload_error = _validate_ledger_only_result_payload(
+ ap=h.ap,
+ runner_id=run.get('runner_id'),
+ event_type=str(event_type),
+ data=event_payload,
+ )
+ if payload_error:
+ return handler.ActionResponse.error(message=payload_error)
+
+ event = await store.append_event(
+ run_id=str(target_run_id),
+ sequence=sequence,
+ event_type=str(event_type),
+ data=event_payload,
+ usage=usage if isinstance(usage, dict) else None,
+ source=str(data.get('source') or result.get('source') or 'runner'),
+ metadata=metadata,
+ )
+ if is_admin:
+ await _record_agent_runner_admin_action(
+ h.ap,
+ store,
+ action='run_append_result',
+ caller_plugin_identity=caller_plugin_identity,
+ permission=AGENT_RUN_ADMIN_PERMISSION,
+ durable_run_id=str(target_run_id),
+ detail={'event_type': str(event_type), 'sequence': sequence},
+ )
+ return handler.ActionResponse.success(data=event)
+ except Exception as e:
+ h.ap.logger.error(f'RUN_APPEND_RESULT error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'Run append result error: {e}')
+
+ @h.action(_plugin_runtime_action('RUN_FINALIZE', 'run_finalize'))
+ async def run_finalize(data: dict[str, Any]) -> handler.ActionResponse:
+ """Finalize one Host-owned run visible to the current run."""
+ run_id = data.get('run_id')
+ target_run_id = data.get('target_run_id') or run_id
+ caller_plugin_identity = data.get('caller_plugin_identity')
+ status = data.get('status')
+ is_admin = _has_agent_runner_admin_permission(
+ h.ap,
+ caller_plugin_identity,
+ AGENT_RUN_ADMIN_PERMISSION,
+ )
+
+ if not is_admin and not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+ if not target_run_id:
+ return handler.ActionResponse.error(message='target_run_id is required')
+ if not status:
+ return handler.ActionResponse.error(message='status is required')
+
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'Run finalize',
+ api_capability='run_finalize',
+ allow_persistent_authorization=True,
+ admin_permission=AGENT_RUN_ADMIN_PERMISSION,
+ )
+ if error:
+ return error
+
+ from ..agent.runner.run_ledger_store import RunLedgerStore
+
+ store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ run = await store.get_run(str(target_run_id))
+ if not run:
+ return handler.ActionResponse.error(message=f'Run {target_run_id} not found')
+ if not is_admin:
+ auth_error = _authorize_target_run(session, run)
+ if auth_error:
+ return auth_error
+ claim_error = await _require_runtime_write_ownership(
+ store=store,
+ session=session,
+ run=run,
+ data=data,
+ api_name='Run finalize',
+ )
+ if claim_error:
+ return claim_error
+
+ updated = await store.finalize_run(
+ run_id=str(target_run_id),
+ status=str(status),
+ status_reason=data.get('status_reason') or data.get('reason'),
+ usage=data.get('usage') if isinstance(data.get('usage'), dict) else None,
+ cost=data.get('cost') if isinstance(data.get('cost'), dict) else None,
+ metadata=data.get('metadata') if isinstance(data.get('metadata'), dict) else None,
+ )
+ if not updated:
+ return handler.ActionResponse.error(message=f'Run {target_run_id} not found')
+ if is_admin:
+ await _record_agent_runner_admin_action(
+ h.ap,
+ store,
+ action='run_finalize',
+ caller_plugin_identity=caller_plugin_identity,
+ permission=AGENT_RUN_ADMIN_PERMISSION,
+ durable_run_id=str(target_run_id),
+ detail={'status': str(status)},
+ )
+ return handler.ActionResponse.success(data=updated)
+ except Exception as e:
+ h.ap.logger.error(f'RUN_FINALIZE error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'Run finalize error: {e}')
+
+ @h.action(_plugin_runtime_action('RUNTIME_REGISTER', 'runtime_register'))
+ async def runtime_register(data: dict[str, Any]) -> handler.ActionResponse:
+ """Register or update one Host-owned runtime registry record."""
+ run_id = data.get('run_id')
+ runtime_id = data.get('runtime_id')
+ caller_plugin_identity = data.get('caller_plugin_identity')
+ is_admin = _has_agent_runner_admin_permission(
+ h.ap,
+ caller_plugin_identity,
+ RUNTIME_ADMIN_PERMISSION,
+ )
+
+ if not is_admin and not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+ if not runtime_id:
+ return handler.ActionResponse.error(message='runtime_id is required')
+
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'Runtime register',
+ api_capability='runtime_register',
+ admin_permission=RUNTIME_ADMIN_PERMISSION,
+ )
+ if error:
+ return error
+
+ from ..agent.runner.run_ledger_store import RunLedgerStore
+
+ store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ runtime = await store.register_runtime(
+ runtime_id=str(runtime_id),
+ status=str(data.get('status') or 'online'),
+ display_name=data.get('display_name'),
+ endpoint=data.get('endpoint'),
+ version=data.get('version'),
+ capabilities=data.get('capabilities') if isinstance(data.get('capabilities'), dict) else {},
+ labels=data.get('labels') if isinstance(data.get('labels'), dict) else {},
+ metadata=data.get('metadata') if isinstance(data.get('metadata'), dict) else {},
+ heartbeat_deadline_seconds=_deadline_seconds_from_payload(data),
+ )
+ if is_admin:
+ await _record_agent_runner_admin_action(
+ h.ap,
+ store,
+ action='runtime_register',
+ caller_plugin_identity=caller_plugin_identity,
+ permission=RUNTIME_ADMIN_PERMISSION,
+ target_runtime_id=str(runtime_id),
+ detail={'status': runtime.get('status')},
+ )
+ return handler.ActionResponse.success(data=runtime)
+ except Exception as e:
+ h.ap.logger.error(f'RUNTIME_REGISTER error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'Runtime register error: {e}')
+
+ @h.action(_plugin_runtime_action('RUNTIME_HEARTBEAT', 'runtime_heartbeat'))
+ async def runtime_heartbeat(data: dict[str, Any]) -> handler.ActionResponse:
+ """Refresh one Host-owned runtime heartbeat."""
+ run_id = data.get('run_id')
+ runtime_id = data.get('runtime_id')
+ caller_plugin_identity = data.get('caller_plugin_identity')
+ is_admin = _has_agent_runner_admin_permission(
+ h.ap,
+ caller_plugin_identity,
+ RUNTIME_ADMIN_PERMISSION,
+ )
+
+ if not is_admin and not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+ if not runtime_id:
+ return handler.ActionResponse.error(message='runtime_id is required')
+
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'Runtime heartbeat',
+ api_capability='runtime_heartbeat',
+ admin_permission=RUNTIME_ADMIN_PERMISSION,
+ )
+ if error:
+ return error
+
+ from ..agent.runner.run_ledger_store import RunLedgerStore
+
+ store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ runtime = await store.heartbeat_runtime(
+ runtime_id=str(runtime_id),
+ status=str(data.get('status') or 'online'),
+ capabilities=data.get('capabilities') if isinstance(data.get('capabilities'), dict) else None,
+ labels=data.get('labels') if isinstance(data.get('labels'), dict) else None,
+ metadata=data.get('metadata') if isinstance(data.get('metadata'), dict) else None,
+ heartbeat_deadline_seconds=_deadline_seconds_from_payload(data),
+ )
+ if runtime is None:
+ return handler.ActionResponse.error(message=f'Runtime {runtime_id} not found')
+ if is_admin:
+ await _record_agent_runner_admin_action(
+ h.ap,
+ store,
+ action='runtime_heartbeat',
+ caller_plugin_identity=caller_plugin_identity,
+ permission=RUNTIME_ADMIN_PERMISSION,
+ target_runtime_id=str(runtime_id),
+ detail={'status': runtime.get('status')},
+ )
+ return handler.ActionResponse.success(data=runtime)
+ except Exception as e:
+ h.ap.logger.error(f'RUNTIME_HEARTBEAT error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'Runtime heartbeat error: {e}')
+
+ @h.action(_plugin_runtime_action('RUNTIME_LIST', 'runtime_list'))
+ async def runtime_list(data: dict[str, Any]) -> handler.ActionResponse:
+ """List Host-owned runtime registry records."""
+ run_id = data.get('run_id')
+ caller_plugin_identity = data.get('caller_plugin_identity')
+ is_admin = _has_agent_runner_admin_permission(
+ h.ap,
+ caller_plugin_identity,
+ RUNTIME_ADMIN_PERMISSION,
+ )
+
+ if not is_admin and not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+
+ _session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'Runtime list',
+ api_capability='runtime_list',
+ admin_permission=RUNTIME_ADMIN_PERMISSION,
+ )
+ if error:
+ return error
+
+ statuses = data.get('statuses')
+ if statuses is not None and not isinstance(statuses, list):
+ return handler.ActionResponse.error(message='statuses must be a list')
+ labels = data.get('labels') if isinstance(data.get('labels'), dict) else {}
+
+ from ..agent.runner.run_ledger_store import RunLedgerStore
+
+ store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ runtimes, total_count = await store.list_runtimes(
+ statuses=[str(status) for status in statuses] if statuses else None,
+ labels=labels,
+ limit=data.get('limit', 50),
+ )
+ if is_admin:
+ await _record_agent_runner_admin_action(
+ h.ap,
+ store,
+ action='runtime_list',
+ caller_plugin_identity=caller_plugin_identity,
+ permission=RUNTIME_ADMIN_PERMISSION,
+ detail={
+ 'statuses': [str(status) for status in statuses] if statuses else None,
+ 'limit': data.get('limit', 50),
+ },
+ )
+ return handler.ActionResponse.success(
+ data={
+ 'items': runtimes,
+ 'next_cursor': None,
+ 'prev_cursor': None,
+ 'has_more': False,
+ 'total_count': total_count,
+ }
+ )
+ except Exception as e:
+ h.ap.logger.error(f'RUNTIME_LIST error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'Runtime list error: {e}')
+
+ @h.action(_plugin_runtime_action('RUNTIME_RECONCILE', 'runtime_reconcile'))
+ async def runtime_reconcile(data: dict[str, Any]) -> handler.ActionResponse:
+ """Reconcile stale runtime heartbeats and expired claim leases."""
+ run_id = data.get('run_id')
+ caller_plugin_identity = data.get('caller_plugin_identity')
+ is_admin = _has_agent_runner_admin_permission(
+ h.ap,
+ caller_plugin_identity,
+ RUNTIME_ADMIN_PERMISSION,
+ )
+
+ if not is_admin:
+ return handler.ActionResponse.error(message='Runtime reconcile access not authorized')
+
+ _session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'Runtime reconcile',
+ api_capability='runtime_reconcile',
+ admin_permission=RUNTIME_ADMIN_PERMISSION,
+ )
+ if error:
+ return error
+
+ stale_after_seconds = data.get('stale_after_seconds')
+ if stale_after_seconds is not None:
+ try:
+ stale_after_seconds = max(float(stale_after_seconds), 0)
+ except (TypeError, ValueError):
+ return handler.ActionResponse.error(message='stale_after_seconds must be a number')
+
+ from ..agent.runner.run_ledger_store import RunLedgerStore
+
+ store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ stale_runtimes = await store.mark_stale_runtimes(
+ stale_after_seconds=stale_after_seconds,
+ )
+ released_claims = await store.release_expired_claims()
+ if is_admin:
+ await _record_agent_runner_admin_action(
+ h.ap,
+ store,
+ action='runtime_reconcile',
+ caller_plugin_identity=caller_plugin_identity,
+ permission=RUNTIME_ADMIN_PERMISSION,
+ detail={
+ 'stale_count': len(stale_runtimes),
+ 'released_claim_count': len(released_claims),
+ },
+ )
+ return handler.ActionResponse.success(
+ data={
+ 'stale_runtimes': stale_runtimes,
+ 'released_claims': released_claims,
+ 'stale_count': len(stale_runtimes),
+ 'released_claim_count': len(released_claims),
+ }
+ )
+ except Exception as e:
+ h.ap.logger.error(f'RUNTIME_RECONCILE error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'Runtime reconcile error: {e}')
+
+ @h.action(_plugin_runtime_action('RUN_STATS', 'run_stats'))
+ async def run_stats(data: dict[str, Any]) -> handler.ActionResponse:
+ """Get run statistics within a time window (admin-only)."""
+ run_id = data.get('run_id')
+ caller_plugin_identity = data.get('caller_plugin_identity')
+ is_admin = _has_agent_runner_admin_permission(
+ h.ap,
+ caller_plugin_identity,
+ AGENT_RUN_ADMIN_PERMISSION,
+ )
+
+ if not is_admin:
+ return handler.ActionResponse.error(message='Run stats access not authorized')
+
+ _session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'Run stats',
+ api_capability='run_stats',
+ admin_permission=AGENT_RUN_ADMIN_PERMISSION,
+ )
+ if error:
+ return error
+
+ end_time = data.get('end_time') or int(time.time())
+ start_time = data.get('start_time') or (end_time - 3600) # Default: 1 hour
+ runner_id = data.get('runner_id')
+
+ from ..agent.runner.run_ledger_store import RunLedgerStore
+
+ store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ stats = await store.get_run_stats(
+ start_time=start_time,
+ end_time=end_time,
+ runner_id=runner_id,
+ )
+ await _record_agent_runner_admin_action(
+ h.ap,
+ store,
+ action='run_stats',
+ caller_plugin_identity=caller_plugin_identity,
+ permission=AGENT_RUN_ADMIN_PERMISSION,
+ detail={
+ 'start_time': start_time,
+ 'end_time': end_time,
+ 'runner_id': runner_id,
+ },
+ )
+ return handler.ActionResponse.success(data=stats)
+ except Exception as e:
+ h.ap.logger.error(f'RUN_STATS error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'Run stats error: {e}')
+
+ @h.action(_plugin_runtime_action('RUNTIME_STATS', 'runtime_stats'))
+ async def runtime_stats(data: dict[str, Any]) -> handler.ActionResponse:
+ """Get runtime registry statistics (admin-only)."""
+ run_id = data.get('run_id')
+ caller_plugin_identity = data.get('caller_plugin_identity')
+ is_admin = _has_agent_runner_admin_permission(
+ h.ap,
+ caller_plugin_identity,
+ RUNTIME_ADMIN_PERMISSION,
+ )
+
+ if not is_admin:
+ return handler.ActionResponse.error(message='Runtime stats access not authorized')
+
+ _session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'Runtime stats',
+ api_capability='runtime_stats',
+ admin_permission=RUNTIME_ADMIN_PERMISSION,
+ )
+ if error:
+ return error
+
+ from ..agent.runner.run_ledger_store import RunLedgerStore
+
+ store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ stats = await store.get_runtime_stats()
+ await _record_agent_runner_admin_action(
+ h.ap,
+ store,
+ action='runtime_stats',
+ caller_plugin_identity=caller_plugin_identity,
+ permission=RUNTIME_ADMIN_PERMISSION,
+ detail={},
+ )
+ return handler.ActionResponse.success(data=stats)
+ except Exception as e:
+ h.ap.logger.error(f'RUNTIME_STATS error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'Runtime stats error: {e}')
+
+ @h.action(_plugin_runtime_action('RUNNER_STATS', 'runner_stats'))
+ async def runner_stats(data: dict[str, Any]) -> handler.ActionResponse:
+ """Get runner-aggregated statistics (admin-only)."""
+ run_id = data.get('run_id')
+ caller_plugin_identity = data.get('caller_plugin_identity')
+ is_admin = _has_agent_runner_admin_permission(
+ h.ap,
+ caller_plugin_identity,
+ AGENT_RUN_ADMIN_PERMISSION,
+ )
+
+ if not is_admin:
+ return handler.ActionResponse.error(message='Runner stats access not authorized')
+
+ _session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'Runner stats',
+ api_capability='runner_stats',
+ admin_permission=AGENT_RUN_ADMIN_PERMISSION,
+ )
+ if error:
+ return error
+
+ end_time = data.get('end_time') or int(time.time())
+ start_time = data.get('start_time') or (end_time - 3600) # Default: 1 hour
+ limit = min(int(data.get('limit', 50)), 100)
+
+ from ..agent.runner.run_ledger_store import RunLedgerStore
+
+ store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ stats = await store.get_runner_stats(
+ start_time=start_time,
+ end_time=end_time,
+ limit=limit,
+ )
+ await _record_agent_runner_admin_action(
+ h.ap,
+ store,
+ action='runner_stats',
+ caller_plugin_identity=caller_plugin_identity,
+ permission=AGENT_RUN_ADMIN_PERMISSION,
+ detail={
+ 'start_time': start_time,
+ 'end_time': end_time,
+ 'limit': limit,
+ },
+ )
+ return handler.ActionResponse.success(data={'items': stats, 'total_count': len(stats), 'has_more': False})
+ except Exception as e:
+ h.ap.logger.error(f'RUNNER_STATS error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'Runner stats error: {e}')
+
+ @h.action(_plugin_runtime_action('RUN_CLAIM', 'run_claim'))
+ async def run_claim(data: dict[str, Any]) -> handler.ActionResponse:
+ """Claim one queued run for a runtime lease."""
+ run_id = data.get('run_id')
+ runtime_id = data.get('runtime_id')
+ caller_plugin_identity = data.get('caller_plugin_identity')
+ is_admin = _has_agent_runner_admin_permission(
+ h.ap,
+ caller_plugin_identity,
+ RUNTIME_ADMIN_PERMISSION,
+ )
+
+ if not is_admin and not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+ if not runtime_id:
+ return handler.ActionResponse.error(message='runtime_id is required')
+
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'Run claim',
+ api_capability='run_claim',
+ admin_permission=RUNTIME_ADMIN_PERMISSION,
+ )
+ if error:
+ return error
+
+ runner_ids = data.get('runner_ids')
+ if runner_ids is not None and not isinstance(runner_ids, list):
+ return handler.ActionResponse.error(message='runner_ids must be a list')
+
+ from ..agent.runner.run_ledger_store import RunLedgerStore
+
+ store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ scope_filters: dict[str, Any] = {}
+ if not is_admin:
+ authorization = _get_run_authorization(session)
+ session_runner_id = session.get('runner_id') or authorization.get('runner_id')
+ if not session_runner_id:
+ return handler.ActionResponse.error(message='Run claim is not available without a runner_id')
+ if runner_ids and any(str(item) != session_runner_id for item in runner_ids):
+ return handler.ActionResponse.error(message='Run claim runner_ids are not accessible by this run')
+ runner_ids = [session_runner_id]
+ scope_filters = {
+ 'conversation_id': authorization.get('conversation_id'),
+ **_run_scope_filters(session),
+ }
+ run = await store.claim_next_run(
+ runtime_id=str(runtime_id),
+ queue_name=data.get('queue_name'),
+ lease_seconds=data.get('lease_seconds', 60),
+ runner_ids=[str(item) for item in runner_ids] if runner_ids else None,
+ **scope_filters,
+ )
+ if run is None:
+ return handler.ActionResponse.error(message='No queued run available')
+ if is_admin:
+ await _record_agent_runner_admin_action(
+ h.ap,
+ store,
+ action='run_claim',
+ caller_plugin_identity=caller_plugin_identity,
+ permission=RUNTIME_ADMIN_PERMISSION,
+ durable_run_id=str(run.get('run_id')),
+ target_runtime_id=str(runtime_id),
+ detail={
+ 'queue_name': data.get('queue_name'),
+ 'runner_ids': [str(item) for item in runner_ids] if runner_ids else None,
+ },
+ )
+ return handler.ActionResponse.success(data=run)
+ except Exception as e:
+ h.ap.logger.error(f'RUN_CLAIM error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'Run claim error: {e}')
+
+ @h.action(_plugin_runtime_action('RUN_RENEW_CLAIM', 'run_renew_claim'))
+ async def run_renew_claim(data: dict[str, Any]) -> handler.ActionResponse:
+ """Renew one run claim lease."""
+ run_id = data.get('run_id')
+ target_run_id = data.get('target_run_id')
+ runtime_id = data.get('runtime_id')
+ claim_token = data.get('claim_token')
+ caller_plugin_identity = data.get('caller_plugin_identity')
+ is_admin = _has_agent_runner_admin_permission(
+ h.ap,
+ caller_plugin_identity,
+ RUNTIME_ADMIN_PERMISSION,
+ )
+
+ if not is_admin and not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+ if not target_run_id:
+ return handler.ActionResponse.error(message='target_run_id is required')
+ if not runtime_id:
+ return handler.ActionResponse.error(message='runtime_id is required')
+ if not claim_token:
+ return handler.ActionResponse.error(message='claim_token is required')
+
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'Run renew claim',
+ api_capability='run_renew_claim',
+ admin_permission=RUNTIME_ADMIN_PERMISSION,
+ )
+ if error:
+ return error
+
+ from ..agent.runner.run_ledger_store import RunLedgerStore
+
+ store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ current = await store.get_run(str(target_run_id))
+ if not current or current.get('claimed_by_runtime_id') != runtime_id:
+ return handler.ActionResponse.error(message=f'Run claim {target_run_id} not found')
+ if not is_admin:
+ auth_error = _authorize_target_run(session, current)
+ if auth_error:
+ return auth_error
+ run = await store.renew_claim(
+ run_id=str(target_run_id),
+ claim_token=str(claim_token),
+ runtime_id=str(runtime_id),
+ lease_seconds=data.get('lease_seconds', 60),
+ )
+ if run is None:
+ return handler.ActionResponse.error(message=f'Run claim {target_run_id} not found')
+ if is_admin:
+ await _record_agent_runner_admin_action(
+ h.ap,
+ store,
+ action='run_renew_claim',
+ caller_plugin_identity=caller_plugin_identity,
+ permission=RUNTIME_ADMIN_PERMISSION,
+ durable_run_id=str(target_run_id),
+ target_runtime_id=str(runtime_id),
+ detail={'lease_seconds': data.get('lease_seconds', 60)},
+ )
+ return handler.ActionResponse.success(data=run)
+ except Exception as e:
+ h.ap.logger.error(f'RUN_RENEW_CLAIM error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'Run renew claim error: {e}')
+
+ @h.action(_plugin_runtime_action('RUN_RELEASE_CLAIM', 'run_release_claim'))
+ async def run_release_claim(data: dict[str, Any]) -> handler.ActionResponse:
+ """Release one run claim lease."""
+ run_id = data.get('run_id')
+ target_run_id = data.get('target_run_id')
+ runtime_id = data.get('runtime_id')
+ claim_token = data.get('claim_token')
+ caller_plugin_identity = data.get('caller_plugin_identity')
+ is_admin = _has_agent_runner_admin_permission(
+ h.ap,
+ caller_plugin_identity,
+ RUNTIME_ADMIN_PERMISSION,
+ )
+
+ if not is_admin and not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+ if not target_run_id:
+ return handler.ActionResponse.error(message='target_run_id is required')
+ if not runtime_id:
+ return handler.ActionResponse.error(message='runtime_id is required')
+ if not claim_token:
+ return handler.ActionResponse.error(message='claim_token is required')
+
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'Run release claim',
+ api_capability='run_release_claim',
+ admin_permission=RUNTIME_ADMIN_PERMISSION,
+ )
+ if error:
+ return error
+
+ from ..agent.runner.run_ledger_store import RunLedgerStore
+
+ store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ current = await store.get_run(str(target_run_id))
+ if not current or current.get('claimed_by_runtime_id') != runtime_id:
+ return handler.ActionResponse.error(message=f'Run claim {target_run_id} not found')
+ if not is_admin:
+ auth_error = _authorize_target_run(session, current)
+ if auth_error:
+ return auth_error
+ release_status = str(data.get('status') or 'queued')
+ if release_status in TERMINAL_STATUSES:
+ return handler.ActionResponse.error(
+ message='Run release claim cannot finalize a run; use run_finalize'
+ )
+ run = await store.release_claim(
+ run_id=str(target_run_id),
+ claim_token=str(claim_token),
+ runtime_id=str(runtime_id),
+ status=str(data.get('status') or 'queued'),
+ status_reason=data.get('status_reason') or data.get('reason'),
+ )
+ if run is None:
+ return handler.ActionResponse.error(message=f'Run claim {target_run_id} not found')
+ if is_admin:
+ await _record_agent_runner_admin_action(
+ h.ap,
+ store,
+ action='run_release_claim',
+ caller_plugin_identity=caller_plugin_identity,
+ permission=RUNTIME_ADMIN_PERMISSION,
+ durable_run_id=str(target_run_id),
+ target_runtime_id=str(runtime_id),
+ detail={
+ 'status': str(data.get('status') or 'queued'),
+ 'status_reason': data.get('status_reason') or data.get('reason'),
+ },
+ )
+ return handler.ActionResponse.success(data=run)
+ except Exception as e:
+ h.ap.logger.error(f'RUN_RELEASE_CLAIM error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'Run release claim error: {e}')
diff --git a/src/langbot/pkg/plugin/agent_state_actions.py b/src/langbot/pkg/plugin/agent_state_actions.py
new file mode 100644
index 000000000..2479ad426
--- /dev/null
+++ b/src/langbot/pkg/plugin/agent_state_actions.py
@@ -0,0 +1,316 @@
+"""Agent-runner steering / state actions."""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+from langbot_plugin.runtime.io import handler
+from langbot_plugin.entities.io.actions.enums import (
+ PluginToRuntimeAction,
+)
+
+
+from ..agent.runner.session_registry import get_session_registry
+
+from .agent_run_support import (
+ _resolve_state_scope,
+ _validate_agent_run_session,
+)
+
+
+def register(h):
+ @h.action(PluginToRuntimeAction.STEERING_PULL)
+ async def steering_pull(data: dict[str, Any]) -> handler.ActionResponse:
+ """Pull pending steering/follow-up inputs for the current run."""
+ run_id = data.get('run_id')
+ mode = data.get('mode', 'all')
+ limit = data.get('limit')
+ caller_plugin_identity = data.get('caller_plugin_identity')
+
+ if not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+
+ if limit is not None:
+ try:
+ limit = int(limit)
+ except (TypeError, ValueError):
+ return handler.ActionResponse.error(message='limit must be an integer')
+ if limit <= 0:
+ return handler.ActionResponse.error(message='limit must be > 0')
+ limit = min(limit, 100)
+
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'Steering pull',
+ api_capability='steering_pull',
+ )
+ if error:
+ return error
+
+ session_registry = get_session_registry()
+ items = await session_registry.pull_steering(
+ run_id,
+ mode=str(mode or 'all'),
+ limit=limit,
+ )
+ if items:
+ try:
+ from ..agent.runner.event_log_store import EventLogStore
+
+ store = EventLogStore(h.ap.persistence_mgr.get_db_engine())
+ for item in items:
+ event = item.get('event') if isinstance(item, dict) else None
+ conversation = item.get('conversation') if isinstance(item, dict) else None
+ actor = item.get('actor') if isinstance(item, dict) else None
+ subject = item.get('subject') if isinstance(item, dict) else None
+ if not isinstance(event, dict):
+ continue
+ await store.append_event(
+ event_id=None,
+ event_type='steering.injected',
+ source='agent_runner',
+ bot_id=conversation.get('bot_id') if isinstance(conversation, dict) else None,
+ workspace_id=conversation.get('workspace_id') if isinstance(conversation, dict) else None,
+ conversation_id=conversation.get('conversation_id') if isinstance(conversation, dict) else None,
+ thread_id=conversation.get('thread_id') if isinstance(conversation, dict) else None,
+ actor_type=actor.get('actor_type') if isinstance(actor, dict) else None,
+ actor_id=actor.get('actor_id') if isinstance(actor, dict) else None,
+ actor_name=actor.get('actor_name') if isinstance(actor, dict) else None,
+ subject_type=subject.get('subject_type') if isinstance(subject, dict) else None,
+ subject_id=subject.get('subject_id') if isinstance(subject, dict) else None,
+ input_summary=f'steering injected from {event.get("event_id")}',
+ run_id=run_id,
+ runner_id=session.get('runner_id') if isinstance(session, dict) else None,
+ metadata={
+ 'steering': {
+ 'status': 'injected',
+ 'source_event_id': event.get('event_id'),
+ 'claimed_by_run_id': item.get('claimed_run_id') if isinstance(item, dict) else run_id,
+ 'claimed_runner_id': item.get('runner_id') if isinstance(item, dict) else None,
+ 'claimed_at': item.get('claimed_at') if isinstance(item, dict) else None,
+ 'pull_mode': str(mode or 'all'),
+ },
+ },
+ )
+ except Exception as exc:
+ h.ap.logger.warning(
+ f'Failed to write steering injection audit for run {run_id}: {exc}',
+ exc_info=True,
+ )
+ return handler.ActionResponse.success(data={'items': items})
+
+ # ================= State APIs (run-scoped, policy-enforced) =================
+
+ @h.action(PluginToRuntimeAction.STATE_GET)
+ async def state_get(data: dict[str, Any]) -> handler.ActionResponse:
+ """Get a state value from host-owned state store.
+
+ Requires run_id authorization and scope enabled by state_policy.
+ """
+ run_id = data.get('run_id')
+ scope = data.get('scope')
+ key = data.get('key')
+ caller_plugin_identity = data.get('caller_plugin_identity')
+
+ if not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+
+ if not scope:
+ return handler.ActionResponse.error(message='scope is required')
+
+ if not key:
+ return handler.ActionResponse.error(message='key is required')
+
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'State get',
+ api_capability='state',
+ )
+ if error:
+ return error
+
+ _state_context, scope_key, state_error = _resolve_state_scope(session, scope)
+ if state_error:
+ return state_error
+
+ # Get state from persistent store
+ from ..agent.runner.persistent_state_store import get_persistent_state_store
+
+ store = get_persistent_state_store(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ value = await store.state_get(scope_key, key)
+ return handler.ActionResponse.success(data={'value': value})
+ except Exception as e:
+ h.ap.logger.error(f'STATE_GET error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'State get error: {e}')
+
+ @h.action(PluginToRuntimeAction.STATE_SET)
+ async def state_set(data: dict[str, Any]) -> handler.ActionResponse:
+ """Set a state value in host-owned state store.
+
+ Requires run_id authorization and scope enabled by state_policy.
+ Value must be JSON-serializable and size-limited.
+ """
+ run_id = data.get('run_id')
+ scope = data.get('scope')
+ key = data.get('key')
+ value = data.get('value')
+ caller_plugin_identity = data.get('caller_plugin_identity')
+
+ if not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+
+ if not scope:
+ return handler.ActionResponse.error(message='scope is required')
+
+ if not key:
+ return handler.ActionResponse.error(message='key is required')
+
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'State set',
+ api_capability='state',
+ )
+ if error:
+ return error
+
+ state_context, scope_key, state_error = _resolve_state_scope(session, scope)
+ if state_error:
+ return state_error
+
+ # Get additional context for DB insert
+ runner_id = session.get('runner_id', '')
+ binding_identity = state_context.get('binding_identity', 'unknown')
+
+ # Set state in persistent store
+ from ..agent.runner.persistent_state_store import get_persistent_state_store
+
+ store = get_persistent_state_store(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ success, error = await store.state_set(
+ scope_key=scope_key,
+ state_key=key,
+ value=value,
+ runner_id=runner_id,
+ binding_identity=binding_identity,
+ scope=scope,
+ context=state_context,
+ logger=h.ap.logger,
+ )
+
+ if not success:
+ return handler.ActionResponse.error(message=error or 'Failed to set state')
+
+ return handler.ActionResponse.success(data={'success': True})
+ except Exception as e:
+ h.ap.logger.error(f'STATE_SET error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'State set error: {e}')
+
+ @h.action(PluginToRuntimeAction.STATE_DELETE)
+ async def state_delete(data: dict[str, Any]) -> handler.ActionResponse:
+ """Delete a state value from host-owned state store.
+
+ Requires run_id authorization and scope enabled by state_policy.
+ """
+ run_id = data.get('run_id')
+ scope = data.get('scope')
+ key = data.get('key')
+ caller_plugin_identity = data.get('caller_plugin_identity')
+
+ if not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+
+ if not scope:
+ return handler.ActionResponse.error(message='scope is required')
+
+ if not key:
+ return handler.ActionResponse.error(message='key is required')
+
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'State delete',
+ api_capability='state',
+ )
+ if error:
+ return error
+
+ _state_context, scope_key, state_error = _resolve_state_scope(session, scope)
+ if state_error:
+ return state_error
+
+ # Delete state from persistent store
+ from ..agent.runner.persistent_state_store import get_persistent_state_store
+
+ store = get_persistent_state_store(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ deleted = await store.state_delete(scope_key, key)
+ return handler.ActionResponse.success(data={'success': deleted})
+ except Exception as e:
+ h.ap.logger.error(f'STATE_DELETE error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'State delete error: {e}')
+
+ @h.action(PluginToRuntimeAction.STATE_LIST)
+ async def state_list(data: dict[str, Any]) -> handler.ActionResponse:
+ """List state keys in a scope.
+
+ Requires run_id authorization and scope enabled by state_policy.
+ """
+ run_id = data.get('run_id')
+ scope = data.get('scope')
+ prefix = data.get('prefix')
+ limit = data.get('limit', 100)
+ caller_plugin_identity = data.get('caller_plugin_identity')
+
+ if not run_id:
+ return handler.ActionResponse.error(message='run_id is required')
+
+ if not scope:
+ return handler.ActionResponse.error(message='scope is required')
+
+ # Validate limit
+ if not isinstance(limit, int) or limit <= 0:
+ limit = 100
+ limit = min(limit, 100) # Cap at 100
+
+ session, error = await _validate_agent_run_session(
+ run_id,
+ caller_plugin_identity,
+ h.ap,
+ 'State list',
+ api_capability='state',
+ )
+ if error:
+ return error
+
+ _state_context, scope_key, state_error = _resolve_state_scope(session, scope)
+ if state_error:
+ return state_error
+
+ # List state keys from persistent store
+ from ..agent.runner.persistent_state_store import get_persistent_state_store
+
+ store = get_persistent_state_store(h.ap.persistence_mgr.get_db_engine())
+
+ try:
+ keys, has_more = await store.state_list(scope_key, prefix, limit)
+ return handler.ActionResponse.success(
+ data={
+ 'keys': keys,
+ 'has_more': has_more,
+ }
+ )
+ except Exception as e:
+ h.ap.logger.error(f'STATE_LIST error: {e}', exc_info=True)
+ return handler.ActionResponse.error(message=f'State list error: {e}')
diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py
index 8c7c55bbe..6fd859386 100644
--- a/src/langbot/pkg/plugin/handler.py
+++ b/src/langbot/pkg/plugin/handler.py
@@ -3,8 +3,6 @@ from __future__ import annotations
import typing
from typing import Any, Union
import base64
-import json
-import time
import traceback
import sqlalchemy
@@ -30,109 +28,12 @@ from ..utils import constants
from ..agent.runner.session_registry import get_session_registry
from ..agent.runner.config_migration import ConfigMigration
from ..agent.runner import config_schema
-from ..agent.runner.result_normalizer import MAX_RESULT_SIZE_BYTES, STRICT_RESULT_PAYLOADS
-from ..agent.runner.run_ledger_store import TERMINAL_STATUSES
-class _RuntimeActionName:
- def __init__(self, value: str):
- self.value = value
-
-
-AGENT_RUN_ADMIN_PERMISSION = 'agent_run:admin'
-RUNTIME_ADMIN_PERMISSION = 'runtime:admin'
-AGENT_RUNNER_ADMIN_PERMISSION = 'agent_runner:admin'
-LEDGER_ONLY_SIDE_EFFECTING_RESULT_TYPES = {
- 'message.delta',
- 'message.completed',
- 'state.updated',
- 'run.completed',
- 'run.failed',
-}
-
-
-def _plugin_runtime_action(name: str, value: str) -> Any:
- return getattr(PluginToRuntimeAction, name, _RuntimeActionName(value))
-
-
-def _normalize_permission_set(value: Any) -> set[str]:
- if isinstance(value, str):
- return {permission.strip() for permission in value.split(',') if permission.strip()}
- if isinstance(value, list):
- return {str(item).strip() for item in value if str(item).strip()}
- if isinstance(value, dict):
- return {str(item).strip() for item, enabled in value.items() if enabled and str(item).strip()}
- return set()
-
-
-def _iter_agent_runner_admin_plugin_configs(ap: app.Application) -> list[dict[str, Any]]:
- instance_config = getattr(ap, 'instance_config', None)
- config_data = getattr(instance_config, 'data', {}) if instance_config is not None else {}
- if not isinstance(config_data, dict):
- return []
- agent_runner_config = config_data.get('agent_runner', {})
- if not isinstance(agent_runner_config, dict):
- return []
- raw_admin_plugins = agent_runner_config.get('admin_plugins', [])
- if isinstance(raw_admin_plugins, dict):
- items: list[dict[str, Any]] = []
- for identity, entry in raw_admin_plugins.items():
- if isinstance(entry, dict):
- merged = dict(entry)
- merged.setdefault('identity', identity)
- items.append(merged)
- else:
- items.append({'identity': identity, 'permissions': entry})
- return items
- if isinstance(raw_admin_plugins, list):
- return [item for item in raw_admin_plugins if isinstance(item, dict)]
- return []
-
-
-def _agent_runner_admin_permissions(ap: app.Application, plugin_identity: str | None) -> set[str]:
- if not isinstance(plugin_identity, str) or not plugin_identity.strip():
- return set()
- normalized_identity = plugin_identity.strip()
- permissions: set[str] = set()
- for entry in _iter_agent_runner_admin_plugin_configs(ap):
- if entry.get('enabled', True) is False:
- continue
- identity = entry.get('identity') or entry.get('plugin_identity') or entry.get('plugin') or entry.get('id')
- if identity != normalized_identity:
- continue
- permissions.update(_normalize_permission_set(entry.get('permissions')))
- permissions.update(_normalize_permission_set(entry.get('scopes')))
- return permissions
-
-
-def _has_agent_runner_admin_permission(
- ap: app.Application,
- plugin_identity: str | None,
- permission: str,
-) -> bool:
- permissions = _agent_runner_admin_permissions(ap, plugin_identity)
- if not permissions:
- return False
- domain = permission.split(':', 1)[0]
- return bool(
- permission in permissions
- or f'{domain}:*' in permissions
- or AGENT_RUNNER_ADMIN_PERMISSION in permissions
- or '*' in permissions
- )
-
-
-def _deadline_seconds_from_payload(data: dict[str, Any], default: int = 60) -> int:
- deadline_at = data.get('heartbeat_deadline_at')
- if deadline_at is not None:
- try:
- return max(int(float(deadline_at) - time.time()), 1)
- except (TypeError, ValueError):
- pass
- try:
- return max(int(data.get('heartbeat_ttl_seconds') or default), 1)
- except (TypeError, ValueError):
- return default
+from . import agent_pull_actions, agent_runner_actions, agent_state_actions
+from .agent_run_support import (
+ _validate_agent_run_session,
+)
def _make_rag_error_response(error: Exception, error_type: str, **extra_context) -> handler.ActionResponse:
@@ -221,370 +122,6 @@ def _build_tool_detail(tool: Any, requested_tool_name: str | None = None) -> dic
}
-def _get_run_authorization(session: dict[str, Any]) -> dict[str, Any]:
- """Return the run-scoped authorization snapshot."""
- return session['authorization']
-
-
-def _run_matches_run_scope(session: dict[str, Any], run: dict[str, Any]) -> bool:
- authorization = _get_run_authorization(session)
- session_run_id = session.get('run_id')
- if run.get('run_id') == session_run_id:
- return True
- session_runner_id = session.get('runner_id') or authorization.get('runner_id')
- if not session_runner_id or run.get('runner_id') != session_runner_id:
- return False
- if not authorization.get('conversation_id'):
- return False
- if run.get('conversation_id') != authorization.get('conversation_id'):
- return False
- if authorization.get('bot_id') is not None and authorization.get('bot_id') != run.get('bot_id'):
- return False
- if authorization.get('workspace_id') is not None and authorization.get('workspace_id') != run.get('workspace_id'):
- return False
- if authorization.get('thread_id') != run.get('thread_id'):
- return False
- return True
-
-
-def _authorize_target_run(
- session: dict[str, Any],
- run: dict[str, Any],
-) -> handler.ActionResponse | None:
- """Authorize non-admin target-run access against scope and runner owner."""
- if _run_matches_run_scope(session, run):
- return None
- return handler.ActionResponse.error(message=f'Run {run.get("run_id")} is not accessible by this run')
-
-
-def _validate_ledger_only_result_payload(
- *,
- ap: app.Application,
- runner_id: str | None,
- event_type: str,
- data: dict[str, Any],
-) -> str | None:
- """Validate result payloads that can be safely stored without side effects."""
- try:
- result_json = json.dumps({'type': event_type, 'data': data})
- except (TypeError, ValueError) as exc:
- return f'event data must be JSON serializable: {exc}'
- if len(result_json) > MAX_RESULT_SIZE_BYTES:
- return f'event payload exceeds {MAX_RESULT_SIZE_BYTES} bytes'
-
- payload_model = STRICT_RESULT_PAYLOADS.get(event_type)
- if payload_model is None:
- return f'unknown result type: {event_type}'
- try:
- payload_model.model_validate(data)
- except Exception as exc:
- return f'invalid {event_type} payload: {exc}'
-
- if event_type in LEDGER_ONLY_SIDE_EFFECTING_RESULT_TYPES:
- if runner_id:
- ap.logger.warning(
- f'Runner {runner_id} attempted ledger-only append for side-effecting result type {event_type}'
- )
- return f'{event_type} must be emitted through the canonical runner result path'
- return None
-
-
-async def _require_runtime_write_ownership(
- *,
- store: Any,
- session: dict[str, Any],
- run: dict[str, Any],
- data: dict[str, Any],
- api_name: str,
-) -> handler.ActionResponse | None:
- """Require current-run ownership or an active runtime claim for run writes."""
- if run.get('run_id') == session.get('run_id') and run.get('status') != 'claimed':
- return None
-
- runtime_id = data.get('runtime_id')
- claim_token = data.get('claim_token')
- if not runtime_id or not claim_token:
- return handler.ActionResponse.error(
- message=f'{api_name} requires active claim ownership for target run {run.get("run_id")}'
- )
-
- if not await store.validate_active_claim(
- run_id=str(run.get('run_id')),
- runtime_id=str(runtime_id),
- claim_token=str(claim_token),
- ):
- return handler.ActionResponse.error(
- message=f'{api_name} claim ownership is not active for target run {run.get("run_id")}'
- )
-
- return None
-
-
-def _resolve_state_scope(
- session: dict[str, Any],
- scope: str,
-) -> tuple[dict[str, Any] | None, str | None, handler.ActionResponse | None]:
- """Resolve state policy/context for an authorized run scope."""
- authorization = _get_run_authorization(session)
- state_policy = authorization['state_policy']
-
- if not state_policy.get('enable_state', True):
- return None, None, handler.ActionResponse.error(message='State access is disabled by binding policy')
-
- state_scopes = state_policy.get('state_scopes', ['conversation', 'actor'])
- if scope not in state_scopes:
- return None, None, handler.ActionResponse.error(message=f'Scope "{scope}" is not enabled by binding policy')
-
- state_context = authorization['state_context']
- scope_key = state_context.get('scope_keys', {}).get(scope)
- if not scope_key:
- return None, None, handler.ActionResponse.error(message=f'Scope key not available for scope "{scope}"')
-
- return state_context, scope_key, None
-
-
-async def _validate_agent_run_session(
- run_id: str,
- caller_plugin_identity: str | None,
- ap: app.Application,
- api_name: str,
- api_capability: str | None = None,
- allow_persistent_authorization: bool = False,
- admin_permission: str | None = None,
-) -> Union[tuple[None, handler.ActionResponse], tuple[Any, None]]:
- """Validate an AgentRunner pull API run session and run-scoped API access."""
- if not run_id and admin_permission and _has_agent_runner_admin_permission(
- ap,
- caller_plugin_identity,
- admin_permission,
- ):
- return {
- 'run_id': run_id,
- 'runner_id': None,
- 'query_id': None,
- 'plugin_identity': caller_plugin_identity,
- 'authorization': {},
- 'status': {},
- 'steering_queue': [],
- }, None
-
- session_registry = get_session_registry()
- session = await session_registry.get(run_id)
- if not session:
- if allow_persistent_authorization:
- session = await _load_persistent_agent_run_session(run_id, ap, api_name)
- if not session:
- return None, handler.ActionResponse.error(message=f'Run session {run_id} not found or expired')
-
- session_plugin_identity = session.get('plugin_identity')
- if not isinstance(session_plugin_identity, str) or not session_plugin_identity.strip():
- ap.logger.warning(f'{api_name}: run_id {run_id} has no plugin_identity')
- return None, handler.ActionResponse.error(message=f'Run session {run_id} has no plugin_identity')
- if not caller_plugin_identity:
- return None, handler.ActionResponse.error(message=f'caller_plugin_identity is required for run_id {run_id}')
- if caller_plugin_identity != session_plugin_identity:
- ap.logger.warning(
- f'{api_name}: caller_plugin_identity {caller_plugin_identity} '
- f'does not match session plugin_identity {session_plugin_identity}'
- )
- return None, handler.ActionResponse.error(message=f'Plugin identity mismatch for run_id {run_id}')
-
- if api_capability:
- available_apis = _get_run_authorization(session).get('available_apis', {})
- has_admin_permission = bool(admin_permission) and _has_agent_runner_admin_permission(
- ap,
- caller_plugin_identity,
- admin_permission,
- )
- if not available_apis.get(api_capability, False) and not has_admin_permission:
- return None, handler.ActionResponse.error(message=f'{api_name} access not authorized')
-
- return session, None
-
-
-async def _load_persistent_agent_run_session(
- run_id: str,
- ap: app.Application,
- api_name: str,
-) -> dict[str, Any] | None:
- """Load an expired run session from the AgentRun authorization snapshot."""
- try:
- from sqlalchemy.ext.asyncio import AsyncSession
- from sqlalchemy.orm import sessionmaker
-
- from ..entity.persistence.agent_run import AgentRun
-
- engine = ap.persistence_mgr.get_db_engine()
- session_factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
- async with session_factory() as db_session:
- result = await db_session.execute(sqlalchemy.select(AgentRun).where(AgentRun.run_id == run_id))
- run = result.scalars().first()
- except Exception as e:
- ap.logger.error(f'{api_name}: failed to load persistent authorization for run_id {run_id}: {e}', exc_info=True)
- return None
-
- if run is None:
- return None
-
- try:
- authorization = json.loads(run.authorization_json) if run.authorization_json else {}
- except (TypeError, ValueError) as e:
- ap.logger.warning(f'{api_name}: run_id {run_id} has invalid authorization_json: {e}')
- return None
-
- if not isinstance(authorization, dict):
- ap.logger.warning(f'{api_name}: run_id {run_id} authorization_json is not an object')
- return None
-
- return {
- 'run_id': run.run_id,
- 'runner_id': authorization.get('runner_id') or run.runner_id,
- 'query_id': None,
- 'plugin_identity': authorization.get('plugin_identity'),
- 'authorization': authorization,
- 'status': {},
- 'steering_queue': [],
- }
-
-
-def _resolve_run_conversation(
- session: dict[str, Any],
- requested_conversation_id: str | None,
- api_name: str,
-) -> tuple[str | None, handler.ActionResponse | None]:
- """Resolve and enforce current-run conversation scope."""
- session_conversation_id = _get_run_authorization(session).get('conversation_id')
-
- if requested_conversation_id:
- if not session_conversation_id:
- return None, handler.ActionResponse.error(message=f'{api_name} is not available without a run conversation')
- if requested_conversation_id != session_conversation_id:
- return None, handler.ActionResponse.error(
- message=f'Conversation {requested_conversation_id} is not accessible by this run'
- )
- return requested_conversation_id, None
-
- return session_conversation_id, None
-
-
-def _run_scope_filters(session: dict[str, Any]) -> dict[str, Any]:
- authorization = _get_run_authorization(session)
- return {
- 'bot_id': authorization.get('bot_id'),
- 'workspace_id': authorization.get('workspace_id'),
- 'thread_id': authorization.get('thread_id'),
- 'strict_thread': True,
- }
-
-
-def _run_ledger_scope_filters(session: dict[str, Any]) -> dict[str, Any]:
- authorization = _get_run_authorization(session)
- filters = _run_scope_filters(session)
- filters['runner_id'] = session.get('runner_id') or authorization.get('runner_id')
- return filters
-
-
-def _event_matches_run_scope(session: dict[str, Any], event: dict[str, Any]) -> bool:
- authorization = _get_run_authorization(session)
- if authorization.get('conversation_id') != event.get('conversation_id'):
- return False
- if authorization.get('bot_id') is not None and authorization.get('bot_id') != event.get('bot_id'):
- return False
- if authorization.get('workspace_id') is not None and authorization.get('workspace_id') != event.get('workspace_id'):
- return False
- if authorization.get('thread_id') != event.get('thread_id'):
- return False
- return True
-
-
-def _project_event_record_for_api(event: dict[str, Any]) -> dict[str, Any]:
- """Project EventLogStore rows onto the SDK AgentEventRecord DTO."""
- seq = event.get('seq') or event.get('id')
- return {
- 'event_id': event.get('event_id'),
- 'event_type': event.get('event_type'),
- 'event_time': event.get('event_time'),
- 'source': event.get('source'),
- 'bot_id': event.get('bot_id'),
- 'workspace_id': event.get('workspace_id'),
- 'conversation_id': event.get('conversation_id'),
- 'thread_id': event.get('thread_id'),
- 'actor_type': event.get('actor_type'),
- 'actor_id': event.get('actor_id'),
- 'actor_name': event.get('actor_name'),
- 'subject_type': event.get('subject_type'),
- 'subject_id': event.get('subject_id'),
- 'input_summary': event.get('input_summary'),
- 'input_ref': event.get('input_ref'),
- 'raw_ref': event.get('raw_ref'),
- 'seq': seq,
- 'cursor': event.get('cursor') or (str(seq) if seq is not None else None),
- 'created_at': event.get('created_at'),
- 'metadata': event.get('metadata') or {},
- }
-
-
-def _project_runner_descriptor_for_api(descriptor: Any) -> dict[str, Any]:
- """Project an AgentRunnerDescriptor-like object onto a JSON dict."""
- if isinstance(descriptor, dict):
- return dict(descriptor)
- if hasattr(descriptor, 'model_dump'):
- return descriptor.model_dump(mode='json')
- return {
- 'id': getattr(descriptor, 'id', None),
- 'source': getattr(descriptor, 'source', None),
- 'label': getattr(descriptor, 'label', {}),
- 'description': getattr(descriptor, 'description', None),
- 'plugin_author': getattr(descriptor, 'plugin_author', None),
- 'plugin_name': getattr(descriptor, 'plugin_name', None),
- 'runner_name': getattr(descriptor, 'runner_name', None),
- 'plugin_version': getattr(descriptor, 'plugin_version', None),
- 'config_schema': getattr(descriptor, 'config_schema', []),
- 'capabilities': getattr(descriptor, 'capabilities', {}),
- 'permissions': getattr(descriptor, 'permissions', {}),
- 'raw_manifest': getattr(descriptor, 'raw_manifest', {}),
- }
-
-
-async def _record_agent_runner_admin_action(
- ap: app.Application,
- store: Any,
- *,
- action: str,
- caller_plugin_identity: str | None,
- permission: str,
- durable_run_id: str | None = None,
- target_runtime_id: str | None = None,
- detail: dict[str, Any] | None = None,
-) -> None:
- """Record a small audit trail for privileged AgentRunner operations."""
- audit_data: dict[str, Any] = {
- 'action': action,
- 'caller_plugin_identity': caller_plugin_identity,
- 'permission': permission,
- }
- if durable_run_id:
- audit_data['target_run_id'] = durable_run_id
- if target_runtime_id:
- audit_data['target_runtime_id'] = target_runtime_id
- if detail:
- audit_data['detail'] = detail
-
- ap.logger.info('Agent runner admin action: %s', audit_data)
- if not durable_run_id or store is None or not hasattr(store, 'append_audit_event'):
- return
-
- try:
- await store.append_audit_event(
- run_id=str(durable_run_id),
- event_type=f'admin.{action}',
- data=audit_data,
- metadata={'permission': permission},
- )
- except Exception as exc:
- ap.logger.warning(f'Failed to record AgentRunner admin audit event: {exc}', exc_info=True)
-
-
def _normalize_uuid_list(values: Any) -> list[str]:
"""Normalize a user/config supplied UUID list while preserving order."""
if not isinstance(values, list):
@@ -1820,1741 +1357,9 @@ class RuntimeConnectionHandler(handler.Handler):
}
)
- @self.action(PluginToRuntimeAction.HISTORY_PAGE)
- async def history_page(data: dict[str, Any]) -> handler.ActionResponse:
- """Page through transcript history for a conversation.
-
- Requires run_id authorization. Only allows access to current run's conversation.
- """
- run_id = data.get('run_id')
- conversation_id = data.get('conversation_id')
- before_cursor = data.get('before_cursor')
- after_cursor = data.get('after_cursor')
- limit = data.get('limit', 50)
- direction = data.get('direction', 'backward')
- include_attachments = data.get('include_attachments', False)
- caller_plugin_identity = data.get('caller_plugin_identity')
-
- if not run_id:
- return handler.ActionResponse.error(message='run_id is required')
-
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'History page',
- api_capability='history_page',
- )
- if error:
- return error
-
- conversation_id, scope_error = _resolve_run_conversation(
- session,
- conversation_id,
- 'History page',
- )
- if scope_error:
- return scope_error
-
- if not conversation_id:
- return handler.ActionResponse.success(
- data={
- 'items': [],
- 'next_cursor': None,
- 'prev_cursor': None,
- 'has_more': False,
- }
- )
-
- # Parse cursors
- before_seq = int(before_cursor) if before_cursor else None
- after_seq = int(after_cursor) if after_cursor else None
-
- # Query transcript
- from ..agent.runner.transcript_store import TranscriptStore
-
- store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
-
- try:
- items, next_seq, prev_seq, has_more = await store.page_transcript(
- conversation_id=conversation_id,
- before_seq=before_seq,
- after_seq=after_seq,
- limit=limit,
- direction=direction,
- include_attachments=include_attachments,
- **_run_scope_filters(session),
- )
-
- return handler.ActionResponse.success(
- data={
- 'items': items,
- 'next_cursor': str(next_seq) if next_seq else None,
- 'prev_cursor': str(prev_seq) if prev_seq else None,
- 'has_more': has_more,
- }
- )
- except Exception as e:
- self.ap.logger.error(f'HISTORY_PAGE error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'History page error: {e}')
-
- @self.action(PluginToRuntimeAction.HISTORY_SEARCH)
- async def history_search(data: dict[str, Any]) -> handler.ActionResponse:
- """Search transcript history.
-
- Requires run_id authorization. Only searches current run's conversation.
- Basic implementation using LIKE filtering.
- """
- run_id = data.get('run_id')
- query_text = data.get('query', '')
- filters = data.get('filters') or {}
- top_k = data.get('top_k', 10)
- caller_plugin_identity = data.get('caller_plugin_identity')
-
- if not run_id:
- return handler.ActionResponse.error(message='run_id is required')
-
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'History search',
- api_capability='history_search',
- )
- if error:
- return error
-
- requested_conversation_id = filters.get('conversation_id')
- conversation_id, scope_error = _resolve_run_conversation(
- session,
- requested_conversation_id,
- 'History search',
- )
- if scope_error:
- return scope_error
-
- if not conversation_id:
- return handler.ActionResponse.success(
- data={
- 'items': [],
- 'total_count': 0,
- 'query': query_text,
- }
- )
-
- # Search transcript
- from ..agent.runner.transcript_store import TranscriptStore
-
- store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
-
- try:
- safe_filters = {k: v for k, v in filters.items() if k != 'conversation_id'}
- items = await store.search_transcript(
- conversation_id=conversation_id,
- query_text=query_text,
- filters=safe_filters,
- top_k=top_k,
- **_run_scope_filters(session),
- )
-
- return handler.ActionResponse.success(
- data={
- 'items': items,
- 'total_count': len(items),
- 'query': query_text,
- }
- )
- except Exception as e:
- self.ap.logger.error(f'HISTORY_SEARCH error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'History search error: {e}')
-
- @self.action(PluginToRuntimeAction.EVENT_GET)
- async def event_get(data: dict[str, Any]) -> handler.ActionResponse:
- """Get a single event record by ID.
-
- Requires run_id authorization. Only allows access to events in current run's conversation.
- """
- run_id = data.get('run_id')
- event_id = data.get('event_id')
- caller_plugin_identity = data.get('caller_plugin_identity')
-
- if not run_id:
- return handler.ActionResponse.error(message='run_id is required')
-
- if not event_id:
- return handler.ActionResponse.error(message='event_id is required')
-
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'Event get',
- api_capability='event_get',
- )
- if error:
- return error
-
- # Get event
- from ..agent.runner.event_log_store import EventLogStore
-
- store = EventLogStore(self.ap.persistence_mgr.get_db_engine())
-
- try:
- event = await store.get_event(event_id)
- if not event:
- return handler.ActionResponse.error(message=f'Event {event_id} not found')
-
- # Validate event is in the same conversation as the run, or was created by the same run.
- session_conversation_id = _get_run_authorization(session).get('conversation_id')
- event_run_id = event.get('run_id')
- if event_run_id and event_run_id == run_id:
- return handler.ActionResponse.success(data=_project_event_record_for_api(event))
- if not session_conversation_id or not _event_matches_run_scope(session, event):
- return handler.ActionResponse.error(message=f'Event {event_id} is not accessible by this run')
-
- return handler.ActionResponse.success(data=_project_event_record_for_api(event))
- except Exception as e:
- self.ap.logger.error(f'EVENT_GET error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'Event get error: {e}')
-
- @self.action(PluginToRuntimeAction.EVENT_PAGE)
- async def event_page(data: dict[str, Any]) -> handler.ActionResponse:
- """Page through event records.
-
- Requires run_id authorization. Only allows access to current run's conversation.
- """
- run_id = data.get('run_id')
- conversation_id = data.get('conversation_id')
- event_types = data.get('event_types')
- before_cursor = data.get('before_cursor')
- limit = data.get('limit', 50)
- caller_plugin_identity = data.get('caller_plugin_identity')
-
- if not run_id:
- return handler.ActionResponse.error(message='run_id is required')
-
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'Event page',
- api_capability='event_page',
- )
- if error:
- return error
-
- conversation_id, scope_error = _resolve_run_conversation(
- session,
- conversation_id,
- 'Event page',
- )
- if scope_error:
- return scope_error
-
- if not conversation_id:
- return handler.ActionResponse.success(
- data={
- 'items': [],
- 'next_cursor': None,
- 'prev_cursor': None,
- 'has_more': False,
- }
- )
-
- # Parse cursor
- before_seq = int(before_cursor) if before_cursor else None
-
- # Query events
- from ..agent.runner.event_log_store import EventLogStore
-
- store = EventLogStore(self.ap.persistence_mgr.get_db_engine())
-
- try:
- items, next_seq, has_more = await store.page_events(
- conversation_id=conversation_id,
- event_types=event_types,
- before_seq=before_seq,
- limit=limit,
- **_run_scope_filters(session),
- )
-
- return handler.ActionResponse.success(
- data={
- 'items': [_project_event_record_for_api(item) for item in items],
- 'next_cursor': str(next_seq) if next_seq else None,
- 'prev_cursor': None,
- 'has_more': has_more,
- }
- )
- except Exception as e:
- self.ap.logger.error(f'EVENT_PAGE error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'Event page error: {e}')
-
- @self.action(_plugin_runtime_action('RUN_GET', 'run_get'))
- async def run_get(data: dict[str, Any]) -> handler.ActionResponse:
- """Get one Host-owned run record visible to the current run."""
- run_id = data.get('run_id')
- target_run_id = data.get('target_run_id') or run_id
- caller_plugin_identity = data.get('caller_plugin_identity')
- is_admin = _has_agent_runner_admin_permission(
- self.ap,
- caller_plugin_identity,
- AGENT_RUN_ADMIN_PERMISSION,
- )
-
- if not is_admin and not run_id:
- return handler.ActionResponse.error(message='run_id is required')
- if not target_run_id:
- return handler.ActionResponse.error(message='target_run_id is required')
-
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'Run get',
- api_capability='run_get',
- allow_persistent_authorization=True,
- admin_permission=AGENT_RUN_ADMIN_PERMISSION,
- )
- if error:
- return error
-
- from ..agent.runner.run_ledger_store import RunLedgerStore
-
- store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
-
- try:
- run = await store.get_run(str(target_run_id))
- if not run:
- return handler.ActionResponse.error(message=f'Run {target_run_id} not found')
- if not is_admin:
- auth_error = _authorize_target_run(session, run)
- if auth_error:
- return auth_error
- if is_admin:
- await _record_agent_runner_admin_action(
- self.ap,
- store,
- action='run_get',
- caller_plugin_identity=caller_plugin_identity,
- permission=AGENT_RUN_ADMIN_PERMISSION,
- detail={'target_run_id': str(target_run_id)},
- )
- return handler.ActionResponse.success(data=run)
- except Exception as e:
- self.ap.logger.error(f'RUN_GET error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'Run get error: {e}')
-
- @self.action(_plugin_runtime_action('RUN_LIST', 'run_list'))
- async def run_list(data: dict[str, Any]) -> handler.ActionResponse:
- """List Host-owned runs visible to the current run conversation."""
- run_id = data.get('run_id')
- conversation_id = data.get('conversation_id')
- statuses = data.get('statuses')
- before_cursor = data.get('before_cursor')
- limit = data.get('limit', 50)
- caller_plugin_identity = data.get('caller_plugin_identity')
- is_admin = _has_agent_runner_admin_permission(
- self.ap,
- caller_plugin_identity,
- AGENT_RUN_ADMIN_PERMISSION,
- )
-
- if not is_admin and not run_id:
- return handler.ActionResponse.error(message='run_id is required')
-
- scope_filters: dict[str, Any] = {}
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'Run list',
- api_capability='run_list',
- allow_persistent_authorization=True,
- admin_permission=AGENT_RUN_ADMIN_PERMISSION,
- )
- if error:
- return error
-
- if not is_admin:
- conversation_id, scope_error = _resolve_run_conversation(
- session,
- conversation_id,
- 'Run list',
- )
- if scope_error:
- return scope_error
- scope_filters = _run_ledger_scope_filters(session)
-
- if not is_admin and not conversation_id:
- return handler.ActionResponse.success(
- data={
- 'items': [],
- 'next_cursor': None,
- 'prev_cursor': None,
- 'has_more': False,
- 'total_count': 0,
- }
- )
-
- if statuses is not None and not isinstance(statuses, list):
- return handler.ActionResponse.error(message='statuses must be a list')
- try:
- before_id = int(before_cursor) if before_cursor else None
- except (TypeError, ValueError):
- return handler.ActionResponse.error(message='before_cursor must be an integer cursor')
-
- from ..agent.runner.run_ledger_store import RunLedgerStore
-
- store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
-
- try:
- items, next_cursor, has_more, total_count = await store.list_runs(
- conversation_id=conversation_id,
- statuses=[str(status) for status in statuses] if statuses else None,
- before_id=before_id,
- limit=limit,
- **scope_filters,
- )
- if is_admin:
- await _record_agent_runner_admin_action(
- self.ap,
- store,
- action='run_list',
- caller_plugin_identity=caller_plugin_identity,
- permission=AGENT_RUN_ADMIN_PERMISSION,
- detail={
- 'statuses': [str(status) for status in statuses] if statuses else None,
- 'limit': limit,
- },
- )
- return handler.ActionResponse.success(
- data={
- 'items': items,
- 'next_cursor': str(next_cursor) if next_cursor else None,
- 'prev_cursor': None,
- 'has_more': has_more,
- 'total_count': total_count,
- }
- )
- except Exception as e:
- self.ap.logger.error(f'RUN_LIST error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'Run list error: {e}')
-
- @self.action(_plugin_runtime_action('RUNNER_LIST', 'runner_list'))
- async def runner_list(data: dict[str, Any]) -> handler.ActionResponse:
- """List Host-discovered AgentRunner descriptors."""
- run_id = data.get('run_id')
- caller_plugin_identity = data.get('caller_plugin_identity')
- is_admin = _has_agent_runner_admin_permission(
- self.ap,
- caller_plugin_identity,
- AGENT_RUN_ADMIN_PERMISSION,
- )
-
- if not is_admin:
- return handler.ActionResponse.error(message='Runner list access not authorized')
-
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'Runner list',
- api_capability='runner_list',
- allow_persistent_authorization=True,
- admin_permission=AGENT_RUN_ADMIN_PERMISSION,
- )
- if error:
- return error
-
- include_plugins = data.get('include_plugins')
- if include_plugins is not None and not isinstance(include_plugins, list):
- return handler.ActionResponse.error(message='include_plugins must be a list')
-
- registry = getattr(self.ap, 'agent_runner_registry', None)
- if registry is None:
- return handler.ActionResponse.success(data={'items': []})
-
- try:
- runners = await registry.list_runners(
- bound_plugins=[str(item) for item in include_plugins] if include_plugins else None,
- use_cache=bool(data.get('use_cache', True)),
- )
- items = [_project_runner_descriptor_for_api(item) for item in runners]
- if is_admin:
- await _record_agent_runner_admin_action(
- self.ap,
- None,
- action='runner_list',
- caller_plugin_identity=caller_plugin_identity,
- permission=AGENT_RUN_ADMIN_PERMISSION,
- detail={
- 'include_plugins': [str(item) for item in include_plugins]
- if include_plugins
- else None,
- 'count': len(items),
- },
- )
- return handler.ActionResponse.success(data={'items': items})
- except Exception as e:
- self.ap.logger.error(f'RUNNER_LIST error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'Runner list error: {e}')
-
- @self.action(_plugin_runtime_action('RUN_EVENTS_PAGE', 'run_events_page'))
- async def run_events_page(data: dict[str, Any]) -> handler.ActionResponse:
- """Page result events for one Host-owned run visible to current run."""
- run_id = data.get('run_id')
- target_run_id = data.get('target_run_id') or run_id
- before_cursor = data.get('before_cursor')
- after_cursor = data.get('after_cursor')
- limit = data.get('limit', 50)
- direction = data.get('direction', 'forward')
- caller_plugin_identity = data.get('caller_plugin_identity')
- is_admin = _has_agent_runner_admin_permission(
- self.ap,
- caller_plugin_identity,
- AGENT_RUN_ADMIN_PERMISSION,
- )
-
- if not is_admin and not run_id:
- return handler.ActionResponse.error(message='run_id is required')
- if not target_run_id:
- return handler.ActionResponse.error(message='target_run_id is required')
-
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'Run events page',
- api_capability='run_events_page',
- allow_persistent_authorization=True,
- admin_permission=AGENT_RUN_ADMIN_PERMISSION,
- )
- if error:
- return error
-
- try:
- before_sequence = int(before_cursor) if before_cursor else None
- after_sequence = int(after_cursor) if after_cursor else None
- except (TypeError, ValueError):
- return handler.ActionResponse.error(message='run event cursors must be integer sequences')
-
- from ..agent.runner.run_ledger_store import RunLedgerStore
-
- store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
-
- try:
- run = await store.get_run(str(target_run_id))
- if not run:
- return handler.ActionResponse.error(message=f'Run {target_run_id} not found')
- if not is_admin:
- auth_error = _authorize_target_run(session, run)
- if auth_error:
- return auth_error
-
- items, next_cursor, prev_cursor, has_more = await store.page_run_events(
- run_id=str(target_run_id),
- before_sequence=before_sequence,
- after_sequence=after_sequence,
- limit=limit,
- direction=str(direction or 'forward'),
- )
- if is_admin:
- await _record_agent_runner_admin_action(
- self.ap,
- store,
- action='run_events_page',
- caller_plugin_identity=caller_plugin_identity,
- permission=AGENT_RUN_ADMIN_PERMISSION,
- detail={'target_run_id': str(target_run_id), 'limit': limit},
- )
- return handler.ActionResponse.success(
- data={
- 'items': items,
- 'next_cursor': str(next_cursor) if next_cursor else None,
- 'prev_cursor': str(prev_cursor) if prev_cursor else None,
- 'has_more': has_more,
- }
- )
- except Exception as e:
- self.ap.logger.error(f'RUN_EVENTS_PAGE error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'Run events page error: {e}')
-
- @self.action(_plugin_runtime_action('RUN_CANCEL', 'run_cancel'))
- async def run_cancel(data: dict[str, Any]) -> handler.ActionResponse:
- """Request cancellation for one Host-owned run visible to the current run."""
- run_id = data.get('run_id')
- target_run_id = data.get('target_run_id') or run_id
- caller_plugin_identity = data.get('caller_plugin_identity')
- is_admin = _has_agent_runner_admin_permission(
- self.ap,
- caller_plugin_identity,
- AGENT_RUN_ADMIN_PERMISSION,
- )
-
- if not is_admin and not run_id:
- return handler.ActionResponse.error(message='run_id is required')
- if not target_run_id:
- return handler.ActionResponse.error(message='target_run_id is required')
-
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'Run cancel',
- api_capability='run_cancel',
- allow_persistent_authorization=True,
- admin_permission=AGENT_RUN_ADMIN_PERMISSION,
- )
- if error:
- return error
-
- from ..agent.runner.run_ledger_store import RunLedgerStore
-
- store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
-
- try:
- run = await store.get_run(str(target_run_id))
- if not run:
- return handler.ActionResponse.error(message=f'Run {target_run_id} not found')
- if not is_admin:
- auth_error = _authorize_target_run(session, run)
- if auth_error:
- return auth_error
-
- updated = await store.request_cancel(
- run_id=str(target_run_id),
- status_reason=data.get('status_reason') or data.get('reason'),
- )
- if not updated:
- return handler.ActionResponse.error(message=f'Run {target_run_id} not found')
- if is_admin:
- await _record_agent_runner_admin_action(
- self.ap,
- store,
- action='run_cancel',
- caller_plugin_identity=caller_plugin_identity,
- permission=AGENT_RUN_ADMIN_PERMISSION,
- durable_run_id=str(target_run_id),
- detail={'status_reason': data.get('status_reason') or data.get('reason')},
- )
- return handler.ActionResponse.success(data=updated)
- except Exception as e:
- self.ap.logger.error(f'RUN_CANCEL error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'Run cancel error: {e}')
-
- @self.action(_plugin_runtime_action('RUN_APPEND_RESULT', 'run_append_result'))
- async def run_append_result(data: dict[str, Any]) -> handler.ActionResponse:
- """Append one result event for a Host-owned run visible to the current run."""
- run_id = data.get('run_id')
- target_run_id = data.get('target_run_id') or run_id
- caller_plugin_identity = data.get('caller_plugin_identity')
- result = data.get('result') if isinstance(data.get('result'), dict) else {}
- is_admin = _has_agent_runner_admin_permission(
- self.ap,
- caller_plugin_identity,
- AGENT_RUN_ADMIN_PERMISSION,
- )
-
- if not is_admin and not run_id:
- return handler.ActionResponse.error(message='run_id is required')
- if not target_run_id:
- return handler.ActionResponse.error(message='target_run_id is required')
-
- try:
- sequence = int(data.get('sequence') or result.get('sequence'))
- except (TypeError, ValueError):
- return handler.ActionResponse.error(message='sequence is required and must be an integer')
-
- event_type = data.get('event_type') or data.get('type') or result.get('type')
- if not event_type:
- return handler.ActionResponse.error(message='event_type is required')
-
- event_data = data.get('data') if isinstance(data.get('data'), dict) else result.get('data')
- usage = data.get('usage') if isinstance(data.get('usage'), dict) else result.get('usage')
- metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else None
-
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'Run append result',
- api_capability='run_append_result',
- allow_persistent_authorization=True,
- admin_permission=AGENT_RUN_ADMIN_PERMISSION,
- )
- if error:
- return error
-
- from ..agent.runner.run_ledger_store import RunLedgerStore
-
- store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
-
- try:
- run = await store.get_run(str(target_run_id))
- if not run:
- return handler.ActionResponse.error(message=f'Run {target_run_id} not found')
- if not is_admin:
- auth_error = _authorize_target_run(session, run)
- if auth_error:
- return auth_error
- if run.get('status') in TERMINAL_STATUSES:
- return handler.ActionResponse.error(
- message=f'Run append result is not allowed for terminal run {target_run_id}'
- )
- claim_error = await _require_runtime_write_ownership(
- store=store,
- session=session,
- run=run,
- data=data,
- api_name='Run append result',
- )
- if claim_error:
- return claim_error
-
- event_payload = event_data if isinstance(event_data, dict) else {}
- payload_error = _validate_ledger_only_result_payload(
- ap=self.ap,
- runner_id=run.get('runner_id'),
- event_type=str(event_type),
- data=event_payload,
- )
- if payload_error:
- return handler.ActionResponse.error(message=payload_error)
-
- event = await store.append_event(
- run_id=str(target_run_id),
- sequence=sequence,
- event_type=str(event_type),
- data=event_payload,
- usage=usage if isinstance(usage, dict) else None,
- source=str(data.get('source') or result.get('source') or 'runner'),
- metadata=metadata,
- )
- if is_admin:
- await _record_agent_runner_admin_action(
- self.ap,
- store,
- action='run_append_result',
- caller_plugin_identity=caller_plugin_identity,
- permission=AGENT_RUN_ADMIN_PERMISSION,
- durable_run_id=str(target_run_id),
- detail={'event_type': str(event_type), 'sequence': sequence},
- )
- return handler.ActionResponse.success(data=event)
- except Exception as e:
- self.ap.logger.error(f'RUN_APPEND_RESULT error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'Run append result error: {e}')
-
- @self.action(_plugin_runtime_action('RUN_FINALIZE', 'run_finalize'))
- async def run_finalize(data: dict[str, Any]) -> handler.ActionResponse:
- """Finalize one Host-owned run visible to the current run."""
- run_id = data.get('run_id')
- target_run_id = data.get('target_run_id') or run_id
- caller_plugin_identity = data.get('caller_plugin_identity')
- status = data.get('status')
- is_admin = _has_agent_runner_admin_permission(
- self.ap,
- caller_plugin_identity,
- AGENT_RUN_ADMIN_PERMISSION,
- )
-
- if not is_admin and not run_id:
- return handler.ActionResponse.error(message='run_id is required')
- if not target_run_id:
- return handler.ActionResponse.error(message='target_run_id is required')
- if not status:
- return handler.ActionResponse.error(message='status is required')
-
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'Run finalize',
- api_capability='run_finalize',
- allow_persistent_authorization=True,
- admin_permission=AGENT_RUN_ADMIN_PERMISSION,
- )
- if error:
- return error
-
- from ..agent.runner.run_ledger_store import RunLedgerStore
-
- store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
-
- try:
- run = await store.get_run(str(target_run_id))
- if not run:
- return handler.ActionResponse.error(message=f'Run {target_run_id} not found')
- if not is_admin:
- auth_error = _authorize_target_run(session, run)
- if auth_error:
- return auth_error
- claim_error = await _require_runtime_write_ownership(
- store=store,
- session=session,
- run=run,
- data=data,
- api_name='Run finalize',
- )
- if claim_error:
- return claim_error
-
- updated = await store.finalize_run(
- run_id=str(target_run_id),
- status=str(status),
- status_reason=data.get('status_reason') or data.get('reason'),
- usage=data.get('usage') if isinstance(data.get('usage'), dict) else None,
- cost=data.get('cost') if isinstance(data.get('cost'), dict) else None,
- metadata=data.get('metadata') if isinstance(data.get('metadata'), dict) else None,
- )
- if not updated:
- return handler.ActionResponse.error(message=f'Run {target_run_id} not found')
- if is_admin:
- await _record_agent_runner_admin_action(
- self.ap,
- store,
- action='run_finalize',
- caller_plugin_identity=caller_plugin_identity,
- permission=AGENT_RUN_ADMIN_PERMISSION,
- durable_run_id=str(target_run_id),
- detail={'status': str(status)},
- )
- return handler.ActionResponse.success(data=updated)
- except Exception as e:
- self.ap.logger.error(f'RUN_FINALIZE error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'Run finalize error: {e}')
-
- @self.action(_plugin_runtime_action('RUNTIME_REGISTER', 'runtime_register'))
- async def runtime_register(data: dict[str, Any]) -> handler.ActionResponse:
- """Register or update one Host-owned runtime registry record."""
- run_id = data.get('run_id')
- runtime_id = data.get('runtime_id')
- caller_plugin_identity = data.get('caller_plugin_identity')
- is_admin = _has_agent_runner_admin_permission(
- self.ap,
- caller_plugin_identity,
- RUNTIME_ADMIN_PERMISSION,
- )
-
- if not is_admin and not run_id:
- return handler.ActionResponse.error(message='run_id is required')
- if not runtime_id:
- return handler.ActionResponse.error(message='runtime_id is required')
-
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'Runtime register',
- api_capability='runtime_register',
- admin_permission=RUNTIME_ADMIN_PERMISSION,
- )
- if error:
- return error
-
- from ..agent.runner.run_ledger_store import RunLedgerStore
-
- store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
-
- try:
- runtime = await store.register_runtime(
- runtime_id=str(runtime_id),
- status=str(data.get('status') or 'online'),
- display_name=data.get('display_name'),
- endpoint=data.get('endpoint'),
- version=data.get('version'),
- capabilities=data.get('capabilities') if isinstance(data.get('capabilities'), dict) else {},
- labels=data.get('labels') if isinstance(data.get('labels'), dict) else {},
- metadata=data.get('metadata') if isinstance(data.get('metadata'), dict) else {},
- heartbeat_deadline_seconds=_deadline_seconds_from_payload(data),
- )
- if is_admin:
- await _record_agent_runner_admin_action(
- self.ap,
- store,
- action='runtime_register',
- caller_plugin_identity=caller_plugin_identity,
- permission=RUNTIME_ADMIN_PERMISSION,
- target_runtime_id=str(runtime_id),
- detail={'status': runtime.get('status')},
- )
- return handler.ActionResponse.success(data=runtime)
- except Exception as e:
- self.ap.logger.error(f'RUNTIME_REGISTER error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'Runtime register error: {e}')
-
- @self.action(_plugin_runtime_action('RUNTIME_HEARTBEAT', 'runtime_heartbeat'))
- async def runtime_heartbeat(data: dict[str, Any]) -> handler.ActionResponse:
- """Refresh one Host-owned runtime heartbeat."""
- run_id = data.get('run_id')
- runtime_id = data.get('runtime_id')
- caller_plugin_identity = data.get('caller_plugin_identity')
- is_admin = _has_agent_runner_admin_permission(
- self.ap,
- caller_plugin_identity,
- RUNTIME_ADMIN_PERMISSION,
- )
-
- if not is_admin and not run_id:
- return handler.ActionResponse.error(message='run_id is required')
- if not runtime_id:
- return handler.ActionResponse.error(message='runtime_id is required')
-
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'Runtime heartbeat',
- api_capability='runtime_heartbeat',
- admin_permission=RUNTIME_ADMIN_PERMISSION,
- )
- if error:
- return error
-
- from ..agent.runner.run_ledger_store import RunLedgerStore
-
- store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
-
- try:
- runtime = await store.heartbeat_runtime(
- runtime_id=str(runtime_id),
- status=str(data.get('status') or 'online'),
- capabilities=data.get('capabilities') if isinstance(data.get('capabilities'), dict) else None,
- labels=data.get('labels') if isinstance(data.get('labels'), dict) else None,
- metadata=data.get('metadata') if isinstance(data.get('metadata'), dict) else None,
- heartbeat_deadline_seconds=_deadline_seconds_from_payload(data),
- )
- if runtime is None:
- return handler.ActionResponse.error(message=f'Runtime {runtime_id} not found')
- if is_admin:
- await _record_agent_runner_admin_action(
- self.ap,
- store,
- action='runtime_heartbeat',
- caller_plugin_identity=caller_plugin_identity,
- permission=RUNTIME_ADMIN_PERMISSION,
- target_runtime_id=str(runtime_id),
- detail={'status': runtime.get('status')},
- )
- return handler.ActionResponse.success(data=runtime)
- except Exception as e:
- self.ap.logger.error(f'RUNTIME_HEARTBEAT error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'Runtime heartbeat error: {e}')
-
- @self.action(_plugin_runtime_action('RUNTIME_LIST', 'runtime_list'))
- async def runtime_list(data: dict[str, Any]) -> handler.ActionResponse:
- """List Host-owned runtime registry records."""
- run_id = data.get('run_id')
- caller_plugin_identity = data.get('caller_plugin_identity')
- is_admin = _has_agent_runner_admin_permission(
- self.ap,
- caller_plugin_identity,
- RUNTIME_ADMIN_PERMISSION,
- )
-
- if not is_admin and not run_id:
- return handler.ActionResponse.error(message='run_id is required')
-
- _session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'Runtime list',
- api_capability='runtime_list',
- admin_permission=RUNTIME_ADMIN_PERMISSION,
- )
- if error:
- return error
-
- statuses = data.get('statuses')
- if statuses is not None and not isinstance(statuses, list):
- return handler.ActionResponse.error(message='statuses must be a list')
- labels = data.get('labels') if isinstance(data.get('labels'), dict) else {}
-
- from ..agent.runner.run_ledger_store import RunLedgerStore
-
- store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
-
- try:
- runtimes, total_count = await store.list_runtimes(
- statuses=[str(status) for status in statuses] if statuses else None,
- labels=labels,
- limit=data.get('limit', 50),
- )
- if is_admin:
- await _record_agent_runner_admin_action(
- self.ap,
- store,
- action='runtime_list',
- caller_plugin_identity=caller_plugin_identity,
- permission=RUNTIME_ADMIN_PERMISSION,
- detail={
- 'statuses': [str(status) for status in statuses] if statuses else None,
- 'limit': data.get('limit', 50),
- },
- )
- return handler.ActionResponse.success(
- data={
- 'items': runtimes,
- 'next_cursor': None,
- 'prev_cursor': None,
- 'has_more': False,
- 'total_count': total_count,
- }
- )
- except Exception as e:
- self.ap.logger.error(f'RUNTIME_LIST error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'Runtime list error: {e}')
-
- @self.action(_plugin_runtime_action('RUNTIME_RECONCILE', 'runtime_reconcile'))
- async def runtime_reconcile(data: dict[str, Any]) -> handler.ActionResponse:
- """Reconcile stale runtime heartbeats and expired claim leases."""
- run_id = data.get('run_id')
- caller_plugin_identity = data.get('caller_plugin_identity')
- is_admin = _has_agent_runner_admin_permission(
- self.ap,
- caller_plugin_identity,
- RUNTIME_ADMIN_PERMISSION,
- )
-
- if not is_admin:
- return handler.ActionResponse.error(message='Runtime reconcile access not authorized')
-
- _session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'Runtime reconcile',
- api_capability='runtime_reconcile',
- admin_permission=RUNTIME_ADMIN_PERMISSION,
- )
- if error:
- return error
-
- stale_after_seconds = data.get('stale_after_seconds')
- if stale_after_seconds is not None:
- try:
- stale_after_seconds = max(float(stale_after_seconds), 0)
- except (TypeError, ValueError):
- return handler.ActionResponse.error(message='stale_after_seconds must be a number')
-
- from ..agent.runner.run_ledger_store import RunLedgerStore
-
- store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
-
- try:
- stale_runtimes = await store.mark_stale_runtimes(
- stale_after_seconds=stale_after_seconds,
- )
- released_claims = await store.release_expired_claims()
- if is_admin:
- await _record_agent_runner_admin_action(
- self.ap,
- store,
- action='runtime_reconcile',
- caller_plugin_identity=caller_plugin_identity,
- permission=RUNTIME_ADMIN_PERMISSION,
- detail={
- 'stale_count': len(stale_runtimes),
- 'released_claim_count': len(released_claims),
- },
- )
- return handler.ActionResponse.success(
- data={
- 'stale_runtimes': stale_runtimes,
- 'released_claims': released_claims,
- 'stale_count': len(stale_runtimes),
- 'released_claim_count': len(released_claims),
- }
- )
- except Exception as e:
- self.ap.logger.error(f'RUNTIME_RECONCILE error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'Runtime reconcile error: {e}')
-
- @self.action(_plugin_runtime_action('RUN_STATS', 'run_stats'))
- async def run_stats(data: dict[str, Any]) -> handler.ActionResponse:
- """Get run statistics within a time window (admin-only)."""
- run_id = data.get('run_id')
- caller_plugin_identity = data.get('caller_plugin_identity')
- is_admin = _has_agent_runner_admin_permission(
- self.ap,
- caller_plugin_identity,
- AGENT_RUN_ADMIN_PERMISSION,
- )
-
- if not is_admin:
- return handler.ActionResponse.error(message='Run stats access not authorized')
-
- _session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'Run stats',
- api_capability='run_stats',
- admin_permission=AGENT_RUN_ADMIN_PERMISSION,
- )
- if error:
- return error
-
- import time
- end_time = data.get('end_time') or int(time.time())
- start_time = data.get('start_time') or (end_time - 3600) # Default: 1 hour
- runner_id = data.get('runner_id')
-
- from ..agent.runner.run_ledger_store import RunLedgerStore
-
- store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
-
- try:
- stats = await store.get_run_stats(
- start_time=start_time,
- end_time=end_time,
- runner_id=runner_id,
- )
- await _record_agent_runner_admin_action(
- self.ap,
- store,
- action='run_stats',
- caller_plugin_identity=caller_plugin_identity,
- permission=AGENT_RUN_ADMIN_PERMISSION,
- detail={
- 'start_time': start_time,
- 'end_time': end_time,
- 'runner_id': runner_id,
- },
- )
- return handler.ActionResponse.success(data=stats)
- except Exception as e:
- self.ap.logger.error(f'RUN_STATS error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'Run stats error: {e}')
-
- @self.action(_plugin_runtime_action('RUNTIME_STATS', 'runtime_stats'))
- async def runtime_stats(data: dict[str, Any]) -> handler.ActionResponse:
- """Get runtime registry statistics (admin-only)."""
- run_id = data.get('run_id')
- caller_plugin_identity = data.get('caller_plugin_identity')
- is_admin = _has_agent_runner_admin_permission(
- self.ap,
- caller_plugin_identity,
- RUNTIME_ADMIN_PERMISSION,
- )
-
- if not is_admin:
- return handler.ActionResponse.error(message='Runtime stats access not authorized')
-
- _session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'Runtime stats',
- api_capability='runtime_stats',
- admin_permission=RUNTIME_ADMIN_PERMISSION,
- )
- if error:
- return error
-
- from ..agent.runner.run_ledger_store import RunLedgerStore
-
- store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
-
- try:
- stats = await store.get_runtime_stats()
- await _record_agent_runner_admin_action(
- self.ap,
- store,
- action='runtime_stats',
- caller_plugin_identity=caller_plugin_identity,
- permission=RUNTIME_ADMIN_PERMISSION,
- detail={},
- )
- return handler.ActionResponse.success(data=stats)
- except Exception as e:
- self.ap.logger.error(f'RUNTIME_STATS error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'Runtime stats error: {e}')
-
- @self.action(_plugin_runtime_action('RUNNER_STATS', 'runner_stats'))
- async def runner_stats(data: dict[str, Any]) -> handler.ActionResponse:
- """Get runner-aggregated statistics (admin-only)."""
- run_id = data.get('run_id')
- caller_plugin_identity = data.get('caller_plugin_identity')
- is_admin = _has_agent_runner_admin_permission(
- self.ap,
- caller_plugin_identity,
- AGENT_RUN_ADMIN_PERMISSION,
- )
-
- if not is_admin:
- return handler.ActionResponse.error(message='Runner stats access not authorized')
-
- _session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'Runner stats',
- api_capability='runner_stats',
- admin_permission=AGENT_RUN_ADMIN_PERMISSION,
- )
- if error:
- return error
-
- import time
- end_time = data.get('end_time') or int(time.time())
- start_time = data.get('start_time') or (end_time - 3600) # Default: 1 hour
- limit = min(int(data.get('limit', 50)), 100)
-
- from ..agent.runner.run_ledger_store import RunLedgerStore
-
- store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
-
- try:
- stats = await store.get_runner_stats(
- start_time=start_time,
- end_time=end_time,
- limit=limit,
- )
- await _record_agent_runner_admin_action(
- self.ap,
- store,
- action='runner_stats',
- caller_plugin_identity=caller_plugin_identity,
- permission=AGENT_RUN_ADMIN_PERMISSION,
- detail={
- 'start_time': start_time,
- 'end_time': end_time,
- 'limit': limit,
- },
- )
- return handler.ActionResponse.success(data={'items': stats, 'total_count': len(stats), 'has_more': False})
- except Exception as e:
- self.ap.logger.error(f'RUNNER_STATS error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'Runner stats error: {e}')
-
- @self.action(_plugin_runtime_action('RUN_CLAIM', 'run_claim'))
- async def run_claim(data: dict[str, Any]) -> handler.ActionResponse:
- """Claim one queued run for a runtime lease."""
- run_id = data.get('run_id')
- runtime_id = data.get('runtime_id')
- caller_plugin_identity = data.get('caller_plugin_identity')
- is_admin = _has_agent_runner_admin_permission(
- self.ap,
- caller_plugin_identity,
- RUNTIME_ADMIN_PERMISSION,
- )
-
- if not is_admin and not run_id:
- return handler.ActionResponse.error(message='run_id is required')
- if not runtime_id:
- return handler.ActionResponse.error(message='runtime_id is required')
-
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'Run claim',
- api_capability='run_claim',
- admin_permission=RUNTIME_ADMIN_PERMISSION,
- )
- if error:
- return error
-
- runner_ids = data.get('runner_ids')
- if runner_ids is not None and not isinstance(runner_ids, list):
- return handler.ActionResponse.error(message='runner_ids must be a list')
-
- from ..agent.runner.run_ledger_store import RunLedgerStore
-
- store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
-
- try:
- scope_filters: dict[str, Any] = {}
- if not is_admin:
- authorization = _get_run_authorization(session)
- session_runner_id = session.get('runner_id') or authorization.get('runner_id')
- if not session_runner_id:
- return handler.ActionResponse.error(message='Run claim is not available without a runner_id')
- if runner_ids and any(str(item) != session_runner_id for item in runner_ids):
- return handler.ActionResponse.error(message='Run claim runner_ids are not accessible by this run')
- runner_ids = [session_runner_id]
- scope_filters = {
- 'conversation_id': authorization.get('conversation_id'),
- **_run_scope_filters(session),
- }
- run = await store.claim_next_run(
- runtime_id=str(runtime_id),
- queue_name=data.get('queue_name'),
- lease_seconds=data.get('lease_seconds', 60),
- runner_ids=[str(item) for item in runner_ids] if runner_ids else None,
- **scope_filters,
- )
- if run is None:
- return handler.ActionResponse.error(message='No queued run available')
- if is_admin:
- await _record_agent_runner_admin_action(
- self.ap,
- store,
- action='run_claim',
- caller_plugin_identity=caller_plugin_identity,
- permission=RUNTIME_ADMIN_PERMISSION,
- durable_run_id=str(run.get('run_id')),
- target_runtime_id=str(runtime_id),
- detail={
- 'queue_name': data.get('queue_name'),
- 'runner_ids': [str(item) for item in runner_ids] if runner_ids else None,
- },
- )
- return handler.ActionResponse.success(data=run)
- except Exception as e:
- self.ap.logger.error(f'RUN_CLAIM error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'Run claim error: {e}')
-
- @self.action(_plugin_runtime_action('RUN_RENEW_CLAIM', 'run_renew_claim'))
- async def run_renew_claim(data: dict[str, Any]) -> handler.ActionResponse:
- """Renew one run claim lease."""
- run_id = data.get('run_id')
- target_run_id = data.get('target_run_id')
- runtime_id = data.get('runtime_id')
- claim_token = data.get('claim_token')
- caller_plugin_identity = data.get('caller_plugin_identity')
- is_admin = _has_agent_runner_admin_permission(
- self.ap,
- caller_plugin_identity,
- RUNTIME_ADMIN_PERMISSION,
- )
-
- if not is_admin and not run_id:
- return handler.ActionResponse.error(message='run_id is required')
- if not target_run_id:
- return handler.ActionResponse.error(message='target_run_id is required')
- if not runtime_id:
- return handler.ActionResponse.error(message='runtime_id is required')
- if not claim_token:
- return handler.ActionResponse.error(message='claim_token is required')
-
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'Run renew claim',
- api_capability='run_renew_claim',
- admin_permission=RUNTIME_ADMIN_PERMISSION,
- )
- if error:
- return error
-
- from ..agent.runner.run_ledger_store import RunLedgerStore
-
- store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
-
- try:
- current = await store.get_run(str(target_run_id))
- if not current or current.get('claimed_by_runtime_id') != runtime_id:
- return handler.ActionResponse.error(message=f'Run claim {target_run_id} not found')
- if not is_admin:
- auth_error = _authorize_target_run(session, current)
- if auth_error:
- return auth_error
- run = await store.renew_claim(
- run_id=str(target_run_id),
- claim_token=str(claim_token),
- runtime_id=str(runtime_id),
- lease_seconds=data.get('lease_seconds', 60),
- )
- if run is None:
- return handler.ActionResponse.error(message=f'Run claim {target_run_id} not found')
- if is_admin:
- await _record_agent_runner_admin_action(
- self.ap,
- store,
- action='run_renew_claim',
- caller_plugin_identity=caller_plugin_identity,
- permission=RUNTIME_ADMIN_PERMISSION,
- durable_run_id=str(target_run_id),
- target_runtime_id=str(runtime_id),
- detail={'lease_seconds': data.get('lease_seconds', 60)},
- )
- return handler.ActionResponse.success(data=run)
- except Exception as e:
- self.ap.logger.error(f'RUN_RENEW_CLAIM error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'Run renew claim error: {e}')
-
- @self.action(_plugin_runtime_action('RUN_RELEASE_CLAIM', 'run_release_claim'))
- async def run_release_claim(data: dict[str, Any]) -> handler.ActionResponse:
- """Release one run claim lease."""
- run_id = data.get('run_id')
- target_run_id = data.get('target_run_id')
- runtime_id = data.get('runtime_id')
- claim_token = data.get('claim_token')
- caller_plugin_identity = data.get('caller_plugin_identity')
- is_admin = _has_agent_runner_admin_permission(
- self.ap,
- caller_plugin_identity,
- RUNTIME_ADMIN_PERMISSION,
- )
-
- if not is_admin and not run_id:
- return handler.ActionResponse.error(message='run_id is required')
- if not target_run_id:
- return handler.ActionResponse.error(message='target_run_id is required')
- if not runtime_id:
- return handler.ActionResponse.error(message='runtime_id is required')
- if not claim_token:
- return handler.ActionResponse.error(message='claim_token is required')
-
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'Run release claim',
- api_capability='run_release_claim',
- admin_permission=RUNTIME_ADMIN_PERMISSION,
- )
- if error:
- return error
-
- from ..agent.runner.run_ledger_store import RunLedgerStore
-
- store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
-
- try:
- current = await store.get_run(str(target_run_id))
- if not current or current.get('claimed_by_runtime_id') != runtime_id:
- return handler.ActionResponse.error(message=f'Run claim {target_run_id} not found')
- if not is_admin:
- auth_error = _authorize_target_run(session, current)
- if auth_error:
- return auth_error
- release_status = str(data.get('status') or 'queued')
- if release_status in TERMINAL_STATUSES:
- return handler.ActionResponse.error(
- message='Run release claim cannot finalize a run; use run_finalize'
- )
- run = await store.release_claim(
- run_id=str(target_run_id),
- claim_token=str(claim_token),
- runtime_id=str(runtime_id),
- status=str(data.get('status') or 'queued'),
- status_reason=data.get('status_reason') or data.get('reason'),
- )
- if run is None:
- return handler.ActionResponse.error(message=f'Run claim {target_run_id} not found')
- if is_admin:
- await _record_agent_runner_admin_action(
- self.ap,
- store,
- action='run_release_claim',
- caller_plugin_identity=caller_plugin_identity,
- permission=RUNTIME_ADMIN_PERMISSION,
- durable_run_id=str(target_run_id),
- target_runtime_id=str(runtime_id),
- detail={
- 'status': str(data.get('status') or 'queued'),
- 'status_reason': data.get('status_reason') or data.get('reason'),
- },
- )
- return handler.ActionResponse.success(data=run)
- except Exception as e:
- self.ap.logger.error(f'RUN_RELEASE_CLAIM error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'Run release claim error: {e}')
-
- @self.action(PluginToRuntimeAction.STEERING_PULL)
- async def steering_pull(data: dict[str, Any]) -> handler.ActionResponse:
- """Pull pending steering/follow-up inputs for the current run."""
- run_id = data.get('run_id')
- mode = data.get('mode', 'all')
- limit = data.get('limit')
- caller_plugin_identity = data.get('caller_plugin_identity')
-
- if not run_id:
- return handler.ActionResponse.error(message='run_id is required')
-
- if limit is not None:
- try:
- limit = int(limit)
- except (TypeError, ValueError):
- return handler.ActionResponse.error(message='limit must be an integer')
- if limit <= 0:
- return handler.ActionResponse.error(message='limit must be > 0')
- limit = min(limit, 100)
-
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'Steering pull',
- api_capability='steering_pull',
- )
- if error:
- return error
-
- session_registry = get_session_registry()
- items = await session_registry.pull_steering(
- run_id,
- mode=str(mode or 'all'),
- limit=limit,
- )
- if items:
- try:
- from ..agent.runner.event_log_store import EventLogStore
-
- store = EventLogStore(self.ap.persistence_mgr.get_db_engine())
- for item in items:
- event = item.get('event') if isinstance(item, dict) else None
- conversation = item.get('conversation') if isinstance(item, dict) else None
- actor = item.get('actor') if isinstance(item, dict) else None
- subject = item.get('subject') if isinstance(item, dict) else None
- if not isinstance(event, dict):
- continue
- await store.append_event(
- event_id=None,
- event_type='steering.injected',
- source='agent_runner',
- bot_id=conversation.get('bot_id') if isinstance(conversation, dict) else None,
- workspace_id=conversation.get('workspace_id') if isinstance(conversation, dict) else None,
- conversation_id=conversation.get('conversation_id')
- if isinstance(conversation, dict)
- else None,
- thread_id=conversation.get('thread_id') if isinstance(conversation, dict) else None,
- actor_type=actor.get('actor_type') if isinstance(actor, dict) else None,
- actor_id=actor.get('actor_id') if isinstance(actor, dict) else None,
- actor_name=actor.get('actor_name') if isinstance(actor, dict) else None,
- subject_type=subject.get('subject_type') if isinstance(subject, dict) else None,
- subject_id=subject.get('subject_id') if isinstance(subject, dict) else None,
- input_summary=f'steering injected from {event.get("event_id")}',
- run_id=run_id,
- runner_id=session.get('runner_id') if isinstance(session, dict) else None,
- metadata={
- 'steering': {
- 'status': 'injected',
- 'source_event_id': event.get('event_id'),
- 'claimed_by_run_id': item.get('claimed_run_id')
- if isinstance(item, dict)
- else run_id,
- 'claimed_runner_id': item.get('runner_id') if isinstance(item, dict) else None,
- 'claimed_at': item.get('claimed_at') if isinstance(item, dict) else None,
- 'pull_mode': str(mode or 'all'),
- },
- },
- )
- except Exception as exc:
- self.ap.logger.warning(
- f'Failed to write steering injection audit for run {run_id}: {exc}',
- exc_info=True,
- )
- return handler.ActionResponse.success(data={'items': items})
-
- # ================= State APIs (run-scoped, policy-enforced) =================
-
- @self.action(PluginToRuntimeAction.STATE_GET)
- async def state_get(data: dict[str, Any]) -> handler.ActionResponse:
- """Get a state value from host-owned state store.
-
- Requires run_id authorization and scope enabled by state_policy.
- """
- run_id = data.get('run_id')
- scope = data.get('scope')
- key = data.get('key')
- caller_plugin_identity = data.get('caller_plugin_identity')
-
- if not run_id:
- return handler.ActionResponse.error(message='run_id is required')
-
- if not scope:
- return handler.ActionResponse.error(message='scope is required')
-
- if not key:
- return handler.ActionResponse.error(message='key is required')
-
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'State get',
- api_capability='state',
- )
- if error:
- return error
-
- _state_context, scope_key, state_error = _resolve_state_scope(session, scope)
- if state_error:
- return state_error
-
- # Get state from persistent store
- from ..agent.runner.persistent_state_store import get_persistent_state_store
-
- store = get_persistent_state_store(self.ap.persistence_mgr.get_db_engine())
-
- try:
- value = await store.state_get(scope_key, key)
- return handler.ActionResponse.success(data={'value': value})
- except Exception as e:
- self.ap.logger.error(f'STATE_GET error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'State get error: {e}')
-
- @self.action(PluginToRuntimeAction.STATE_SET)
- async def state_set(data: dict[str, Any]) -> handler.ActionResponse:
- """Set a state value in host-owned state store.
-
- Requires run_id authorization and scope enabled by state_policy.
- Value must be JSON-serializable and size-limited.
- """
- run_id = data.get('run_id')
- scope = data.get('scope')
- key = data.get('key')
- value = data.get('value')
- caller_plugin_identity = data.get('caller_plugin_identity')
-
- if not run_id:
- return handler.ActionResponse.error(message='run_id is required')
-
- if not scope:
- return handler.ActionResponse.error(message='scope is required')
-
- if not key:
- return handler.ActionResponse.error(message='key is required')
-
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'State set',
- api_capability='state',
- )
- if error:
- return error
-
- state_context, scope_key, state_error = _resolve_state_scope(session, scope)
- if state_error:
- return state_error
-
- # Get additional context for DB insert
- runner_id = session.get('runner_id', '')
- binding_identity = state_context.get('binding_identity', 'unknown')
-
- # Set state in persistent store
- from ..agent.runner.persistent_state_store import get_persistent_state_store
-
- store = get_persistent_state_store(self.ap.persistence_mgr.get_db_engine())
-
- try:
- success, error = await store.state_set(
- scope_key=scope_key,
- state_key=key,
- value=value,
- runner_id=runner_id,
- binding_identity=binding_identity,
- scope=scope,
- context=state_context,
- logger=self.ap.logger,
- )
-
- if not success:
- return handler.ActionResponse.error(message=error or 'Failed to set state')
-
- return handler.ActionResponse.success(data={'success': True})
- except Exception as e:
- self.ap.logger.error(f'STATE_SET error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'State set error: {e}')
-
- @self.action(PluginToRuntimeAction.STATE_DELETE)
- async def state_delete(data: dict[str, Any]) -> handler.ActionResponse:
- """Delete a state value from host-owned state store.
-
- Requires run_id authorization and scope enabled by state_policy.
- """
- run_id = data.get('run_id')
- scope = data.get('scope')
- key = data.get('key')
- caller_plugin_identity = data.get('caller_plugin_identity')
-
- if not run_id:
- return handler.ActionResponse.error(message='run_id is required')
-
- if not scope:
- return handler.ActionResponse.error(message='scope is required')
-
- if not key:
- return handler.ActionResponse.error(message='key is required')
-
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'State delete',
- api_capability='state',
- )
- if error:
- return error
-
- _state_context, scope_key, state_error = _resolve_state_scope(session, scope)
- if state_error:
- return state_error
-
- # Delete state from persistent store
- from ..agent.runner.persistent_state_store import get_persistent_state_store
-
- store = get_persistent_state_store(self.ap.persistence_mgr.get_db_engine())
-
- try:
- deleted = await store.state_delete(scope_key, key)
- return handler.ActionResponse.success(data={'success': deleted})
- except Exception as e:
- self.ap.logger.error(f'STATE_DELETE error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'State delete error: {e}')
-
- @self.action(PluginToRuntimeAction.STATE_LIST)
- async def state_list(data: dict[str, Any]) -> handler.ActionResponse:
- """List state keys in a scope.
-
- Requires run_id authorization and scope enabled by state_policy.
- """
- run_id = data.get('run_id')
- scope = data.get('scope')
- prefix = data.get('prefix')
- limit = data.get('limit', 100)
- caller_plugin_identity = data.get('caller_plugin_identity')
-
- if not run_id:
- return handler.ActionResponse.error(message='run_id is required')
-
- if not scope:
- return handler.ActionResponse.error(message='scope is required')
-
- # Validate limit
- if not isinstance(limit, int) or limit <= 0:
- limit = 100
- limit = min(limit, 100) # Cap at 100
-
- session, error = await _validate_agent_run_session(
- run_id,
- caller_plugin_identity,
- self.ap,
- 'State list',
- api_capability='state',
- )
- if error:
- return error
-
- _state_context, scope_key, state_error = _resolve_state_scope(session, scope)
- if state_error:
- return state_error
-
- # List state keys from persistent store
- from ..agent.runner.persistent_state_store import get_persistent_state_store
-
- store = get_persistent_state_store(self.ap.persistence_mgr.get_db_engine())
-
- try:
- keys, has_more = await store.state_list(scope_key, prefix, limit)
- return handler.ActionResponse.success(
- data={
- 'keys': keys,
- 'has_more': has_more,
- }
- )
- except Exception as e:
- self.ap.logger.error(f'STATE_LIST error: {e}', exc_info=True)
- return handler.ActionResponse.error(message=f'State list error: {e}')
+ agent_pull_actions.register(self)
+ agent_runner_actions.register(self)
+ agent_state_actions.register(self)
@self.action(CommonAction.PING)
async def ping(data: dict[str, Any]) -> handler.ActionResponse:
diff --git a/tests/unit_tests/agent/test_state_api_auth.py b/tests/unit_tests/agent/test_state_api_auth.py
index 5e1300f2c..315bdfb7b 100644
--- a/tests/unit_tests/agent/test_state_api_auth.py
+++ b/tests/unit_tests/agent/test_state_api_auth.py
@@ -90,7 +90,7 @@ class TestStateAPIHandlerAuthorization:
async def fake_disconnect():
return True
- with patch('langbot.pkg.plugin.handler.get_session_registry', return_value=session_registry):
+ with patch('langbot.pkg.plugin.agent_run_support.get_session_registry', return_value=session_registry):
handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
# Get the STATE_GET action handler (actions dict is keyed by action value string)
@@ -111,7 +111,7 @@ class TestStateAPIHandlerAuthorization:
async def fake_disconnect():
return True
- with patch('langbot.pkg.plugin.handler.get_session_registry', return_value=session_registry):
+ with patch('langbot.pkg.plugin.agent_run_support.get_session_registry', return_value=session_registry):
handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
@@ -146,7 +146,7 @@ class TestStateAPIHandlerAuthorization:
async def fake_disconnect():
return True
- with patch('langbot.pkg.plugin.handler.get_session_registry', return_value=session_registry):
+ with patch('langbot.pkg.plugin.agent_run_support.get_session_registry', return_value=session_registry):
handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
@@ -182,7 +182,7 @@ class TestStateAPIHandlerAuthorization:
async def fake_disconnect():
return True
- with patch('langbot.pkg.plugin.handler.get_session_registry', return_value=session_registry):
+ with patch('langbot.pkg.plugin.agent_run_support.get_session_registry', return_value=session_registry):
handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
@@ -219,7 +219,7 @@ class TestStateAPIHandlerAuthorization:
async def fake_disconnect():
return True
- with patch('langbot.pkg.plugin.handler.get_session_registry', return_value=session_registry):
+ with patch('langbot.pkg.plugin.agent_run_support.get_session_registry', return_value=session_registry):
handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
@@ -255,7 +255,7 @@ class TestStateAPIHandlerAuthorization:
async def fake_disconnect():
return True
- with patch('langbot.pkg.plugin.handler.get_session_registry', return_value=session_registry):
+ with patch('langbot.pkg.plugin.agent_run_support.get_session_registry', return_value=session_registry):
handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
@@ -292,7 +292,7 @@ class TestStateAPIHandlerAuthorization:
async def fake_disconnect():
return True
- with patch('langbot.pkg.plugin.handler.get_session_registry', return_value=session_registry):
+ with patch('langbot.pkg.plugin.agent_run_support.get_session_registry', return_value=session_registry):
handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
@@ -340,7 +340,7 @@ class TestStateAPIFullFlowWithRealDB:
async def fake_disconnect():
return True
- with patch('langbot.pkg.plugin.handler.get_session_registry', return_value=session_registry):
+ with patch('langbot.pkg.plugin.agent_run_support.get_session_registry', return_value=session_registry):
handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
# Verify session has correct state_context
@@ -446,7 +446,7 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
async def fake_disconnect():
return True
- with patch('langbot.pkg.plugin.handler.get_session_registry', return_value=session_registry):
+ with patch('langbot.pkg.plugin.agent_run_support.get_session_registry', return_value=session_registry):
handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
@@ -490,7 +490,7 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
async def fake_disconnect():
return True
- with patch('langbot.pkg.plugin.handler.get_session_registry', return_value=session_registry):
+ with patch('langbot.pkg.plugin.agent_run_support.get_session_registry', return_value=session_registry):
handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
state_set_handler = handler.actions[PluginToRuntimeAction.STATE_SET.value]
From 2b03095d4eedb169e46e345e0b2bfb26d2411714 Mon Sep 17 00:00:00 2001
From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com>
Date: Mon, 22 Jun 2026 13:39:45 +0800
Subject: [PATCH 8/8] refactor(tools): unify tool-detail normalization in
ToolManager
Drop the PluginToolLoader.get_tool() override that returned a raw
ComponentManifest, so every loader's get_tool() now returns a uniform
resource_tool.LLMTool (PluginToolLoader.get_tools() already did this
conversion). This removes the only source of tool-shape heterogeneity.
- ToolManager.get_tool_schema(): drop the ComponentManifest-vs-LLMTool branch
- ToolManager.get_tool_detail(): new host-level shape {name, description,
human_desc, parameters}
- handler.py GET_TOOL_DETAIL: call tool_mgr.get_tool_detail(); delete the
handler-local _build_tool_detail + _i18n_to_dict/_i18n_to_text adapters and
the litellm TODO
- ToolLookupResult is now just LLMTool
The dropped label/spec fields were not consumed by any runner (local-agent
build_llm_tool and external harnesses use only name/description/parameters).
---
src/langbot/pkg/plugin/handler.py | 66 +------------------
src/langbot/pkg/provider/tools/loader.py | 4 +-
.../pkg/provider/tools/loaders/plugin.py | 7 --
src/langbot/pkg/provider/tools/toolmgr.py | 29 +++++---
tests/unit_tests/agent/test_handler_auth.py | 56 +++++++++-------
5 files changed, 56 insertions(+), 106 deletions(-)
diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py
index 6fd859386..3f79182b8 100644
--- a/src/langbot/pkg/plugin/handler.py
+++ b/src/langbot/pkg/plugin/handler.py
@@ -62,66 +62,6 @@ def _pop_query_llm_usage(query: Any) -> dict[str, Any] | None:
return None
-def _i18n_to_dict(value: Any) -> dict[str, Any]:
- """Convert SDK i18n values to plain dictionaries."""
- if value is None:
- return {}
- if isinstance(value, dict):
- return value
- if hasattr(value, 'to_dict'):
- return value.to_dict()
- if hasattr(value, 'model_dump'):
- return value.model_dump()
- return {'en_US': str(value)}
-
-
-def _i18n_to_text(value: Any) -> str:
- """Return a stable human-readable text from SDK i18n values."""
- data = _i18n_to_dict(value)
- for key in ('en_US', 'zh_Hans', 'zh_Hant'):
- text = data.get(key)
- if text:
- return str(text)
- for text in data.values():
- if text:
- return str(text)
- return ''
-
-
-def _build_tool_detail(tool: Any, requested_tool_name: str | None = None) -> dict[str, Any]:
- """Normalize LLMTool and plugin ComponentManifest objects for tool detail APIs."""
- # TODO(litellm): This handler-local adapter is temporary. Once LiteLLM-backed
- # tool schema normalization owns tool detail generation, simplify GET_TOOL_DETAIL
- # and make ToolManager return one host-level tool detail shape.
- if hasattr(tool, 'metadata') and hasattr(tool, 'spec'):
- metadata = tool.metadata
- spec = tool.spec or {}
- description = spec.get('llm_prompt') or _i18n_to_text(getattr(metadata, 'description', None))
- parameters = spec.get('parameters') or {}
-
- return {
- 'name': requested_tool_name or getattr(metadata, 'name', ''),
- 'label': _i18n_to_dict(getattr(metadata, 'label', None)),
- 'description': description,
- 'human_desc': description,
- 'parameters': parameters,
- 'spec': spec,
- }
-
- name = getattr(tool, 'name', requested_tool_name or '')
- description = getattr(tool, 'description', None) or getattr(tool, 'human_desc', '') or ''
- parameters = getattr(tool, 'parameters', None) or {}
-
- return {
- 'name': name,
- 'label': {},
- 'description': description,
- 'human_desc': getattr(tool, 'human_desc', description) or description,
- 'parameters': parameters,
- 'spec': {'parameters': parameters},
- }
-
-
def _normalize_uuid_list(values: Any) -> list[str]:
"""Normalize a user/config supplied UUID list while preserving order."""
if not isinstance(values, list):
@@ -761,14 +701,12 @@ class RuntimeConnectionHandler(handler.Handler):
return error
try:
- tool = await self.ap.tool_mgr.get_tool_by_name(tool_name)
- if tool is None:
+ tool_detail = await self.ap.tool_mgr.get_tool_detail(tool_name)
+ if tool_detail is None:
return handler.ActionResponse.error(
message=f'Tool {tool_name} not found',
)
- tool_detail = _build_tool_detail(tool, requested_tool_name=tool_name)
-
return handler.ActionResponse.success(data={'tool': tool_detail})
except Exception as e:
traceback.print_exc()
diff --git a/src/langbot/pkg/provider/tools/loader.py b/src/langbot/pkg/provider/tools/loader.py
index f97e82164..22b4a305b 100644
--- a/src/langbot/pkg/provider/tools/loader.py
+++ b/src/langbot/pkg/provider/tools/loader.py
@@ -4,14 +4,14 @@ import abc
import typing
from typing import TYPE_CHECKING
-from langbot_plugin.api.definition.components.manifest import ComponentManifest
from langbot_plugin.api.entities.events import pipeline_query
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
if TYPE_CHECKING:
from ...core import app
-ToolLookupResult = resource_tool.LLMTool | ComponentManifest
+# All loaders normalize their tools to resource_tool.LLMTool.
+ToolLookupResult = resource_tool.LLMTool
preregistered_loaders: list[typing.Type[ToolLoader]] = []
diff --git a/src/langbot/pkg/provider/tools/loaders/plugin.py b/src/langbot/pkg/provider/tools/loaders/plugin.py
index 544882d34..44f40e421 100644
--- a/src/langbot/pkg/provider/tools/loaders/plugin.py
+++ b/src/langbot/pkg/provider/tools/loaders/plugin.py
@@ -3,7 +3,6 @@ from __future__ import annotations
import typing
import traceback
-from langbot_plugin.api.definition.components.manifest import ComponentManifest
from langbot_plugin.api.entities.events import pipeline_query
from .. import loader
@@ -40,12 +39,6 @@ class PluginToolLoader(loader.ToolLoader):
return True
return False
- async def get_tool(self, name: str) -> ComponentManifest | None:
- for tool in await self.ap.plugin_connector.list_tools():
- if tool.metadata.name == name:
- return tool
- return None
-
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
try:
return await self.ap.plugin_connector.call_tool(
diff --git a/src/langbot/pkg/provider/tools/toolmgr.py b/src/langbot/pkg/provider/tools/toolmgr.py
index ad73cf567..41bf31f6a 100644
--- a/src/langbot/pkg/provider/tools/toolmgr.py
+++ b/src/langbot/pkg/provider/tools/toolmgr.py
@@ -90,18 +90,31 @@ class ToolManager:
"""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.
+ definitions without a separate get_tool_detail round-trip. All loaders
+ return resource_tool.LLMTool, so no per-shape branching is needed.
+ 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)
+ return tool.description, (tool.parameters or None)
+
+ async def get_tool_detail(self, name: str) -> dict | None:
+ """Return the host-level tool detail shape for a tool by name.
+
+ All loaders return resource_tool.LLMTool, so the shape is uniform:
+ {name, description, human_desc, parameters}. Returns None when the tool
+ is not found.
+ """
+ tool = await self.get_tool_by_name(name)
+ if tool is None:
+ return None
+ return {
+ 'name': tool.name,
+ 'description': tool.description,
+ 'human_desc': tool.human_desc,
+ 'parameters': tool.parameters or {},
+ }
async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list:
tools = []
diff --git a/tests/unit_tests/agent/test_handler_auth.py b/tests/unit_tests/agent/test_handler_auth.py
index 18516951e..d7865d450 100644
--- a/tests/unit_tests/agent/test_handler_auth.py
+++ b/tests/unit_tests/agent/test_handler_auth.py
@@ -14,12 +14,11 @@ Authorization paths:
from __future__ import annotations
import pytest
-import types
from unittest.mock import AsyncMock, MagicMock
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry
-from langbot.pkg.plugin.handler import _build_tool_detail, _get_pipeline_knowledge_base_uuids
+from langbot.pkg.plugin.handler import _get_pipeline_knowledge_base_uuids
# Import shared test fixtures from conftest.py
from .conftest import make_resources, make_session
@@ -287,31 +286,39 @@ class TestInvokeLLMStreamAuthorization:
assert run_id is None
-def test_build_tool_detail_normalizes_plugin_component_manifest():
- """GET_TOOL_DETAIL returns a uniform schema for ordinary plugin Tool manifests."""
- manifest_tool = types.SimpleNamespace(
- metadata=types.SimpleNamespace(
- name='search',
- label={'en_US': 'Search'},
- description={'en_US': 'Search public data'},
- ),
- spec={
- 'llm_prompt': 'Search test data',
- 'parameters': {
- 'type': 'object',
- 'properties': {'q': {'type': 'string'}},
- },
- },
+@pytest.mark.asyncio
+async def test_tool_manager_get_tool_detail_returns_uniform_schema():
+ """ToolManager.get_tool_detail returns a uniform host-level tool detail shape.
+
+ All loaders normalize to resource_tool.LLMTool, so GET_TOOL_DETAIL no longer
+ needs a handler-local adapter for plugin ComponentManifest objects.
+ """
+ import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
+ from langbot.pkg.provider.tools.toolmgr import ToolManager
+
+ tool = resource_tool.LLMTool(
+ name='search',
+ human_desc='Search public data',
+ description='Search test data',
+ parameters={'type': 'object', 'properties': {'q': {'type': 'string'}}},
+ func=lambda **kwargs: {},
)
- detail = _build_tool_detail(manifest_tool, requested_tool_name='author/plugin/search')
+ mgr = ToolManager.__new__(ToolManager)
- assert detail['name'] == 'author/plugin/search'
- assert detail['description'] == 'Search test data'
- assert detail['human_desc'] == 'Search test data'
- assert detail['parameters']['properties']['q']['type'] == 'string'
- assert detail['label'] == {'en_US': 'Search'}
- assert detail['spec'] == manifest_tool.spec
+ async def fake_get_tool_by_name(name):
+ return tool if name == 'search' else None
+
+ mgr.get_tool_by_name = fake_get_tool_by_name
+
+ detail = await mgr.get_tool_detail('search')
+ assert detail == {
+ 'name': 'search',
+ 'description': 'Search test data',
+ 'human_desc': 'Search public data',
+ 'parameters': {'type': 'object', 'properties': {'q': {'type': 'string'}}},
+ }
+ assert await mgr.get_tool_detail('missing') is None
class TestCallToolAuthorization:
@@ -1510,7 +1517,6 @@ class TestStorageResourcePermissionHelper:
assert registry.is_resource_allowed(session, 'storage', 'workspace') is False
-
class TestRealActionHandlerSimulation:
"""Tests that simulate real RuntimeConnectionHandler action registration and execution.