diff --git a/docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md b/docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md new file mode 100644 index 000000000..9a7b2f5d4 --- /dev/null +++ b/docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md @@ -0,0 +1,149 @@ +# Agent-owned Context 协议设计 + +本文档描述插件化 AgentRunner 场景下的上下文边界**设计理由**。结论先行:LangBot 不应成为最终 agentic context manager;它提供 context substrate,AgentRunner 或其背后的 runtime 自己决定如何管理历史、压缩、召回和 KV cache。 + +> 涉及的数据结构(`AgentRunContext`、`ContextAccess`、`AgentRunAPIProxy` 等)唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。本文只讲语义和约束,不重抄 schema。 + +## 1. 设计原则 + +### 1.1 Agent 拥有上下文策略 + +不同 runner 背后的 runtime 差异很大: + +- 官方 local-agent 可能依赖 LangBot 的模型、工具、知识库和存储。 +- Claude Code SDK / Codex 类 runtime 有自己的 session、transcript、tool loop 和上下文压缩。 +- Pi Agent SDK 或外部 agent 平台可能只需要当前事件和一个外部 conversation key。 + +因此 LangBot 不应强行决定最终传给模型的历史窗口。Host 只提供:当前事件的完整结构化信息、稳定身份和会话引用、可授权读取的 history / event / state API、sandbox/workspace 文件能力、可投影给外部 harness 的 scoped context / SDK-owned MCP bridge / resource handles、payload hard cap 和权限 guardrail。 + +### 1.2 Host 不定义通用历史窗口 + +历史窗口策略不是 AgentRunner 协议或 Query entry adapter 的核心概念。Host 只提供 history pull API、cursor、hard cap 和权限边界;runner 自己决定是否读取、读取多少、如何截断和压缩。 + +正确的问题不是"LangBot 每轮裁几轮历史给 agent",而是: + +- 这类 runner 是否自管 context? +- 事件到来时 host 应 inline 哪些最小信息? +- agent 需要更多上下文时通过什么 API 拉取? +- host 如何保证安全、可审计和可分页? + +### 1.3 Host 保存事实源,Agent 管理 working context + +三类数据要分开: + +- `EventLog`: Host 保存原始事件、工具调用、投递结果、错误和系统事件。 +- `Transcript`: Host 从 EventLog 投影出的对话视图,用于 UI、审计和按需历史读取。 +- `Working context`: Agent 本轮实际送进模型或 runtime 的上下文,由 AgentRunner 决定。 + +LangBot 不提供 host-side inline history window。简单 runner 如果需要历史窗口,应在 runner 内部通过 Host history API 拉取并裁剪。 + +## 2. Event 到来时传什么 + +默认 `AgentRunContext`(PROTOCOL_V1 §5.2)应尽量小且稳定。默认规则: + +- Host MUST NOT inline full history by default. +- Host SHOULD inline only current event / input and context handles. +- Runner owns working-context assembly. +- Runner MAY use Host history / event / state / storage API and sandbox/workspace file tools when authorized. +- Official runners MUST consume Host infrastructure through the same public API as third-party runners. + +### 2.1 必须 inline 的内容 + +当前 event 的类型/id/时间/source;当前输入文本和结构化内容;附件/文件/图片的 metadata、path 或 URL;actor / subject / conversation / thread / bot / workspace;delivery 能力;已授权资源列表;context cursors 和可用 API 能力;Agent/runner config。这些是 agent 决定下一步所需的最低信息。 + +### 2.2 默认不 inline 的内容 + +完整历史消息、大文件全文、大工具结果、全量知识库内容、平台原始 payload 大对象、每轮重新生成的大段 summary。这些会破坏跨进程序列化成本、泄露范围、KV cache 稳定性,也会迫使 host 替 agent 做 context 策略。 + +### 2.3 不提供 Host Inline History Window + +`AgentRunContext` 不包含 `bootstrap` 字段。Host 不下发历史窗口,也不通过 Pipeline 配置决定窗口大小。runner 若需要类似 `recent_tail` 的策略,应在自己的 manifest/config schema 中声明参数,并在 runner 内部通过 history API 读取、裁剪和压缩。Host 只负责权限、分页、hard cap 和事实源。 + +## 3. ContextAccess 的作用 + +`ContextAccess`(PROTOCOL_V1 §5.8)是 host 交给 agent 的上下文读取入口描述,告诉 agent:当前事件位于哪条 conversation / thread、若需要更多历史从哪个 cursor 开始拉、host inline 了什么没 inline 什么、当前 run 有哪些 context API 权限。 + +## 4. Agent 如何获取更多上下文 + +所有 API 都走 `AgentRunAPIProxy`(PROTOCOL_V1 §8),由 host 用 `run_id` 校验。 + +外部 harness 不能直接访问 LangBot 资源。无论是 history、event、state、model、tool、knowledge base,还是 LangBot skills,都必须通过 SDK runtime 转发到 Host API,并由 Host 按 active `run_id`、runner identity、binding resource policy 和 caller plugin identity 校验。当前运行文件进入授权 sandbox/workspace 后,再由 runner 用 read/write/exec 类工具按需访问。harness 自己的 native tools 只属于 harness 执行环境,不能绕过 SDK runtime 访问 LangBot 内部资源。 + +### 4.1 History + +```python +await api.history_page(conversation_id=ctx.context.conversation_id, + before_cursor=ctx.context.latest_cursor, + limit=50, direction="backward", include_attachments=False) +``` + +返回 `HistoryPage`(schema 见 PROTOCOL_V1 §8)。 + +约束:`limit` 有 host hard cap;默认只能读当前 conversation / thread;跨会话读取需 binding policy / run authorization snapshot 授权;可返回 attachment ref,不默认返回大文件内容。 + +### 4.2 Search + +```python +await api.history_search(query="用户之前提到的数据库连接信息", + filters={"conversation_id": ..., "event_types": ["message.received"]}, + top_k=10) +``` + +Search 可先用数据库全文索引,后续接 embedding recall。它是 host 检索能力,不等于 agent 的长期记忆策略。 + +### 4.3 Event / State + +- Event API(`events.get` / `events.page`)用于读取非消息事件、工具事件、系统事件。Agent 不应把所有事件都当成 user/assistant message。 +- State API(`state.get` / `set`)是可选寄宿能力。自管 runtime 可以完全不用;依附 LangBot 的官方 runner 可以使用,例如 `external.session_id`、`summary.checkpoint`。 + +### 4.4 大文件与工具协作 + +大文件、多模态输入和工具产物不要内联进 prompt 或 tool result:message/content 里只放小文本和必要摘要;当前事件附件由 Host staged 到授权 sandbox/workspace,并在 input attachment 中给出轻量 metadata/path。工具之间传递大结果时传 sandbox path 或 attachment ref,不传完整 blob。Host 只保证当前 run 授权范围,默认不允许插件直接读任意本地路径;临时文件由 sandbox 生命周期和清理机制管理。 + +### 4.5 External harness context projection + +外部 harness 的总体边界以 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) §4.8 为准。本节只描述 context projection 的推荐形态。 + +Claude Code、Codex、Kimi Code 这类 runtime 通常已有自己的 session、工具 loop、MCP 加载、上下文压缩和工作目录。LangBot 不应把它们改造成"host prompt assembler",而应提供可审计的事件和资源投影。推荐 projection 形态: + +- `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 暴露。 +- `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 保存。 + +当前官方外部 harness 路径由 ACP / Claude Code / Codex 等 runner 插件承担(现状见 OFFICIAL_RUNNER_PLUGINS §7)。这类 projection 是"把 LangBot 事实源和授权资源句柄交给 harness",不是"把 LangBot 资源本体或内部权限交给 harness",也不是"由 LangBot 决定最终模型上下文"。 + +## 5. Runner 上下文边界 + +Host 只给当前事件、当前输入和 context handles。Runner 是否能拉取历史、事件、state 或 storage、是否能访问 sandbox/workspace 文件,以运行时 `ctx.context.available_apis` 和工具授权为准;runner 自己决定是否拉取历史、是否搜索、何时摘要、如何构造最终 prompt。 + +## 6. KV cache 友好的上下文管理 + +支持 Claude Code SDK、Codex、Pi Agent SDK 等 runtime 时,必须避免每轮由 LangBot 重组大块 prompt: + +- 稳定 session key:`workspace/bot/binding/runner/conversation/thread`。 +- 每轮只传 delta:当前 event、attachment refs/path、少量 runtime metadata。 +- 历史 append-only:不要每轮改写同一段 history 文本。 +- Summary checkpoint 稳定:只有压缩发生时产生新 checkpoint。 +- 大文件和工具结果写入 sandbox/workspace。 +- Tool/context API schema 稳定,数据通过 API 拉取而非塞入 prompt。 +- 对自管 runtime,优先让它复用自身 session/cache,而不是强制 LangBot 每轮重放 transcript。 +- 模型窗口元信息应作为 resource/runtime metadata 暴露给 runner,由 runner 决定预算和压缩策略。 + +稳定 session key 的用途是隔离外部 runtime 的 resume/cache/state,不是改变 PROTOCOL_V1 §13 定义的 Agent 复用和 dispatch 边界。只有当某个外部 harness 的同一 native session 不支持并发 turn 时,runner 或 future runtime control plane 才应按 external session key 做 turn-level 串行化。 + +对长期运行的 external harness / daemon,推荐运行形态是 reader 与 writer 分离:一个 session reader 独占读取 stdout/SSE/native event stream,并把 native event 转成 `AgentRunResult` 或 task progress;用户输入只作为 turn write 进入该 session。当前一次性 CLI subprocess runner 可以继续在单次 `run(ctx)` 内同步收集 stdout,但后续改成长连接时不应让多个 request 同时读取同一 native stream。 + +## 7. Host guardrail + +Agent 自管 context 不代表无限制访问。LangBot 仍必须控制:每次 run 的 active `run_id`、runner identity、当前 binding 的 resource policy、conversation / actor / subject scope、page size / sandbox file read size / API rate limit、跨会话读取权限、数据脱敏和敏感变量过滤、审计日志。Host 不负责"最佳上下文策略",但负责"不越权、不爆内存、不不可审计"。 + +外部 harness 的 native tools、shell、MCP 或 skill 机制不构成 LangBot 资源授权边界。只要访问的是 LangBot 持有的资源,就必须经 SDK runtime 转发并接受 Host 校验;完整边界见 HOST_SDK §4.8。 + +## 8. 官方 runner 与业务编排边界 + +官方 runner 插件可以把状态寄宿在 LangBot,但必须和第三方 runner 一样通过公开 Host API 消费。LangBot core 不内置官方 agent 的业务流程(prompt 组装、tool loop、RAG 编排、summary/compaction、"local-agent 专用"状态字段)。 + +官方 local-agent 应作为"依附 LangBot 基础设施的复杂 runner 参考实现":transcript/history 通过 `api.history_page()` / `api.history_search()` 读取,summary/checkpoint/外部 session id/用户偏好通过 `api.state_get()` / `api.state_set()` 或 storage 方法保存,图片/文件/工具大结果通过 sandbox/workspace read/write 工具访问,模型/工具/知识库通过 `api.invoke_llm()` / `api.call_tool()` / `api.retrieve_knowledge()` 调用。这样 LangBot 保持为通用 agent host,不变成内置 agent 框架。具体迁移要求见 [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md)。 diff --git a/docs/agent-runner-pluginization/AGENT_RUNNER_QA_GUIDE.md b/docs/agent-runner-pluginization/AGENT_RUNNER_QA_GUIDE.md new file mode 100644 index 000000000..bcd193243 --- /dev/null +++ b/docs/agent-runner-pluginization/AGENT_RUNNER_QA_GUIDE.md @@ -0,0 +1,227 @@ +# Agent Runner QA 指南 + +本文档是 agent-runner 插件化下一轮测试的唯一 QA 入口。它合并并取代旧的 Phase 1 验收矩阵与 2026-05-18 / 2026-05-29 两份本地 QA 报告。 + +目标不是保留完整历史流水账,而是指导测试 agent 用最小但高价值的路径判断当前分支是否仍然健康。 + +## 1. 测试边界 + +当前主线验证的是 AgentRunner Protocol v1: + +```text +event -> binding -> runner.run(ctx) -> result stream +``` + +本指南验证: + +- Host 能通过当前 Query entry adapter 进入 event-first `run(event, binding)` 主链路。 +- Runner 来自插件 registry,而不是旧内置 runner 分支。 +- `local-agent` 能消费 Host 模型、工具、知识库、history、state、sandbox 文件等基础设施。 +- 外部 harness runner(ACP / Claude Code / Codex 等直接 runner 插件)能消费 event-first context,并把外部 session 指针写回 host-owned state。 +- 错误、权限裁剪、无输出、timeout 等路径不会破坏主聊天流程。 + +本指南不验证: + +- Runtime Control Plane v2。 +- EventGateway / EventRouter 完整落地由外部 EBA 分支联调;本指南只验证本分支 Host 底座。 +- 发布级 path isolation、secret filtering、MCP allowlist、资源配额和 workspace cleanup。 +- 所有外部服务 runner 的真实凭据联调。 + +这些属于后续能力或发布门槛,分别见 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md) 与 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 + +## 2. 状态定义 + +测试报告只使用以下状态: + +| 状态 | 含义 | +| --- | --- | +| PASS | 按步骤执行,用户可见行为和日志证据都满足通过条件。 | +| FAIL | 环境可用,但行为不满足通过条件。 | +| BLOCKED | 凭据、CLI、外部服务、测试数据或本地配置缺失导致无法执行。必须写清阻塞原因。 | +| N/A | 当前 runner 或平台明确不支持该能力。必须引用 manifest、文档或配置说明。 | + +不能使用“看起来正常”“大概通过”“基本没问题”等模糊状态。 + +## 3. 执行顺序 + +推荐按以下顺序执行,前一层失败时不要继续扩大测试面: + +1. Host / SDK / runner 单测。 +2. WebUI 登录与 Pipeline Debug Chat 基础 smoke。 +3. `local-agent` 高价值场景。 +4. 外部 code-agent harness smoke。 +5. 权限和错误路径补充检查。 +6. 汇总 PASS / FAIL / BLOCKED,并给出下一步建议。 + +用户可见流程必须通过 WebUI 或真实消息平台验证。API / curl 只能作为诊断证据,不能单独让 UI case PASS。 + +## 4. 必跑基线 + +### 4.1 单测基线 + +在 LangBot 仓库运行: + +```bash +uv run --frozen pytest tests/unit_tests/agent +``` + +如果本次改动只触及默认配置或 API service,也至少补跑相关目标测试,例如: + +```bash +uv run pytest tests/unit_tests/api/test_pipeline_service_defaults.py +``` + +通过条件: + +- agent 单测全 PASS,或失败项已确认与本次 agent-runner 路径无关。 +- 若失败来自 `context_builder`、`orchestrator`、`session_registry`、`resource_builder`、`plugin/handler.py` 的 run action 权限路径,不应进入 UI smoke。 + +### 4.2 环境基线 + +用 `langbot-skills` 做环境检查: + +```bash +cd "$LANGBOT_SKILLS_REPO" +bin/lbs env doctor +bin/lbs case list +``` + +`LANGBOT_SKILLS_REPO` 指向当前工作区里的 `langbot-skills` 仓库。优先使用已有 case,而不是临时发明测试路径。 + +推荐首批 case: + +- `webui-login-state` +- `pipeline-debug-chat` +- `local-agent-basic-debug-chat` +- `local-agent-rag-debug-chat`(改动涉及 RAG / knowledge) +- `local-agent-plugin-tool-call-debug-chat`(改动涉及 tool / resource policy) + +## 5. WebUI 主链路 Smoke + +### 5.1 Runner registry + +步骤: + +1. 打开 WebUI Pipeline 配置页。 +2. 查看 AI runner 下拉列表。 +3. 选择 `plugin:langbot/local-agent/default`。 +4. 保存并刷新页面。 + +通过条件: + +- runner 选项来自插件 registry。 +- 保存后配置仍为 `ai.runner.id` + `ai.runner_config[id]`。 +- `runner_config` 表示 Agent/runner config,不表示插件实例状态。 +- 不读取或回写旧 `ai.runner.runner` 字段。 +- 不出现旧内置 runner stage 名(例如裸 `local-agent`)作为当前选中项或配置 surface。 +- 插件没有循环重启或 metadata 加载失败。 + +### 5.2 主聊天路径 + +步骤: + +1. 使用绑定 `plugin:langbot/local-agent/default` 的 Pipeline。 +2. 在 Debug Chat 发送确定性普通文本。 +3. 查看 WebUI 回复和后端日志。 + +通过条件: + +- 用户可见回复正常。 +- 后端日志显示走 `AgentRunOrchestrator` / `RUN_AGENT`。 +- 不走旧内置 local-agent 主执行分支。 +- conversation transcript 写入用户消息和助手消息。 + +## 6. `local-agent` 高价值测试 + +只保留最能覆盖架构边界的场景。 + +| ID | 场景 | 操作 | 通过条件 | +| --- | --- | --- | --- | +| LA-01 | 绑定 prompt | 配置 system prompt 后发送文本。 | runner 使用 `ctx.config.prompt`,不读取 `ctx.adapter.extra["prompt"]`;回复体现绑定 prompt。 | +| LA-02 | history API | 连续两轮对话,第二轮引用第一轮 marker。 | runner 通过 Host history API 或自管上下文读取历史,不依赖 inline history window。 | +| LA-03 | 流式 / 非流式 | 分别用支持流式和关闭流式的路径发送文本。 | 流式 UI 不重复、不空白;非流式只输出最终消息。 | +| LA-04 | 工具调用 | 绑定测试工具,发送会触发工具的 prompt。 | `ctx.resources.tools` 只包含授权工具;工具调用 started/completed;最终回复包含工具结果。 | +| LA-05 | RAG | 绑定测试知识库,发送命中文档的 prompt。 | `ctx.resources.knowledge_bases` 包含所选知识库;runner 通过授权 API 检索;回复使用检索内容。 | +| LA-06 | 多模态 | 发送图片输入。 | `ctx.input.contents` 保留图片;支持视觉模型时正常处理,不支持时受控失败。 | +| LA-07 | fallback / 错误 | 模拟 primary 模型失败或 runner 抛错。 | fallback 或 `run.failed` 行为受控;后续请求不受影响。 | +| LA-08 | 无输出保护 | 测试 runner 完成但不产出消息。 | 不产生空白成功回复;按受控失败或明确缺陷处理。 | +| LA-09 | steering / 运行中追加消息 | 使用支持 steering 的 runner,第一条消息触发长 run;run 未结束时在同 conversation 追加第二条消息。 | 第二条消息被 active run claim,不启动并发 run;runner 通过 `steering_pull` 看到追加输入;EventLog 有 `queued` -> `steering.injected`,若未消费则有 `steering.dropped` 终态;后续普通消息仍可处理。 | + +Rerank、remove-think、文件输入等场景只在本次改动直接涉及时补测,不作为每轮必跑项。 + +## 7. Code-agent Harness Smoke + +这些测试用于验证 ACP、Claude Code、Codex 这类自管 runtime 能走同一条 Host 协议路径。若目标 harness 没有 CLI/daemon、登录态、代理配置或远端 workspace,标记 BLOCKED,不要伪造 PASS。 + +Smoke 前应优先保留一层轻量单测或 fixture 测试:session 创建/复用、消息发送、结果解析、`run_id` 注入和 LangBot MCP gateway 必须有稳定测试覆盖。WebUI smoke 证明真实链路可用,但不能替代转换层和错误映射测试。 + +### 7.1 外部 harness runner + +步骤: + +1. 确认目标 harness(例如 ACP daemon、Claude Code 或 Codex)在对应机器上可执行且已登录。 +2. 绑定目标 runner,例如 `plugin:langbot/acp-agent-runner/default`、`plugin:langbot/claude-code-agent/default` 或 `plugin:langbot/codex-agent/default`。 +3. 配置 runner 必要字段,例如 remote target、workspace、provider、startup timeout、reuse session 等。 +4. 在 Debug Chat 执行一次确定性真实 smoke。 +5. 检查 LangBot MCP gateway、`run_id` 回填和 host-owned state。 + +通过条件: + +- WebUI 可见回复包含预期 sentinel。 +- 发送给 harness 的消息包含当前 LangBot `run_id` 和可访问资源摘要。 +- Harness 通过 gateway 调用 `langbot_history_page`、`langbot_retrieve_knowledge` 或 `langbot_call_tool` 时必须携带正确 `run_id`;错误 run id 被拒绝。 +- `external.session_id` 写入 host-owned state。 +- 外部 harness 错误、timeout、empty output 都转成受控 `run.failed`。 +- resume 到同一 external session 时,全局锁边界符合 PROTOCOL_V1 §13。 + +### 7.2 API 型外部 runner + +Dify、n8n、Coze、DashScope、Langflow、Tbox 等外部服务 runner 不作为每轮必跑项。只有在本次改动触及对应 runner 或凭据已经可用时执行 smoke。 + +通过条件: + +- runner 可选,配置可保存。 +- 请求成功,或外部服务错误被清晰返回。 +- 外部服务凭据缺失时标记 BLOCKED,并记录缺失项。 + +## 8. 权限与隔离补充 + +以下优先用单测 / targeted fixture 覆盖,不要求每次通过 UI 人工构造恶意 runner。 + +| 场景 | 推荐证据 | +| --- | --- | +| 未授权模型调用被拒绝 | `plugin/handler.py` run action 权限测试或目标单测。 | +| 未授权工具调用被拒绝 | `ctx.resources.tools` 与 host action 拒绝日志。 | +| 未授权知识库检索被拒绝 | `ctx.resources.knowledge_bases` 与 host action 拒绝日志。 | +| run_id 结束后复用被拒绝 | session registry 注销测试。 | +| 插件身份不匹配被拒绝 | `caller_plugin_identity` mismatch 测试。 | +| 绑定插件身份的 run_id 省略 caller identity 被拒绝 | `_validate_run_authorization(..., caller_plugin_identity=None)` 返回错误。 | +| 未注册 Runtime 连接伪造插件身份被剥离 | SDK runtime forwarding 测试:请求自带 `caller_plugin_identity` 时,未注册连接转发前必须 `pop`,已注册连接必须覆盖为真实插件身份。 | +| storage/state scope 越权被拒绝 | state/storage proxy 单测。 | +| steering claim 异常不杀 consumer loop | controller 单测:无效 runner / registry 异常只让当前消息回到普通 session 槽位路径,消息消费循环继续。 | +| steering queue 未消费有终态 | session registry / orchestrator 单测:队列有上限;run unregister 时未 pull 项写 `steering.dropped` 审计。 | + +如果这些单测失败,不能用 WebUI 正常回复替代。 + +## 9. 证据要求 + +每轮测试报告至少记录: + +- LangBot commit、SDK commit、相关 runner 插件 commit。 +- Pipeline UUID/name、runner id、关键 runner config 摘要。 +- WebUI 截图或 Playwright 操作记录。 +- 后端日志中对应 query id / run id 的关键行。 +- `langbot-skills` case/report 路径。 +- 外部 harness runner 的 context 文件、session id、working directory、CLI 错误摘要。 +- FAIL/BLOCKED 的复现步骤和归属仓库建议。 + +报告结论必须回答: + +- 是否建议继续进入下一阶段测试。 +- 是否存在主聊天路径阻塞。 +- 是否只是凭据 / 外部服务 / 本机 CLI 缺失导致 BLOCKED。 +- 是否需要进入 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) 的发布级验收。 + +## 10. 历史高价值记录 + +历史高价值记录与当前 runner 验收状态见 [STATUS.md](./STATUS.md)。本指南只保留可重复执行的测试步骤和证据要求。 diff --git a/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md b/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md new file mode 100644 index 000000000..35fbba0f0 --- /dev/null +++ b/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md @@ -0,0 +1,92 @@ +# Event Based Agent 接入设计 + +> 本文记录 EBA 如何接入当前 AgentRunner Protocol v1 / Host 底座。EventGateway、EventRouter、Event subscription/notification 由外部 EBA 分支实现并联调;本分支只保留 event-first 入口和 envelope/binding models。 +> +> 数据结构唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)(runner 可见)与 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)(Host 内部模型);本文只讲 EBA 语义,不重抄 schema。 +> 与当前 runner 外化分支、后续 Agent Platform / Runtime Control Plane 的边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。 + +本文描述 EBA 接入时,事件如何进入 LangBot、如何触发 AgentRunner,以及如何复用插件化 agent 基础设施。本分支不实现完整 EventBus / EventRouter / Platform API;这些能力正在外部 EBA 分支联调。这里的目标是把协议边界说清楚,避免当前消息入口继续绑死 Pipeline 和用户文本消息。 + +## 1. 设计目标 + +- 消息、撤回、入群、好友申请、定时任务、API 调用都能抽象为 host event。 +- EventRouter 可以根据 event type、bot、workspace、conversation、actor、subject 解析 `AgentBinding`。 +- AgentRunner 通过同一套 orchestrator 被调用。 +- 非消息事件不伪造成用户文本消息。 +- 平台动作执行通过显式 capability / permission / result type 预留,不混入普通文本回复。 + +## 2. 事件不是消息 + +`message.received` 只是事件的一种。协议不应假设:一定有用户文本、一定有 conversation history、一定要返回一条聊天消息、actor 一定等于 sender、subject 一定等于当前消息。 + +| event_type | actor | subject | input | +| --- | --- | --- | --- | +| `message.received` | 发消息的人 | 当前消息 | 文本、图片、文件等 | +| `message.recalled` | 撤回操作者,未知时为系统 | 被撤回消息 | 通常为空 | +| `group.member_joined` | 新成员或邀请人 | 群/成员关系 | 通常为空 | +| `friend.request_received` | 申请人 | 好友申请 | 验证消息或申请理由 | +| `schedule.triggered` | 系统 | 定时任务 | 任务 payload | +| `api.invoked` | API caller | API request | request payload | + +## 3. 稳定事件名 + +先保留的稳定事件名(作为插件协议的一部分保持稳定): + +- `message.received` +- `message.recalled` +- `group.member_joined` +- `friend.request_received` + +平台原始事件名只能进入 `ctx.event.source_event_type` / `raw_ref`,不能成为 `ctx.event.event_type` 的公共契约。 + +## 4. Event Envelope 与 Binding + +- 入口事件用 `AgentEventEnvelope`(HOST_SDK §4.1)承载;顶层字段使用 LangBot 稳定协议名,平台原始事件名和原始 payload 放 `metadata` / `raw_ref`。 +- 触发关系用 `AgentBinding`(HOST_SDK §4.2)表达。EBA 阶段 binding 通过 `event_types`、`scope`、`filters` 决定哪些事件触发当前 bot / channel 绑定的 Agent。 + +EBA dispatch 基数、Agent 复用和 fan-out 边界以 PROTOCOL_V1 §13 为准;本节只说明外部 EBA 分支的 EventRouter 如何产出当前 v1 主线需要的 binding。 + +Binding scope 示例:workspace 全局、bot 级、platform channel 级、conversation / group / thread 级、user / actor 级。旧 Pipeline 可迁移为 `message.received` 的临时 binding source,但目标持久配置应是 Agent,不是 Pipeline。 + +Event Source 可包括:`platform_adapter`(飞书、QQ、微信、Telegram 等)、`webui`、`http_api`、`scheduler`、`system`。EventRouter 不应写死平台 adapter 的类名。 + +## 5. EventRouter 调用链 + +```text +Platform Adapter / WebUI / API + -> Event Gateway normalize payload + -> EventLog append raw event + -> EventRouter resolve one effective AgentBinding + -> AgentRunOrchestrator.run(event, binding) + -> AgentRunContextBuilder.build(event, binding) + -> PluginRuntimeConnector.run_agent() + -> AgentRunResult stream + -> DeliveryController render / platform action +``` + +约束:必须复用现有 orchestrator,不能为 EBA 单独实现另一套 plugin runner 调用协议;非消息事件不能绕过 resource authorization;delivery 和 platform action 走统一权限模型;外部 harness runner 也通过同一套 envelope/binding/context/result 协议接入,不为 Claude Code / Codex / Kimi 单独发明队列协议。observer / fan-out / parallel arbitration 的额外语义仍按 PROTOCOL_V1 §13 处理。 + +## 6. 平台动作执行 + +EBA 后 `action.requested`(PROTOCOL_V1 §7.3,当前仅 telemetry 不执行)将用于请求 host 执行平台动作: + +```json +{ "type": "action.requested", + "data": { "action": "friend.request.accept", + "target": {"platform": "wechat", "request_id": "..."}, + "payload": {"reason": "policy matched"} } } +``` + +Host 必须校验:binding / platform action policy 是否授权该 action、actor / bot / workspace 是否允许、是否需要人工审批,以及当前 run session / caller identity 是否匹配。EBA 还可能预留 `delivery.requested`(请求投递到某 surface)。 + +Delivery 方面,event 不一定回复到当前聊天窗口:消息事件通常带 reply target;系统事件可能没有默认 reply target,需要 runner 返回 `action.requested` 或由 binding 的 delivery policy 决定投递位置(`DeliveryContext` 见 PROTOCOL_V1 §5.7)。 + +## 7. 与 Context 协议的关系 + +EBA 事件进入 AgentRunner 时仍遵循 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md):inline 当前事件、大 payload 用 raw/staged file ref、不默认 inline 完整 history、agent 按需通过 API 拉取、Host 保留 EventLog 和权限 guardrail。非消息事件可以被投影进 Transcript,但不能强制伪装为 user message;AgentRunner 根据 event type 自己决定是否纳入模型上下文。 + +## 8. EBA 分支联调内容 + +外部 EBA 分支负责联调 EventGateway 完整实现、EventRouter 与 BindingResolver 集成、`AgentBinding` 持久模型和 UI、`DeliveryContext` 完整实现、platform action permission model 和执行器、真实平台事件接入。 + +当前底座已完成:① 把当前 Pipeline 消息入口适配成 `message.received` event → ② 增加 `AgentBinding` 抽象,先由 current config 生成 → ③ context builder 改为从 event + binding 构造 → ④ 引入 EventLog / Transcript。外部 EBA 分支在此基础上联调:⑤ 非消息事件协议测试与真实事件来源 → ⑥ 真实 EventRouter、binding persistence / UI 和 platform action。 diff --git a/docs/agent-runner-pluginization/EXTENSION_SCOPE_MATRIX.md b/docs/agent-runner-pluginization/EXTENSION_SCOPE_MATRIX.md new file mode 100644 index 000000000..eb7904956 --- /dev/null +++ b/docs/agent-runner-pluginization/EXTENSION_SCOPE_MATRIX.md @@ -0,0 +1,51 @@ +# AgentRunner 外化扩展边界矩阵 + +本文用于回答一个问题:本分支只做 AgentRunner 外化时,哪些能力已经作为扩展底座完成,哪些由外部 EBA / Agent Platform / Runtime Control Plane 分支接入,后续分支接入时应该走哪个扩展点。 + +结论:本分支不实现完整 Agent Platform,也不实现完整 EBA。EBA 完整事件网关与事件路由由外部 EBA 分支联调。本分支必须把 runner 外化的 Host / SDK 边界做干净,让外部分支只需要接入持久模型、事件路由或 runtime task,而不需要重写 `AgentRunner Protocol v1`。 + +调度基数、Agent 复用、插件实例无状态、Pipeline adapter 和 fan-out 边界的单一事实源是 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13;本矩阵只说明后续能力应该接入哪个扩展点。 + +## 1. 分支边界 + +| 范围 | 本分支职责 | 不在本分支做 | +| --- | --- | --- | +| AgentRunner Protocol v1 | 定义 Host 调用 runner 的稳定合同:discovery、`AgentRunContext`、result stream、Host pull API、错误和权限边界。 | 不定义 Agent Platform 的产品数据库模型;不定义 runtime task queue。 | +| Host runner 外化底座 | 提供 `AgentEventEnvelope`、`AgentBinding` 运行投影、`run(event, binding)`、resource authorization、run-scoped session、EventLog / Transcript / State / sandbox 文件边界。 | 不实现 EventGateway、scheduler、integration provider、Agent 管控面 UI。 | +| 当前 Pipeline 入口 | 通过 `QueryEntryAdapter` 把旧 Query / Pipeline config 投影成 event + binding,作为迁移期入口。 | 不继续把 Pipeline 当作长期 agent 配置中心。 | +| 官方 runner 插件 | 作为协议消费者验证 local-agent / 外部 harness runner 能接入 Host 基础设施。 | 不让官方 runner 的内部实现反向决定 Host / SDK 协议形态。 | + +## 2. 扩展矩阵 + +| 能力 | 当前分支状态 | 后续归属 | 后续接入方式 | 禁止事项 | +| --- | --- | --- | --- | --- | +| Product `Agent` | 已有运行期 `AgentConfig` / `AgentBinding` 投影;还没有正式持久化产品对象。 | Agent Platform / binding persistence UI。 | 持久 Agent 保存 runner id、runner config、resource/state/delivery policy;运行前投影为 `AgentBinding`。 | 不把持久 Agent schema 加进 SDK 协议;插件实例边界见 PROTOCOL_V1 §13。 | +| Bot / channel 绑定 Agent | 已有单次运行前的 `AgentBinding` 解析投影;目标调度语义见 PROTOCOL_V1 §13。 | EBA / Agent Platform。 | EventRouter 根据 bot、channel、workspace、conversation、event type 解析有效 `AgentBinding`。 | 不在本矩阵重定义 fan-out / observer 语义;需要时按 §3 新增设计。 | +| Agent session / run | 当前只有 `run_id` 和 active `AgentRunSessionRegistry`,用于权限校验和生命周期。 | Agent Platform / Runtime Control Plane。 | 如需要可新增持久 `AgentRun` / `AgentSession` / task 表,但执行仍回到 `run(event, binding)` 或 runtime-managed 等价入口。 | 不把持久 session 字段塞进 `AgentRunContext` 顶层;不要求所有 runner 长期持有 LangBot session。 | +| EventLog / Transcript / Sandbox files | 已完成 Host-owned store、history pull API 和 sandbox 文件边界;runner 不直接写 DB。 | 本分支持续维护底座;Agent Platform 可复用。 | 外部 EBA、scheduler、integration、runtime task 都写同一套 EventLog / Transcript;当前 run 文件通过 sandbox/workspace staging 共享。 | 不让 runner / sandbox 直接访问 Host DB;不把大 payload 内联进 prompt。 | +| Host-owned state / storage | 已有 state snapshot、`state.updated` 处理和 State API;storage 作为授权能力保留。 | 本分支持续维护底座;Runtime / Platform 可复用。 | 外部 session id、working directory、checkpoint 等小 JSON 用 state;当前 run 大对象用 sandbox/workspace 文件。 | 不把跨轮次状态存在插件实例内;不绕过 run-scoped authorization。 | +| EventGateway / EventRouter | 本分支只提供 event-first envelope 和 `run(event, binding)` 入口。 | EBA 分支(联调中)。 | EventGateway 规范化平台/WebUI/API/scheduler 事件;EventRouter 解析一个 binding;调用现有 orchestrator。 | 不为 EBA 新增另一套 runner 调用协议;不把非消息事件伪装成 user message。 | +| Scheduler / Automation | 不实现。文档中只把 `scheduler` 作为 future event source。 | EBA / Agent Platform。 | 定时任务触发 `schedule.triggered` host event,复用 EventGateway -> EventRouter -> `run(event, binding)`。 | 不直接调用某个 runner 插件;不绕过 EventLog / authorization。 | +| Integration provider | 不实现。IM platform adapter 仍是当前平台接入系统。 | EBA / Agent Platform。 | OAuth/webhook/outbound provider 应先转成 canonical host event 或 platform action,再交给 AgentRunner。 | 不把 Linear/Slack/GitHub 等 provider 私有 payload 扩散到 runner 协议顶层。 | +| Platform action / delivery | `action.requested` 已预留但当前仅 telemetry,不执行。`DeliveryContext` 只作为上下文/策略投影。 | EBA / platform action executor。 | 后续 executor 校验 runner capability、binding policy、actor/bot/workspace 权限和审批后执行。 | 不让 runner 直接调用平台 adapter 私有 API;不把平台动作伪装成文本回复副作用。 | +| Runtime registry / worker / task queue | 不实现。当前官方外部 harness 通过 ACP、远端 daemon、本机 subprocess 或外部 HTTP API runner 调用目标运行环境,不在本分支维护通用 worker。 | Runtime Control Plane v2。 | 第一阶段先补 Host-owned `AgentRun` / `AgentRunEvent` / run control primitives;完整 runtime registry、heartbeat、task queue、daemon claim、progress/audit 是后续可选阶段。 | 不把 heartbeat/task/warm pool 放进 Protocol v1;不让管理插件拥有 runtime/task 事实源。 | +| Warm pool / reconcile / diagnose | 不实现。 | Runtime Control Plane v2 / deployment layer。 | 作为 task/runtime 的运维能力,围绕 Host-owned runtime/task/audit 表实现。 | 不把 runtime 运维语义写进普通 runner 协议;不把 pod/task 细节泄漏给普通 runner。 | +| Agent memory | 不实现通用长期记忆产品层;提供 history/state/storage 和 sandbox 文件基础能力。 | Agent Platform 或具体 runner/plugin。 | 平台 memory 可通过 Host storage/state 或独立产品表实现,runner 通过授权 API 拉取。 | 不在 Host core 内置通用 agentic memory 策略;不默认把 memory 全量 inline 到 context。 | +| External harness native session | ACP / Claude Code / Codex 等 runner 支持 external session id state handoff 和 LangBot resource projection。 | 官方 runner 后续增强;Runtime Control Plane v2 可接管执行。 | 外部 harness 调用继续走 `runner.run(ctx)`;如后续引入长连接/daemon 模式,按 external session key 串行 turn,reader 独占 native stream。 | 不把具体 provider native wire 变成 LangBot 协议;全局锁边界见 PROTOCOL_V1 §13。 | + +## 3. 后续分支接入规则 + +外部 EBA、Agent Platform 或 Runtime Control Plane 分支接入时,默认遵守以下规则: + +- 新入口只生产或解析 Host 内部模型:`AgentEventEnvelope`、持久 Agent 投影出的 `AgentBinding`、以及必要的 delivery/resource/state policy。 +- runner 调用仍走 `AgentRunOrchestrator.run(event, binding)`,除非 Runtime Control Plane 明确引入 runtime-managed 执行模式;即便如此,runner 可见合同仍应保持 Protocol v1。 +- Host-owned facts 继续写入 EventLog / Transcript / State,当前 run 文件继续走 sandbox/workspace;产品层可以新增更高阶视图,但不能替代这些事实源。 +- 新能力如果需要持久化,优先加 Host-owned 表或 service;不要把事实源藏在插件 storage 或 runner subprocess 内。 +- 新 result type 可以按 Protocol v1 的演进规则增加;不能用入口 adapter 私有字段绕过 schema。 +- 任何 fan-out、observer agent、parallel arbitration、platform action execution 都必须单独定义 delivery、state conflict、approval 和 audit 语义。 + +## 4. 与 Agent Platform 产品层的关系 + +这里的 Agent Platform 指面向 agent 产品层的实体拆分:`Agent` 描述可配置 agent,`Session` / `SessionMessage` 描述会话事实,`Automation` 描述自动触发,`IntegrationBinding` 描述外部集成连接,`Memory` 描述长期记忆,`WarmTask` 描述预热/后台任务。这些拆分对 LangBot 后续产品层有参考价值,但不能直接搬进本分支。 + +LangBot 当前分支的对应目标是更底层的:把 IM/WebUI/API 等入口统一投影到 Host event,把 Agent / binding 配置统一投影到 runner binding,把 runner 能力统一收束到 Protocol v1。完整 Agent Platform 可以在这个底座之上构建,而不应反过来污染本分支的 runner 外化边界。 diff --git a/docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md b/docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md new file mode 100644 index 000000000..b8ba6fb7b --- /dev/null +++ b/docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md @@ -0,0 +1,259 @@ +# LangBot Host 与 SDK 基础设施设计 + +本文档描述 LangBot 作为 agent host 的内部能力与分层架构,以及 Host 内部模型。 + +- SDK ↔ Host 的协议数据结构(`AgentRunContext`、`AgentRunnerManifest`、`AgentRunResult`、`AgentRunAPIProxy` 等)的**唯一定义在** [PROTOCOL_V1.md](./PROTOCOL_V1.md);本文只引用,不重抄。 +- 测试执行入口和 smoke 记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md);安全发布门槛见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 +- 本文定义的 Host 内部模型(`AgentEventEnvelope`、`AgentBinding`、`AgentRunnerDescriptor`)不属于 SDK 协议字段。 + +## 1. 目标 + +LangBot 要转为 agent host,而不是内置 runner 容器: + +- 接收 IM、WebUI、API 和外部 EBA 分支 EventRouter 产生的事件。 +- 根据事件、bot、workspace、scope 解析应该调用的 Agent / agent binding。 +- 发现、校验和调用插件提供的 AgentRunner。 +- 为每次 run 提供受限资源、状态、存储、上下文引用和生命周期控制。 +- 接收 AgentRunner 返回的事件流,投递到 IM、WebUI 或其他 output surface。 + +## 2. 非目标 + +- 不把 Pipeline 当作长期架构中心。 +- 不要求所有 AgentRunner 依赖 LangBot 的上下文管理。 +- 不要求官方 local-agent 的旧行为反向塑造 host 协议。 +- 不在 host 中实现通用 agentic prompt assembler。 +- 不强制 runner 使用 LangBot state / storage;只提供可选、受控的寄宿能力。 +- 不实现 EventGateway / EventRouter:它们由外部 EBA 分支提供并联调。本分支只定义 host-side envelope/binding models 和 `run(event, binding)` 入口。 + +## 3. 分层架构 + +```text +IM / WebUI / API / EventRouter (external EBA branch) + | + v +Event Gateway (external EBA branch) + | + v +AgentBindingResolver + | + v +AgentRunOrchestrator + |-- AgentRunnerRegistry + |-- AgentResourceBuilder + |-- AgentContextBuilder + |-- AgentRunSessionRegistry + |-- PersistentStateStore / EventLogStore / TranscriptStore + |-- Sandbox / workspace file tools + v +Plugin Runtime / AgentRunner + | + v +AgentRunResult stream + | + v +Delivery / Renderer / Platform API +``` + +目标产品模型、单绑定调度、Agent 复用、插件实例无状态和 fan-out 边界以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13 为准。本文只说明 Host 如何把当前入口投影为内部模型。当前 Pipeline 只应接入在 Query entry adapter 位置:它可以继续产生 `message.received` 并投影出临时 `AgentConfig` / `AgentBinding`,但不应再拥有 runner 选择、上下文裁剪和业务 agent 执行的核心语义。EventGateway / EventRouter 由外部 EBA 分支实现并联调。 + +## 4. LangBot 侧能力 + +### 4.1 Event Gateway / EventRouter(External EBA Branch Integration Point) + +> EventGateway / EventRouter 由外部 EBA 分支实现并联调,不在本分支范围。本分支只保留 event-first 入口和 envelope/binding models。 + +Event Gateway 将把入口统一成 host event(IM 平台消息、WebUI debug chat、API 触发、后续非消息事件),输出稳定的 `AgentEventEnvelope`(Host 内部模型): + +```python +class AgentEventEnvelope(BaseModel): + event_id: str + event_type: str + event_time: int | None + source: str + bot_id: str | None + workspace_id: str | None + conversation_id: str | None + thread_id: str | None + actor: ActorRef | None + subject: SubjectRef | None + input: AgentInput # 见 PROTOCOL_V1 §5.6 + delivery: DeliveryContext # 见 PROTOCOL_V1 §5.7 + raw_ref: RawEventRef | None + metadata: dict[str, Any] = {} +``` + +`AgentEventEnvelope` 是 Host 内部入口模型;投影给 runner 的是 `ctx.event`(PROTOCOL_V1 §5.4)。原始平台 payload 存为 raw event 或 staged file reference,不扩散到 runner 协议顶层。 + +**当前 adapter source**:`QueryEntryAdapter.query_to_event(query)` 从 Query 生成 `AgentEventEnvelope`。 + +### 4.2 AgentConfig 与 AgentBinding + +`AgentConfig` 是迁移期的 Host 内部 Agent 配置投影(不暴露给 SDK)。当前 Query entry adapter 从 Pipeline config 投影出它;未来持久 Agent 也应先投影成这个运行期配置,再由 BindingResolver 结合事件和 scope 解析为 `AgentBinding`。 + +```python +class AgentConfig(BaseModel): + agent_id: str | None = None + runner_id: str + runner_config: dict[str, Any] = {} + resource_policy: ResourcePolicy = ResourcePolicy() + state_policy: StatePolicy = StatePolicy() + delivery_policy: DeliveryPolicy = DeliveryPolicy() + event_types: list[str] = ["message.received"] + enabled: bool = True + metadata: dict[str, Any] = {} +``` + +`AgentBinding` 是"什么事件调用哪个 AgentRunner、带什么 Agent 配置"的 Host 内部运行投影(不暴露给 SDK)。它是 EventRouter / 当前 QueryEntryAdapter 在一次运行前解析出的有效绑定。 + +```python +class AgentBinding(BaseModel): + binding_id: str + enabled: bool + scope: BindingScope + event_types: list[str] + filters: list[EventFilter] = [] # EBA 阶段使用,见 EVENT_BASED_AGENT + runner_id: str + runner_config: dict[str, Any] + resource_policy: ResourcePolicy + state_policy: StatePolicy + delivery_policy: DeliveryPolicy +``` + +BindingResolver 的基数、fan-out 和冲突处理约束见 PROTOCOL_V1 §13;本节只定义 Host 内部投影形态。 + +**当前 adapter source**:`QueryEntryAdapter.config_to_agent_config(query, runner_id)` +先把 current config 投影为迁移期 `AgentConfig`,再由 +`AgentBindingResolver.resolve_one(event, [agent_config])` 解析出唯一 +`AgentBinding`。Pipeline 当前只是迁移期 Agent config source(AI runner config +→ runner_config、extension preference → resource_policy、output settings → +delivery_policy),但新设计不再把这些字段命名为 Pipeline 专属概念。 + +### 4.3 AgentRunnerRegistry + +Registry 收集 runner descriptor(来自插件 runtime、开发期本地插件): + +```python +class AgentRunnerDescriptor(BaseModel): + id: str + source: Literal["plugin"] + label: I18nObject + description: I18nObject | None = None + plugin_author: str + plugin_name: str + runner_name: str + capabilities: AgentRunnerCapabilities # 见 PROTOCOL_V1 §4.3 + permissions: AgentRunnerPermissions # 见 PROTOCOL_V1 §4.4 + config_schema: list[DynamicFormItemSchema] + plugin_version: str | None = None + raw_manifest: dict[str, Any] = {} +``` + +职责:调用 `plugin_connector.list_agent_runners()` 拉取 runner、校验 typed `AgentRunnerManifest`、输出 descriptor、缓存 discovery 结果并提供 `refresh()`。单个插件 manifest 失败只记 warning,不影响其它 runner。`plugin:author/name/runner` 是稳定 id 格式;插件实例边界见 PROTOCOL_V1 §13。 + +Host 内置 runner / adapter 不能作为 `AgentRunnerDescriptor.source` 绕过插件 +runtime、`run_id`、`ctx.resources` 和 `AgentRunAPIProxy` 权限链。若需要 +开发期调试 adapter,应放在 Host 内部测试入口,不进入可选 runner 列表。 + +刷新触发点:插件安装/卸载/升级/重启后;Pipeline metadata 请求时发现缓存为空;可选 TTL(优先保证正确性)。 + +### 4.4 AgentRunOrchestrator + +Orchestrator 是唯一运行入口: + +```text +run(event, binding) + -> resolve runner descriptor + -> build resources + -> build context + -> register run session + -> call plugin runtime + -> normalize result stream + -> update state + -> unregister run session +``` + +它负责:`run_id` 生成和生命周期、timeout/deadline/cancellation、插件异常隔离、result schema 校验和大小限制、`state.updated` 处理、delivery backpressure 和 telemetry。 + +典型 run 时序: + +```text +QueryEntryAdapter / EventRouter + -> AgentRunOrchestrator.run(event, binding) + -> AgentRunnerRegistry.resolve(runner_id) + -> AgentResourceBuilder.freeze_snapshot(binding, event) + -> AgentRunSessionRegistry.register(run_id, runner_id, snapshot) + -> AgentContextBuilder.build(event, binding, snapshot) + -> PluginRuntimeConnector.run_agent(ctx) + -> AgentRunAPIProxy action + -> validate active run session + caller identity + snapshot + -> Host API / Store + <- AgentRunResult stream + -> apply state.updated to PersistentStateStore + -> write message.completed to Transcript + -> keep current-run files and large tool outputs in sandbox/workspace + -> render delivery or raise RunnerExecutionError + -> AgentRunSessionRegistry.unregister(run_id) +``` + +`run_from_query()` 保留为 Query entry adapter 入口,但内部转换成 event + binding 后走统一 `run()`。约束:`ChatMessageHandler` 不解析 `plugin:*`、不实例化 wrapper、不知道 runner 组件细节;`PipelineService` 从 registry 读取 metadata,不直接访问插件 runtime;跨请求持久化状态必须走授权 storage / 外部服务。 + +### 4.5 Resource Authorization + +LangBot 在每次 run 前生成 `ctx.resources`(PROTOCOL_V1 §6),来自 manifest permissions 与 binding policy 的交集: + +1. `descriptor.permissions` 声明 runner 需要的 LangBot 资源访问上限。 +2. binding / resource policy 允许的资源范围。 +3. Agent/runner config 中选择的模型、知识库、文件等资源。 +4. 当前 event / actor / bot / workspace 的实际权限。 +5. `ctx.context.available_apis` 暴露的 pull API 能力。 + +这次裁剪结果必须冻结为 run-scoped authorization snapshot,并由 +`AgentRunSessionRegistry` 按 `run_id` 保存。`ctx.resources` 是投影给 runner +看的同一份授权结果;运行期每个 proxy action 只依据该 snapshot 校验 active +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。 + +资源裁剪应通用,不写死 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 资源。 + +### 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 只能作为过渡实现,不能作为正式生产语义。 + +### 4.7 EventLog / Transcript / Sandbox Files(事实源) + +- `EventLog`: durable append-only,保存原始事件、系统事件、工具调用、投递结果、错误。 +- `Transcript`: 从 EventLog 投影出的对话视图,用于 UI、审计和按需历史读取。 +- `Sandbox / workspace files`: 当前 run 的上传文件、平台附件、工具大结果和临时产物。Host 负责 staging 与授权边界,runner 通过 read/write/exec 类工具按需访问。 + +三类数据与 working context 的边界、读取约束见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。AgentRunner 可读取这些能力,但不被迫使用 LangBot 作为唯一记忆系统。 + +### 4.8 External harness resource projection + +Claude Code、Codex、Kimi Code 等外部 harness runner 可能不直接调用 LangBot 的 model/tool loop,而是把 LangBot 事件和授权资源句柄投影到自己的 harness 执行。Host 侧仍保持统一边界:Host 负责构造 event-first context、资源授权、state/storage、EventLog/Transcript、sandbox/workspace 文件边界和审计;Host 或 binding policy 决定哪些 MCP bridge、skill-backed tool、sandbox path、history/state 句柄可投影给 runner;runner plugin 把 scoped projection 转成目标 harness 可消费形式;所有 LangBot 资源访问必须经 SDK runtime / `AgentRunAPIProxy` / SDK-owned MCP bridge 转发并接受 Host 校验;外部 harness 负责自己的 native session、tool loop、压缩、权限模式和 resume,但不能用 native tools 绕过 Host 授权。 + +投影的具体形态(context 文件、resource handles、LangBot MCP gateway、state pointers)见 AGENT_CONTEXT_PROTOCOL §4.5;当前 code-agent harness runner 形态见 OFFICIAL_RUNNER_PLUGINS §7。发布级隔离要求见 SECURITY_HARDENING。 + +## 5. SDK 侧协议 + +SDK 组件入口如下;所有数据结构定义见 PROTOCOL_V1。 + +```python +class AgentRunner(BaseComponent): + __kind__ = "AgentRunner" + + @classmethod + def get_config_schema(cls) -> list[dict]: ... + + async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]: ... + # ctx: PROTOCOL_V1 §5.2 ; AgentRunResult: PROTOCOL_V1 §7 +``` + +- Manifest / capabilities / effective access:PROTOCOL_V1 §4。Capabilities 来自组件 manifest 的 `spec.capabilities`,不是 SDK 基类 classmethod。 +- `AgentRunContext`:PROTOCOL_V1 §5.2。`messages` / `bootstrap` 不是协议字段。 +- `AgentRunResult`:PROTOCOL_V1 §7。 +- `AgentRunAPIProxy`:PROTOCOL_V1 §8,是 runner 访问 host 能力的唯一入口,所有请求带 `run_id`。 diff --git a/docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md b/docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md new file mode 100644 index 000000000..bb232f221 --- /dev/null +++ b/docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md @@ -0,0 +1,138 @@ +# 官方 AgentRunner 插件迁移计划 + +本文档描述内置 `RequestRunner` 迁出 LangBot 后,官方 runner 插件如何组织、迁移和验收。它是 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) 和 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) 的下游落地计划,不是 LangBot 宿主协议的设计前提。QA 入口和 smoke 记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。 + +官方 `local-agent` 可以外移,也可以重写。设计重点不是保留旧内置 runner 的内部结构,而是验证一个依附 LangBot host 基础设施的官方 agent 能否完整工作。同时,LangBot host 协议必须服务 Claude Code SDK、Codex、Pi Agent SDK、外部 Agent 平台等自管 context/runtime 的 runner,不能被官方插件的实现细节绑死。 + +## 1. 仓库组织 + +官方 runner 插件与 LangBot 主仓库、SDK 仓库以不同节奏迭代:LangBot 主仓库只维护宿主协议和调度,SDK 仓库维护 AgentRunner 组件和 runtime 协议,官方 runner 插件承载业务 runner 的具体实现和第三方平台适配。 + +当前推荐"官方插件可独立发布,必要时共享 SDK helper"。开发期采用本地多目录布局: + +```text +langbot-app/ + langbot-local-agent/ # plugin:langbot/local-agent/default + manifest.yaml + components/agent_runner/default.{yaml,py} + langbot-agent-runner/ # 外部服务 runner 仓库 + acp-agent-runner/ claude-code-agent/ codex-agent/ dify-agent/ n8n-agent/ ... +``` + +后续可聚合进 monorepo,也可继续独立发布——这个选择不影响协议设计。重复逻辑优先沉淀到 SDK 或明确的共享 helper 包,不要把宿主私有结构泄漏给插件。旧 `src/langbot/pkg/provider/runners/*` 只作为历史行为对齐基准;当前未发布分支不提供旧内置 runner 的运行时 fallback。 + +## 2. 插件命名和 runner id + +| 旧 runner | 官方插件 | runner id | +| --- | --- | --- | +| `local-agent` | `langbot/local-agent` | `plugin:langbot/local-agent/default` | +| `dify-service-api` | `langbot/dify-agent` | `plugin:langbot/dify-agent/default` | +| `n8n-service-api` | `langbot/n8n-agent` | `plugin:langbot/n8n-agent/default` | +| `coze-api` | `langbot/coze-agent` | `plugin:langbot/coze-agent/default` | +| - | `langbot/acp-agent-runner` | `plugin:langbot/acp-agent-runner/default` | +| - | `langbot/claude-code-agent` | `plugin:langbot/claude-code-agent/default` | +| - | `langbot/codex-agent` | `plugin:langbot/codex-agent/default` | +| `dashscope-app-api` | `langbot/dashscope-agent` | `plugin:langbot/dashscope-agent/default` | +| `deerflow-api` | `langbot/deerflow-agent` | `plugin:langbot/deerflow-agent/default` | +| `langflow-api` | `langbot/langflow-agent` | `plugin:langbot/langflow-agent/default` | +| `tbox-app-api` | `langbot/tbox-agent` | `plugin:langbot/tbox-agent/default` | +| `weknora-api` | `langbot/weknora-agent` | `plugin:langbot/weknora-agent/default` | + +每个插件可后续提供多个 runner,但迁移目标的默认 runner 统一叫 `default`。 + +## 3. 迁移批次 + +- **Batch 1(打通协议)**:`local-agent`(能力最完整基准)、`acp-agent-runner` / `claude-code-agent` / `codex-agent`(外部 code-agent harness 路径)、`dify-agent`(传统 service API runner)。 +- **Batch 2(外部 workflow)**:`n8n-agent`、`langflow-agent`(webhook/workflow 输入输出、timeout、外部 conversation id)。 +- **Batch 3(平台 Agent API)**:`coze-agent`、`dashscope-agent`、`tbox-agent`、`deerflow-agent`、`weknora-agent`(平台特有响应格式、引用资料、文件/图片输入、外部 thread/session 状态)。 + +## 4. 每个官方插件的组件要求 + +每个插件至少包含一个 `AgentRunner` 组件,manifest 示例: + +```yaml +apiVersion: langbot/v1 +kind: AgentRunner +metadata: + name: default + label: { en_US: Dify Agent, zh_Hans: Dify Agent } + description: + en_US: Run a Dify application as a LangBot AgentRunner. + zh_Hans: 将 Dify 应用作为 LangBot AgentRunner 运行。 +spec: + config: [] + capabilities: # 字段语义见 PROTOCOL_V1 §4.3 + streaming: true +execution: + python: { path: ./main.py, attr: DefaultAgentRunner } +``` + +## 5. local-agent 插件方向 + +`local-agent` 是官方插件中能力最完整的消费者,但不是宿主协议的设计中心。它需要证明:一个主要依附 LangBot host 能力的 agent runner 可以通过公开协议完成模型、工具、知识库、状态、history、sandbox 文件访问、上下文压缩和消息投递。 + +迁移或重写需覆盖旧内置 runner 的用户可见能力:model primary/fallback 选择、prompt、knowledge-bases、rerank-model、rerank-top-k、function calling、streaming、multimodal input、conversation history、monitoring metadata。 + +责任边界与 Host API 消费方式见 AGENT_CONTEXT_PROTOCOL §8。关键约束: + +- 从 `ctx.config` 读取静态绑定 `prompt`,**不**读取 `ctx.adapter.extra["prompt"]`;不消费 Query entry adapter 生成的历史窗口。 +- 通过 `AgentRunAPIProxy.history` 拉取 transcript,而不是依赖 host 每轮强塞历史窗口。 +- `ctx.input.contents` 保留图片/文件等多模态内容;RAG 只替换/插入文本部分,不丢图片/文件。 +- 不能绕过 `ctx.resources` 调用未授权模型、工具或知识库。 +- manifest 声明功能能力、LangBot 资源 permissions 和配置表单;实际授权来自 manifest permissions 与 binding resource policy、runner config、`ctx.context.available_apis` 和 Host run session snapshot 的交集。 + +### 5.1 Native Execution / Skills 后续接入 + +本阶段不把 sandbox/skills 做成 AgentRunner 协议字段。后续 sandbox/skills 分支合并后,命令执行、文件操作、skill、MCP managed process 应先由 Host / sandbox 封装成 scoped tools,再通过 `ctx.resources.tools` 和 SDK runtime 转发暴露给 runner。这让 local-agent 只消费授权后的 Host 基础设施,而不是直接持有宿主机执行能力。 + +## 6. 外部 runner 插件要求 + +外部平台 runner 迁移遵循:旧配置字段尽量保持同名便于 migration 复制;输出统一转换为 `AgentRunResult`;外部 API timeout 从 runner config 读取;平台 conversation id 存 plugin storage 或 context runtime state,不依赖 LangBot 内置 conversation uuid 私有结构;流式按平台能力声明,没有流式就只发 `message.completed`。 + +### 6.1 Code-agent harness runner + +Claude Code、Codex、Kimi Code 这类 runner 不一定通过 LangBot 的模型/工具 loop 执行,可以依赖自己的 harness,但仍必须遵守统一 Host 边界。总体边界见 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) §4.8;context projection 形态见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) §4.5;发布级要求见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 + +本文件只补充官方 runner 的实现要求:输入来自 `ctx.event` / `ctx.input`,不依赖 Pipeline 私有 `Query`;外部 session id / workspace / checkpoint 写入 Host state 或 plugin storage;插件实例边界见 PROTOCOL_V1 §13;CLI / subprocess runner 必须处理 timeout、取消、空输出、非零退出和 stderr 映射。 + +实现结构应把 provider-native output 解析与 LangBot result stream 组装分开:Claude stream-json、Codex JSONL、Kimi / OpenCode 事件等只在 runner adapter 内解析,输出统一归一为 `AgentRunResult`(`message.completed` / `message.delta`、`state.updated`、`run.completed` / `run.failed`)。文件和工具大结果留在当前 run 的 sandbox/workspace,通过消息 metadata、attachment ref 或 path 指向。未知 native event 不应导致 run 崩溃;应记录诊断 metadata 或 warning。新增 harness 时优先补 native fixture -> `AgentRunResult` 的转换测试,再接 WebUI smoke。 + +并发约束应按外部 session 粒度表达,而不是按 Agent / runner id / 插件实例表达;Agent 复用和全局锁边界见 PROTOCOL_V1 §13。若 runner 使用 `external.session_id` / `thread_id` resume 到同一 native session,且该 harness 不支持并发 turn,runner 应按稳定 external session key 串行写入;一次性 subprocess runner 可以只在单次 `run(ctx)` 内处理,长连接/daemon runner 则应采用 reader 独占 native stream、turn writer 串行写入的结构。 + +### 6.2 LangBot MCP gateway + +外部 harness 不能直接持有进程内的 `plugin_runtime_handler`,也不能用自己的 native tools 直接访问 LangBot 资源。外部 harness runner 应通过稳定 HTTP MCP gateway 或 SDK-owned bridge 把 harness 的工具请求转回 SDK runtime / Host API: + +- Gateway 由 runner 插件启动,暴露稳定的 `langbot_history_page`、`langbot_retrieve_knowledge`、`langbot_call_tool` 等最小工具面。 +- Harness 每次调用必须携带当前 LangBot `run_id`;Host 仍按 run session、caller identity 和授权快照校验。 +- Gateway 只转发 LangBot 资产访问,不承担外部 harness 的文件、进程或 native tool 权限边界。 + +第一批工具保持很小:history page、knowledge retrieve、authorized tool call。新增工具必须先有 Host action 权限与 run-scoped authorization,再由 gateway 投影。 + +## 7. Code-agent harness runner 当前形态 + +外部 code-agent harness 由直接 runner 插件承接,例如 `acp-agent-runner`、`claude-code-agent`、`codex-agent`,每个 runner 负责把目标 harness 的 native session、workspace、MCP bridge 和输出事件转换为统一 `AgentRunResult`。本地 smoke 验收入口与记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。 + +当前形态: + +- Runner ID 示例:`plugin:langbot/acp-agent-runner/default`、`plugin:langbot/claude-code-agent/default`、`plugin:langbot/codex-agent/default`。 +- Runner 可通过 ACP、远端 daemon、本机 subprocess 或外部 HTTP API 调用 harness;harness 的安装、登录态、workspace 和 provider-native 权限由该运行环境负责。 +- Runner 会把当前 LangBot `run_id`、可访问资源摘要和 gateway 使用规则注入本次消息;harness 通过 gateway 回填 `run_id` 后访问 LangBot 资产。 +- 外部 session id / workspace / checkpoint 写回 Host state 或 plugin storage,后续轮次可复用目标 harness 会话。 + +### 7.1 当前限制 + +这不是发布级安全边界实现;LangBot 只约束 LangBot 持有资产的访问,外部 harness 的文件、进程、workspace、provider-native MCP 和模型凭据由对应 runner 的运行环境承担。当前 `run_id` 可由系统提示词、ACP metadata 或 runner 自有 session metadata 传递给 harness 并由 gateway 校验。runtime 管控面方向见 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md)。 + +## 8. 发布和安装策略 + +最终 LangBot 安装/升级时需保证官方 runner 插件可用,可选方案:首次启动检测缺失并提示安装;打包发行版预装;migration 前检查插件存在性。当前分支未发布,因此不把历史配置兼容或旧内置 runner fallback 写入运行时协议面。建议顺序:开发阶段用本地路径插件 → 发布前支持 marketplace 安装 → 若发布升级需要迁移历史配置,再在 release gate 中实现一次性 migration 并要求官方插件已可用。 + +## 9. 验收标准 + +- 每个目标 runner 都有对应官方 AgentRunner 插件和稳定 runner id;当前配置只使用 `ai.runner.id` + `ai.runner_config[id]`。 +- LangBot 主聊天路径不再通过 `RequestRunner` 执行业务 runner。 +- 官方插件测试覆盖非流式、流式、错误、timeout、配置缺失。 +- `local-agent` 能完成模型 fallback、tool calling、知识库检索、多模态输入、静态绑定 prompt 消费、history API 拉取、rerank。 +- 外部 code-agent harness runner 能消费 event-first context、投影 scoped resources、保存 external session state,并通过 WebUI Debug Chat smoke。 +- `local-agent` 覆盖旧内置 runner 的用户可见核心能力;代码结构和运行路径不需要相同。 diff --git a/docs/agent-runner-pluginization/PROTOCOL_V1.md b/docs/agent-runner-pluginization/PROTOCOL_V1.md new file mode 100644 index 000000000..b7e410c68 --- /dev/null +++ b/docs/agent-runner-pluginization/PROTOCOL_V1.md @@ -0,0 +1,725 @@ +# LangBot AgentRunner Protocol v1 + +本文档是 LangBot Host 与插件 SDK / Runtime / AgentRunner 之间协议合同的**唯一规范来源(single source of truth)**。 + +- 本文件描述当前 Protocol v1 稳定合同,不混入验收流水。当前实现状态见 [STATUS.md](./STATUS.md),测试执行入口见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md),安全发布门槛见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 +- 本文件之外的任何文档**不得重新定义这里的数据结构**,只能引用,例如"见 PROTOCOL_V1 §4.2"。 +- Host 内部模型(`AgentEventEnvelope`、`AgentBinding`、Descriptor、各 Store)不属于 SDK 协议,定义在 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。 + +## 1. 协议目标 + +Protocol v1 只解决四件事: + +- LangBot 如何发现插件提供的 AgentRunner。 +- LangBot 如何把一次事件调用封装成 `AgentRunContext`。 +- AgentRunner 如何以事件流形式返回运行结果。 +- AgentRunner 如何通过受限 API 访问 LangBot host 能力。 + +Protocol v1 **不定义**: + +- LangBot 内部如何持久化 `AgentBinding`(见 HOST_SDK)。 +- AgentRunner 内部如何组装 prompt、压缩历史、管理 memory(见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md))。 +- 官方 runner 的具体实现(见 [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md))。 +- Pipeline 的长期配置模型。 +- 发布级安全 hardening 的完整实现(见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md))。 + +## 2. 参与方 + +| 名称 | 职责 | +| --- | --- | +| LangBot Host | 事件入口、绑定解析、权限、资源、存储、生命周期、结果投递。 | +| Plugin Runtime | 加载插件,响应 Host 的 runner discovery 和 run 调用。 | +| AgentRunner | 插件提供的 agent 执行组件。 | +| AgentRunAPIProxy | AgentRunner 访问 Host 能力的受限 API。 | +| AgentBinding | Host 内部的事件到 runner 绑定配置,不直接暴露给 SDK(见 HOST_SDK §4.2)。 | + +产品层的 `Agent` 替代旧 Pipeline 承载 agent 配置:bot / IM channel +绑定一个 Agent,一个 Agent 可以被多个 bot / channel 复用。Host 内部的 +`AgentBinding` 是一次事件运行前解析出的有效绑定,只影响 Host 构造出的 +`ctx.config`、`ctx.resources`、`ctx.context` 和 `ctx.delivery`。SDK 不需要知道 +Agent / binding 的持久化形态。 + +外部 harness runner(Claude Code、Codex、Kimi Code 等)也是 `AgentRunner`:它们消费 event-first `AgentRunContext`、返回 `AgentRunResult`,并通过 Host 授权的 state/storage API 保存跨轮次指针;当前运行文件和工具大结果进入 sandbox/workspace。它们内部可以继续使用自己的 session、tool loop、MCP、上下文压缩和权限模型。 + +## 3. 协议演进 + +当前 AgentRunner 合同不暴露显式 `protocol_version` 字段。协议演进先按字段级兼容规则处理: + +- 新增可选字段保持向后兼容。 +- 删除字段或改变既有字段语义,需要在 SDK 发布前完成;发布后应走新的显式兼容方案。 +- 结果流演进:Host **必须忽略未知 result type 并记录 warning**(除非该 type 明确要求强校验)。SDK envelope 接收入站未知 `type` 字符串,runner 侧可按原字符串转发或忽略;新增 result type 不提升大版本。 +- SDK 入站 context 类实体偏宽松,用于兼容 Host 附加的非核心字段;manifest、result payload、page/result 返回与错误模型偏严格,未知字段默认禁止。安全边界仍在 Host,SDK 校验只提升开发体验。 + +## 4. Discovery 协议 + +### 4.1 LIST_AGENT_RUNNERS + +Host 调用 Plugin Runtime 获取当前插件暴露的 runner 列表,请求无额外 payload。返回: + +```python +class ListAgentRunnersResponse(BaseModel): + runners: list[AgentRunnerDiscovery] + +class AgentRunnerDiscovery(BaseModel): + plugin_author: str + plugin_name: str + runner_name: str + manifest: AgentRunnerManifest +``` + +`manifest` 是 SDK typed `AgentRunnerManifest`,由 Runtime 从插件组件 manifest 解析并校验后返回。`plugin_author` / `plugin_name` / `runner_name` 保留为 transport 寻址字段;Host 以它们生成稳定 runner id,并把 `manifest.id` 校验为 `plugin:author/name/runner`。单个 runner manifest 解析失败时 Runtime/Host 记录 warning 并跳过该 runner,不影响同一插件或其它插件的 runner discovery。 + +### 4.2 AgentRunnerManifest + +这里的 manifest 指 Runtime 返回给 Host 的 typed runner manifest: + +```python +class AgentRunnerManifest(BaseModel): + id: str + name: str + label: I18nObject + description: I18nObject | None = None + capabilities: AgentRunnerCapabilities = AgentRunnerCapabilities() + permissions: AgentRunnerPermissions = AgentRunnerPermissions() + config_schema: list[DynamicFormItemSchema] = [] + metadata: dict[str, Any] = {} +``` + +- runner id 由 Host 生成,格式 `plugin:author/name/runner`。 +- `name` 是插件内 runner 名称,例如 `default`。 +- `config_schema` 只描述绑定配置表单,不代表插件实例状态。 +- `capabilities` 是 Host 用于 UI 和资源投影的 typed bool model;它不是权限授予。 +- `permissions` 是 runner 申请的 LangBot 资源访问上限;实际授权仍必须与 binding policy 求交。 +- `metadata` 只放展示、诊断、非稳定扩展信息。 + +### 4.3 Capabilities + +```python +class AgentRunnerCapabilities(BaseModel): + streaming: bool = False + tool_calling: bool = False + knowledge_retrieval: bool = False + multimodal_input: bool = False + skill_authoring: bool = False + interrupt: bool = False + steering: bool = False + + model_config = ConfigDict(extra="forbid") +``` + +- `streaming`: runner 可以返回 `message.delta`。 +- `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`。 +- `interrupt`: runner 支持取消或中断。 +- `steering`: runner 支持在 turn 边界通过 Host pull API 消费同 conversation 在途追加消息。 + +Capabilities 字段全部是 `bool`,未知 key 禁止进入 typed manifest。早期草案里的上下文/会话类 capability 已删除;对应语义由 event-first context 和 runner-owned context 原则表达。 + +### 4.4 Permissions 与 Effective Access + +```python +class AgentRunnerPermissions(BaseModel): + models: list[Literal["invoke", "stream", "rerank"]] = [] + tools: list[Literal["detail", "call"]] = [] + knowledge_bases: list[Literal["list", "retrieve"]] = [] + history: list[Literal["page", "search"]] = [] + events: list[Literal["get", "page"]] = [] + storage: list[Literal["plugin", "workspace"]] = [] + files: list[Literal["config", "knowledge"]] = [] + + model_config = ConfigDict(extra="forbid") +``` + +平台动作执行不属于当前 permissions。Platform action executor / EBA action 分支落地前,runner 只能返回 `action.requested` telemetry,Host 不执行平台动作。 + +Runner 实际可用 LangBot 资源来自 Host 在 run 前冻结的授权快照: + +```text +effective_access = manifest.permissions ∩ binding.resource_policy ∩ current scope/config +``` + +具体落地: + +1. `AgentResourceBuilder` 先用 manifest permissions 与 binding resource policy / runner config 求交,生成 `ctx.resources`。 +2. `AgentContextBuilder` 用 manifest permissions 与 binding state/storage policy 求交,生成 `ctx.context.available_apis`。 +3. `AgentRunSessionRegistry` 冻结 run-scoped resources 与 available APIs。 +4. Runtime handler / `AgentRunAPIProxy` 按 active `run_id`、runner identity、caller plugin identity、resource id、scope、payload size、rate limit 和 deadline 校验每次调用。 + +反承诺:manifest permissions **只约束 LangBot 持有的资源访问**。它不承诺限制外部 harness 的 native shell、文件系统、CLI、MCP、网络或本机权限;这些能力由 operator/runtime/sandbox 另行约束,见 HOST_SDK §4.8 与 SECURITY_HARDENING。 + +默认原则: + +- Host 不得默认 inline 全量历史。 +- Host 只 inline 当前 event / input 和 context handles。 +- Runner 拥有 working context assembly。 +- Runner 可在授权后通过 Host history / event / state API 拉取更多上下文,并通过授权 sandbox/workspace 工具访问当前运行文件。 +- 历史窗口策略不属于 Protocol v1 字段,也不属于 Host 通用语义。 + +context 边界的设计理由见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。 + +## 5. Run 协议 + +### 5.1 RUN_AGENT + +Host 调用 Runtime: + +```python +class AgentRunRequest(BaseModel): + runner_id: str + runner_name: str + context: AgentRunContext +``` + +Runtime 返回 `AgentRunResult` 异步流。底层 transport 可继续用 `plugin_author` / `plugin_name` / `runner_name` 定位组件,但协议语义以 `runner_id` 和 `context` 为准。 + +### 5.2 AgentRunContext + +这是 SDK 看到的**唯一权威 context 定义**。 + +```python +class AgentRunContext(BaseModel): + run_id: str + trigger: AgentTrigger + event: AgentEventContext + conversation: ConversationContext | None = None + actor: ActorContext | None = None + subject: SubjectContext | None = None + input: AgentInput + delivery: DeliveryContext + resources: AgentResources + context: ContextAccess + state: AgentRunState + runtime: AgentRuntimeContext + config: dict[str, Any] = {} + adapter: AdapterContext | None = None + metadata: dict[str, Any] = {} +``` + +核心约束: + +- `event` 是必选字段,Protocol v1 是 event-first。 +- `input` 表示当前事件的主输入,不等于历史消息。 +- `bootstrap` / `messages` **不是协议字段**;Host 不内联历史窗口。 +- `adapter` 只放入口 adapter 的非核心元数据,runner 不应依赖它做长期能力。 +- `config` 是 Agent/runner config,不是插件实例状态。 + +### 5.3 AgentTrigger + +```python +class AgentTrigger(BaseModel): + type: str + source: Literal["platform", "webui", "api", "scheduler", "system", "host_adapter"] + timestamp: int | None = None +``` + +`trigger.type` 应与 `event.event_type` 一致或更粗粒度。例如入口适配器触发消息时: + +```json +{ "type": "message.received", "source": "host_adapter" } +``` + +### 5.4 AgentEventContext + +```python +class AgentEventContext(BaseModel): + event_id: str + event_type: str + event_time: int | None = None + source: str + source_event_type: str | None = None + raw_ref: RawEventRef | None = None + data: dict[str, Any] = {} +``` + +- `event_type` 使用 LangBot 稳定协议名,例如 `message.received`。稳定事件名清单见 [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md)。 +- 平台原始事件名放入 `source_event_type`。 +- 大型原始 payload 必须放入 `raw_ref` 或 staged file,不应直接塞入 `data`。 + +### 5.5 Conversation / Actor / Subject + +```python +class ConversationContext(BaseModel): + conversation_id: str | None = None + thread_id: str | None = None + launcher_type: str | None = None + launcher_id: str | None = None + sender_id: str | None = None + bot_id: str | None = None + workspace_id: str | None = None + session_id: str | None = None + +class ActorContext(BaseModel): + actor_type: str + actor_id: str | None = None + actor_name: str | None = None + metadata: dict[str, Any] = {} + +class SubjectContext(BaseModel): + subject_type: str + subject_id: str | None = None + data: dict[str, Any] = {} +``` + +示例: + +- 消息事件:actor 是发消息的人,subject 是当前消息。 +- 入群事件:actor 是新成员或邀请人,subject 是群/成员关系。 +- 定时事件:actor 可以是 system,subject 是 schedule。 + +### 5.6 AgentInput + +```python +class AgentInput(BaseModel): + text: str | None = None + contents: list[ContentElement] = [] + attachments: list[InputAttachment] = [] +``` + +- 文本、多模态、附件都属于当前 event input。 +- 大文件、图片、音频、工具大结果应进入授权 sandbox/workspace,input attachment 只携带轻量 metadata/path/url/content。 +- 平台原始消息链不属于 SDK `AgentInput`;需要诊断时放在 Host 内部 envelope 或 `ctx.adapter.extra` 的一次性兼容字段中,不作为长期 runner 合同。 + +### 5.7 DeliveryContext + +```python +class DeliveryContext(BaseModel): + surface: str + reply_target: dict[str, Any] | None = None + supports_streaming: bool = False + supports_edit: bool = False + supports_reaction: bool = False + max_message_size: int | None = None + platform_capabilities: dict[str, Any] = {} +``` + +Runner 可参考 delivery 能力决定返回 `message.delta`、`message.completed` 或 `action.requested`。 + +### 5.8 ContextAccess + +```python +class ContextAccess(BaseModel): + conversation_id: str | None = None + thread_id: str | None = None + latest_cursor: str | None = None + event_seq: int | None = None + transcript_seq: int | None = None + has_history_before: bool = False + inline_policy: InlineContextPolicy + available_apis: ContextAPICapabilities + +class InlineContextPolicy(BaseModel): + mode: Literal["none", "current_event", "recent_tail", "summary_tail"] + delivered_count: int = 0 + source_total_count: int | None = None + messages_complete: bool = False + reason: str | None = None + +class ContextAPICapabilities(BaseModel): + prompt_get: bool = False + history_page: bool = False + history_search: bool = False + event_get: bool = False + event_page: bool = False + state: bool = False + storage: bool = False + steering_pull: bool = False +``` + +`ContextAccess` 告诉 runner:Host inline 了什么、没 inline 什么、需要更多上下文时走哪些 API。它是 runner 按需读取上下文的入口说明,不是 Host 的业务上下文编排策略。 + +### 5.9 AgentRuntimeContext + +```python +class AgentRuntimeContext(BaseModel): + langbot_version: str | None = None + trace_id: str | None = None + deadline_at: float | None = None + metadata: dict[str, Any] = {} +``` + +### 5.10 AgentRunState + +```python +class AgentRunState(BaseModel): + conversation: dict[str, Any] = {} + actor: dict[str, Any] = {} + subject: dict[str, Any] = {} + runner: dict[str, Any] = {} +``` + +State 是可选 host-owned snapshot。Runner 也可以完全自管状态。 + +## 6. Resources + +```python +class SkillResource(BaseModel): + skill_name: str + display_name: str | None = None + description: str | None = None + +class AgentResources(BaseModel): + models: list[ModelResource] = [] + tools: list[ToolResource] = [] + knowledge_bases: list[KnowledgeBaseResource] = [] + skills: list[SkillResource] = [] + storage: StorageResource = StorageResource() + platform_capabilities: dict[str, Any] = {} +``` + +`skills` 只包含本次 run 中 pipeline-visible 的 skill facts,例如 `skill_name`、`display_name` 和 `description`。Host 不把这些 facts 追加到 system prompt,也不把它们编排进工具描述;runner 可以自行决定是否放入 model prompt、转换成 MCP surface,或只在自己的策略层使用。 + +资源列表是本次 run 的授权结果。History / Event / State / Storage 访问通过 `ctx.context.available_apis` 和 Host 侧 run session 校验控制,不作为可枚举 resource list 暴露。Runner 只能通过 `AgentRunAPIProxy` 访问这些能力。当前事件的文件和工具大结果优先进入授权 sandbox/workspace,由 runner 通过 read/write/exec 类工具按需读取。 + +## 7. Result Stream + +### 7.1 AgentRunResult envelope + +```python +JSONValue = str | int | float | bool | None | list["JSONValue"] | dict[str, "JSONValue"] + +ResultType = Literal[ + "message.delta", + "message.completed", + "tool.call.started", + "tool.call.completed", + "state.updated", + "action.requested", + "run.completed", + "run.failed", +] + +class AgentRunResult(BaseModel): + run_id: str + type: AgentRunResultType | str + data: dict[str, Any] = {} + usage: LLMTokenUsage | None = None + sequence: int | None = None + timestamp: int | None = None +``` + +SDK 当前实现是单一 envelope:`type` 枚举 + `data` dict。Payload 由 SDK typed model 构造并 dump,但 wire 不改成 discriminated union;这样新旧版本偏斜时 Host 仍可按 §3 忽略未知 `type`。 + +`usage` 是 runner 可选上报的 token 使用量,沿用 SDK `LLMTokenUsage`: + +```python +class LLMTokenUsage(BaseModel): + prompt_tokens: int | None = None + completion_tokens: int | None = None + total_tokens: int | None = None + # provider-specific detail/cached/reasoning counters are preserved as extra fields +``` + +约束: + +- 运行时能观测到 provider/runtime usage 时,SHOULD 在 terminal `run.completed.usage` 上报本次 run 的最终聚合 token usage。 +- `run.failed.usage` MAY 上报失败前已经产生的部分 usage。 +- 不能观测 usage 的 runner 合法地省略该字段;缺失表示 unknown,Host 不得按 0 处理。 +- ACP 等外部协议不保证统一 usage;ACP runner 只能在具体 provider/native event 提供 usage 时填充本字段。 +- cost 不作为 runner result 的权威字段。Host 后续应基于 usage、model identity、时间和自身价格表计算账单成本;provider 原始 cost 如需保留,可放在 `usage` extra 字段中作为非权威 telemetry。 + +Host 边界分级校验: + +- `message.delta`、`message.completed`、`state.updated`、`action.requested`、`run.completed`、`run.failed` 属于会影响投递或 Host 副作用的严格 payload;校验失败时丢弃该 result 并记录 warning。 +- `tool.call.started`、`tool.call.completed` 当前只作为 telemetry,payload 宽松兼容。 +- 未知 `type` 忽略并记录 warning。 + +### 7.2 稳定 result payloads + +| type | `data` payload | +| --- | --- | +| `message.delta` | `{ "chunk": MessageChunk }` | +| `message.completed` | `{ "message": Message }` | +| `tool.call.started` | `{ "tool_call_id": str, "tool_name": str, "parameters": dict }` | +| `tool.call.completed` | `{ "tool_call_id": str, "tool_name": str, "result": dict \| None, "error": str \| None }` | +| `state.updated` | `{ "scope": "conversation" \| "actor" \| "subject" \| "runner", "key": str, "value": JSONValue }` | +| `action.requested` | `{ "action": str, "target": dict \| None, "payload": dict \| None }` | +| `run.completed` | `{ "finish_reason": str, "message"?: Message }` | +| `run.failed` | `{ "code": str, "error": str, "retryable": bool }` | + +Runner 生成的大文件、工具输出和临时产物不通过 result event 回传;应写入当前 run 的授权 sandbox/workspace,再用消息文本、metadata 或 attachment reference 指向它们。 + +### 7.3 稳定 result types + +| type | 说明 | 当前消费 | +| --- | --- | --- | +| `message.delta` | 流式消息片段。 | ✅ | +| `message.completed` | 完整消息。 | ✅ | +| `tool.call.started` | 工具调用开始的可观测事件。 | telemetry | +| `tool.call.completed` | 工具调用完成的可观测事件。 | telemetry | +| `state.updated` | runner 请求更新 host-owned state。 | ✅ | +| `action.requested` | runner 请求 Host 执行平台动作。 | **reserved / 仅 telemetry,不执行** | +| `run.completed` | run 正常结束。 | ✅ | +| `run.failed` | run 失败。 | ✅ | + +`action.requested` 是为 EBA 和 platform API 保留的协议表面:本分支 Host 收到后只记 telemetry,**不执行**,runner 作者不应在当前 Host 底座中依赖其副作用。真实执行器由外部 EBA / platform action 分支接入;执行模型见 EVENT_BASED_AGENT §6。 + +Host 必须校验 `state.updated` 的 scope、key、value 大小和 JSON 可序列化性。本分支 `action.requested` 仍只记录 telemetry。 + +### 7.4 Stream delivery semantics + +- Host 按 Runtime stream 顺序消费 result。当前 v1 不定义跨连接 replay,也不承诺 at-least-once;从 Host 视角,收到的 result 最多应用一次。 +- `sequence` 是单个 `run_id` 内的结果序号。in-process / stdio 这类天然有序的在线 stream 可以省略;任何会缓冲、重放、跨进程队列或 runtime-managed task 的 transport 必须提供从 1 开始严格递增的 `sequence`。 +- Host 看到已提供 `sequence` 的 result 时,应按 `(run_id, sequence)` 做重复检测,并在缺号或乱序时记录 warning;除非 transport 明确声明 replay 语义,Host 不应自行等待缺失序号重排用户可见输出。 +- `run.failed.data.retryable` 只表示整次 run 理论上可由上层重试;Protocol v1 不自动重试 run,也不自动重试 proxy action。 +- History / Event / Transcript cursor 是 opaque token。runner 不得解析 cursor,也不得假设 cursor 在不同 API、conversation、thread 或 retention window 之间可比较;当前实现即使返回数字字符串,也只是实现细节。 + +### 7.5 示例 + +```json +{ "type": "message.delta", "data": { "chunk": { "role": "assistant", "content": "hel" } } } +{ "type": "message.completed", "data": { "message": { "role": "assistant", "content": "hello" } } } +{ "type": "state.updated", "data": { "scope": "conversation", "key": "external.session_id", "value": "abc" } } +{ "type": "action.requested", "data": { "action": "message.edit", "target": {"message_id": "..."}, "payload": {"text": "..."} } } +``` + +## 8. AgentRunAPIProxy + +所有 proxy action 必须携带 `run_id`。Host 必须校验:active run session 存在、caller plugin identity 匹配、resource 在本次 `ctx.resources` 中授权、scope 不越界、payload size / rate limit / deadline 合法。 + +```python +# Model +await api.invoke_llm(llm_model_uuid, messages, funcs=None, extra_args=None) +await api.invoke_llm_with_usage(llm_model_uuid, messages, funcs=None, extra_args=None) +async for chunk in api.invoke_llm_stream(llm_model_uuid, messages, funcs=None, extra_args=None): + ... +async for event in api.invoke_llm_stream_events(llm_model_uuid, messages, funcs=None, extra_args=None): + ... +await api.invoke_rerank(rerank_model_id, query, documents, top_k=None) + +# Tool +await api.get_tool_detail(tool_name) +await api.call_tool(tool_name, parameters) + +# Knowledge +await api.retrieve_knowledge(kb_id, query_text, top_k=5, filters=None) + +# History(返回 Transcript projection,不返回原始平台 payload) +await api.get_prompt() +await api.history_page(conversation_id=None, before_cursor=None, after_cursor=None, + limit=50, direction="backward", include_attachments=False) +await api.history_search(query, filters=None, top_k=10) + +# Event(返回稳定 event envelope 或受限 raw ref,不默认返回大 payload) +await api.event_get(event_id) +await api.event_page(conversation_id=None, event_types=None, before_cursor=None, limit=50) +await api.steering_pull(mode="all", limit=None) + +# State / Storage +await api.state_get(scope, key); await api.state_set(scope, key, value); await api.state_delete(scope, key) +await api.state_list(scope, prefix=None, limit=100) +await api.get_plugin_storage(key); await api.set_plugin_storage(key, value); await api.delete_plugin_storage(key) +await api.get_plugin_storage_keys() +await api.get_workspace_storage(key); await api.set_workspace_storage(key, value); await api.delete_workspace_storage(key) +await api.get_workspace_storage_keys() + +# Host info +await api.get_langbot_version() +``` + +`invoke_llm()` / `invoke_llm_stream()` 的第一个参数在 SDK 中命名为 +`llm_model_uuid`,wire payload 字段也是 `llm_model_uuid`。该值对 runner +仍是 opaque identifier,不应解析其内部格式。 + +`invoke_llm()` 和 `invoke_llm_stream()` 保持兼容:前者返回 `Message`,后者只 +yield `MessageChunk`。需要 provider 真实 token 计量的 runner 应使用 +`invoke_llm_with_usage()` 或 `invoke_llm_stream_events()`。Host response 可在 +原有 `{message: ...}` / `{chunk: ...}` 外额外携带可选 `usage` 字段;streaming +场景允许在所有 chunk 之后追加一个 usage-only event。`usage` 至少保留 +OpenAI-compatible 的 `prompt_tokens`、`completion_tokens`、`total_tokens`, +若 provider 返回 `prompt_tokens_details` / `completion_tokens_details` 或 +cache token counters,Host / SDK 不应丢弃这些字段。没有 usage 的 provider +必须继续返回成功响应,SDK 将 usage 置为 `None`。 + +`get_prompt()` 返回当前 query-backed run 的 Host effective prompt messages: +`list[Message]` 的 JSON 形式。该能力只在 `ctx.context.available_apis.prompt_get` +为 true 时可用;没有 query 缓存、prompt 已过期或非 query entry run 时 Host +可以返回错误或空列表。Runner 应在不可用时回退到自己的 config/prompt 策略。 + +`steering_pull(mode="all")` 是推荐默认:Host 按 claim 顺序返回全部 pending steering 输入并清空对应队列。`mode="one-at-a-time"` 仅用于 runner 主动节流,每次返回一条。Host 不合并多条用户消息;runner 负责在 turn 边界决定模型侧格式。 + +Steering 审计使用 EventLog 而不是 Transcript schema 扩展:被 active run 吸收的原始 `message.received` 事件保留原事件类型,并在 `metadata.steering` 标记 `status="queued"`、`trigger_behavior="absorbed_into_active_run"`、`claimed_by_run_id`、`claimed_runner_id`、`claimed_at`。Runner 成功 pull 后,Host 追加 `steering.injected` EventLog 记录,`metadata.steering.status="injected"` 并引用 `source_event_id`。若 run 结束时仍有已 claim 但未 pull 的 steering 输入,Host 追加 `steering.dropped` EventLog 记录,`metadata.steering.status="dropped"` 并引用 `source_event_id`;这不是用户消息事实的删除,只是 dispatch 终态。Transcript 继续只表示会话事实,不承担 dispatch 行为标记。 + +`state` 与 `storage` 的建议边界:`state` 放小型 JSON(conversation / actor / subject / runner),`storage` 放 blob 或较大数据(插件私有数据、workspace 数据、checkpoint)。 + +Compaction checkpoint 的推荐 state 约定: + +- scope: `conversation` +- key: `runner.compaction.checkpoint` +- value: + +```json +{ + "schema_version": "langbot.local_agent.compaction_checkpoint.v1", + "summary": "...", + "covers_until": "transcript-cursor-or-seq", + "tokens_before": 12345, + "created_at": 1710000000, + "conversation_id": "conv-..." +} +``` + +`covers_until` 是摘要覆盖到的 transcript 游标锚点。Runner 读取 checkpoint 后应只拉取该游标之后的 transcript;若 checkpoint 缺失、schema 不匹配、conversation 不匹配或游标不可用,应回退到无 checkpoint 的尾部历史拉取行为。 + +Proxy 返回数据结构也属于本协议: + +```python +class TranscriptItem(BaseModel): + transcript_id: str + event_id: str + conversation_id: str | None = None + thread_id: str | None = None + role: str + item_type: str = "message" + content: str | None = None + content_json: dict[str, Any] | None = None + attachment_refs: list[dict[str, Any]] = [] + seq: int | None = None + cursor: str | None = None + created_at: int | None = None + metadata: dict[str, Any] = {} + +class HistoryPage(BaseModel): + items: list[TranscriptItem] = [] + next_cursor: str | None = None + prev_cursor: str | None = None + has_more: bool = False + total_count: int | None = None + +class HistorySearchResult(BaseModel): + items: list[TranscriptItem] = [] + total_count: int | None = None + query: str + +class AgentEventRecord(BaseModel): + event_id: str + event_type: str + event_time: int | None = None + source: str + bot_id: str | None = None + workspace_id: str | None = None + conversation_id: str | None = None + thread_id: str | None = None + actor_type: str | None = None + actor_id: str | None = None + actor_name: str | None = None + subject_type: str | None = None + subject_id: str | None = None + input_summary: str | None = None + input_ref: str | None = None + raw_ref: str | None = None + seq: int | None = None + cursor: str | None = None + created_at: int | None = None + metadata: dict[str, Any] = {} + +class EventPage(BaseModel): + items: list[AgentEventRecord] = [] + next_cursor: str | None = None + prev_cursor: str | None = None + has_more: bool = False + total_count: int | None = None + +class SteeringInputItem(BaseModel): + claimed_run_id: str + runner_id: str + claimed_at: int | None = None + event: AgentEventContext + input: AgentInput + conversation: ConversationContext | None = None + actor: ActorContext | None = None + subject: SubjectContext | None = None + metadata: dict[str, Any] = {} + +class SteeringPullResult(BaseModel): + items: list[SteeringInputItem] = [] +``` + +## 9. 错误模型 + +```python +class AgentAPIError(BaseModel): + code: str + message: str + retryable: bool = False + details: dict[str, Any] = {} +``` + +| code | 说明 | +| --- | --- | +| `unauthorized` | 未授权访问资源或 scope。 | +| `not_found` | 资源不存在或对当前 runner 不可见。 | +| `deadline_exceeded` | 超过 run deadline。 | +| `payload_too_large` | 请求或响应过大。 | +| `rate_limited` | Host 限流。 | +| `invalid_argument` | 参数错误。 | +| `runtime_error` | Host 或下游能力错误。 | + +SDK runner-facing proxy 在 Host 返回结构化错误或畸形响应时抛出 `AgentAPIException`,其中 `error` 字段为 `AgentAPIError`。Legacy transport 只返回字符串错误时,SDK 使用 `host.action_error` 包装,避免 runner 继续依赖裸 `KeyError` 或字符串匹配。 + +Runner 失败使用 `run.failed`: + +```json +{ "type": "run.failed", "data": { "code": "runner.error", "error": "failed to call external agent", "retryable": false } } +``` + +## 10. Timeout 与 Cancellation + +- Host 在 `ctx.runtime.deadline_at` 下发总 deadline;SDK proxy 必须用该 deadline 限制单次 action timeout。 +- Host 可以取消 active run;Runtime 应尽力中断 runner。 +- Protocol v1 的 run 绑定当前 Host 进程和当前 runtime channel,不保证跨 Host 重启恢复。Host 重启、runtime channel 断开或 run session 丢失时,Runtime / external harness connector 必须 fail-fast 并尽力取消仍在执行的 runner,不得继续使用旧 `run_id` 调用 Host API。 +- Runner 支持中断时应返回或触发 `run.failed`,code 为 `cancelled`。 +- Host 必须 unregister active run session。 + +## 11. Security 与 Guardrail(协议层) + +Protocol v1 的安全边界在 Host: + +- Runner 不能直接访问未授权 model/tool/kb/history/storage/sandbox。 +- SDK 本地校验只提升开发体验,不能替代 Host 校验。 +- 所有 resource id 对 runner 来说都是 opaque。 +- 默认只能访问当前 conversation / thread 的 history;跨会话、workspace 级访问必须额外授权。 +- 大 payload 不应塞进 result event;当前 run 的文件和工具大结果应进入授权 sandbox/workspace,由 read/write/exec 类工具按需访问。 +- Host 必须记录 run_id、runner_id、action、resource、scope、result。 + +Host 不负责业务编排:不拼接全量历史、不替 runner 做 prompt assembly、不内置 agent memory / tool loop / 上下文压缩策略。这些由官方或第三方 AgentRunner 插件实现。 + +外部 harness runner 的边界统一见 HOST_SDK §4.8。简言之:harness native permission mode、allowed/disallowed tools、shell/MCP 权限只是额外执行约束,不能替代 Host 对 LangBot 资源的授权。 + +> 发布级路径隔离、MCP allowlist、secret redaction、配额、workspace 清理等**不属于** v1 协议闭环,是生产默认启用前的 release gate,见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 + +## 12. Pipeline Adapter 边界 + +Pipeline 是当前入口 adapter,不是协议中心。目标产品模型中 Agent 会替代 +Pipeline 承载 runner config、resource policy 和 delivery policy;当前 Query +entry adapter 只是迁移桥。它负责: + +- 从 `Query` 构造 `AgentEventContext` 和临时 `AgentBinding`(见 HOST_SDK §4.2)。 +- 从当前 Agent/runner config 构造 `ctx.config`。 +- 将 Query-only 字段放入 `ctx.adapter`,例如 filtered params 放 `ctx.adapter.extra["params"]`。 + +约束: + +- adapter **不**定义历史窗口、prompt 组装或 agentic context 策略。 +- `ctx.adapter.extra` 只允许承载一次性、JSON-safe、入口相关的非核心元数据,例如 `params`;不得承载 `prompt`、history window、RAG 结果、tool schema 或授权资源。 +- 静态绑定 prompt 属于 `ctx.config.prompt`。preprocessing / hook 后的动态有效指令不通过 `ctx.adapter.extra` 主动推送;后续如需要保留这类能力,应通过 Host prompt/instruction pull API 暴露(占位见 HOST_SDK §4.8)。 +- 新 runner 不应长期依赖 `adapter`,应只依赖 event-first context 和 Host API。 + +## 13. 已确认约束 + +- v1 / EBA 主线是 `one event -> one AgentBinding -> one run_id -> one runner`。 +- 一个 bot / IM channel 在同一时间只绑定一个负责 agentic 处理的 Agent;一个 Agent 可以被多个 bot / channel 复用。 +- 如果配置层出现多个匹配 AgentBinding,BindingResolver 必须按明确规则选出一个或拒绝配置,不应默认 fan-out。 +- observer agent、多 runner fan-out、并行裁决、result 合并等能力需要单独设计 delivery、state、platform action 和 audit 语义,不属于当前 v1 契约。 +- `AgentRunnerDescriptor.source` 只允许 `plugin`;Host 内置 adapter 不能作为 runner source 绕过插件/runtime/proxy 权限链。 +- `ctx.resources` 与 proxy action 校验必须来自同一个 run authorization snapshot;runtime handler 不应重新执行资源裁剪。 +- v1 不要求 Agent、AgentRunner 插件实例或 runner id 全局串行。多个 bot / channel 可复用同一个 Agent;并发隔离依赖 `run_id`、binding、conversation / thread scope 和 Host authorization snapshot。 +- 外部 harness runner 当前是 MVP / dev path,证明协议可接入,不代表发布级安全边界或 Docker 生产可用性完成。 + +## 14. 开放问题 + +- `AgentBinding` 是否需要进入 SDK 文档作为只读诊断信息,还是完全 Host 内部。 +- State 与 Storage 的边界是否需要更强类型。 +- platform action 的审批模型如何表达。 +- Host 侧 scoped MCP / skill / workspace projection 是否需要从 runner config 上移为一等 resource projection API。 diff --git a/docs/agent-runner-pluginization/README.md b/docs/agent-runner-pluginization/README.md new file mode 100644 index 000000000..7aa6657ed --- /dev/null +++ b/docs/agent-runner-pluginization/README.md @@ -0,0 +1,154 @@ +# Agent Runner 插件化文档入口 + +本文档是 agent-runner 插件化工作的路由页。具体设计拆到独立文档中维护,避免把 LangBot 宿主架构、SDK 协议、上下文管理、EBA 接入边界和官方 runner 迁移混在同一份 README 里。 + +## 背景与问题 + +旧 runner 路径主要围绕 Pipeline / Query 和 `pkg/provider/runners` 内置实现展开,扩展外部 agent runtime 时容易把 runner 选择、上下文裁剪、资源授权和消息投递绑在同一条聊天链路里。这个分支要把 LangBot 收敛成 Agent Host:Host 负责事件、绑定、授权、事实源和结果投递;AgentRunner 作为插件或外部 harness 消费统一协议并自主管理 prompt / history / memory。 + +## 文档维护原则(单一事实源) + +- **协议数据结构(schema)唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。** 其他文档不得重抄 schema,只能引用,例如"见 PROTOCOL_V1 §4.2"。 +- 当前实现状态、spec 差距与 runner 验收状态归 [STATUS.md](./STATUS.md);测试执行入口归 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md),安全发布门槛归 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 +- Host 内部模型(`AgentEventEnvelope`、`AgentBinding`、Descriptor、各 Store)定义在 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md),不属于 SDK 协议。 +- 其余专题文档只讲"为什么/边界/怎么用",避免重复叙述。 + +## 本分支目标 + +**本分支目标:AgentRunner 外化 / 插件化基础设施** + +本分支只做 LangBot 作为 Agent Host 的基础能力建设,为后续用 `Agent` +替代 Pipeline 承载 agent 配置打底: + +- LangBot 与 SDK 的稳定协议合同(Protocol v1) +- Host-side `AgentEventEnvelope` / `AgentBinding` 模型 +- `run(event, binding)` event-first 入口 +- `QueryEntryAdapter`:Query → AgentEventEnvelope + AgentBinding +- EventLog / Transcript / PersistentStateStore +- History / Event / State pull APIs +- Sandbox/workspace read/write/exec 文件能力,用于当前 run 的上传文件、工具大结果和临时产物 +- SDK runtime forwarding pull APIs + `caller_plugin_identity` 验证路径 + +## 本分支不实现 + +以下能力由其他分支负责,本分支只保留 integration point。EBA 完整事件网关与事件路由当前由外部 EBA 分支联调: + +- **EventGateway / EventRouter**:完整事件网关实现、事件路由、事件持久化管理 +- **Event subscription / Event notification**:事件订阅、推送通知 +- **BindingResolver persistence UI**:绑定配置的持久化 UI 和 event router 集成(如由其他模块负责) +- **Scheduler / Background event source**:定时任务、后台事件源 +- **完整 Agent Platform / daemon control plane**:Host-owned `AgentRun` / `AgentRunEvent`、run control primitives、最小 runtime heartbeat/claim lease 已作为 v2 foundation 落地;业务队列、Platform UI、daemon supervisor、runtime wakeup channel 和分布式 runtime 管控仍不属于 Protocol v1 主线。 + +EventGateway / EventRouter 在本文档中描述为 **external EBA branch integration point**,由外部 EBA 分支提供并联调。本分支只定义 host-side envelope/binding models 和 `run(event, binding)` orchestrator 入口。 + +本分支与外部 EBA / Agent Platform / Runtime Control Plane 的扩展边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。 + +## 目标产品模型 + +未来产品层应把 `Agent` 理解为 Pipeline 的替代物:原先 bot 绑定 Pipeline,Pipeline 携带 agent/provider/RAG/tool 等配置;后续应改为 bot 或 IM channel 绑定一个 Agent,Agent 携带 runner id、runner config、resource/state/delivery policy 等 agent 配置。 + +调度基数、Agent 复用、插件实例无状态、Pipeline adapter 和 fan-out 边界的规范来源是 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13;README 不复写这些约束。 + +## 当前入口关系 + +**当前 Pipeline 是入口 adapter,不再是 agent runner 设计核心。** + +主入口仍可由 Pipeline 触发,但内部已转换成 event-first path:`run_from_query()` 经 `QueryEntryAdapter` 把 `Query` 转换为 `AgentEventEnvelope` + `AgentBinding`,再委托到统一的 `run(event, binding, ...)`。Pipeline path 因此获得了 event-first host capabilities(EventLog / Transcript / PersistentStateStore 写入,History / Event / State pull API 和 sandbox/workspace 文件读写能力可用)。 + +下一轮测试路径、状态定义和 smoke 记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。 + +## 术语表 + +| 术语 | 含义 | +| --- | --- | +| Protocol v1 | Host 调用 AgentRunner 的 runner 可见合同:discovery、`AgentRunContext`、result stream、Host pull API 和错误模型。 | +| Agent | 目标产品层配置对象,保存 runner id、runner config 和资源/状态/投递策略;不等于插件实例。 | +| AgentConfig | Host 内部迁移期配置投影,由当前 Pipeline config 或未来持久 Agent 生成。 | +| AgentBinding / binding | Host 在一次事件运行前解析出的有效绑定,决定调用哪个 runner 以及带什么策略。 | +| envelope | Host 内部事件封装,即 `AgentEventEnvelope`;runner 看到的是由它投影出的 `ctx.event`。 | +| descriptor / manifest | runner discovery 的能力和配置描述;manifest 来自插件,descriptor 是 Host 校验后的注册表视图。 | +| EBA | Event Based Agent,把消息、撤回、入群、定时任务等都统一成 host event 的接入方向;完整网关和路由在外部 EBA 分支联调。 | +| harness runner | ACP、Claude Code、Codex 等已有自身 session / tool loop / MCP / 压缩机制的外部 runtime adapter。 | +| projection | Host 把内部事实源、授权资源或配置裁剪成 runner / harness 可消费视图的过程。 | +| Runtime Control Plane | v2 Host 能力层,当前已落地 Host-owned run/result ledger、run control primitives、最小 runtime heartbeat/claim lease;完整 daemon worker 管控、task wakeup 和 Agent Platform 产品形态不是 Protocol v1 主线。 | + +## 设计文档 + +| 文档 | 关注点 | +| --- | --- | +| [PROTOCOL_V1.md](./PROTOCOL_V1.md) | **🔒 唯一 schema 事实源**。LangBot Host 与 SDK / Runtime / AgentRunner 的协议合同:版本协商、discovery、run context、result stream、proxy actions、错误和 adapter 边界。 | +| [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) | LangBot 宿主能力与分层架构、Host 内部模型(`AgentEventEnvelope` / `AgentBinding` / Descriptor / 各 Store)、runner 发现、绑定、资源授权、状态、存储、生命周期和调用链。 | +| [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) | Agent-owned context 方向:事件到来时 LangBot 传什么,agent 如何按需拉取更多历史 / state、如何访问 sandbox/workspace 文件,以及如何支持 KV cache 友好的上下文管理。 | +| [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md) | AgentRunner 外化与外部 EBA / Agent Platform / Runtime Control Plane 的扩展边界矩阵,说明哪些是本分支底座、哪些由外部分支接入。 | +| [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md) | EBA 接入边界:事件模型、事件来源、触发绑定、非消息事件如何复用 AgentRunner 调度;完整 EventGateway / EventRouter 由外部 EBA 分支联调。 | +| [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md) | Agent Platform v2 / runtime 管控面决策:`AgentRun` / `AgentRunEvent` / run control 已作为 Host 事实源落地,最小 runtime heartbeat/claim lease 已落地;完整 runtime registry / daemon 管控仍是后续可选阶段。 | +| [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md) | 官方 runner 插件迁移,包括 local-agent 和外部 runner。它是下游落地计划,不是 LangBot 基础能力设计的前置约束。 | +| [RUN_STEERING_AND_CHECKPOINT.md](./RUN_STEERING_AND_CHECKPOINT.md) | 运行中消息注入(steering / follow-up)与压缩摘要持久化(compaction checkpoint)的设计与落地状态记录;schema 仍以 PROTOCOL_V1 为准。 | +| [STATUS.md](./STATUS.md) | 当前实现状态、spec 与实现已知差距、runner 验收状态和历史高价值记录。 | +| [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) | Agent Runner QA 指南:保留最高价值测试路径,指导 agent 开展下一轮 WebUI / runner smoke 验证。 | +| [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) | 安全发布级 hardening 的后续发布门槛:路径隔离、权限边界、secret、资源配额、MCP / skill 投影和审计。 | + +## 工作拆分 + +### 1. LangBot + SDK 基础设施 + +目标是把 LangBot 从内置 runner 执行器变成 agent host: + +- LangBot 与 SDK 的稳定协议合同 +- runner manifest / descriptor / registry +- Agent / binding 配置解析 +- run orchestration 和生命周期管理 +- resource authorization 与 `run_id` 级权限校验 +- host-owned state / storage / event log / transcript 能力 +- sandbox/workspace 文件 staging 与 read/write/exec 能力 +- SDK `AgentRunner`、`AgentRunContext`、`AgentRunResult`、`AgentRunAPIProxy` + +协议合同详见 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。 + +详见 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。 + +### 2. Agent-owned context + +LangBot 不应成为最终 agentic context manager。它应提供事实源、默认上下文引用和按需读取 API;agent 或其背后的 runtime 负责历史剪裁、摘要、召回和 KV cache 策略。 + +Host 不定义通用历史窗口字段或策略;runner 通过 Host pull API 按需拉取历史并自行管理 working context。 + +详见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。 + +### 3. Event Based Agent(External Branch) + +消息只是事件的一种。外部 EBA 分支中的 `message.received`、`message.recalled`、`group.member_joined`、`friend.request_received` 等事件都应能通过统一事件 envelope 触发 AgentRunner。 + +EBA dispatch 的基数和 fan-out 边界仍以 PROTOCOL_V1 §13 为准;本文档只列出本分支提供给外部 EBA 分支复用的入口点。 + +**本分支不实现 EBA 完整能力,只提供:** +- event-first envelope (`AgentEventEnvelope`) +- AgentBinding model +- `run(event, binding)` 入口 +- QueryEntryAdapter(当前 AgentEventEnvelope / AgentBinding 的 Query entry adapter source) + +详见 [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md)。 + +### 4. 官方 runner 插件 + +官方 `local-agent` 和外部 runner 迁移是下游工作。它们需要依附 LangBot 提供的宿主能力,但不应反过来决定宿主协议。 + +`local-agent` 可以外移,也可以重写。验收重点是它能完整消费 LangBot 的模型、工具、知识库、存储、事件、history API 和 result stream,而不是保留旧内置 runner 的内部结构。 + +详见 [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md)。 + +### 5. Runtime Control Plane v2(Foundation Partial) + +当前 AgentRunner v1 主线仍以 `event -> binding -> runner.run(ctx) -> result stream` 为 runner 可见合同。Host 侧已经新增持久 `AgentRun` / `AgentRunEvent`、result persistence、cancel/finalize/query 等通用 run control primitives,并提供受权限保护的最小 runtime register/heartbeat/list、claim/renew/release 和 reconcile 原语。 + +在这些 Host 能力之上,可以构建独立 agent 管控面插件;插件负责 UI、策略和编排体验,runtime/task 的事实源仍由 Host 持有。完整 daemon supervisor、任务唤醒/长轮询/WebSocket、跨 Host 分布式锁、provider 登录态诊断和产品化业务队列仍是后续工作。 + +详见 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md)。 + +## 约束事实源 + +本分支已确认约束不在 README 重写: + +- Runner 可见协议、result stream 和调度边界见 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。 +- Host 内部 `AgentConfig` / `AgentBinding` 投影见 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。 +- 外部 EBA / Agent Platform / Runtime Control Plane 接入边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。 diff --git a/docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md b/docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md new file mode 100644 index 000000000..4b4bb6f60 --- /dev/null +++ b/docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md @@ -0,0 +1,541 @@ +# Agent Platform / Runtime Control Plane Decision Note + +本文档记录 AgentRunner 插件化之后,LangBot 如何继续演进成 Agent Platform 基础设施层。这里讨论的是 Host capability layer,不是 `AgentRunner Protocol v2`,也不是把某个具体 Agent Platform 产品写进 LangBot core。 + +> 本文是当前决策版。协议数据结构仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准;测试执行入口见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md);扩展边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。 +> +> 实现状态说明:本文描述的是 Runtime Control Plane v2 的目标能力和分阶段落地建议。当前 AgentRunner 插件化主线已经具备 event-first context、run-scoped authorization、EventLog / Transcript / State / sandbox 文件等 Host capability,并已落地持久 `AgentRun` / `AgentRunEvent` ledger、run control actions、最小 runtime heartbeat/claim lease 和 admin reconcile 原语。完整 Agent Platform 产品形态、daemon supervisor、runtime wakeup channel 和分布式 runtime 管控仍未完成。当前实现状态以 [STATUS.md](./STATUS.md) 为准。 + +## 1. 当前决策 + +LangBot 后续定位应更像 **Agent Host / infrastructure provider / transfer layer**,而不是把某个完整 Agent Platform 产品固化进 core。 + +结论: + +- **Agent Platform 产品形态做成插件**。插件负责 agent 管理、策略、业务队列、UI、编排、多 agent 协作和产品体验。 +- **Agent Platform 所需的基础事实源做进 Host**。当前 Host 已保存 event、state、transcript、sandbox 文件边界、active run 权限快照、持久 run/result ledger、审计关联和通用控制状态。 +- **最小 runtime registry / heartbeat / claim lease 已作为 Host 原语落地,但不等于完整 daemon worker 管控**。远程 harness / daemon 的进程托管、wakeup channel、provider 登录态诊断和分布式调度仍可以先由 AgentRunner 插件和 SDK remote layer 自己维护。 +- **不把业务调度写进 Host**。Host 提供通用 run/result/control primitives,Platform 插件决定哪些事件触发哪些 agent、如何排队、如何分配、是否 fan-out。 + +推荐分层: + +```text +LangBot Host + Current base: EventLog / runtime AgentBinding / State / Transcript / sandbox files / active run authorization + Current v2 foundation: Run / RunEvent / audit / result persistence / control primitives / minimal runtime heartbeat and claim lease + Planned: Agent / Binding persistence / daemon supervisor / wakeup channel / distributed runtime operations + +Agent Platform plugin + Agent management UI / project-task model / event routing policy + Business queue / multi-agent orchestration / runtime selection policy + +AgentRunner plugin / external harness runtime + Connects ACP / remote daemon / local subprocess / HTTP API + Executes and converts provider-native events to AgentRunResult +``` + +## 2. Platform 与非 Platform 的区别 + +当前 LangBot 已经具备 Agent Host 的核心特征: + +- 抹平不同 AgentRunner。 +- 从 IM / Pipeline 入口触发 runner。 +- 有 event-first context 方向。 +- 有 Host-owned EventLog / Transcript / State 和 sandbox/workspace 文件边界。 +- 有 runner config 下发和 active run-scoped authorization。 +- 有 `run_id` 串联 event、transcript、state、sandbox 文件和内存授权上下文。 + +这还不是完整 Agent Platform。完整 Platform 至少还需要: + +- 可管理的 agent 资产:agent profile、binding、resource policy、runner config、可用状态。 +- 可观察的执行生命周期:run status、result stream、失败原因、文件引用、审计、回放。 +- 可运营的控制面:取消、重试、排队、并发、超时、恢复、诊断。 +- 可产品化的调度体验:事件订阅、路由策略、任务板、多 agent 协作、项目/工作区视图。 + +因此,区别不只是“有没有调度”,而是是否具备: + +```text +managed agent assets + observable run lifecycle + operational run control +``` + +Host 负责这些能力的通用事实源和安全边界;Platform 插件负责把它们组装成具体产品。 + +### 2.1 当前实现边界 + +当前代码中的 `run_id` 已经连接 active run 授权、持久 run ledger 和多个 Host 事实源: + +- `EventLog` 保存输入事件和审计入口,并记录 `run_id` / `runner_id`。 +- `Transcript` 保存对话历史投影,并用 `run_id` 关联 assistant 输出。 +- Sandbox/workspace 保存当前运行输入文件和 runner 产物,并用 `run_id` 做访问边界的一部分。 +- `PersistentStateStore` 保存 runner state,但不等同于 run lifecycle。 +- `AgentRunSessionRegistry` 保存 active run 的内存态授权快照,用于 proxy action 校验;进程结束或 run 结束后不作为可回放事实源。 +- `AgentRun` 保存 run lifecycle、scope、authorization snapshot、queue/claim 状态、cancel intent、usage/cost 和 metadata。 +- `AgentRunEvent` 保存 runner/result/admin event stream,按 `run_id + sequence` 做可回放分页。 +- `AgentRuntime` 保存最小 runtime registry / heartbeat 事实,用于 runtime list、stale mark 和 claim lease reconcile。 + +因此本文后续提到的 `AgentRun` / `AgentRunEvent`、`run_append_result`、`run_finalize`、`run_cancel`、`runtime_register`、`runtime_heartbeat`、`run_claim` 等基础原语已经存在。仍未完成的是独立 platform `run_create` action、Host-owned Agent / Binding 持久模型、业务队列产品形态、daemon supervisor、runtime wakeup channel、跨 Host 分布式锁和 provider/runtime 诊断面。 + +## 3. 基础概念 + +### 3.1 Event + +Event 表示“发生了什么”: + +```text +message.received +github.issue.opened +scheduler.tick +user.approved +system.webhook.received +``` + +EBA 负责把外部输入标准化成 event。Event 本身不是 queue,也不等同于一次 agent 执行。当前 `EventLog` 记录的是输入事件和审计事实;未来 `AgentRunEvent` 记录的是某次 run 的输出事件流,二者不能混用。 + +### 3.2 Run + +Run 表示“某个 agent / binding / runner 针对某个 event 的一次执行”。 + +Run 应由 Host 持久化,成为执行状态、结果、权限和审计的事实源: + +```text +run_id +event_id +agent_id / binding_id +runner_id +status +created_at / started_at / finished_at +error / failure_reason +delivery target +metadata +``` + +当前 `AgentRunSessionRegistry` 只保存 active run 的内存态授权信息,不足以支撑 Platform 的回放、审计、取消、重试和异步执行。 + +### 3.3 RunEvent / RunResult + +RunEvent 是一次 run 过程中产生的结果事件流,对应 runner 返回的 `AgentRunResult`。它不同于 EBA/EventLog 的输入事件: + +```text +message.delta +message.completed +tool.call.started +tool.call.completed +state.updated +action.requested +run.completed +run.failed +``` + +Host 应保存这些输出事件,按 `run_id + sequence` 可回放。Transcript、State 可以由这些 result event 触发写入现有 store,并保留能回溯到 `AgentRunEvent` 的关联。文件和工具大结果留在当前 run 的 sandbox/workspace 中,不作为 result event blob 回传。 + +### 3.4 Queue + +Queue 不是 EBA 的替代品。 + +EBA 负责产生 event;queue 负责处理“这个 event 对应的执行 work item 何时执行、谁来执行、如何取消/重试/恢复”。 + +队列可以分两层: + +- **业务队列**:由 Platform 插件管理,例如项目任务、优先级、agent team、workflow、人工审批。 +- **执行队列 / run queue**:可选 Host 原语,例如 queued / running / completed / failed / cancelled、claim lease、dispatch timeout、orphan recovery。 + +第一阶段不要求 Host 内置完整执行队列。Platform 插件可以先管理业务队列;在 Phase 1 / Phase 2 能力落地前,插件仍只能通过现有 `AgentRunOrchestrator.run(...)` 同步执行路径和现有 Host stores 获得有限的 run 关联能力。 + +### 3.5 Runtime / Daemon + +Runtime / daemon 表示执行位置或执行能力,例如某台机器上的 Claude Code / Codex CLI。 + +当前决策: + +- Host 不在第一阶段维护完整 runtime registry。 +- AgentRunner 插件可以通过 SDK remote layer 与 daemon 保持连接、心跳和执行通道。 +- 外部 harness / agent 不应直接访问 LangBot Host 或数据库。访问 LangBot 资源必须通过 daemon / AgentRunner plugin / SDK runtime / `AgentRunAPIProxy` / scoped MCP bridge,并接受 run-scoped authorization 校验。 +- 如果后续多个插件都需要共享 runtime 状态,再把薄的 `RuntimeLease` / registry 下沉为 Host 通用能力。 + +## 4. Host 应新增的最小能力 + +第一阶段最重要的不是 daemon registry,而是让 Host 成为 run/result 的事实源。 + +### 4.1 AgentRun Store + +新增持久 `AgentRun`: + +```text +id / run_id +event_id +agent_id +binding_id +runner_id +conversation_id / thread_id +workspace_id / bot_id +status +status_reason +created_at / started_at / finished_at / updated_at +deadline_at +cancel_requested_at +usage_json +cost_json +metadata_json +``` + +建议 status 至少包含: + +```text +created +running +completed +failed +cancelled +timeout +``` + +如果后续加执行队列,再引入: + +```text +queued +claimed +dispatching +``` + +### 4.2 AgentRunEvent Store + +新增持久 `AgentRunEvent`: + +```text +id +run_id +sequence +type +data_json +usage_json +created_at +source +metadata_json +``` + +约束: + +- 同一 `run_id` 内 `sequence` 单调递增。 +- append 必须幂等,支持远程 daemon / plugin 重试。 +- 未知 result type 可保存但 Host 只对已知类型执行副作用。 +- 大 payload 仍应进入 sandbox/workspace,不直接塞入 result event。 +- `usage_json` 保存 `AgentRunResult.usage` 原样结构;缺失表示 unknown,不等于 0。 + +### 4.3 Run Control API + +Host 提供通用控制原语: + +```text +run.create +run.get +run.list +run.events.page +run.cancel +run.append_result +run.finalize +``` + +语义: + +- `run.create` 创建 Host-owned run 和授权快照。 +- `run.append_result` 只允许受信 SDK/runtime 路径调用,必须绑定 run 创建时固化的授权快照,写入 `AgentRunEvent` 并触发 transcript/state/delivery 副作用。 +- `run.finalize` 关闭 run,更新 terminal status。 +- `run.cancel` 设置取消意图;同步 runner 通过 context/deadline 感知,远程 runner 通过插件/daemon 通道感知。 + +第一阶段可以只暴露给插件 runtime action,不一定先做公开 HTTP API。 + +### 4.4 Result Persistence In Orchestrator + +当前 `AgentRunOrchestrator.run()` 已经处理: + +```text +event -> binding -> context -> runner invocation -> result normalization +``` + +需要补齐: + +- run 开始时创建 `AgentRun`。 +- 每个 `AgentRunResult` 进入 `AgentRunEvent`。 +- `run.completed` / 正常 generator 结束时标记 completed。 +- `run.failed` / exception / timeout 标记 failed 或 timeout。 +- terminal result 携带 usage 时,写入 `AgentRunEvent.usage_json` 并汇总到 `AgentRun.usage_json`。 +- `state.updated`、transcript 写入继续走现有 journal,但应与 `AgentRunEvent` 有可追踪关系。 + +### 4.5 Usage / Cost Accounting + +SDK 侧 `AgentRunResult` 已提供可选 `usage` 字段,用于把不同 runner / external harness / provider-native event 的 token usage 归一到同一个 run result envelope。 + +语义: + +- `run.completed.usage` SHOULD 表示本次 run 的最终聚合 token usage。 +- `run.failed.usage` MAY 表示失败前已知的部分 token usage。 +- 没有 usage 表示 upstream runtime 没有报告或 adapter 暂未接入;Host 不得按 0 计费或按 0 判断上下文消耗。 +- Host 应把 event-level usage 原样写入 `AgentRunEvent.usage_json`,并在 terminal event 或 finalize 阶段汇总到 `AgentRun.usage_json`。 +- cost 应由 Host 根据 usage、runner/model identity、发生时间和价格表计算,写入 `AgentRun.cost_json`;runner/provider 上报的 cost 只能作为非权威 telemetry 保留在 metadata 或 usage extra 中。 + +这层约束先解决协议位置和持久化位置;具体 ACP、remote daemon、local subprocess runner 如何从 native event 中抽取 usage,可在各插件后续适配。 + +### 4.6 Authorization Snapshot + +异步或远程执行时,run 创建时必须固化授权快照: + +- runner identity +- binding identity +- caller plugin identity +- resource policy +- allowed tools/models/files/knowledge bases/storage scopes +- state scopes +- conversation/thread/workspace scope + +后续 append result、state API、history API 和 sandbox/workspace 文件访问都以这个 snapshot 校验,不重新扩大权限。 + +## 5. SDK 侧应新增的最小能力 + +SDK 不需要马上定义完整 daemon registry,但需要让插件和 runner 使用 Host run/result 能力。 + +### 5.1 Entities + +新增或补齐: + +```text +AgentRun +AgentRunStatus +AgentRunEvent +RunEventPage +RunCreateRequest / RunCreateResult +RunAppendResultRequest +``` + +这些是 Host control primitives,不替代 `AgentRunContext` / `AgentRunResult`。 + +### 5.2 Proxy Methods + +在 SDK proxy 中提供: + +```python +create_run(...) +get_run(run_id) +list_runs(...) +page_run_events(run_id, cursor=None, limit=...) +cancel_run(run_id) +append_run_result(run_id, result, sequence=None) +finalize_run(run_id, status, error=None) +``` + +访问边界: + +- 普通 AgentRunner 在同步 `run(ctx)` 内不一定需要直接调用这些 API;Host orchestrator 可自动记录。 +- Platform 插件可以创建/查询/取消 run。 +- AgentRunner 插件或 daemon bridge 可以 append/finalize 自己负责的 run。 +- 外部 harness 仍不能直接调用 Host;必须经 SDK runtime / proxy / bridge。 + +### 5.3 Plugin-Daemon Heartbeat + +远程 daemon 的初始心跳可以是 SDK / AgentRunner plugin 私有能力: + +```text +daemon <-> AgentRunner plugin / SDK remote layer <-> LangBot plugin runtime <-> Host +``` + +Host 第一阶段只需要知道: + +- 相关插件是否在线。 +- run 是否有 progress/result。 +- run 是否超时或取消。 + +如果后续需要跨插件共享 daemon 可用性,再把 heartbeat/registry 下沉为 Host 能力。 + +## 6. Platform 插件应负责什么 + +Agent Platform 插件可以负责: + +- 管理哪些 agent 可用。 +- 维护产品层 agent profile、项目、任务板、workflow、team。 +- 订阅 EBA event,决定哪些 event 触发哪些 agent。 +- 维护业务 queue:优先级、重试策略、人工审批、分配规则。 +- 选择 runner / runtime / daemon。 +- 在 Run Control API 落地后,调用 Host run API 创建、取消、查询执行。 +- 展示 run status、result stream、文件引用、失败原因和审计。 + +Platform 插件不应负责: + +- 在 Host Run Ledger 落地后,私有保存通用 run/result 事实源。 +- 绕过 Host 直接写 transcript/state 或越权访问 sandbox/workspace 文件。 +- 让外部 harness 直接访问 LangBot DB 或 Host 内部资源。 +- 把某个业务队列语义强塞进 AgentRunner Protocol v1。 + +## 7. 与 EBA 的关系 + +EBA 做好后,事件流可以进入两种路径。 + +直接执行路径: + +```text +EventGateway + -> EventRouter resolves AgentBinding + -> AgentRunOrchestrator.run(event, binding) + -> Host records AgentRun / AgentRunEvent (after Run Ledger lands) + -> delivery +``` + +Platform 插件编排路径: + +```text +EventGateway + -> Platform plugin receives/subscribes event + -> plugin applies policy / business queue + -> plugin creates Host run (after Run Control API lands) + -> runner/plugin/daemon executes + -> Host records result and state + -> plugin displays / Host delivers +``` + +这两条路径最终应共享 Host run/result/state 事实源和 sandbox/workspace 文件边界。当前阶段可共享的是 event/transcript/state、sandbox 文件和同步执行链路;持久 run/result ledger 需要 Runtime Control Plane v2 Phase 1 补齐。区别在于是否有 Platform 插件参与产品化调度和业务队列。 + +## 8. 与 AgentRunner Protocol v1 的关系 + +本设计不改变 v1 的 runner 可见合同: + +```text +AgentRunContext -> AgentRunner.run(ctx) -> AgentRunResult stream +``` + +必须保持: + +- `AgentRunContext` 不塞入 daemon/worker/pod 细节。 +- `AgentRunResult` 仍是 runner 输出的统一事件流。 +- 普通 runner 不需要知道 task queue / runtime registry。 +- 远程 harness 可以自管 session、tool loop、MCP、上下文压缩,但访问 LangBot 资源必须通过 SDK proxy / bridge。 +- Runtime-managed execution 是 placement / transport 选择,不是普通 runner 协议的强制概念。 + +## 9. 分阶段实施建议 + +### Phase 1: Run Ledger(Foundation Implemented) + +目标:Host 成为执行状态和结果事实源。 + +范围: + +- `AgentRun` 表。 +- `AgentRunEvent` 表。 +- Orchestrator 自动创建/更新 run。 +- Journal 持久化每个 `AgentRunResult`。 +- Run 查询和事件分页 API。 +- SDK entities + proxy 方法。 + +复杂度:中等。 + +预计改动: + +```text +Host: 12-20 个文件 +SDK: 4-8 个文件 +Tests: 8-15 个文件 +``` + +### Phase 2: Platform Plugin Queue On Host Run Primitives(Control Primitives Partially Implemented; Product Queue Pending) + +目标:Platform 插件管理业务 queue,Host 提供 run/result/cancel 原语。 + +范围: + +- `run.create` +- `run.cancel` +- `run.append_result` +- `run.finalize` +- result append 的 sequence/idempotency。 +- 受权限保护的远程 append/finalize。 +- Platform 插件可基于 Host run 构建任务板和调度体验。 + +复杂度:中等偏高。 + +预计改动: + +```text +Host: 20-35 个文件 +SDK: 8-14 个文件 +Tests: 15-25 个文件 +``` + +### Phase 3: Optional Host Execution Queue / Claim Lease(Claim Lease Primitive Implemented; Full Queue Pending) + +目标:当多个插件重复实现 claim/cancel/retry/recovery 时,再下沉执行队列到 Host。 + +范围: + +- `queued/running/completed/failed/cancelled` 状态机扩展。 +- `claim_run` / `lease_until`。 +- dispatch timeout。 +- retry / orphan recovery。 +- cancel propagation。 +- 并发 claim 防重。 + +复杂度:高。 + +预计改动: + +```text +Host: 35-55 个文件 +SDK: 12-20 个文件 +Tests: 25-40 个文件 +``` + +### Phase 4: Optional Runtime Registry(Minimal Registry Implemented; Full Daemon Control Pending) + +目标:当 Host 需要统一管理多个 daemon / worker 时,再引入 runtime registry。 + +范围: + +- runtime register / heartbeat / deregister。 +- capability report:provider、version、login status、workspace access、slot。 +- runtime online/offline。 +- runtime scoped auth。 +- runtime audit。 +- runtime gone recovery。 +- task wakeup / long polling / websocket。 +- 多 Host 实例下的 relay / distributed lock。 + +复杂度:很高。 + +预计改动: + +```text +Host: 55-80+ 个文件 +SDK: 18-30 个文件 +Tests: 40+ 个文件 +``` + +不建议现在直接进入此阶段。 + +## 10. 设计原则 + +- 先把 run/result 事实源做进 Host,再谈完整 runtime control plane。 +- Agent Platform 产品做插件;Host 做基础设施。 +- Host 不写业务调度策略,但要保存通用状态、结果、权限和审计。 +- EBA event 不是 queue;queue 是执行生命周期问题。 +- 业务 queue 可以先在 Platform 插件里;执行 queue 只有在复用需求明确后再下沉 Host。 +- Daemon registry 不应污染 AgentRunner Protocol v1。 +- 外部 harness 不直接访问 LangBot Host 或 DB。 +- 所有 LangBot 资源访问必须走 SDK runtime / `AgentRunAPIProxy` / scoped MCP bridge。 +- Docker / remote / local subprocess 只是 runtime placement,不是 runner 协议差异。 + +## 11. 非目标 + +当前阶段不做: + +- 完整 Multica 式 runtime registry。 +- Host 内置项目管理、任务板、agent team、workflow 产品逻辑。 +- 把 daemon heartbeat / worker liveness 放进 `AgentRunContext`。 +- 把业务 queue 定义为 AgentRunner Protocol 字段。 +- 让 Platform 插件私有保存 run/result 事实源。 +- 让外部 agent/harness 直连 Host 内部资源。 + +## 12. 待定问题 + +- Host 是否需要最小持久 `Agent` / `Binding` 模型,还是继续由 Pipeline / Platform 插件投影运行期 `AgentBinding`。 +- Platform 插件创建 run 时,是否传完整 `AgentBinding` snapshot,还是引用 Host-owned binding id。 +- `AgentRunEvent` 与现有 `EventLog` / `Transcript` 的查询关系:直接 join,还是通过专门 view 聚合。 +- `run.append_result` 的认证粒度:runner plugin identity、run token、scoped capability token,或 SDK runtime 内部 channel。 +- 取消语义:同步 runner、external harness runtime/session 如何统一感知 cancel。 +- 何时把插件私有 daemon heartbeat 提升为 Host `RuntimeLease`。 +- 若未来 Host 做 claim lease,Platform 插件业务 queue 与 Host execution queue 如何避免双队列混乱。 diff --git a/docs/agent-runner-pluginization/RUN_STEERING_AND_CHECKPOINT.md b/docs/agent-runner-pluginization/RUN_STEERING_AND_CHECKPOINT.md new file mode 100644 index 000000000..cc0b11135 --- /dev/null +++ b/docs/agent-runner-pluginization/RUN_STEERING_AND_CHECKPOINT.md @@ -0,0 +1,154 @@ +# Run Steering 与 Compaction Checkpoint(Design Note) + +本文档记录两项 Host/runner 协作能力:**运行中消息注入(steering / follow-up)**和 +**压缩摘要持久化(compaction checkpoint)**。两者来自官方 local-agent 对照 +Pi agent harness(`pi-mono/packages/agent`,下称 pi-agent-core)的差距分析: +local-agent 已移植 Pi 的事件生命周期、并行工具语义、hook 扩展点和压缩预算模型, +这两项需要 Host 协议、授权与 runner turn 边界协同才能闭环。 + +> 本文是设计备忘,不是 schema 事实源。涉及的数据结构最终落到 +> [PROTOCOL_V1.md](./PROTOCOL_V1.md);上下文边界语义以 +> [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) 为准; +> run 持久化与控制原语以 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md) 为准。 + +## 1. Run Steering / Follow-up(运行中消息注入) + +### 1.1 问题 + +IM 场景下用户在 agent 运行中追加消息非常常见(补充信息、纠正方向、"算了别查了")。 +当前主线是 `one event -> one AgentBinding -> one run_id -> one runner` +(PROTOCOL_V1 §13):同会话的新消息要么等待当前 run 结束后触发新 run, +要么并发触发独立 run。两种行为都无法把新消息送进**正在执行的 tool loop**, +用户体验是"agent 自顾自跑完过期任务,然后才看到新消息"。 + +cancel(PROTOCOL_V1 §10)不解决这个问题:cancel 丢弃已完成的工作; +steering 是在保留当前进度的前提下改变后续方向。 + +### 1.2 Pi 的参考语义 + +pi-agent-core 区分两个队列,注入时机都在 turn 边界,不打断进行中的模型流或工具执行: + +- **steering**:运行中插入。当前 assistant 消息的全部 tool call 完成后、 + 下一次模型调用前,注入排队的用户消息;模型在下一 turn 看到它们。 +- **follow-up**:排队后续工作。仅当没有 pending tool call 且没有 steering 消息、 + run 即将自然结束时检查;若有排队消息则注入并继续下一 turn,而不是结束 run。 + +两个队列各自支持 `one-at-a-time`(每次注入一条)和 `all`(一次注入全部)模式。 + +### 1.3 设计方向 + +职责划分遵循既有原则:Host 拥有事件路由和会话事实源,runner 拥有 turn 边界。 + +- **Host 侧**:BindingResolver / dispatch 层识别"同 conversation 存在 active run + 且 runner 声明支持 steering"的新消息事件,将其写入 run-scoped steering queue, + 并标记该事件已被在途 run 认领(不再触发新 run,避免破坏 §13 的基数约束)。 + 事件仍照常进 EventLog / Transcript(事实源不变,改变的只是触发行为)。 +- **Runner 侧**:在 turn 边界(tool batch 完成后、下一次模型调用前,以及 run + 即将自然结束前)通过 run-scoped pull API 拉取 pending steering 输入, + 注入 working context。local-agent 的 `AgentLoopHooks.prepare_next_turn` / + `should_stop_after_turn` 已预留了对应的注入点。 +- **能力协商**:runner manifest 声明 `steering` capability(参照 PROTOCOL_V1 §4.3); + 未声明的 runner 保持现状(新消息按现有规则另起 run)。 +- **回执**:被 steering 消费的事件通过 EventLog 审计。原始 `message.received` + 记录在 `metadata.steering` 标记 queued/absorbed 与 `claimed_by_run_id`; + runner 成功 pull 后,Host 追加 `steering.injected` 记录并引用源事件。 + run 结束时仍未被 pull 的已 claim 输入,Host 追加 `steering.dropped` 记录作为 + dispatch 终态;原始 Transcript 事实不删除。 + Transcript 继续只表示会话事实,不扩展 dispatch 行为字段。 + +已落地的协议面(最终定义归 PROTOCOL_V1): + +1. `ContextAccess.available_apis` 增加 steering pull 能力位。 +2. `AgentRunAPIProxy` 增加 steering 拉取 action:默认 `mode=all`,Host 保序返回全部 + pending 输入;`one-at-a-time` 仅作为 runner 主动节流选项。 +3. dispatch 层的"认领"规则:`message.received` 可被同 conversation 的 active run + 吸收,原事件写 EventLog / Transcript,dispatch 行为写入 EventLog metadata。 +4. Host 对单 run steering queue 设置内存上限,队列满时不再 claim 新消息,消息回到 + 正常 dispatch 路径,避免 active run 无限吞入同会话输入。 + +### 1.4 边界 + +- 不引入 Host 替 runner 做 prompt 拼接:Host 只递队列,注入位置和格式由 runner 决定。 +- 不与 observer / fan-out 混淆:steering 仍是单 run 内的输入补充,不产生第二个 runner。 +- 远程 / 外部 harness runner(claude-code、codex 等)若其底层 session 自带 + steering 能力,adapter 可以直接转发;协议面保持一致。 + +## 2. Compaction Checkpoint 持久化 + +### 2.1 问题 + +local-agent 当前是无状态 runner:每次 run 重新拉取 transcript 尾部 +(默认 50 条)、重新估算 token、重新生成压缩摘要。后果: + +- 长会话中每 run 重复压缩计算,摘要每次重新生成,不同 run 之间措辞漂移, + 对 provider KV cache 不友好(AGENT_CONTEXT_PROTOCOL §"Summary checkpoint 稳定" + 已写明期望:只有压缩发生时才产生新 checkpoint)。 +- 历史一旦超过 fetch limit,更早的内容永久不可见——没有 checkpoint 记录 + "已压缩到哪里、压缩出了什么"。 + +pi-agent-core 把 compaction 条目持久化进 session tree:摘要带 +`tokensBefore` 和覆盖范围,后续 turn 直接复用,只在再次越过阈值时增量压缩。 + +### 2.2 现状盘点 + +协议面和主消费路径已具备: + +- State / Storage API 已定义(PROTOCOL_V1 §8 "State / Storage"), + 且 AGENT_CONTEXT_PROTOCOL 已点名 `summary.checkpoint` 是 state 的预期用法。 +- Host 会根据 binding state policy 暴露 `ContextAccess.available_apis.state`。 +- local-agent 会在 state API 可用时读取/写入 `runner.compaction.checkpoint`; + 缺失、schema 不匹配、conversation 不匹配或游标失败时回退尾部历史拉取。 +- LLM 生成摘要**不依赖**本项 Host 能力——runner 用已授权的 `invoke_llm` + 即可生成;checkpoint 只解决"存下来、下次复用"。 + +### 2.3 设计方向 + +- **存放位置**:state,scope=`conversation`(小 JSON,符合 PROTOCOL_V1 §8 + 对 state/storage 的边界建议)。若未来摘要膨胀,超出部分放 storage 并在 + state 中留引用。 +- **key 约定**:`runner.compaction.checkpoint`(runner 命名空间内)。 +- **内容约定**(schema 落 PROTOCOL_V1 或 runner 文档,此处只列语义): + - `schema_version` + - `summary`:压缩摘要文本(LLM 生成或确定性生成) + - `covers_until`:已被摘要覆盖的 transcript 游标(seq / message id), + 是增量压缩和"从哪继续拉历史"的锚点 + - `tokens_before` / `created_at`:诊断与失效判断 +- **消费流程**:run 开始时读 checkpoint → 只拉取 `covers_until` 之后的 + transcript → 压缩触发时基于旧摘要增量生成新摘要、写回新 checkpoint。 + checkpoint 缺失或解析失败时回退到现行为(全量拉尾部),保证向后兼容。 +- **失效规则**:`covers_until` 在 Host transcript 中不存在(会话被清理 / 重置) + 即作废;runner 不得信任跨 conversation 的 checkpoint。 +- **授权**:Host 对声明需要 state 的 runner binding 开启 + `available_apis.state`;校验沿用现有 run-scoped state 校验 + (scope、key、value 大小、JSON 可序列化,见 PROTOCOL_V1 §7.2 对 + `state.updated` 的要求)。 + +### 2.4 相关但独立的工作 + +- **tokenizer / usage metadata 透传**:runner 目前用 chars/4 启发式估 token, + 对 CJK 偏低 3-4 倍,压缩触发系统性偏晚。Host 应在模型响应或 + `ctx.runtime.metadata` 透传 provider usage(prompt/completion tokens)与 + model context window(LiteLLM model-info 工作)。该项不阻塞 checkpoint + 落地,但决定压缩触发的准确性。 + +## 3. 实施拆分 + +| 项 | 归属 | 依赖 | +| --- | --- | --- | +| steering queue、事件认领、基础审计 | LangBot Host(dispatch / binding 层) | 已落地,含队列上限与未消费 dropped 终态 | +| steering pull API + capability 位 | PROTOCOL_V1 + SDK proxy | 已落地 | +| turn 边界拉取与注入 | langbot-local-agent | 已落地 | +| local-agent 对 state API 的 checkpoint 读写 | langbot-local-agent | 已落地 | +| checkpoint key / 内容 / 失效约定 | PROTOCOL_V1 + local-agent README | 已落地 | +| LLM 压缩摘要生成 | langbot-local-agent | 已落地(`invoke_llm`,失败回退确定性摘要) | +| usage / context-window metadata 透传 | LangBot Host(model 层) | LiteLLM model-info | + +剩余工作应优先补 usage / context-window metadata。streaming delivery 衔接依赖 +`ctx.delivery` 编辑/追加语义,不建议在协议能力缺失时硬编码。 + +## 4. 开放问题 + +- streaming delivery 下 steering 注入后,前序 turn 已流出的内容与新 turn + 输出在 IM 消息编辑面的衔接(涉及 `ctx.delivery` 能力,待 delivery 演进定)。 +- checkpoint 是否需要 Host 侧主动失效通知(如会话清空时删除对应 state key)。 + 当前实现靠 runner 读取时校验并回退,功能不阻塞。 diff --git a/docs/agent-runner-pluginization/SECURITY_HARDENING.md b/docs/agent-runner-pluginization/SECURITY_HARDENING.md new file mode 100644 index 000000000..43f74518c --- /dev/null +++ b/docs/agent-runner-pluginization/SECURITY_HARDENING.md @@ -0,0 +1,209 @@ +# Agent Runner Security Boundary + +本文档记录 agent-runner 插件化后的安全边界和最小护栏。 + +## 状态 + +**当前结论:不采用高强度监管模型。** + +LangBot 的目标不是托管一个强隔离、不可信 code runner 平台。AgentRunner 插件,尤其是 ACP / Claude Code / Codex / OpenCode / Kimi Code 这类外部 harness,默认视为 **operator-owned execution**:用户或部署者显式配置并承担其文件系统、进程、网络、workspace、provider 登录态和 native tool 风险。 + +LangBot 需要负责的是保护 **LangBot 自己持有的资源**,包括模型、知识库、LangBot tools、history、event、state、plugin/workspace storage、sandbox/workspace 文件访问等。只要这些资源访问是 run-scoped、permission-scoped、可校验、可诊断的,当前阶段即可接受。 + +这意味着: + +- 不要求 LangBot 在应用层实现完整 OS sandbox、VM、cgroup、seccomp、CPU / memory / network quota。 +- 不要求为 ACP runner 做复杂审批流;用户选择 ACP runner 即表示显式 opt-in。 +- 不要求在非 Docker 进程部署里做强监管;只要文档明确风险归属即可。 +- Docker / K8s 可以提供部署级隔离,但不是 LangBot agent-runner 协议发布的前置条件。 +- 不能宣传 LangBot 已经提供 managed sandbox;除非未来真的提供受管执行环境。 + +## 责任边界 + +### LangBot Host 负责 + +- **资源授权**:根据 runner manifest permissions、binding resource policy、run scope 生成本次 run 可访问的资源快照。 +- **运行期校验**:所有带 `run_id` 的 SDK / Host action 必须校验 active run session、caller plugin identity、resource id 和 operation。 +- **Scoped projection**:只把授权后的资源摘要、MCP server config、context、attachment/path ref、state snapshot 投影给 runner。 +- **LangBot 文件路径约束**:LangBot 自己 staged 和读取的文件必须限制在声明 root 内,防止 path escape。 +- **基础 secret 策略**:不要主动把 LangBot 持有的 API key / token / secret 投影给 runner;日志和错误里做常见 secret 字段脱敏。 +- **基础运行约束**:提供 timeout、取消传播、输出大小限制或错误映射的基础能力。 +- **audit-lite**:记录 event、run id、runner id、binding、资源授权摘要、关键失败、state/file/transcript 事实。 + +### Runner Plugin 负责 + +- 遵守 Host 下发的 `ctx.resources`、`ctx.context.available_apis`、runner config 和 state policy。 +- 把 LangBot 资源投影成目标平台可消费的形式,例如 MCP config、context prompt、HTTP header、run token。 +- 不绕过 SDK / Host action 直接访问 LangBot 内部资源。 +- 对自己启动的外部进程做合理封装,包括参数构造、timeout、取消、输出解析和错误映射。 +- 清楚记录自身 README 中的 provider 风险、部署假设和限制。 + +### 部署者 / 用户负责 + +- ACP / external harness 的 workspace 内容、文件系统访问、进程权限、网络访问、provider-native tool 权限。 +- Docker / K8s 的 image、volume、secret、network policy、resource limit、namespace、service account 配置。 +- 本机进程部署时的 OS 用户权限、PATH、HOME、CLI 登录态、全局配置和外部 MCP 配置。 +- 是否允许 runner 对某个目录执行真实写操作。 + +### 外部 Harness 负责 + +Claude Code、Codex、OpenCode、Kimi Code、Gemini CLI 等外部工具继续使用自己的权限模型、MCP 加载策略、session/resume、sandbox 或 approval 能力。LangBot 不承诺约束这些工具对其所在容器或宿主 OS 用户本来可访问资源的能力。 + +## 部署场景策略 + +| 场景 | LangBot 策略 | 不由 LangBot 承担 | +| --- | --- | --- | +| 普通进程部署 | 文档提示 operator-owned execution;Host 只保护 LangBot 资源。 | 阻止外部 CLI 读取同一 OS 用户可访问的文件、进程、HOME、全局 CLI 配置。 | +| Docker / K8s 部署 | 继续使用相同 Host 资源边界;容器隔离由部署环境提供。 | 应用层重复实现容器/VM/cgroup/seccomp/network quota。 | +| ACP runner | 用户显式选择 runner 和 workspace;LangBot 注入 scoped MCP / run token。 | ACP CLI native tools、workspace 写入、provider 登录态和外部 MCP 行为。 | +| 外部 SaaS runner,例如 Dify | LangBot 通过 run token / gateway 限制 LangBot 资产访问。 | SaaS 平台内部 agent 执行策略、模型工具消息格式、平台侧日志。 | +| 未来 managed runner | 只有当 LangBot 明确提供受管执行环境时,才需要单独定义强隔离 SLA。 | 当前协议闭环不承诺 managed sandbox。 | + +## 最小护栏 + +以下是当前阶段需要维持的最小要求。它们是保护 LangBot 资源边界的要求,不是完整监管外部进程的要求。 + +### Resource Permission Boundary + +每次 run 前必须冻结授权快照: + +- runner manifest permissions 是资源访问上限。 +- binding resource policy / runner config 决定本次实际授权。 +- runtime action 按 `run_id` + `caller_plugin_identity` + resource id + operation 校验。 +- manifest permissions 只约束 LangBot 持有资源,不约束 external harness native tools。 + +当前实现方向是正确的:`AgentRunSessionRegistry` 保存 run-scoped snapshot,`plugin/handler.py` 对模型、工具、知识库、history、state、storage 等 action 做运行期校验,sandbox/workspace 文件访问由 scoped tool 边界控制。 + +### MCP / Asset Gateway Boundary + +LangBot MCP / asset gateway 只暴露当前 run 授权的工具面: + +- `langbot_list_assets` +- `langbot_get_current_event` +- `langbot_history_page` +- `langbot_retrieve_knowledge` +- `langbot_get_tool_detail` +- `langbot_call_tool` + +外部平台需要使用短期 `run_token` 或 Authorization bearer token。token 缺失、错误或过期时必须拒绝访问。 + +不要求当前阶段实现 admin 级 MCP allowlist、dangerous tool approval 或复杂审批流。是否注册外部 MCP provider 是部署者/用户行为。 + +### Workspace / Path Boundary + +LangBot 只需要约束自己管理的路径: + +- Host staged 文件必须校验 `realpath` 和 root containment。 +- Attachment/file metadata 不应暴露 Host-only storage key / host path。 +- Context 文件、sandbox/workspace 文件如由 LangBot 创建,应放在可清理的位置。 + +用户配置给 ACP runner 的 workspace 不属于 LangBot 的强监管范围。Docker/K8s 下依赖 volume 挂载边界;普通进程部署下依赖 OS 用户权限和用户自担风险。 + +### Secret Handling + +这里的 secret 指 API key、provider token、run token、MCP token、platform secret、数据库密码等。 + +当前阶段只要求基础策略: + +- LangBot 不主动把自己持有的 secret 投影给 runner,除非这是 runner config 明确需要的外部服务凭据。 +- run token 是短期、run-scoped 的,不应长期保存。 +- 日志、错误、transcript、attachment/file metadata 尽量避免打印常见 secret 字段。 +- 配置 UI / API 返回时继续沿用现有 secret masking 规则。 + +不要求当前阶段实现完整 DLP、全链路敏感数据追踪、secret lineage 或自动轮换体系。 + +### Process / Runtime Bounds + +LangBot 需要提供基本可控性: + +- Host run deadline / runner timeout。 +- runner 侧请求 timeout。 +- generator close / cancel 传播。 +- 输出和 inline payload size 上限。 +- 错误映射为受控 runner failure。 + +不要求 LangBot 为外部 harness 实现 CPU、内存、磁盘、网络、进程树强隔离。需要这些能力时由 Docker/K8s、systemd、容器平台或用户机器策略提供。 + +### UI / Admin Surface + +前端可以展示 runner 权限摘要,但它是信息披露,不是审批系统。 + +权限摘要指 runner manifest 声明的 LangBot 资源权限,例如: + +- `tools.detail` +- `tools.call` +- `knowledge_bases.retrieve` +- `history.page` +- `storage.plugin` + +当前阶段不要求强制弹窗、管理员审批、dangerous tool approval 或生产禁用开关。可以在 runner 配置区展示简短提示:此 runner 能访问哪些 LangBot 资源,外部 harness 执行风险由用户/部署者承担。 + +### Audit Lite + +需要记录足够排查问题的事实: + +- run id、runner id、binding、event。 +- 授权资源摘要。 +- state update、file write/read event、transcript message。 +- MCP / pull API 拒绝时的 warning。 +- steering queued / injected / dropped。 + +不要求当前阶段建立独立安全审计产品、审批记录系统或 SIEM 级事件模型。 + +## 降级后的检查表 + +| 项目 | 当前要求 | 状态判断 | +| --- | --- | --- | +| Path isolation | 只约束 LangBot 管理的 context/sandbox 文件路径;runner workspace 归用户/部署环境。 | Minimal required | +| 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 | +| Process isolation | 由 Docker/K8s/用户机器负责。 | Out of scope | +| State lifecycle | scope 隔离、JSON size limit、基础 cleanup primitive。 | Basic required | +| Audit | 记录运行事实和拒绝原因。 | Audit-lite | +| UI / Admin control | 权限摘要可展示;不要求审批流。 | Optional | +| Test matrix | 覆盖 run auth、MCP token、permission deny、timeout、sandbox path、state size。 | Focused tests | + +## 当前实现快照 + +截至 2026-06-15,已有实现覆盖: + +- SDK typed AgentRunner manifest、capabilities、permissions。 +- Host resource builder 按 manifest permissions 和 binding policy 生成 `ctx.resources`。 +- Active run session snapshot 和 `caller_plugin_identity` 校验。 +- History / event / state / tool / knowledge runtime action 的 run-scoped 校验。 +- Sandbox file path `realpath` + root containment。 +- Persistent state scope 隔离和 JSON size limit。 +- SDK-owned MCP bridge 和 long-lived asset gateway。 +- Dify / ACP runner 对 LangBot asset gateway 的接入。 +- Runner timeout、Dify HTTP timeout、ACP startup / initialize / request timeout。 + +仍可继续优化但不阻塞当前发布的事项: + +- 前端展示 runner LangBot 资源权限摘要。 +- 常见 secret 字段 redaction 收敛成统一 helper。 +- Context/sandbox file TTL cleanup 调度。 +- 更完整的 MCP 调用 audit。 +- 更好的文档提示:ACP runner 是 operator-owned execution。 + +## 非目标 + +以下不属于当前 agent-runner pluginization 的安全目标: + +- 防止 ACP / external harness 修改其 workspace。 +- 防止外部 CLI 读取同一容器或 OS 用户本来可读的文件。 +- 管控 external harness 的 provider-native tools、approval、MCP、browser、shell。 +- 在 LangBot 应用层实现 VM / container / cgroup / seccomp / network policy。 +- 为 Docker/K8s 部署替代平台自身的 secret、volume、network、resource limit 管理。 +- 实现企业级审批系统、SIEM、DLP 或安全运营面板。 + +## 发布口径 + +可以对外说明: + +> AgentRunner 插件通过 run-scoped authorization 和 scoped MCP gateway 保护 LangBot 持有资源。外部 code harness 的执行环境由用户或部署平台负责隔离;LangBot 当前不提供 managed sandbox。 + +不能对外说明: + +> LangBot 已经安全沙箱化 Claude Code / Codex / OpenCode 等外部 runner。 diff --git a/docs/agent-runner-pluginization/STATUS.md b/docs/agent-runner-pluginization/STATUS.md new file mode 100644 index 000000000..ad9d8ea90 --- /dev/null +++ b/docs/agent-runner-pluginization/STATUS.md @@ -0,0 +1,57 @@ +# AgentRunner Pluginization Status + +本文档是 `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。 + +## 实现状态 + +| 领域 | 状态 | 说明 | +| --- | --- | --- | +| SDK manifest schema | Done | `AgentRunnerManifest` 包含 typed `capabilities` / `permissions`;未知 capability / permission key 禁止进入 typed model。 | +| Runner discovery | Done | Runtime 返回 typed manifest;Host registry 校验单个 runner,失败 warning + skip,不影响其它 runner。 | +| Host resource authorization | Done | `ctx.resources` 和 `ctx.context.available_apis` 由 manifest permissions 与 binding policy / run scope 求交后生成。 | +| Run authorization snapshot | Done | active run session 冻结 run-scoped resources 与 available APIs;runtime handler 按 snapshot 校验 pull API。 | +| 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。 | +| 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` 审计终态。 | +| SDK v1 contract closure | Done | SDK 提供 `AgentAPIError` / `AgentAPIException`、typed `SteeringPullResult`、未知 result type 宽容解析、result `sequence` 注入与取消传播。 | + +## Spec 与实现已知差距 + +- `action.requested` 仍只作为 telemetry / reserved surface;platform action executor 不在本分支执行。 +- EventGateway / EventRouter 完整实现由外部 EBA 分支联调;本分支只提供 event-first host envelope / binding / run 入口。 +- State 与 storage 的长期类型边界仍可继续收窄;当前合同只要求 JSON-safe state 与受控 storage API。 +- 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 权限由用户或部署环境承担。 +- Runtime Control Plane v2 当前只落地 Host 事实源和控制原语;还没有内置 Agent Platform UI、业务队列、daemon 进程托管、runtime wakeup channel、跨 Host 分布式锁或 provider 登录态诊断。 + +## Runner 验收状态 + +| Runner | 状态 | 最近证据 | +| --- | --- | --- | +| `plugin:langbot/local-agent/default` | Unit-pass; UI smoke pending | 2026-06-10 本地 pytest / ruff 通过;WebUI smoke 由人工统一执行。 | +| `plugin:langbot/acp-agent-runner/default` / `plugin:langbot/claude-code-agent/default` / `plugin:langbot/codex-agent/default` | Unit-pass; E2E pending | 通过 runner 仓库单测覆盖 session、run_id 注入和 LangBot MCP gateway;真实 harness E2E 取决于对应运行环境、CLI/daemon 可用性和 provider 登录态。 | +| Dify / n8n / Coze / DashScope / Langflow / Tbox / DeerFlow / WeKnora | Unit-pass; credential smoke optional | 2026-06-13 plugin layout / parser tests 通过;真实服务凭据 smoke 非每轮必跑。 | + +## Host / SDK 验收状态 + +| 范围 | 状态 | 最近证据 | +| --- | --- | --- | +| LangBot Runtime Control Plane v2 foundation | Unit-pass; product E2E pending | 2026-06-16 `tests/unit_tests/agent/test_run_ledger_store.py`、`test_run_ledger_api_auth.py`、`test_orchestrator_integration.py` 通过,覆盖 ledger、admin permissions、runtime heartbeat、claim/reconcile、orchestrator 持久化和取消传播。 | +| SDK AgentRunner control entities / proxy | Unit-pass | 2026-06-16 SDK agent-runner 相关单测通过,覆盖 typed run ledger entities、AgentRunAPIProxy、MCP bridge、runtime manager 与 pull API handlers。 | + +## 历史高价值记录 + +历史报告已合并为本状态页和 QA 指南,不再保留单独进度文档。后续若需要追溯,优先查看 `langbot-skills/reports/` 下的原始执行报告。 + +截至 2026-05-29,已有本地 smoke 证明: + +- `local-agent` 可以通过 Pipeline Debug Chat 走插件化 `AgentRunOrchestrator` 主链路。 +- 外部 harness runner 可以通过同一条 `run(event, binding)` 路径执行;当前官方实现已收敛到 ACP / Claude Code / Codex 等直接 runner 插件。 + +这些记录只证明本地协议闭环可用,不代表 LangBot 提供 managed sandbox 或 external harness OS 级隔离。 diff --git a/src/langbot/pkg/agent/__init__.py b/src/langbot/pkg/agent/__init__.py new file mode 100644 index 000000000..4da739d70 --- /dev/null +++ b/src/langbot/pkg/agent/__init__.py @@ -0,0 +1,37 @@ +"""Agent runner subsystem for LangBot.""" +from __future__ import annotations + +from .runner.descriptor import AgentRunnerDescriptor +from .runner.id import parse_runner_id, format_runner_id, RunnerIdParts, is_plugin_runner_id +from .runner.errors import ( + AgentRunnerError, + RunnerNotFoundError, + RunnerNotAuthorizedError, + RunnerProtocolError, + RunnerExecutionError, +) +from .runner.registry import AgentRunnerRegistry +from .runner.context_builder import AgentRunContextBuilder +from .runner.resource_builder import AgentResourceBuilder +from .runner.result_normalizer import AgentResultNormalizer +from .runner.orchestrator import AgentRunOrchestrator +from .runner.config_migration import ConfigMigration + +__all__ = [ + 'AgentRunnerDescriptor', + 'parse_runner_id', + 'format_runner_id', + 'is_plugin_runner_id', + 'RunnerIdParts', + 'AgentRunnerError', + 'RunnerNotFoundError', + 'RunnerNotAuthorizedError', + 'RunnerProtocolError', + 'RunnerExecutionError', + 'AgentRunnerRegistry', + 'AgentRunContextBuilder', + 'AgentResourceBuilder', + 'AgentResultNormalizer', + 'AgentRunOrchestrator', + 'ConfigMigration', +] \ No newline at end of file diff --git a/src/langbot/pkg/agent/runner/__init__.py b/src/langbot/pkg/agent/runner/__init__.py new file mode 100644 index 000000000..0dc533aa7 --- /dev/null +++ b/src/langbot/pkg/agent/runner/__init__.py @@ -0,0 +1,66 @@ +"""Agent runner modules.""" + +from __future__ import annotations + +from .descriptor import AgentRunnerDescriptor +from .id import parse_runner_id, format_runner_id, RunnerIdParts +from .errors import ( + AgentRunnerError, + RunnerNotFoundError, + RunnerNotAuthorizedError, + RunnerProtocolError, + RunnerExecutionError, +) +from .registry import AgentRunnerRegistry +from .context_builder import AgentRunContextBuilder +from .resource_builder import AgentResourceBuilder +from .result_normalizer import AgentResultNormalizer +from .orchestrator import AgentRunOrchestrator +from .config_migration import ConfigMigration +from .default_config import AgentRunnerDefaultConfigService +from .binding_resolver import AgentBindingResolver, AgentBindingResolutionError +from .session_registry import ( + AgentRunSessionRegistry, + AgentRunSession, + RunAuthorizationSnapshot, + get_session_registry, +) +from .run_ledger_store import RunLedgerStore +from .events import ( + MESSAGE_RECEIVED, + MESSAGE_RECALLED, + GROUP_MEMBER_JOINED, + FRIEND_REQUEST_RECEIVED, + RESERVED_EVENT_TYPES, +) + +__all__ = [ + 'AgentRunnerDescriptor', + 'parse_runner_id', + 'format_runner_id', + 'RunnerIdParts', + 'AgentRunnerError', + 'RunnerNotFoundError', + 'RunnerNotAuthorizedError', + 'RunnerProtocolError', + 'RunnerExecutionError', + 'AgentRunnerRegistry', + 'AgentRunContextBuilder', + 'AgentResourceBuilder', + 'AgentResultNormalizer', + 'AgentRunOrchestrator', + 'ConfigMigration', + 'AgentRunnerDefaultConfigService', + 'AgentBindingResolver', + 'AgentBindingResolutionError', + 'AgentRunSessionRegistry', + 'AgentRunSession', + 'RunAuthorizationSnapshot', + 'get_session_registry', + 'RunLedgerStore', + 'MESSAGE_RECEIVED', + 'MESSAGE_RECALLED', + 'GROUP_MEMBER_JOINED', + 'FRIEND_REQUEST_RECEIVED', + 'RESERVED_EVENT_TYPES', +] diff --git a/src/langbot/pkg/agent/runner/binding_resolver.py b/src/langbot/pkg/agent/runner/binding_resolver.py new file mode 100644 index 000000000..fea5ad6b5 --- /dev/null +++ b/src/langbot/pkg/agent/runner/binding_resolver.py @@ -0,0 +1,70 @@ +"""Resolve host events to one effective Agent binding.""" + +from __future__ import annotations + +from .host_models import AgentConfig, AgentBinding, AgentEventEnvelope, BindingScope + + +class AgentBindingResolutionError(Exception): + """Raised when an event cannot resolve to exactly one Agent binding.""" + + +class AgentBindingResolver: + """Resolve an event to a single AgentBinding. + + The target product model is one bot / IM channel -> one Agent. Fan-out, + observer agents, or multi-runner arbitration require separate delivery and + state semantics and are intentionally not hidden in this resolver. + """ + + def resolve_one( + self, + event: AgentEventEnvelope, + agents: list[AgentConfig], + ) -> AgentBinding: + """Resolve exactly one enabled Agent for the event. + + Callers that source agents from bot/workspace/global configuration must + pre-filter candidates to the event scope before calling this resolver. + The current AgentConfig model represents one already-selected product + Agent and does not carry enough scope metadata to make that decision + safely here. + """ + matches = [ + agent + for agent in agents + if agent.enabled and event.event_type in agent.event_types + ] + + if not matches: + raise AgentBindingResolutionError( + f'No Agent binding matches event_type={event.event_type}' + ) + + if len(matches) > 1: + agent_ids = ', '.join(agent.agent_id or '' for agent in matches) + raise AgentBindingResolutionError( + f'Multiple Agent bindings match event_type={event.event_type}: {agent_ids}' + ) + + return self._to_binding(matches[0]) + + def _to_binding(self, agent: AgentConfig) -> AgentBinding: + """Project product-level Agent config into the run-time binding model.""" + scope = BindingScope( + scope_type='agent', + scope_id=agent.agent_id, + ) + + return AgentBinding( + binding_id=f"agent_{agent.agent_id or 'default'}_{agent.runner_id}", + scope=scope, + event_types=list(agent.event_types), + runner_id=agent.runner_id, + runner_config=agent.runner_config, + resource_policy=agent.resource_policy, + state_policy=agent.state_policy, + delivery_policy=agent.delivery_policy, + enabled=agent.enabled, + agent_id=agent.agent_id, + ) diff --git a/src/langbot/pkg/agent/runner/config_migration.py b/src/langbot/pkg/agent/runner/config_migration.py new file mode 100644 index 000000000..0470aa234 --- /dev/null +++ b/src/langbot/pkg/agent/runner/config_migration.py @@ -0,0 +1,171 @@ +"""Helpers for the current AgentRunner config shape.""" + +from __future__ import annotations + +import typing + + +LEGACY_RUNNER_ID_MAP: dict[str, str] = { + 'local-agent': 'plugin:langbot/local-agent/default', + 'dify-service-api': 'plugin:langbot/dify-agent/default', + 'n8n-service-api': 'plugin:langbot/n8n-agent/default', + 'coze-api': 'plugin:langbot/coze-agent/default', + 'dashscope-app-api': 'plugin:langbot/dashscope-agent/default', + 'deerflow-api': 'plugin:langbot/deerflow-agent/default', + 'langflow-api': 'plugin:langbot/langflow-agent/default', + 'tbox-app-api': 'plugin:langbot/tbox-agent/default', + 'weknora-api': 'plugin:langbot/weknora-agent/default', +} + + +class ConfigMigration: + """Configuration helper for agent runner IDs. + + Responsibilities: + - Resolve runner ID from ai.runner.id + - Migrate legacy ai.runner.runner + ai. blocks + - Extract current Agent/runner config from ai.runner_config + - Keep the current config container shape stable on save + """ + + @staticmethod + def resolve_runner_id(pipeline_config: dict[str, typing.Any]) -> str | None: + """Resolve runner ID from current configuration. + + Args: + pipeline_config: Current configuration container + + Returns: + Runner ID string, or None if not configured + """ + ai_config = pipeline_config.get('ai', {}) + runner_config = ai_config.get('runner', {}) + + runner_id = runner_config.get('id') + if runner_id: + return runner_id + + legacy_runner = runner_config.get('runner') + if isinstance(legacy_runner, str): + return LEGACY_RUNNER_ID_MAP.get(legacy_runner) + + return None + + @staticmethod + def resolve_runner_config( + pipeline_config: dict[str, typing.Any], + runner_id: str, + ) -> dict[str, typing.Any]: + """Resolve Agent/runner configuration from the current container. + + Args: + pipeline_config: Current configuration container + runner_id: Resolved runner ID + + Returns: + Runner configuration dict (empty if not found) + """ + ai_config = pipeline_config.get('ai', {}) + + runner_configs = ai_config.get('runner_config', {}) + if runner_id in runner_configs: + return runner_configs[runner_id] + + legacy_runner = ConfigMigration._legacy_runner_name_for_id(runner_id) + if legacy_runner and isinstance(ai_config.get(legacy_runner), dict): + return ConfigMigration._normalize_legacy_runner_config( + legacy_runner, + ai_config[legacy_runner], + ) + + return {} + + @staticmethod + def get_expire_time(pipeline_config: dict[str, typing.Any]) -> int: + """Get conversation expire time from configuration. + + Args: + pipeline_config: Current configuration container + + Returns: + Expire time in seconds (0 means no expiry) + """ + ai_config = pipeline_config.get('ai', {}) + runner_config = ai_config.get('runner', {}) + return runner_config.get('expire-time', 0) + + @staticmethod + def migrate_pipeline_config(pipeline_config: dict[str, typing.Any]) -> dict[str, typing.Any]: + """Normalize the current config container before saving. + + Args: + pipeline_config: Original configuration + + Returns: + Configuration with explicit ai.runner and ai.runner_config containers + """ + new_config = dict(pipeline_config) + if 'ai' not in new_config: + return new_config + + ai_config = dict(new_config.get('ai', {})) + + runner_config = dict(ai_config.get('runner', {})) + runner_configs = dict(ai_config.get('runner_config', {})) + + legacy_runner = runner_config.get('runner') + mapped_runner_id = None + if isinstance(legacy_runner, str): + mapped_runner_id = LEGACY_RUNNER_ID_MAP.get(legacy_runner) + + if mapped_runner_id and not runner_config.get('id'): + runner_config = { + key: value + for key, value in runner_config.items() + if key != 'runner' + } + runner_config['id'] = mapped_runner_id + + if mapped_runner_id and mapped_runner_id not in runner_configs: + legacy_config = ai_config.get(legacy_runner) + if isinstance(legacy_config, dict): + runner_configs[mapped_runner_id] = ConfigMigration._normalize_legacy_runner_config( + legacy_runner, + legacy_config, + ) + + ai_config['runner'] = runner_config + ai_config['runner_config'] = runner_configs + if mapped_runner_id and legacy_runner in ai_config: + ai_config.pop(legacy_runner, None) + new_config['ai'] = ai_config + + return new_config + + @staticmethod + def _legacy_runner_name_for_id(runner_id: str) -> str | None: + for legacy_runner, mapped_runner_id in LEGACY_RUNNER_ID_MAP.items(): + if mapped_runner_id == runner_id: + return legacy_runner + return None + + @staticmethod + def _normalize_legacy_runner_config( + legacy_runner: str, + legacy_config: dict[str, typing.Any], + ) -> dict[str, typing.Any]: + """Normalize legacy runner config blocks to current plugin schema quirks.""" + normalized = dict(legacy_config) + + if legacy_runner == 'local-agent': + model = normalized.get('model') + if isinstance(model, str): + normalized['model'] = { + 'primary': model, + 'fallbacks': [], + } + knowledge_base = normalized.pop('knowledge-base', None) + if 'knowledge-bases' not in normalized and isinstance(knowledge_base, str): + normalized['knowledge-bases'] = [] if knowledge_base in {'', '__none__', '__none'} else [knowledge_base] + + return normalized diff --git a/src/langbot/pkg/agent/runner/config_schema.py b/src/langbot/pkg/agent/runner/config_schema.py new file mode 100644 index 000000000..ba841e59b --- /dev/null +++ b/src/langbot/pkg/agent/runner/config_schema.py @@ -0,0 +1,204 @@ +"""Helpers for interpreting AgentRunner DynamicForm configuration.""" +from __future__ import annotations + +import typing + +from .descriptor import AgentRunnerDescriptor + + +FORM_ITEM_TYPE_ALIASES = { + 'select-llm-model': 'llm-model-selector', + 'select-knowledge-bases': 'knowledge-base-multi-selector', +} +LLM_MODEL_SELECTOR_TYPES = {'model-fallback-selector', 'llm-model-selector'} +KB_SELECTOR_TYPES = {'knowledge-base-multi-selector'} +PROMPT_EDITOR_TYPES = {'prompt-editor'} +NONE_SENTINELS = {'', '__none__', '__none'} + + +def normalize_schema_item_type(item_type: typing.Any) -> typing.Any: + """Normalize legacy/frontend DynamicForm aliases to protocol field types.""" + if not isinstance(item_type, str): + return item_type + return FORM_ITEM_TYPE_ALIASES.get(item_type, item_type) + + +def iter_schema_items( + descriptor: AgentRunnerDescriptor | None, + field_types: set[str], +) -> typing.Iterator[dict[str, typing.Any]]: + """Yield descriptor config schema items whose type is in field_types.""" + if descriptor is None: + return + for item in descriptor.config_schema or []: + if not isinstance(item, dict): + continue + if normalize_schema_item_type(item.get('type')) in field_types: + yield item + + +def uses_host_models(descriptor: AgentRunnerDescriptor | None) -> bool: + """Return whether LangBot should resolve model resources for this runner.""" + return any(True for _ in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES)) + + +def uses_host_tools(descriptor: AgentRunnerDescriptor | None) -> bool: + """Return whether LangBot should expose tool resources to this runner.""" + return descriptor is not None and descriptor.supports_tool_calling() + + +def uses_host_knowledge_bases(descriptor: AgentRunnerDescriptor | None) -> bool: + """Return whether LangBot should expose knowledge-base resources to this runner.""" + return descriptor is not None and descriptor.supports_knowledge_retrieval() + + +def supports_skill_authoring(descriptor: AgentRunnerDescriptor | None) -> bool: + """Return whether the runner wants Host skill-authoring tools.""" + if descriptor is None: + return False + return descriptor.capabilities.skill_authoring + + +def extract_prompt_config( + descriptor: AgentRunnerDescriptor | None, + runner_config: dict[str, typing.Any], + default_prompt: list[dict[str, typing.Any]], +) -> list[dict[str, typing.Any]]: + """Extract the prompt-editor value selected by the runner schema.""" + for item in iter_schema_items(descriptor, PROMPT_EDITOR_TYPES): + field_name = item.get('name') + if field_name and field_name in runner_config: + configured_prompt = runner_config[field_name] + if isinstance(configured_prompt, list): + return configured_prompt + default_value = item.get('default') + if isinstance(default_value, list): + return default_value + return default_prompt + + +def extract_model_selection( + descriptor: AgentRunnerDescriptor | None, + runner_config: dict[str, typing.Any], +) -> tuple[str, list[str]]: + """Extract primary/fallback LLM selections from schema-defined fields.""" + primary_uuid = '' + fallback_uuids: list[str] = [] + + for item in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES): + field_name = item.get('name') + if not field_name: + continue + + value = runner_config.get(field_name, item.get('default')) + item_type = normalize_schema_item_type(item.get('type')) + if item_type == 'model-fallback-selector': + if isinstance(value, str): + primary_uuid = value + elif isinstance(value, dict): + primary_uuid = value.get('primary') or '' + fallbacks = value.get('fallbacks', []) + if isinstance(fallbacks, list): + fallback_uuids = [fallback for fallback in fallbacks if isinstance(fallback, str)] + break + + if item_type == 'llm-model-selector' and isinstance(value, str): + primary_uuid = value + break + + return primary_uuid, fallback_uuids + + +def extract_knowledge_base_uuids( + descriptor: AgentRunnerDescriptor | None, + runner_config: dict[str, typing.Any], +) -> list[str]: + """Extract configured knowledge-base UUIDs from schema-defined fields.""" + if not uses_host_knowledge_bases(descriptor): + return [] + + kb_uuids: list[str] = [] + for item in iter_schema_items(descriptor, KB_SELECTOR_TYPES): + field_name = item.get('name') + if not field_name: + continue + value = runner_config.get(field_name, item.get('default', [])) + if isinstance(value, list): + kb_uuids.extend( + kb_uuid for kb_uuid in value if isinstance(kb_uuid, str) and kb_uuid not in NONE_SENTINELS + ) + + return list(dict.fromkeys(kb_uuids)) + + +def iter_config_model_refs( + descriptor: AgentRunnerDescriptor, + runner_config: dict[str, typing.Any], +) -> typing.Iterator[tuple[str, str]]: + """Yield model references declared by schema-defined model selector fields.""" + for item in descriptor.config_schema or []: + if not isinstance(item, dict): + continue + + field_name = item.get('name') + field_type = normalize_schema_item_type(item.get('type')) + if not field_name or field_name not in runner_config: + continue + + value = runner_config.get(field_name) + if field_type == 'model-fallback-selector': + if isinstance(value, str) and value not in NONE_SENTINELS: + yield 'llm', value + elif isinstance(value, dict): + primary = value.get('primary') + if isinstance(primary, str) and primary not in NONE_SENTINELS: + yield 'llm', primary + fallbacks = value.get('fallbacks', []) + if isinstance(fallbacks, list): + for fallback_uuid in fallbacks: + if isinstance(fallback_uuid, str) and fallback_uuid not in NONE_SENTINELS: + yield 'llm', fallback_uuid + elif field_type == 'llm-model-selector': + if isinstance(value, str) and value not in NONE_SENTINELS: + yield 'llm', value + elif field_type == 'rerank-model-selector': + if isinstance(value, str) and value not in NONE_SENTINELS: + yield 'rerank', value + + +def set_empty_llm_model_selection( + descriptor: AgentRunnerDescriptor, + runner_config: dict[str, typing.Any], + model_uuid: str, +) -> bool: + """Set the first empty schema-defined LLM selector to model_uuid.""" + for item in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES): + field_name = item.get('name') + field_type = normalize_schema_item_type(item.get('type')) + if not field_name: + continue + + value = runner_config.get(field_name, item.get('default')) + if field_type == 'model-fallback-selector': + if isinstance(value, dict): + primary = value.get('primary') or '' + if primary not in NONE_SENTINELS: + return False + fallbacks = value.get('fallbacks', []) + runner_config[field_name] = { + 'primary': model_uuid, + 'fallbacks': fallbacks if isinstance(fallbacks, list) else [], + } + return True + if isinstance(value, str) and value not in NONE_SENTINELS: + return False + runner_config[field_name] = {'primary': model_uuid, 'fallbacks': []} + return True + + if field_type == 'llm-model-selector': + if isinstance(value, str) and value not in NONE_SENTINELS: + return False + runner_config[field_name] = model_uuid + return True + + return False diff --git a/src/langbot/pkg/agent/runner/context_builder.py b/src/langbot/pkg/agent/runner/context_builder.py new file mode 100644 index 000000000..7da30b40f --- /dev/null +++ b/src/langbot/pkg/agent/runner/context_builder.py @@ -0,0 +1,490 @@ +"""Agent run context builder for provisioning AgentRunContext envelopes.""" + +from __future__ import annotations + +import uuid +import time +import typing + +from ...core import app +from .descriptor import AgentRunnerDescriptor +from .persistent_state_store import get_persistent_state_store +from .host_models import AgentEventEnvelope, AgentBinding + + +DEFAULT_RUNNER_TIMEOUT_SECONDS = 300 + + +# Internal models for the agent runner context protocol. + + +class AgentTrigger(typing.TypedDict): + """Agent trigger information.""" + + type: str + source: str + timestamp: int | None + + +class ConversationContext(typing.TypedDict): + """Conversation context.""" + + conversation_id: str | None + thread_id: str | None + launcher_type: str | None + launcher_id: str | None + sender_id: str | None + bot_id: str | None + workspace_id: str | None + session_id: str | None + + +class AgentInput(typing.TypedDict): + """Agent input.""" + + text: str | None + contents: list[dict[str, typing.Any]] + attachments: list[dict[str, typing.Any]] + + +class AgentRunState(typing.TypedDict): + """Agent run state with 4 scopes.""" + + conversation: dict[str, typing.Any] + actor: dict[str, typing.Any] + subject: dict[str, typing.Any] + runner: dict[str, typing.Any] + + +# Resource payload models matching langbot-plugin-sdk/resources.py. + + +class ModelResource(typing.TypedDict): + """Model resource payload.""" + + model_id: str + model_type: str | None + provider: str | None + operations: list[str] + + +class ToolResource(typing.TypedDict): + """Tool resource payload.""" + + tool_name: str + tool_type: str | None + description: str | None + operations: list[str] + + +class KnowledgeBaseResource(typing.TypedDict): + """Knowledge base resource payload.""" + + kb_id: str + kb_name: str | None + kb_type: str | None + operations: list[str] + + +class SkillResource(typing.TypedDict): + """Skill resource payload.""" + + skill_name: str + display_name: str | None + description: str | None + + +class StorageResource(typing.TypedDict): + """Storage resource payload.""" + + plugin_storage: bool + workspace_storage: bool + + +class AgentResources(typing.TypedDict): + """Agent resources payload.""" + + models: list[ModelResource] + tools: list[ToolResource] + knowledge_bases: list[KnowledgeBaseResource] + skills: list[SkillResource] + storage: StorageResource + platform_capabilities: dict[str, typing.Any] + + +class AgentRuntimeContext(typing.TypedDict): + """Agent runtime context.""" + + langbot_version: str | None + trace_id: str | None + deadline_at: float | None + metadata: dict[str, typing.Any] + + +class AgentRunContextPayload(typing.TypedDict): + """AgentRunContext payload passed to an agent runner. + + Protocol v1 structure - matches SDK AgentRunContext. + + Note: The 'config' field contains the current Agent/runner config + from ai.runner_config[runner_id] while the current Query entry remains + a temporary configuration container. It is not plugin instance config. + """ + + run_id: str + trigger: AgentTrigger + conversation: ConversationContext | None + event: dict[str, typing.Any] # REQUIRED for Protocol v1 + actor: dict[str, typing.Any] | None + subject: dict[str, typing.Any] | None + input: AgentInput + delivery: dict[str, typing.Any] # REQUIRED for Protocol v1 + resources: AgentResources + context: dict[str, typing.Any] # ContextAccess - REQUIRED for Protocol v1 + state: AgentRunState + runtime: AgentRuntimeContext + config: dict[str, typing.Any] # Agent/runner config from ai.runner_config[runner_id] + adapter: dict[str, typing.Any] | None # Entry adapter context + metadata: dict[str, typing.Any] # Additional metadata + + +class AgentRunContextBuilder: + """Builder for provisioning AgentRunContext. + + Responsibilities: + - Generate new run_id (UUID, not query id) + - Set trigger type based on event source + - Build conversation context from event + - Build input from event + - Build state snapshot from PersistentStateStore + - Build runtime context with host info, trace_id, deadline + - Set config from current Agent/runner configuration. + + Query adaptation belongs to QueryEntryAdapter, not this builder. + """ + + ap: app.Application + + def __init__(self, ap: app.Application): + self.ap = ap + + @staticmethod + def _positive_int(value: typing.Any) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int) and value > 0: + return value + if isinstance(value, str) and value.isdigit(): + parsed_value = int(value) + if parsed_value > 0: + return parsed_value + return None + + @staticmethod + def _is_llm_model_resource(model_resource: ModelResource) -> bool: + operations = model_resource.get('operations') + if isinstance(operations, list) and operations: + return bool({'invoke', 'stream'} & {str(operation) for operation in operations}) + return model_resource.get('model_type') != 'rerank' + + async def _build_model_context_window_tokens(self, resources: AgentResources) -> int | None: + model_mgr = getattr(self.ap, 'model_mgr', None) + if model_mgr is None: + return None + + for model_resource in resources.get('models', []): + if not self._is_llm_model_resource(model_resource): + continue + + model_uuid = model_resource.get('model_id') + if not isinstance(model_uuid, str) or not model_uuid: + continue + + try: + model = await model_mgr.get_model_by_uuid(model_uuid) + except Exception as exc: + logger = getattr(self.ap, 'logger', None) + if logger is not None: + logger.debug(f'Failed to resolve model context window for {model_uuid}: {exc}') + continue + + model_entity = getattr(model, 'model_entity', None) + context_length = self._positive_int(getattr(model_entity, 'context_length', None)) + return context_length + + return None + + async def build_context_from_event( + self, + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, + resources: AgentResources, + ) -> AgentRunContextPayload: + """Build AgentRunContext from event-first envelope. + + This is the main entry point for Protocol v1. + Does NOT inline full history by default. + + Args: + event: Event envelope + binding: Agent binding + descriptor: Runner descriptor + resources: Built resources + + Returns: + AgentRunContextPayload for the runner + """ + # Generate new run_id + run_id = str(uuid.uuid4()) + + # Build trigger from event + trigger: AgentTrigger = { + 'type': event.event_type, + 'source': event.source, + 'timestamp': event.event_time or int(time.time()), + } + + # Build conversation context from event + conversation: ConversationContext | None = None + if event.conversation_id: + conversation = { + 'session_id': None, + 'conversation_id': event.conversation_id, + 'thread_id': event.thread_id, + 'launcher_type': None, # Will be filled from actor/subject if needed + 'launcher_id': None, + 'sender_id': event.actor.actor_id if event.actor else None, + 'bot_id': event.bot_id, + 'workspace_id': event.workspace_id, + } + + # Build event context (Protocol v1 event-first) + event_context = { + 'event_id': event.event_id, + 'event_type': event.event_type, + 'event_time': event.event_time, + 'source': event.source, + 'source_event_type': event.source_event_type, + 'raw_ref': event.raw_ref.model_dump(mode='json') if event.raw_ref else None, + 'data': event.data, + } + + # Build actor context + actor_context = None + if event.actor: + actor_context = { + 'actor_type': event.actor.actor_type, + 'actor_id': event.actor.actor_id, + 'actor_name': event.actor.actor_name, + } + + # Build subject context + subject_context = None + if event.subject: + subject_context = { + 'subject_type': event.subject.subject_type, + 'subject_id': event.subject.subject_id, + 'data': event.subject.data, + } + + # Build input from event + input: AgentInput = { + 'text': event.input.text, + 'contents': [c.model_dump(mode='json') if hasattr(c, 'model_dump') else c for c in event.input.contents], + 'attachments': [ + a.model_dump(mode='json') if hasattr(a, 'model_dump') else a for a in event.input.attachments + ], + } + + # Build context access (no history inlined by default for Protocol v1) + # Populate with actual values from stores + context_access = await self._build_context_access(event, descriptor, binding) + + # Build state snapshot from persistent state store (event-first Protocol v1) + persistent_state_store = get_persistent_state_store(self.ap.persistence_mgr.get_db_engine()) + state: AgentRunState = await persistent_state_store.build_snapshot_from_event(event, binding, descriptor) + + model_context_window_tokens = await self._build_model_context_window_tokens(resources) + + # Build runtime context + runtime: AgentRuntimeContext = { + 'langbot_version': self.ap.ver_mgr.get_current_version(), + 'trace_id': run_id, + 'deadline_at': self._build_deadline_from_binding(binding), + 'metadata': { + 'bot_id': event.bot_id, + 'workspace_id': event.workspace_id, + 'streaming_supported': event.delivery.supports_streaming, + 'model_context_window_tokens': model_context_window_tokens, + }, + } + + # Build delivery context + delivery_context = { + 'surface': event.delivery.surface, + 'reply_target': event.delivery.reply_target, + 'supports_streaming': event.delivery.supports_streaming, + 'supports_edit': event.delivery.supports_edit, + 'supports_reaction': event.delivery.supports_reaction, + 'max_message_size': event.delivery.max_message_size, + 'platform_capabilities': event.delivery.platform_capabilities, + } + + # Build adapter context (empty for event-first) + adapter_context = { + 'extra': {}, + } + + # Build full context - Protocol v1 structure + context: AgentRunContextPayload = { + 'run_id': run_id, + 'trigger': trigger, + 'conversation': conversation, + 'event': event_context, # REQUIRED + 'actor': actor_context, + 'subject': subject_context, + 'input': input, + 'delivery': delivery_context, # REQUIRED + 'resources': resources, + 'context': context_access, # ContextAccess - REQUIRED + 'state': state, + 'runtime': runtime, + 'config': binding.runner_config, + 'adapter': adapter_context, + 'metadata': {}, # Additional metadata + } + + return context + + def _build_deadline_from_binding(self, binding: AgentBinding) -> float | None: + """Build deadline timestamp from binding timeout config. + + Args: + binding: Agent binding with runner_config + + Returns: + Deadline timestamp or None + """ + timeout = binding.runner_config.get('timeout', DEFAULT_RUNNER_TIMEOUT_SECONDS) + if timeout is None: + return None + + try: + timeout_seconds = float(timeout) + except (TypeError, ValueError): + return None + + if timeout_seconds <= 0: + return None + + return time.time() + timeout_seconds + + async def _build_context_access( + self, + event: AgentEventEnvelope, + descriptor: AgentRunnerDescriptor, + binding: AgentBinding | None = None, + ) -> dict[str, typing.Any]: + """Build ContextAccess with actual values from stores. + + Args: + event: Event envelope + descriptor: Runner descriptor + binding: Agent binding (required for state_policy in event-first mode) + + Returns: + ContextAccess dict + """ + conversation_id = event.conversation_id + permissions = descriptor.permissions + history_perms = set(permissions.history) + event_perms = set(permissions.events) + storage_perms = set(permissions.storage) + + history_page_enabled = 'page' in history_perms and conversation_id is not None + history_search_enabled = 'search' in history_perms and conversation_id is not None + event_get_enabled = 'get' in event_perms + event_page_enabled = 'page' in event_perms and conversation_id is not None + steering_pull_enabled = ( + bool(getattr(descriptor.capabilities, 'steering', False)) and conversation_id is not None + ) + run_get_enabled = True + run_list_enabled = conversation_id is not None + run_events_page_enabled = True + run_cancel_enabled = True + run_append_result_enabled = False + run_finalize_enabled = False + run_claim_enabled = False + run_renew_claim_enabled = False + run_release_claim_enabled = False + runtime_register_enabled = False + runtime_heartbeat_enabled = False + runtime_list_enabled = False + + # Determine state API availability based on binding state_policy. + state_enabled = False + storage_enabled = False + if binding is not None: + state_policy = binding.state_policy + if state_policy.enable_state and state_policy.state_scopes: + state_enabled = True + + resource_policy = binding.resource_policy + storage_enabled = ('plugin' in storage_perms and resource_policy.allow_plugin_storage) or ( + 'workspace' in storage_perms and resource_policy.allow_workspace_storage + ) + + # Get latest cursor and has_history_before if conversation exists + latest_cursor = None + has_history_before = False + + if conversation_id: + try: + from .transcript_store import TranscriptStore + + store = TranscriptStore(self.ap.persistence_mgr.get_db_engine()) + + latest_cursor = await store.get_latest_cursor(conversation_id) + if latest_cursor: + has_history_before = True + except Exception as e: + self.ap.logger.warning(f'Failed to get transcript cursor: {e}') + + return { + 'conversation_id': conversation_id, + 'thread_id': event.thread_id, + 'latest_cursor': latest_cursor, + 'event_seq': None, # Will be populated when EventLog is written + 'transcript_seq': int(latest_cursor) if latest_cursor else None, + 'has_history_before': has_history_before, + 'inline_policy': { + 'mode': 'current_event', + 'delivered_count': 0, + 'source_total_count': None, + 'messages_complete': False, + 'reason': 'current_event_only', + }, + 'available_apis': { + 'prompt_get': False, + 'history_page': history_page_enabled, + 'history_search': history_search_enabled, + 'event_get': event_get_enabled, + 'event_page': event_page_enabled, + 'state': state_enabled, + 'storage': storage_enabled, + 'steering_pull': steering_pull_enabled, + 'run_get': run_get_enabled, + 'run_list': run_list_enabled, + 'run_events_page': run_events_page_enabled, + 'run_cancel': run_cancel_enabled, + 'run_append_result': run_append_result_enabled, + 'run_finalize': run_finalize_enabled, + 'run_claim': run_claim_enabled, + 'run_renew_claim': run_renew_claim_enabled, + 'run_release_claim': run_release_claim_enabled, + 'runtime_register': runtime_register_enabled, + 'runtime_heartbeat': runtime_heartbeat_enabled, + 'runtime_list': runtime_list_enabled, + }, + } diff --git a/src/langbot/pkg/agent/runner/default_config.py b/src/langbot/pkg/agent/runner/default_config.py new file mode 100644 index 000000000..6c884fb4e --- /dev/null +++ b/src/langbot/pkg/agent/runner/default_config.py @@ -0,0 +1,72 @@ +"""Default AgentRunner binding configuration helpers.""" + +from __future__ import annotations + +import sqlalchemy + +from ...core import app +from ...entity.persistence import pipeline as persistence_pipeline +from . import config_schema +from .config_migration import ConfigMigration + + +class AgentRunnerDefaultConfigService: + """Apply AgentRunner schema-defined defaults to host binding config.""" + + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def _get_runner_descriptor(self, runner_id: str): + registry = getattr(self.ap, 'agent_runner_registry', None) + if registry is None: + return None + try: + return await registry.get(runner_id, bound_plugins=None) + except Exception as e: + logger = getattr(self.ap, 'logger', None) + if logger: + logger.warning(f'Failed to load AgentRunner descriptor while setting default model: {e}') + return None + + async def auto_set_default_pipeline_llm_model(self, model_uuid: str) -> bool: + """Set model_uuid into the default pipeline runner config when the selector is empty.""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( + persistence_pipeline.LegacyPipeline.is_default == True + ) + ) + pipeline = result.first() + if pipeline is None: + return False + + return await self.set_pipeline_llm_model_if_empty(pipeline, model_uuid) + + async def set_pipeline_llm_model_if_empty( + self, + pipeline: persistence_pipeline.LegacyPipeline, + model_uuid: str, + ) -> bool: + """Set model_uuid into a pipeline's schema-defined LLM selector if it is empty.""" + pipeline_config = pipeline.config + if not isinstance(pipeline_config, dict): + return False + + runner_id = ConfigMigration.resolve_runner_id(pipeline_config) + if not runner_id: + return False + + descriptor = await self._get_runner_descriptor(runner_id) + if descriptor is None: + return False + + ai_config = pipeline_config.setdefault('ai', {}) + runner_configs = ai_config.setdefault('runner_config', {}) + runner_config = runner_configs.setdefault(runner_id, {}) + + if not config_schema.set_empty_llm_model_selection(descriptor, runner_config, model_uuid): + return False + + await self.ap.pipeline_service.update_pipeline(pipeline.uuid, {'config': pipeline_config}) + return True diff --git a/src/langbot/pkg/agent/runner/descriptor.py b/src/langbot/pkg/agent/runner/descriptor.py new file mode 100644 index 000000000..2397f169e --- /dev/null +++ b/src/langbot/pkg/agent/runner/descriptor.py @@ -0,0 +1,82 @@ +"""Agent runner descriptor.""" +from __future__ import annotations + +import typing +import pydantic + +from langbot_plugin.api.entities.builtin.agent_runner.manifest import ( + AgentRunnerCapabilities, + AgentRunnerPermissions, +) + + +class AgentRunnerDescriptor(pydantic.BaseModel): + """Descriptor for an agent runner. + + Represents the discovered metadata for a runner, including + its identity, capabilities, permissions, and configuration schema. + """ + + id: str + """Unique runner ID: plugin:author/plugin_name/runner_name""" + + source: typing.Literal['plugin'] + """Runner source type""" + + label: dict[str, str] + """Display labels keyed by locale (e.g., en_US, zh_Hans)""" + + description: dict[str, str] | None = None + """Optional description keyed by locale""" + + plugin_author: str + """Plugin author from manifest""" + + plugin_name: str + """Plugin name from manifest""" + + runner_name: str + """AgentRunner component name from manifest""" + + plugin_version: str | None = None + """Optional plugin version""" + + config_schema: list[dict[str, typing.Any]] = pydantic.Field(default_factory=list) + """Configuration schema using DynamicForm format""" + + capabilities: AgentRunnerCapabilities = pydantic.Field( + default_factory=AgentRunnerCapabilities + ) + """Runner capabilities: streaming, tool_calling, knowledge_retrieval, etc.""" + + permissions: AgentRunnerPermissions = pydantic.Field( + default_factory=AgentRunnerPermissions + ) + """Requested LangBot resource permissions.""" + + raw_manifest: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Original manifest for reference""" + + model_config = pydantic.ConfigDict( + extra='allow', + ) + + def get_plugin_id(self) -> str: + """Return plugin identifier as author/name.""" + return f'{self.plugin_author}/{self.plugin_name}' + + def supports_streaming(self) -> bool: + """Check if runner supports streaming output.""" + return self.capabilities.streaming + + def supports_tool_calling(self) -> bool: + """Check if runner supports tool calling.""" + return self.capabilities.tool_calling + + def supports_knowledge_retrieval(self) -> bool: + """Check if runner supports knowledge retrieval.""" + return self.capabilities.knowledge_retrieval + + def supports_steering(self) -> bool: + """Check if runner supports run steering/follow-up input.""" + return bool(getattr(self.capabilities, 'steering', False)) diff --git a/src/langbot/pkg/agent/runner/errors.py b/src/langbot/pkg/agent/runner/errors.py new file mode 100644 index 000000000..1755eff9f --- /dev/null +++ b/src/langbot/pkg/agent/runner/errors.py @@ -0,0 +1,37 @@ +"""Agent runner errors.""" +from __future__ import annotations + + +class AgentRunnerError(Exception): + """Base error for agent runner operations.""" + pass + + +class RunnerNotFoundError(AgentRunnerError): + """Runner not found in registry.""" + def __init__(self, runner_id: str): + self.runner_id = runner_id + super().__init__(f'Agent runner not found: {runner_id}') + + +class RunnerNotAuthorizedError(AgentRunnerError): + """Runner not authorized for this binding.""" + def __init__(self, runner_id: str, bound_plugins: list[str] | None): + self.runner_id = runner_id + self.bound_plugins = bound_plugins + super().__init__(f'Agent runner {runner_id} not authorized for bound_plugins={bound_plugins}') + + +class RunnerProtocolError(AgentRunnerError): + """Runner protocol version mismatch or invalid manifest.""" + def __init__(self, runner_id: str, message: str): + self.runner_id = runner_id + super().__init__(f'Agent runner protocol error for {runner_id}: {message}') + + +class RunnerExecutionError(AgentRunnerError): + """Runner execution failed.""" + def __init__(self, runner_id: str, message: str, retryable: bool = False): + self.runner_id = runner_id + self.retryable = retryable + super().__init__(f'Agent runner {runner_id} execution failed: {message}') diff --git a/src/langbot/pkg/agent/runner/event_log_store.py b/src/langbot/pkg/agent/runner/event_log_store.py new file mode 100644 index 000000000..eb7277146 --- /dev/null +++ b/src/langbot/pkg/agent/runner/event_log_store.py @@ -0,0 +1,315 @@ +"""EventLog store for writing and querying event records.""" +from __future__ import annotations + +import json +import datetime +import typing +import uuid + +import sqlalchemy +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession +from sqlalchemy.orm import sessionmaker + +from ...entity.persistence.event_log import EventLog + + +UTC = datetime.timezone.utc + + +def _utc_now() -> datetime.datetime: + return datetime.datetime.now(UTC) + + +def _datetime_to_epoch(value: datetime.datetime | None) -> int | None: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=UTC) + else: + value = value.astimezone(UTC) + return int(value.timestamp()) + + +class EventLogStore: + """Store for EventLog records. + + Handles writing events to the event log and querying them. + All methods are async and use the provided database engine. + """ + + engine: AsyncEngine + + # Hard limits + MAX_INPUT_SUMMARY_LENGTH = 1000 + + def __init__(self, engine: AsyncEngine): + self.engine = engine + self._session_factory = sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False + ) + + async def append_event( + self, + event_id: str | None, + event_type: str, + source: str, + bot_id: str | None = None, + workspace_id: str | None = None, + conversation_id: str | None = None, + thread_id: str | None = None, + actor_type: str | None = None, + actor_id: str | None = None, + actor_name: str | None = None, + subject_type: str | None = None, + subject_id: str | None = None, + input_summary: str | None = None, + input_json: dict[str, typing.Any] | None = None, + raw_ref: str | None = None, + run_id: str | None = None, + runner_id: str | None = None, + event_time: datetime.datetime | None = None, + metadata: dict[str, typing.Any] | None = None, + ) -> str: + """Append an event to the event log. + + Args: + event_id: Unique event ID (generated if None) + event_type: Event type + source: Event source + bot_id: Bot UUID + workspace_id: Workspace ID + conversation_id: Conversation ID + thread_id: Thread ID + actor_type: Actor type + actor_id: Actor ID + actor_name: Actor display name + subject_type: Subject type + subject_id: Subject ID + input_summary: Brief input summary + input_json: Full input JSON + raw_ref: Reference to raw event payload + run_id: Run ID processing this event + runner_id: Runner ID processing this event + event_time: When the event occurred + metadata: Additional metadata + + Returns: + The event_id + """ + if event_id is None: + event_id = str(uuid.uuid4()) + + # Truncate input summary if too long + if input_summary and len(input_summary) > self.MAX_INPUT_SUMMARY_LENGTH: + input_summary = input_summary[:self.MAX_INPUT_SUMMARY_LENGTH - 3] + "..." + + async with self._session_factory() as session: + event = EventLog( + event_id=event_id, + event_type=event_type, + event_time=event_time, + source=source, + bot_id=bot_id, + workspace_id=workspace_id, + conversation_id=conversation_id, + thread_id=thread_id, + actor_type=actor_type, + actor_id=actor_id, + actor_name=actor_name, + subject_type=subject_type, + subject_id=subject_id, + input_summary=input_summary, + input_json=json.dumps(input_json) if input_json else None, + raw_ref=raw_ref, + run_id=run_id, + runner_id=runner_id, + metadata_json=json.dumps(metadata) if metadata else None, + created_at=_utc_now(), + ) + session.add(event) + await session.commit() + + return event_id + + async def get_event( + self, + event_id: str, + ) -> dict[str, typing.Any] | None: + """Get a single event by ID. + + Args: + event_id: Event ID + + Returns: + Event record as dict, or None if not found + """ + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.select(EventLog).where(EventLog.event_id == event_id) + ) + row = result.scalars().first() + if row is None: + return None + return self._row_to_dict(row) + + async def page_events( + self, + conversation_id: str | None = None, + event_types: list[str] | None = None, + before_seq: int | None = None, + limit: int = 50, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + strict_thread: bool = False, + ) -> tuple[list[dict[str, typing.Any]], int | None, bool]: + """Page through event records. + + Args: + conversation_id: Filter by conversation ID + event_types: Filter by event types + before_seq: Get events before this sequence number + limit: Maximum items to return (capped at 100) + bot_id: Optional bot scope filter + workspace_id: Optional workspace scope filter + thread_id: Optional thread scope filter + strict_thread: When true, require thread_id equality including NULL + + Returns: + Tuple of (items, next_seq, has_more) + """ + limit = min(limit, 100) # Hard cap + + async with self._session_factory() as session: + query = sqlalchemy.select(EventLog) + + if conversation_id is not None: + query = query.where(EventLog.conversation_id == conversation_id) + query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread) + + if event_types: + query = query.where(EventLog.event_type.in_(event_types)) + + if before_seq is not None: + query = query.where(EventLog.id < before_seq) + + query = query.order_by(EventLog.id.desc()).limit(limit + 1) + + result = await session.execute(query) + rows = result.scalars().all() + + items = [self._row_to_dict(row) for row in rows[:limit]] + has_more = len(rows) > limit + next_seq = items[-1]['id'] if items and has_more else None + + return items, next_seq, has_more + + async def get_latest_cursor( + self, + conversation_id: str, + ) -> str | None: + """Get the latest cursor for a conversation. + + Args: + conversation_id: Conversation ID + + Returns: + Cursor string (seq number), or None if no events + """ + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.select(EventLog.id) + .where(EventLog.conversation_id == conversation_id) + .order_by(EventLog.id.desc()) + .limit(1) + ) + row = result.scalars().first() + if row is None: + return None + return str(row) + + async def has_events_before( + self, + conversation_id: str, + seq: int, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + strict_thread: bool = False, + ) -> bool: + """Check if there are events before a sequence number. + + Args: + conversation_id: Conversation ID + seq: Sequence number + + Returns: + True if there are events before + """ + async with self._session_factory() as session: + query = ( + sqlalchemy.select(sqlalchemy.func.count()) + .select_from(EventLog) + .where(EventLog.conversation_id == conversation_id, EventLog.id < seq) + ) + query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread) + result = await session.execute(query) + count = result.scalar() + return count > 0 + + def _apply_scope_filters( + self, + query: typing.Any, + bot_id: str | None, + workspace_id: str | None, + thread_id: str | None, + strict_thread: bool, + ) -> typing.Any: + if bot_id is not None: + query = query.where(EventLog.bot_id == bot_id) + if workspace_id is not None: + query = query.where(EventLog.workspace_id == workspace_id) + if strict_thread: + if thread_id is None: + query = query.where(EventLog.thread_id.is_(None)) + else: + query = query.where(EventLog.thread_id == thread_id) + return query + + async def cleanup_events_older_than( + self, + before: datetime.datetime, + ) -> int: + """Delete EventLog rows created before the supplied timestamp.""" + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.delete(EventLog).where(EventLog.created_at < before) + ) + await session.commit() + return result.rowcount or 0 + + def _row_to_dict(self, row: EventLog) -> dict[str, typing.Any]: + """Convert an EventLog row to dict.""" + return { + 'id': row.id, + 'event_id': row.event_id, + 'event_type': row.event_type, + 'event_time': _datetime_to_epoch(row.event_time), + 'source': row.source, + 'bot_id': row.bot_id, + 'workspace_id': row.workspace_id, + 'conversation_id': row.conversation_id, + 'thread_id': row.thread_id, + 'actor_type': row.actor_type, + 'actor_id': row.actor_id, + 'actor_name': row.actor_name, + 'subject_type': row.subject_type, + 'subject_id': row.subject_id, + 'input_summary': row.input_summary, + 'input_json': json.loads(row.input_json) if row.input_json else None, + 'raw_ref': row.raw_ref, + 'run_id': row.run_id, + 'runner_id': row.runner_id, + 'created_at': _datetime_to_epoch(row.created_at), + 'metadata': json.loads(row.metadata_json) if row.metadata_json else {}, + } diff --git a/src/langbot/pkg/agent/runner/events.py b/src/langbot/pkg/agent/runner/events.py new file mode 100644 index 000000000..53ea266e2 --- /dev/null +++ b/src/langbot/pkg/agent/runner/events.py @@ -0,0 +1,25 @@ +"""Canonical AgentRunner event names reserved for future EBA integration.""" +from __future__ import annotations + + +MESSAGE_RECEIVED = 'message.received' +"""A normal message entered the current Pipeline.""" + +MESSAGE_RECALLED = 'message.recalled' +"""A platform message was recalled or deleted.""" + +GROUP_MEMBER_JOINED = 'group.member_joined' +"""A new member joined a group/channel conversation.""" + +FRIEND_REQUEST_RECEIVED = 'friend.request_received' +"""A new friend/contact request was received.""" + + +RESERVED_EVENT_TYPES = frozenset( + { + MESSAGE_RECEIVED, + MESSAGE_RECALLED, + GROUP_MEMBER_JOINED, + FRIEND_REQUEST_RECEIVED, + } +) diff --git a/src/langbot/pkg/agent/runner/host_models.py b/src/langbot/pkg/agent/runner/host_models.py new file mode 100644 index 000000000..389f47cb0 --- /dev/null +++ b/src/langbot/pkg/agent/runner/host_models.py @@ -0,0 +1,210 @@ +"""Agent event envelope and binding models for LangBot Host. + +These are Host-internal models, not exposed to SDK. +""" +from __future__ import annotations + +import typing +import pydantic + +from langbot_plugin.api.entities.builtin.agent_runner.event import ( + ActorContext, + SubjectContext, + RawEventRef, +) +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + + +class AgentEventEnvelope(pydantic.BaseModel): + """Event envelope for LangBot Host event gateway. + + This is the unified input model that replaces Query-first approach. + IM / WebUI / API / EventRouter all produce this envelope. + """ + + event_id: str + """Unique event identifier.""" + + event_type: str + """Event type (message.received, message.recalled, etc.).""" + + event_time: int | None = None + """Event timestamp (epoch seconds).""" + + source: str + """Event source (platform, webui, api, scheduler, system).""" + + source_event_type: str | None = None + """Original source event type, when available.""" + + bot_id: str | None = None + """Bot UUID handling this event.""" + + workspace_id: str | None = None + """Workspace ID (for multi-tenant).""" + + conversation_id: str | None = None + """Conversation ID.""" + + thread_id: str | None = None + """Thread ID (for platforms supporting threads).""" + + actor: ActorContext | None = None + """Actor (who triggered the event).""" + + subject: SubjectContext | None = None + """Subject (what the event is about).""" + + input: AgentInput + """Event input.""" + + delivery: DeliveryContext + """Delivery context.""" + + raw_ref: RawEventRef | None = None + """Reference to raw event payload.""" + + data: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Small structured event payload. Large payloads should be referenced via raw_ref.""" + + +# Binding scope types +class BindingScope(pydantic.BaseModel): + """Scope for agent binding.""" + + scope_type: typing.Literal["agent", "bot", "workspace", "global"] = "agent" + """Scope type.""" + + scope_id: str | None = None + """Scope identifier (agent_id, bot_uuid, etc.).""" + + +class ResourcePolicy(pydantic.BaseModel): + """Resource policy for agent binding. + + Controls what resources the runner can access. + """ + + allowed_model_uuids: list[str] | None = None + """Additional model UUID grants. None means no additional model grants.""" + + allowed_tool_names: list[str] | None = None + """Additional tool name grants. None means no additional tool grants.""" + + allowed_kb_uuids: list[str] | None = None + """Additional knowledge base UUID grants. None means no additional KB grants.""" + + allowed_skill_names: list[str] | None = None + """Allowed skill names. None means all currently visible skills are allowed.""" + + allow_plugin_storage: bool = True + """Whether plugin storage is allowed.""" + + allow_workspace_storage: bool = False + """Whether workspace storage is allowed.""" + + +class StatePolicy(pydantic.BaseModel): + """State policy for agent binding. + + Controls state management behavior. + """ + + enable_state: bool = True + """Whether host-owned state is enabled.""" + + state_scopes: list[typing.Literal["conversation", "actor", "subject", "runner"]] = ( + pydantic.Field(default_factory=lambda: ["conversation", "actor"]) + ) + """Enabled state scopes.""" + + +class DeliveryPolicy(pydantic.BaseModel): + """Delivery policy for agent binding. + + Controls how results are delivered. + """ + + enable_streaming: bool = True + """Whether streaming output is enabled.""" + + enable_reply: bool = True + """Whether reply is enabled.""" + + max_message_size: int | None = None + """Maximum message size.""" + + +class AgentConfig(pydantic.BaseModel): + """Host-side Agent configuration. + + Product-level Agent is the target replacement for Pipeline-owned agent + config. Current Pipeline entry paths can project their config into this + model during migration. + """ + + agent_id: str | None = None + """Host-side Agent/config identifier.""" + + runner_id: str + """Runner ID to invoke.""" + + runner_config: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Agent/runner binding configuration.""" + + resource_policy: ResourcePolicy = pydantic.Field(default_factory=ResourcePolicy) + """Resource policy for this Agent.""" + + state_policy: StatePolicy = pydantic.Field(default_factory=StatePolicy) + """State policy for this Agent.""" + + delivery_policy: DeliveryPolicy = pydantic.Field(default_factory=DeliveryPolicy) + """Delivery policy for this Agent.""" + + event_types: list[str] = pydantic.Field(default_factory=lambda: ["message.received"]) + """Event types this Agent handles.""" + + enabled: bool = True + """Whether this Agent can be selected by a binding resolver.""" + + metadata: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Non-protocol diagnostic metadata, such as legacy config source.""" + + +class AgentBinding(pydantic.BaseModel): + """Binding configuration for mapping events to runners. + + This is Host-internal model for event-to-runner binding. + It replaces the old Pipeline runner config role. + """ + + binding_id: str + """Unique binding identifier.""" + + scope: BindingScope = pydantic.Field(default_factory=BindingScope) + """Binding scope.""" + + event_types: list[str] = pydantic.Field(default_factory=lambda: ["message.received"]) + """Event types this binding handles.""" + + runner_id: str + """Runner ID to invoke.""" + + runner_config: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Current Agent/runner configuration.""" + + resource_policy: ResourcePolicy = pydantic.Field(default_factory=ResourcePolicy) + """Resource policy.""" + + state_policy: StatePolicy = pydantic.Field(default_factory=StatePolicy) + """State policy.""" + + delivery_policy: DeliveryPolicy = pydantic.Field(default_factory=DeliveryPolicy) + """Delivery policy.""" + + enabled: bool = True + """Whether binding is enabled.""" + + agent_id: str | None = None + """Host-side Agent/config identifier for this binding.""" diff --git a/src/langbot/pkg/agent/runner/id.py b/src/langbot/pkg/agent/runner/id.py new file mode 100644 index 000000000..e01099041 --- /dev/null +++ b/src/langbot/pkg/agent/runner/id.py @@ -0,0 +1,91 @@ +"""Agent runner ID parsing and formatting.""" +from __future__ import annotations + +import dataclasses + + +@dataclasses.dataclass(frozen=True) +class RunnerIdParts: + """Parsed runner ID components.""" + source: str # 'plugin' (future: 'builtin') + plugin_author: str + plugin_name: str + runner_name: str + + def to_plugin_id(self) -> str: + """Return plugin identifier as author/name.""" + return f'{self.plugin_author}/{self.plugin_name}' + + +def parse_runner_id(runner_id: str) -> RunnerIdParts: + """Parse runner ID string into components. + + Args: + runner_id: Runner ID in format 'plugin:author/plugin_name/runner_name' + + Returns: + RunnerIdParts with parsed components + + Raises: + ValueError: If runner_id format is invalid + """ + if runner_id.startswith('plugin:'): + parts = runner_id[7:].split('/') + if len(parts) != 3: + raise ValueError( + f'Invalid plugin runner ID format: {runner_id}. ' + f'Expected: plugin:author/plugin_name/runner_name' + ) + plugin_author, plugin_name, runner_name = parts + if not plugin_author or not plugin_name or not runner_name: + raise ValueError( + f'Invalid plugin runner ID: {runner_id}. ' + f'author, plugin_name, and runner_name must be non-empty' + ) + return RunnerIdParts( + source='plugin', + plugin_author=plugin_author, + plugin_name=plugin_name, + runner_name=runner_name, + ) + else: + # Only plugin runner IDs are valid at the protocol boundary. + raise ValueError( + f'Invalid runner ID format: {runner_id}. ' + f'Expected: plugin:author/plugin_name/runner_name' + ) + + +def format_runner_id( + source: str, + plugin_author: str, + plugin_name: str, + runner_name: str, +) -> str: + """Format runner ID from components. + + Args: + source: Runner source ('plugin') + plugin_author: Plugin author + plugin_name: Plugin name + runner_name: Runner component name + + Returns: + Runner ID string + """ + if source == 'plugin': + return f'plugin:{plugin_author}/{plugin_name}/{runner_name}' + else: + raise ValueError(f'Invalid runner source: {source}') + + +def is_plugin_runner_id(runner_id: str) -> bool: + """Check if runner ID is a plugin runner. + + Args: + runner_id: Runner ID string + + Returns: + True if runner ID starts with 'plugin:' + """ + return runner_id.startswith('plugin:') diff --git a/src/langbot/pkg/agent/runner/invoker.py b/src/langbot/pkg/agent/runner/invoker.py new file mode 100644 index 000000000..4f45747b6 --- /dev/null +++ b/src/langbot/pkg/agent/runner/invoker.py @@ -0,0 +1,131 @@ +"""Plugin-runtime invocation for AgentRunner executions.""" + +from __future__ import annotations + +import asyncio +import time +import traceback +import typing + +from langbot_plugin.entities.io.errors import ActionCallTimeoutError + +from ...core import app +from .context_builder import AgentRunContextPayload +from .descriptor import AgentRunnerDescriptor +from .errors import RunnerExecutionError + + +class AgentRunnerInvoker: + """Invoke an AgentRunner through the plugin runtime. + + This keeps runtime transport, deadline enforcement, and transport error + mapping out of the orchestration state machine. + """ + + ap: app.Application + + def __init__(self, ap: app.Application): + self.ap = ap + + async def invoke( + self, + descriptor: AgentRunnerDescriptor, + context: AgentRunContextPayload, + ) -> typing.AsyncGenerator[dict[str, typing.Any], None]: + """Invoke the runner and yield raw result dictionaries.""" + if not self.ap.plugin_connector.is_enable_plugin: + raise RunnerExecutionError( + descriptor.id, + 'Plugin system is disabled', + retryable=False, + ) + + try: + gen = self.ap.plugin_connector.run_agent( + plugin_author=descriptor.plugin_author, + plugin_name=descriptor.plugin_name, + runner_name=descriptor.runner_name, + context=context, + ) + + while True: + try: + result_dict = await self._next_with_deadline(gen, descriptor, context) + except StopAsyncIteration: + break + yield result_dict + + except asyncio.TimeoutError as e: + raise RunnerExecutionError( + descriptor.id, + 'Runner timed out (code: runner.timeout)', + retryable=True, + ) from e + except ActionCallTimeoutError as e: + raise RunnerExecutionError( + descriptor.id, + f'{e} (code: runner.timeout)', + retryable=True, + ) from e + except RunnerExecutionError: + raise + except Exception as e: + self.ap.logger.error( + f'Runner {descriptor.id} unexpected error: {traceback.format_exc()}' + ) + raise RunnerExecutionError( + descriptor.id, + str(e), + retryable=False, + ) + + async def _next_with_deadline( + self, + gen: typing.AsyncGenerator[dict[str, typing.Any], None], + descriptor: AgentRunnerDescriptor, + context: AgentRunContextPayload, + ) -> dict[str, typing.Any]: + """Read the next runner result while enforcing the run deadline.""" + remaining = self._remaining_deadline_seconds(context) + if remaining is not None and remaining <= 0: + await self._close_generator(gen, descriptor) + raise asyncio.TimeoutError + + try: + if remaining is None: + return await anext(gen) + return await asyncio.wait_for(anext(gen), timeout=remaining) + except StopAsyncIteration: + if self._is_deadline_exhausted(context): + raise asyncio.TimeoutError + raise + except asyncio.TimeoutError: + await self._close_generator(gen, descriptor) + raise + + def _remaining_deadline_seconds( + self, + context: AgentRunContextPayload, + ) -> float | None: + runtime = context.get('runtime') or {} + deadline_at = runtime.get('deadline_at') + if deadline_at is None: + return None + try: + return float(deadline_at) - time.time() + except (TypeError, ValueError): + return None + + def _is_deadline_exhausted(self, context: AgentRunContextPayload) -> bool: + remaining = self._remaining_deadline_seconds(context) + return remaining is not None and remaining <= 0 + + async def _close_generator( + self, + gen: typing.AsyncGenerator[dict[str, typing.Any], None], + descriptor: AgentRunnerDescriptor, + ) -> None: + try: + await gen.aclose() + except Exception as e: + self.ap.logger.warning(f'Failed to close timed-out runner {descriptor.id}: {e}') diff --git a/src/langbot/pkg/agent/runner/orchestrator.py b/src/langbot/pkg/agent/runner/orchestrator.py new file mode 100644 index 000000000..008fc810a --- /dev/null +++ b/src/langbot/pkg/agent/runner/orchestrator.py @@ -0,0 +1,536 @@ +"""Agent run orchestrator for coordinating runner execution.""" + +from __future__ import annotations + +import time +import typing + +from langbot_plugin.api.entities.builtin.provider import message as provider_message +from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query + +from ...core import app +from .binding_resolver import AgentBindingResolver +from .context_builder import AgentRunContextBuilder, AgentRunContextPayload +from .descriptor import AgentRunnerDescriptor +from .host_models import AgentBinding, AgentEventEnvelope +from .invoker import AgentRunnerInvoker +from .query_bridge import QueryRunBridge +from .registry import AgentRunnerRegistry +from .resource_builder import AgentResourceBuilder +from .result_normalizer import AgentResultNormalizer +from .run_journal import AgentRunJournal +from .session_registry import AgentRunSessionRegistry, get_session_registry +from .state_scope import build_state_context +from ...provider.tools.loaders import skill as skill_loader + + +ACTIVATED_SKILL_NAMES_STATE_KEY = 'host.activated_skills' + + +class AgentRunOrchestrator: + """Coordinate one AgentRunner execution. + + The orchestrator keeps the run state machine readable and delegates + transport, Query bridging, and persistence side effects to narrower + collaborators. + """ + + ap: app.Application + registry: AgentRunnerRegistry + context_builder: AgentRunContextBuilder + resource_builder: AgentResourceBuilder + result_normalizer: AgentResultNormalizer + binding_resolver: AgentBindingResolver + query_bridge: QueryRunBridge + invoker: AgentRunnerInvoker + journal: AgentRunJournal + _session_registry: AgentRunSessionRegistry + + def __init__( + self, + ap: app.Application, + registry: AgentRunnerRegistry, + ): + self.ap = ap + self.registry = registry + self.context_builder = AgentRunContextBuilder(ap) + self.resource_builder = AgentResourceBuilder(ap) + self.result_normalizer = AgentResultNormalizer(ap) + self.binding_resolver = AgentBindingResolver() + self.query_bridge = QueryRunBridge(self.binding_resolver) + self.invoker = AgentRunnerInvoker(ap) + self.journal = AgentRunJournal(ap) + self._session_registry = get_session_registry() + + async def run( + self, + event: AgentEventEnvelope, + binding: AgentBinding, + bound_plugins: list[str] | None = None, + adapter_context: dict[str, typing.Any] | None = None, + ) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]: + """Run an AgentRunner from an event-first envelope.""" + runner_id = binding.runner_id + descriptor = await self.registry.get(runner_id, bound_plugins) + + resources = await self.resource_builder.build_resources_from_binding( + event=event, + binding=binding, + descriptor=descriptor, + ) + + context = await self.context_builder.build_context_from_event( + event=event, + binding=binding, + descriptor=descriptor, + resources=resources, + ) + + session_query_id = None + if adapter_context: + query = adapter_context.get('_query') + if query is not None: + skill_loader.restore_activated_skills_from_state( + self.ap, + query, + context.get('state', {}), + ) + session_query_id = adapter_context.get('query_id') + if query is not None or session_query_id is not None: + context['context']['available_apis']['prompt_get'] = True + if 'params' in adapter_context: + context['adapter']['extra']['params'] = adapter_context['params'] + + state_context = build_state_context(event, binding, descriptor) + run_id = context['run_id'] + available_apis = context.get('context', {}).get('available_apis') + run_authorization = { + 'runner_id': descriptor.id, + 'binding_id': binding.binding_id, + 'plugin_identity': descriptor.get_plugin_id(), + 'resources': resources, + 'available_apis': available_apis, + 'conversation_id': event.conversation_id, + 'bot_id': event.bot_id, + 'workspace_id': event.workspace_id, + 'thread_id': event.thread_id, + 'state_policy': { + 'enable_state': binding.state_policy.enable_state, + 'state_scopes': list(binding.state_policy.state_scopes), + }, + 'state_context': state_context, + } + + seen_sequences: set[int] = set() + last_sequence = 0 + assistant_transcript_written = False + terminal_status: str | None = None + terminal_reason: str | None = None + terminal_usage: dict[str, typing.Any] | None = None + + try: + await self.journal.create_run( + event=event, + binding=binding, + descriptor=descriptor, + context=context, + authorization=run_authorization, + ) + await self._session_registry.register( + run_id=run_id, + runner_id=descriptor.id, + query_id=session_query_id, + plugin_identity=descriptor.get_plugin_id(), + resources=resources, + available_apis=context.get('context', {}).get('available_apis'), + conversation_id=event.conversation_id, + bot_id=event.bot_id, + workspace_id=event.workspace_id, + thread_id=event.thread_id, + state_policy={ + 'enable_state': binding.state_policy.enable_state, + 'state_scopes': list(binding.state_policy.state_scopes), + }, + state_context=state_context, + ) + + event_log_id = await self.journal.write_event_log( + event=event, + binding=binding, + run_id=run_id, + runner_id=descriptor.id, + ) + if event.event_type == 'message.received' and event.conversation_id: + await self.journal.write_user_transcript( + event=event, + event_log_id=event_log_id, + ) + + async for result_dict in self.invoker.invoke(descriptor, context): + result_dict = dict(result_dict) + sequence = result_dict.get('sequence') + if sequence is not None: + try: + sequence_int = int(sequence) + except (TypeError, ValueError): + self.ap.logger.warning(f'Runner {descriptor.id} returned invalid result sequence: {sequence}') + sequence_int = last_sequence + 1 + result_dict['sequence'] = sequence_int + else: + if sequence_int in seen_sequences: + self.ap.logger.warning( + f'Runner {descriptor.id} returned duplicate result sequence ' + f'{sequence_int} for run {run_id}; dropping duplicate' + ) + continue + if sequence_int <= 0: + self.ap.logger.warning( + f'Runner {descriptor.id} returned non-positive result sequence ' + f'{sequence_int} for run {run_id}' + ) + sequence_int = last_sequence + 1 + result_dict['sequence'] = sequence_int + elif last_sequence and sequence_int != last_sequence + 1: + self.ap.logger.warning( + f'Runner {descriptor.id} result sequence gap or out-of-order ' + f'for run {run_id}: previous={last_sequence}, current={sequence_int}' + ) + seen_sequences.add(sequence_int) + last_sequence = max(last_sequence, sequence_int) + else: + sequence_int = last_sequence + 1 + result_dict['sequence'] = sequence_int + seen_sequences.add(sequence_int) + last_sequence = sequence_int + + result_type = result_dict.get('type') + if result_type and not self.result_normalizer.validate_payload( + result_type, + result_dict.get('data', {}), + descriptor, + ): + continue + + await self.journal.append_run_result( + result_dict=result_dict, + run_id=run_id, + sequence=sequence_int, + ) + + if result_type == 'state.updated': + await self.journal.handle_state_updated_event( + result_dict, + event, + binding, + descriptor, + run_id=run_id, + ) + await self.result_normalizer.normalize(result_dict, descriptor) + continue + + if result_type == 'run.completed': + terminal_status = 'completed' + terminal_reason = ( + result_dict.get('data', {}).get('finish_reason') + if isinstance(result_dict.get('data'), dict) + else None + ) + usage = result_dict.get('usage') + if isinstance(usage, dict): + terminal_usage = usage + elif result_type == 'run.failed': + terminal_status = 'failed' + data = result_dict.get('data') if isinstance(result_dict.get('data'), dict) else {} + terminal_reason = data.get('error') or data.get('code') + usage = result_dict.get('usage') + if isinstance(usage, dict): + terminal_usage = usage + + has_completed_message = result_type == 'message.completed' or ( + result_type == 'run.completed' + and isinstance(result_dict.get('data'), dict) + and bool(result_dict['data'].get('message')) + ) + if has_completed_message and event.conversation_id and not assistant_transcript_written: + await self.journal.write_assistant_transcript( + result_dict=result_dict, + event=event, + run_id=run_id, + runner_id=descriptor.id, + ) + assistant_transcript_written = True + + result = await self.result_normalizer.normalize(result_dict, descriptor) + if result is not None: + yield result + + run_snapshot = await self.journal.get_run(run_id) + if run_snapshot and run_snapshot.get('cancel_requested_at') is not None: + terminal_status = 'cancelled' + terminal_reason = run_snapshot.get('status_reason') or 'cancel_requested' + break + await self.journal.finalize_run( + run_id=run_id, + status=terminal_status or 'completed', + status_reason=terminal_reason, + usage=terminal_usage, + ) + except Exception as exc: + failed_usage = terminal_usage + await self.journal.finalize_run( + run_id=run_id, + status='timeout' if self._is_deadline_exhausted(context) else 'failed', + status_reason=str(exc), + usage=failed_usage, + ) + raise + finally: + session = await self._session_registry.unregister(run_id) + pending_steering = session.get('steering_queue', []) if session else [] + if pending_steering: + try: + await self.journal.write_steering_dropped_audits( + pending_steering, + run_id, + descriptor.id, + ) + except Exception as exc: + self.ap.logger.warning( + f'Failed to write dropped steering audit for run {run_id}: {exc}', + exc_info=True, + ) + + async def run_from_query( + self, + query: pipeline_query.Query, + ) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]: + """Run an AgentRunner from the current Pipeline Query entry point.""" + plan = self.query_bridge.build_plan(query) + adapter_context = dict(plan.adapter_context) + adapter_context['_query'] = query + + # Materialize inbound attachments into sandbox before running + await self._materialize_inbound_attachments(query, plan.event) + + async for result in self.run( + plan.event, + plan.binding, + bound_plugins=plan.bound_plugins, + adapter_context=adapter_context, + ): + yield result + + async def _materialize_inbound_attachments( + self, + query: pipeline_query.Query, + event: AgentEventEnvelope, + ) -> None: + """Persist inbound attachments into the sandbox and update event.input.attachments. + + No-op when the box service is unavailable or there are no attachments. + On success, updates each attachment in event.input.attachments with the + sandbox path so runners can tell the model where to find the files. + """ + box_service = getattr(self.ap, 'box_service', None) + if box_service is None or not getattr(box_service, 'available', False): + return + + try: + materialized = await box_service.materialize_inbound_attachments(query) + except Exception as e: + # Never break the chat turn over attachment IO + self.ap.logger.warning(f'Inbound attachment materialization failed: {e}') + return + + if not materialized: + return + + # Build a lookup by name for matching + materialized_by_name = {att.get('name'): att for att in materialized if att.get('name')} + + # Update event.input.attachments with sandbox paths + if event.input and event.input.attachments: + for attachment in event.input.attachments: + name = attachment.name + if name and name in materialized_by_name: + mat = materialized_by_name[name] + # Update the attachment with sandbox path + attachment.path = mat.get('path') + attachment.size = mat.get('size') or attachment.size + attachment.mime_type = attachment.mime_type or mat.get('mime_type') + + # Store materialized descriptors in query variables for downstream use + query.variables['_sandbox_inbound_attachments'] = materialized + + def resolve_runner_id_for_telemetry(self, query: pipeline_query.Query) -> str | None: + """Resolve runner ID for telemetry/logging without full execution.""" + return self.query_bridge.resolve_runner_id_for_telemetry(query) + + async def try_claim_steering_from_query( + self, + query: pipeline_query.Query, + ) -> bool: + """Claim a query as steering input for an active run when possible.""" + plan = self.query_bridge.build_plan(query) + event = plan.event + binding = plan.binding + + if event.event_type != 'message.received' or not event.conversation_id: + return False + + descriptor = await self.registry.get(binding.runner_id, plan.bound_plugins) + if not descriptor.supports_steering(): + return False + + target_run_id = await self._session_registry.find_steering_target( + conversation_id=event.conversation_id, + runner_id=descriptor.id, + bot_id=event.bot_id, + workspace_id=event.workspace_id, + thread_id=event.thread_id, + ) + if target_run_id is None: + return False + + steering_item = self._build_steering_item(event, target_run_id, descriptor.id) + if not await self._session_registry.enqueue_steering(target_run_id, steering_item): + return False + + try: + event_log_id = await self.journal.write_event_log( + event=event, + binding=binding, + run_id=target_run_id, + runner_id=descriptor.id, + metadata={ + 'steering': { + 'status': 'queued', + 'trigger_behavior': 'absorbed_into_active_run', + 'claimed_by_run_id': target_run_id, + 'claimed_runner_id': descriptor.id, + 'claimed_at': steering_item.get('claimed_at'), + }, + }, + ) + await self.journal.write_user_transcript(event, event_log_id) + except Exception as exc: + self.ap.logger.warning( + f'Failed to persist steering event {event.event_id} for run {target_run_id}: {exc}', + exc_info=True, + ) + + self.ap.logger.info(f'Claimed event {event.event_id} as steering input for run {target_run_id}') + return True + + def _build_steering_item( + self, + event: AgentEventEnvelope, + run_id: str, + runner_id: str, + ) -> dict[str, typing.Any]: + """Build the run-scoped steering item returned by the Host pull API.""" + return { + 'claimed_run_id': run_id, + 'runner_id': runner_id, + 'claimed_at': int(time.time()), + 'event': { + 'event_id': event.event_id, + 'event_type': event.event_type, + 'event_time': event.event_time, + 'source': event.source, + 'source_event_type': event.source_event_type, + 'raw_ref': event.raw_ref.model_dump(mode='json') if event.raw_ref else None, + 'data': event.data, + }, + 'conversation': { + 'conversation_id': event.conversation_id, + 'thread_id': event.thread_id, + 'bot_id': event.bot_id, + 'workspace_id': event.workspace_id, + }, + 'actor': event.actor.model_dump(mode='json') if event.actor else None, + 'subject': event.subject.model_dump(mode='json') if event.subject else None, + 'input': { + 'text': event.input.text if event.input else None, + 'contents': [ + c.model_dump(mode='json') if hasattr(c, 'model_dump') else c + for c in (event.input.contents if event.input else []) + ], + 'attachments': [ + a.model_dump(mode='json') if hasattr(a, 'model_dump') else a + for a in (event.input.attachments if event.input else []) + ], + }, + } + + async def _invoke_runner( + self, + descriptor: AgentRunnerDescriptor, + context: AgentRunContextPayload, + ) -> typing.AsyncGenerator[dict[str, typing.Any], None]: + """Compatibility delegate for older tests and internal callers.""" + async for result in self.invoker.invoke(descriptor, context): + yield result + + async def _next_with_deadline( + self, + gen: typing.AsyncGenerator[dict[str, typing.Any], None], + descriptor: AgentRunnerDescriptor, + context: AgentRunContextPayload, + ) -> dict[str, typing.Any]: + return await self.invoker._next_with_deadline(gen, descriptor, context) + + def _remaining_deadline_seconds( + self, + context: AgentRunContextPayload, + ) -> float | None: + return self.invoker._remaining_deadline_seconds(context) + + def _is_deadline_exhausted(self, context: AgentRunContextPayload) -> bool: + return self.invoker._is_deadline_exhausted(context) + + async def _close_generator( + self, + gen: typing.AsyncGenerator[dict[str, typing.Any], None], + descriptor: AgentRunnerDescriptor, + ) -> None: + await self.invoker._close_generator(gen, descriptor) + + async def _handle_state_updated_event( + self, + result_dict: dict[str, typing.Any], + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, + ) -> None: + await self.journal.handle_state_updated_event(result_dict, event, binding, descriptor) + + async def _write_event_log( + self, + event: AgentEventEnvelope, + binding: AgentBinding, + run_id: str, + runner_id: str, + ) -> str: + return await self.journal.write_event_log(event, binding, run_id, runner_id) + + async def _write_user_transcript( + self, + event: AgentEventEnvelope, + event_log_id: str, + ) -> None: + await self.journal.write_user_transcript(event, event_log_id) + + async def _write_assistant_transcript( + self, + result_dict: dict[str, typing.Any], + event: AgentEventEnvelope, + run_id: str, + runner_id: str, + ) -> None: + await self.journal.write_assistant_transcript( + result_dict=result_dict, + event=event, + run_id=run_id, + runner_id=runner_id, + ) diff --git a/src/langbot/pkg/agent/runner/persistent_state_store.py b/src/langbot/pkg/agent/runner/persistent_state_store.py new file mode 100644 index 000000000..e5c2ad567 --- /dev/null +++ b/src/langbot/pkg/agent/runner/persistent_state_store.py @@ -0,0 +1,435 @@ +"""Persistent state store for AgentRunner protocol state. + +This module provides a database-backed state store for event-first Protocol v1. +""" +from __future__ import annotations + +import typing +import json +import threading +from datetime import datetime + +import sqlalchemy +from sqlalchemy.ext.asyncio import AsyncEngine +from sqlalchemy import select, delete, update +from sqlalchemy.dialects.postgresql import insert as postgresql_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from sqlalchemy.exc import IntegrityError + +from .descriptor import AgentRunnerDescriptor +from .host_models import AgentEventEnvelope, AgentBinding +from .state_scope import ( + VALID_STATE_SCOPES, + build_state_scope_key, + get_binding_identity, + normalize_state_key, +) +from ...entity.persistence.agent_runner_state import AgentRunnerState + + +# Maximum value_json size (256KB) +MAX_VALUE_JSON_BYTES = 256 * 1024 + + +class PersistentStateStore: + """Database-backed state store for AgentRunner protocol state. + + IMPORTANT: This is HOST-OWNED protocol state, NOT plugin instance state. + + This store provides: + 1. Persistent storage across runs via database + 2. Scope isolation by runner_id + binding_identity + scope + 3. Policy enforcement (enable_state, state_scopes) + 4. JSON value validation and size limits + + Used by: + - Event-first Protocol v1 (async methods) + - State API handlers (get/set/delete/list) + """ + + def __init__(self, db_engine: AsyncEngine): + self._db_engine = db_engine + + def _get_scope_key( + self, + scope: str, + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, + ) -> str | None: + """Get scope key for given scope.""" + return build_state_scope_key(scope, event, binding, descriptor) + + def _check_scope_enabled(self, scope: str, binding: AgentBinding) -> bool: + """Check if scope is enabled by binding's state_policy.""" + state_policy = binding.state_policy + if not state_policy.enable_state: + return False + return scope in state_policy.state_scopes + + def _validate_json_value( + self, + value: typing.Any, + logger: typing.Any = None, + ) -> tuple[str | None, str | None]: + """Validate and serialize value to JSON. + + Returns: + Tuple of (json_string, error_message). If error_message is not None, + json_string will be None. + """ + try: + json_str = json.dumps(value, ensure_ascii=False) + except (TypeError, ValueError) as e: + return None, f'Value is not JSON-serializable: {e}' + + # Check size limit + json_bytes = len(json_str.encode('utf-8')) + if json_bytes > MAX_VALUE_JSON_BYTES: + return None, f'Value size {json_bytes} bytes exceeds limit {MAX_VALUE_JSON_BYTES} bytes' + + return json_str, None + + async def _upsert_state_row( + self, + conn: typing.Any, + values: dict[str, typing.Any], + ) -> None: + """Insert or update a state row by the logical scope/key identity.""" + update_values = { + 'value_json': values['value_json'], + 'updated_at': values['updated_at'], + } + constraint_columns = ['scope_key', 'state_key'] + dialect_name = self._db_engine.dialect.name + + if dialect_name == 'sqlite': + stmt = sqlite_insert(AgentRunnerState).values(**values) + await conn.execute( + stmt.on_conflict_do_update( + index_elements=constraint_columns, + set_=update_values, + ) + ) + return + + if dialect_name == 'postgresql': + stmt = postgresql_insert(AgentRunnerState).values(**values) + await conn.execute( + stmt.on_conflict_do_update( + index_elements=constraint_columns, + set_=update_values, + ) + ) + return + + try: + await conn.execute(sqlalchemy.insert(AgentRunnerState).values(**values)) + except IntegrityError: + await conn.execute( + update(AgentRunnerState) + .where(AgentRunnerState.scope_key == values['scope_key']) + .where(AgentRunnerState.state_key == values['state_key']) + .values(**update_values) + ) + + # ========== Async DB Operations ========== + + async def build_snapshot_from_event( + self, + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, + ) -> dict[str, dict[str, typing.Any]]: + """Build state snapshot for all scopes from event and binding. + + Reads from database, respects state_policy. + """ + state_policy = binding.state_policy + + # If state is disabled, return all empty scopes + if not state_policy.enable_state: + return { + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + } + + snapshot: dict[str, dict[str, typing.Any]] = { + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + } + + async with self._db_engine.connect() as conn: + for scope in VALID_STATE_SCOPES: + if not self._check_scope_enabled(scope, binding): + continue + + scope_key = self._get_scope_key(scope, event, binding, descriptor) + if not scope_key: + continue + + # Query all state entries for this scope_key + result = await conn.execute( + select(AgentRunnerState.state_key, AgentRunnerState.value_json) + .where(AgentRunnerState.scope_key == scope_key) + ) + rows = result.fetchall() + + for row in rows: + key = row.state_key + value_json = row.value_json + if value_json: + try: + snapshot[scope][key] = json.loads(value_json) + except json.JSONDecodeError: + pass # Skip invalid JSON + + # Seed external.conversation_id from event.conversation_id if not set + if self._check_scope_enabled('conversation', binding) and event.conversation_id: + if 'external.conversation_id' not in snapshot['conversation']: + snapshot['conversation']['external.conversation_id'] = event.conversation_id + + return snapshot + + async def apply_update_from_event( + self, + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, + scope: str, + key: str, + value: typing.Any, + logger: typing.Any = None, + ) -> tuple[bool, str | None]: + """Apply a state update from event context. + + Returns: + Tuple of (success, error_message). If success is False, error_message + contains the reason. + """ + state_policy = binding.state_policy + + # Check if state is disabled + if not state_policy.enable_state: + return False, 'State is disabled by binding policy' + + # Validate scope + if scope not in VALID_STATE_SCOPES: + return False, f'Invalid scope: {scope}' + + # Check if scope is enabled + if not self._check_scope_enabled(scope, binding): + return False, f'Scope "{scope}" not enabled by binding policy' + + # Map accepted key aliases + key = normalize_state_key(key) + + # Get scope key + scope_key = self._get_scope_key(scope, event, binding, descriptor) + if not scope_key: + return False, f'Missing identity for scope "{scope}"' + + # Validate and serialize value + value_json, error = self._validate_json_value(value, logger) + if error: + return False, error + + # Build context fields + binding_identity = get_binding_identity(binding) + + now = datetime.utcnow() + async with self._db_engine.begin() as conn: + await self._upsert_state_row( + conn, + { + 'runner_id': descriptor.id, + 'binding_identity': binding_identity, + 'scope': scope, + 'scope_key': scope_key, + 'state_key': key, + 'value_json': value_json, + 'bot_id': event.bot_id, + 'workspace_id': event.workspace_id, + 'conversation_id': event.conversation_id, + 'thread_id': event.thread_id, + 'actor_type': event.actor.actor_type if event.actor else None, + 'actor_id': event.actor.actor_id if event.actor else None, + 'subject_type': event.subject.subject_type if event.subject else None, + 'subject_id': event.subject.subject_id if event.subject else None, + 'created_at': now, + 'updated_at': now, + }, + ) + + return True, None + + async def state_get( + self, + scope_key: str, + state_key: str, + ) -> typing.Any: + """Get a single state value by scope_key and state_key. + + Used by State API handlers. + """ + state_key = normalize_state_key(state_key) + + async with self._db_engine.connect() as conn: + result = await conn.execute( + select(AgentRunnerState.value_json) + .where(AgentRunnerState.scope_key == scope_key) + .where(AgentRunnerState.state_key == state_key) + ) + row = result.first() + + if not row or not row.value_json: + return None + + try: + return json.loads(row.value_json) + except json.JSONDecodeError: + return None + + async def state_set( + self, + scope_key: str, + state_key: str, + value: typing.Any, + runner_id: str, + binding_identity: str, + scope: str, + context: dict[str, typing.Any] | None = None, + logger: typing.Any = None, + ) -> tuple[bool, str | None]: + """Set a state value. + + Used by State API handlers. + Context contains optional fields like bot_id, conversation_id, etc. + """ + state_key = normalize_state_key(state_key) + + # Validate and serialize value + value_json, error = self._validate_json_value(value, logger) + if error: + return False, error + + context = context or {} + + now = datetime.utcnow() + async with self._db_engine.begin() as conn: + await self._upsert_state_row( + conn, + { + 'runner_id': runner_id, + 'binding_identity': binding_identity, + 'scope': scope, + 'scope_key': scope_key, + 'state_key': state_key, + 'value_json': value_json, + 'bot_id': context.get('bot_id'), + 'workspace_id': context.get('workspace_id'), + 'conversation_id': context.get('conversation_id'), + 'thread_id': context.get('thread_id'), + 'actor_type': context.get('actor_type'), + 'actor_id': context.get('actor_id'), + 'subject_type': context.get('subject_type'), + 'subject_id': context.get('subject_id'), + 'created_at': now, + 'updated_at': now, + }, + ) + + return True, None + + async def state_delete( + self, + scope_key: str, + state_key: str, + ) -> bool: + """Delete a state value. + + Returns True if deleted, False if not found. + """ + state_key = normalize_state_key(state_key) + + async with self._db_engine.begin() as conn: + result = await conn.execute( + delete(AgentRunnerState) + .where(AgentRunnerState.scope_key == scope_key) + .where(AgentRunnerState.state_key == state_key) + ) + return (result.rowcount or 0) > 0 + + async def state_list( + self, + scope_key: str, + prefix: str | None = None, + limit: int = 100, + ) -> tuple[list[str], bool]: + """List state keys in a scope. + + Returns tuple of (keys, has_more). + """ + # Enforce limit cap + limit = min(limit, 100) + + async with self._db_engine.connect() as conn: + query = ( + select(AgentRunnerState.state_key) + .where(AgentRunnerState.scope_key == scope_key) + .order_by(AgentRunnerState.state_key) + .limit(limit + 1) # Fetch one extra to check has_more + ) + + if prefix: + prefix = normalize_state_key(prefix) + query = query.where( + AgentRunnerState.state_key.like(f'{prefix}%') + ) + + result = await conn.execute(query) + rows = result.fetchall() + + keys = [row.state_key for row in rows[:limit]] + has_more = len(rows) > limit + + return keys, has_more + + async def clear_all(self) -> None: + """Clear all state entries (for testing).""" + async with self._db_engine.begin() as conn: + await conn.execute(delete(AgentRunnerState)) + + +# Global singleton persistent state store +_persistent_state_store: PersistentStateStore | None = None +_persistent_state_store_lock = threading.Lock() + + +def get_persistent_state_store(db_engine: AsyncEngine | None = None) -> PersistentStateStore: + """Get the global persistent state store singleton. + + Args: + db_engine: Database engine (required on first call) + + Returns: + PersistentStateStore singleton + """ + global _persistent_state_store + with _persistent_state_store_lock: + if _persistent_state_store is None: + if db_engine is None: + raise RuntimeError("db_engine required for first call to get_persistent_state_store") + _persistent_state_store = PersistentStateStore(db_engine) + return _persistent_state_store + + +def reset_persistent_state_store() -> None: + """Reset the global persistent state store (for testing).""" + global _persistent_state_store + with _persistent_state_store_lock: + _persistent_state_store = None diff --git a/src/langbot/pkg/agent/runner/query_bridge.py b/src/langbot/pkg/agent/runner/query_bridge.py new file mode 100644 index 000000000..42e4601e3 --- /dev/null +++ b/src/langbot/pkg/agent/runner/query_bridge.py @@ -0,0 +1,56 @@ +"""Pipeline Query bridge for AgentRunner execution.""" + +from __future__ import annotations + +import dataclasses +import typing + +from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query + +from .binding_resolver import AgentBindingResolver +from .config_migration import ConfigMigration +from .errors import RunnerNotFoundError +from .host_models import AgentBinding, AgentEventEnvelope +from .query_entry_adapter import QueryEntryAdapter + + +@dataclasses.dataclass(frozen=True) +class QueryRunPlan: + """Projected event-first execution request for a Query-backed run.""" + + event: AgentEventEnvelope + binding: AgentBinding + bound_plugins: list[str] | None + adapter_context: dict[str, typing.Any] + + +class QueryRunBridge: + """Project the current Pipeline Query entry point into Protocol v1 inputs.""" + + binding_resolver: AgentBindingResolver + + def __init__(self, binding_resolver: AgentBindingResolver): + self.binding_resolver = binding_resolver + + def build_plan(self, query: pipeline_query.Query) -> QueryRunPlan: + """Build an event-first run plan from a Pipeline Query.""" + runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config) + if not runner_id: + raise RunnerNotFoundError('no runner configured') + + event = QueryEntryAdapter.query_to_event(query) + agent_config = QueryEntryAdapter.config_to_agent_config(query, runner_id) + binding = self.binding_resolver.resolve_one(event, [agent_config]) + bound_plugins = query.variables.get('_pipeline_bound_plugins') + adapter_context = QueryEntryAdapter.build_adapter_context(query, binding) + + return QueryRunPlan( + event=event, + binding=binding, + bound_plugins=bound_plugins, + adapter_context=adapter_context, + ) + + def resolve_runner_id_for_telemetry(self, query: pipeline_query.Query) -> str | None: + """Resolve runner ID for telemetry/logging without full execution.""" + return ConfigMigration.resolve_runner_id(query.pipeline_config) diff --git a/src/langbot/pkg/agent/runner/query_entry_adapter.py b/src/langbot/pkg/agent/runner/query_entry_adapter.py new file mode 100644 index 000000000..a5540bb64 --- /dev/null +++ b/src/langbot/pkg/agent/runner/query_entry_adapter.py @@ -0,0 +1,649 @@ +"""Query entry adapter for converting Query to event-first envelope. + +This adapter bridges the current Query entry point with the event-first +Protocol v1 architecture without exposing Query internals to runners. +""" +from __future__ import annotations + +import hashlib +import typing + +from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.agent_runner.event import ( + AgentEventContext, + ConversationContext, + ActorContext, + SubjectContext, + RawEventRef, +) +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + +from .host_models import ( + AgentConfig, + AgentEventEnvelope, + ResourcePolicy, + StatePolicy, + DeliveryPolicy, +) +from .config_migration import ConfigMigration +from . import events as runner_events + + +class QueryEntryAdapter: + """Adapter for converting Query to event-first envelope. + + This adapter is responsible for: + - Converting Query to AgentEventEnvelope + - Projecting current Pipeline config to temporary AgentConfig + - Putting Query-only fields into adapter context + """ + + INTERNAL_PREFIX = '_' + SENSITIVE_PATTERNS = ('secret', 'token', 'key', 'password', 'credential', 'api_key', 'apikey') + PERMISSION_VARS = ('_pipeline_bound_plugins', '_authorized', '_permission') + EVENT_DATA_MAX_STRING_BYTES = 512 + + @classmethod + def query_to_event( + cls, + query: pipeline_query.Query, + ) -> AgentEventEnvelope: + """Convert Query to AgentEventEnvelope. + + Args: + query: Current entry query + + Returns: + AgentEventEnvelope for event-first processing + """ + # Build event context + event = cls._build_event_context(query) + + # Build conversation context + conversation = cls._build_conversation_context(query) + + # Build actor context + actor = cls._build_actor_context(query) + + # Build subject context + subject = cls._build_subject_context(query) + + # Build input + input = cls._build_input(query) + + # Build delivery context + delivery = cls._build_delivery_context(query) + + # Build raw ref + raw_ref = cls._build_raw_ref(query) + + return AgentEventEnvelope( + event_id=event.event_id or str(query.query_id), + event_type=event.event_type or runner_events.MESSAGE_RECEIVED, + event_time=event.event_time, + source="host_adapter", + source_event_type=event.source_event_type, + bot_id=query.bot_uuid, + workspace_id=None, # Not available in Query + conversation_id=conversation.conversation_id, + thread_id=conversation.thread_id, + actor=actor, + subject=subject, + input=input, + delivery=delivery, + raw_ref=raw_ref, + data=event.data, + ) + + @classmethod + def config_to_agent_config( + cls, + query: pipeline_query.Query, + runner_id: str, + ) -> AgentConfig: + """Project the current Pipeline config container into target Agent config.""" + pipeline_config = query.pipeline_config or {} + runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id) + agent_id = getattr(query, 'pipeline_uuid', None) + + # Build resource policy from current config + resource_policy = ResourcePolicy( + allowed_model_uuids=cls._extract_allowed_models(query), + allowed_tool_names=cls._extract_allowed_tools(query), + allowed_kb_uuids=cls._extract_allowed_kbs(query), + allowed_skill_names=cls._extract_allowed_skills(query), + ) + + # Build state policy + state_policy = StatePolicy( + enable_state=True, + state_scopes=["conversation", "actor", "subject", "runner"], + ) + + # Build delivery policy + delivery_policy = DeliveryPolicy( + enable_streaming=True, + enable_reply=True, + ) + + return AgentConfig( + agent_id=agent_id, + runner_id=runner_id, + runner_config=runner_config, + resource_policy=resource_policy, + state_policy=state_policy, + delivery_policy=delivery_policy, + event_types=[runner_events.MESSAGE_RECEIVED], + enabled=True, + metadata={'source': 'pipeline_adapter'}, + ) + + @classmethod + def build_adapter_context( + cls, + query: pipeline_query.Query, + binding: AgentBinding, + ) -> dict[str, typing.Any]: + """Build Query-derived fields for the current entry adapter.""" + return { + 'params': cls.build_params(query), + 'query_id': getattr(query, 'query_id', None), + } + + @classmethod + def build_params(cls, query: pipeline_query.Query) -> dict[str, typing.Any]: + """Build adapter params from Pipeline variables with host filtering.""" + params: dict[str, typing.Any] = {} + variables = getattr(query, 'variables', None) + if not variables: + return params + + for key, value in variables.items(): + if key.startswith(cls.INTERNAL_PREFIX): + continue + key_lower = key.lower() + if any(pattern in key_lower for pattern in cls.SENSITIVE_PATTERNS): + continue + if any(key == perm_var or key.startswith(perm_var) for perm_var in cls.PERMISSION_VARS): + continue + if cls.is_json_serializable(value): + params[key] = value + + return params + + @classmethod + def is_json_serializable(cls, value: typing.Any) -> bool: + """Return whether a value can safely cross the adapter boundary as JSON.""" + if value is None or isinstance(value, (str, int, float, bool)): + return True + if isinstance(value, (list, tuple)): + return all(cls.is_json_serializable(item) for item in value) + if isinstance(value, dict): + return all( + isinstance(k, str) and cls.is_json_serializable(v) + for k, v in value.items() + ) + return False + + # Private helper methods + + @classmethod + def _build_event_context( + cls, + query: pipeline_query.Query, + ) -> AgentEventContext: + """Build AgentEventContext from Query.""" + message_event = getattr(query, 'message_event', None) + + event_data: dict[str, typing.Any] = {} + if message_event and hasattr(message_event, 'model_dump'): + try: + raw_event_data = message_event.model_dump(mode='json') + except TypeError: + raw_event_data = message_event.model_dump() + except Exception: + raw_event_data = {} + if isinstance(raw_event_data, dict): + event_data = cls._compact_event_data(raw_event_data) + + source_event_type = None + if message_event: + source_event_type = getattr(message_event, 'type', None) + + message_chain = getattr(query, 'message_chain', None) + message_id = getattr(message_chain, 'message_id', None) + if message_id == -1: + message_id = None + + event_time = None + if message_event: + event_time = getattr(message_event, 'time', None) + if isinstance(event_time, (int, float)): + event_time = int(event_time) + + source_event_id = str(message_id or query.query_id) + return AgentEventContext( + event_id=cls._build_scoped_event_id(query, source_event_id, event_time), + event_type=runner_events.MESSAGE_RECEIVED, + event_time=event_time, + source="host_adapter", + source_event_type=source_event_type, + data=event_data, + ) + + @classmethod + def _compact_event_data( + cls, + event_data: dict[str, typing.Any], + ) -> dict[str, typing.Any]: + """Keep only small scalar source-event metadata in event.data.""" + compact: dict[str, typing.Any] = {} + for key, value in event_data.items(): + if key == 'source_platform_object' or key.startswith('_'): + continue + if value is None or isinstance(value, (bool, int, float)): + compact[key] = value + continue + if isinstance(value, str): + if len(value.encode('utf-8')) <= cls.EVENT_DATA_MAX_STRING_BYTES: + compact[key] = value + continue + return compact + + @classmethod + def _build_scoped_event_id( + cls, + query: pipeline_query.Query, + source_event_id: str, + event_time: int | None, + ) -> str: + """Build a globally unique host event id from pipeline-local ids.""" + launcher_type = getattr(query, 'launcher_type', None) + launcher_type_value = getattr(launcher_type, 'value', launcher_type) if launcher_type is not None else None + scope_parts = [ + 'host_adapter', + getattr(query, 'pipeline_uuid', None), + getattr(query, 'bot_uuid', None), + launcher_type_value, + getattr(query, 'launcher_id', None), + getattr(query, 'sender_id', None), + source_event_id, + event_time, + ] + scoped = '|'.join('' if part is None else str(part) for part in scope_parts) + digest = hashlib.sha256(scoped.encode('utf-8')).hexdigest()[:32] + return f'host:{digest}' + + @classmethod + def _build_conversation_context( + cls, + query: pipeline_query.Query, + ) -> ConversationContext: + """Build ConversationContext from Query.""" + # Handle launcher_type safely + launcher_type = getattr(query, 'launcher_type', None) + launcher_type_value = None + if launcher_type is not None: + launcher_type_value = getattr(launcher_type, 'value', launcher_type) + + # Handle launcher_id + launcher_id = getattr(query, 'launcher_id', None) + + # Build session_id from launcher info if available + session_id = None + if launcher_type_value and launcher_id: + session_id = f'{launcher_type_value}_{launcher_id}' + + # Handle session and conversation_id + conversation_id = None + session = getattr(query, 'session', None) + if session: + conversation = getattr(session, 'using_conversation', None) + if conversation: + conversation_id = getattr(conversation, 'uuid', None) + + if not conversation_id: + variables = getattr(query, 'variables', None) or {} + conversation_id = variables.get('conversation_id') or None + + if not conversation_id: + conversation_id = session_id + + # Handle sender_id + sender_id = getattr(query, 'sender_id', None) + if sender_id is not None: + sender_id = str(sender_id) + + # Handle bot_uuid + bot_uuid = getattr(query, 'bot_uuid', None) + + return ConversationContext( + conversation_id=str(conversation_id) if conversation_id is not None else None, + thread_id=None, + launcher_type=launcher_type_value, + launcher_id=launcher_id, + sender_id=sender_id, + bot_id=bot_uuid, + workspace_id=None, + session_id=session_id, + ) + + @classmethod + def _build_actor_context( + cls, + query: pipeline_query.Query, + ) -> ActorContext: + """Build ActorContext from Query.""" + message_event = getattr(query, 'message_event', None) + sender = getattr(message_event, 'sender', None) if message_event else None + sender_id = getattr(query, 'sender_id', None) + actor_id = getattr(sender, 'id', None) if sender else None + if actor_id is None: + actor_id = sender_id + actor_name = sender.get_name() if sender and hasattr(sender, 'get_name') else None + + return ActorContext( + actor_type="user", + actor_id=str(actor_id) if actor_id is not None else None, + actor_name=actor_name, + metadata={}, + ) + + @classmethod + def _build_subject_context( + cls, + query: pipeline_query.Query, + ) -> SubjectContext: + """Build SubjectContext from Query.""" + message_chain = getattr(query, 'message_chain', None) + message_id = getattr(message_chain, 'message_id', None) if message_chain else None + if message_id == -1: + message_id = None + + query_id = getattr(query, 'query_id', None) + + # Safely get launcher_type + launcher_type = getattr(query, 'launcher_type', None) + launcher_type_value = None + if launcher_type is not None: + launcher_type_value = getattr(launcher_type, 'value', launcher_type) + + return SubjectContext( + subject_type="message", + subject_id=str(message_id or query_id or ''), + data={ + "launcher_type": launcher_type_value, + "launcher_id": getattr(query, 'launcher_id', None), + "sender_id": str(getattr(query, 'sender_id', '')) if getattr(query, 'sender_id', None) else None, + "bot_uuid": getattr(query, 'bot_uuid', None), + }, + ) + + @classmethod + def _build_input( + cls, + query: pipeline_query.Query, + ) -> AgentInput: + """Build AgentInput from Query.""" + text = None + text_parts: list[str] = [] + contents: list[dict[str, typing.Any]] = [] + + user_message = getattr(query, 'user_message', None) + if user_message: + content = getattr(user_message, 'content', None) + if isinstance(content, list): + for elem in content: + elem_dict = None + if hasattr(elem, 'model_dump'): + elem_dict = elem.model_dump(mode='json') + elif isinstance(elem, dict): + elem_dict = elem + + if not isinstance(elem_dict, dict): + continue + + contents.append(elem_dict) + if elem_dict.get('type') == 'text': + elem_text = elem_dict.get('text') + if elem_text: + text_parts.append(elem_text) + elif content is not None: + text = str(content) + contents.append({'type': 'text', 'text': text}) + + if not contents: + message_chain = getattr(query, 'message_chain', None) or [] + for component in message_chain: + if isinstance(component, platform_message.Plain): + component_text = getattr(component, 'text', '') + if component_text: + text_parts.append(component_text) + contents.append({'type': 'text', 'text': component_text}) + elif isinstance(component, platform_message.Image): + image_base64 = getattr(component, 'base64', None) + image_url = getattr(component, 'url', None) + if image_base64: + contents.append({'type': 'image_base64', 'image_base64': image_base64}) + elif image_url: + contents.append({'type': 'image_url', 'image_url': {'url': image_url}}) + + if text_parts: + text = ''.join(text_parts) + + attachments = cls._build_attachments(query, contents) + + return AgentInput( + text=text, + contents=contents, + attachments=attachments, + ) + + @classmethod + def _build_attachments( + cls, + query: pipeline_query.Query, + contents: list[dict[str, typing.Any]], + ) -> list[dict[str, typing.Any]]: + """Extract attachments from query.""" + attachments: list[dict[str, typing.Any]] = [] + seen_keys: dict[tuple[str, str, str], set[str]] = {} + + def add_attachment(attachment: dict[str, typing.Any]) -> None: + key = cls._attachment_dedupe_key(attachment) + if key is not None: + source = str(attachment.get('source') or '') + sources = seen_keys.setdefault(key, set()) + if source and sources and source not in sources: + return + if source: + sources.add(source) + attachments.append(attachment) + + for elem in contents: + elem_type = elem.get('type') + + if elem_type == 'image_url': + image_url = elem.get('image_url') or {} + add_attachment({ + 'type': 'image', + 'source': 'url', + 'url': image_url.get('url') if isinstance(image_url, dict) else str(image_url), + }) + elif elem_type == 'image_base64': + add_attachment({ + 'type': 'image', + 'source': 'base64', + 'content': elem.get('image_base64'), + }) + elif elem_type == 'file_url': + add_attachment({ + 'type': 'file', + 'source': 'url', + 'url': elem.get('file_url'), + 'name': elem.get('file_name'), + }) + elif elem_type == 'file_base64': + add_attachment({ + 'type': 'file', + 'source': 'base64', + 'content': elem.get('file_base64'), + 'name': elem.get('file_name'), + }) + + message_chain = getattr(query, 'message_chain', None) + if message_chain: + try: + message_components = iter(message_chain) + except TypeError: + message_components = iter(()) + + for component in message_components: + if isinstance(component, platform_message.Image): + image_id = component.image_id or None + image_url = component.url or None + image_base64 = component.base64 or None + add_attachment({ + 'type': 'image', + 'source': 'message_chain', + 'id': image_id, + 'url': image_url, + 'content': image_base64, + }) + elif isinstance(component, platform_message.File): + add_attachment({ + 'type': 'file', + 'source': 'message_chain', + 'id': component.id or None, + 'name': component.name or None, + 'url': component.url or None, + 'content': component.base64 or None, + }) + elif isinstance(component, platform_message.Voice): + add_attachment({ + 'type': 'voice', + 'source': 'message_chain', + 'id': component.voice_id or None, + 'url': component.url or None, + 'content': component.base64 or None, + }) + + return attachments + + @classmethod + def _attachment_dedupe_key( + cls, + attachment: dict[str, typing.Any], + ) -> tuple[str, str, str] | None: + """Return a stable key for the same attachment across content sources.""" + attachment_type = attachment.get('type') + if not attachment_type: + return None + for field in ('id', 'url', 'content'): + value = attachment.get(field) + if value: + if field == 'content': + value = hashlib.sha256(str(value).encode('utf-8')).hexdigest() + return str(attachment_type), field, str(value) + return None + + @classmethod + def _build_delivery_context( + cls, + query: pipeline_query.Query, + ) -> DeliveryContext: + """Build DeliveryContext from Query.""" + message_chain = getattr(query, 'message_chain', None) + return DeliveryContext( + surface="platform", + reply_target={ + "message_id": getattr(message_chain, 'message_id', None), + }, + supports_streaming=True, + supports_edit=False, + supports_reaction=False, + platform_capabilities={}, + ) + + @classmethod + def _build_raw_ref( + cls, + query: pipeline_query.Query, + ) -> RawEventRef | None: + """Build RawEventRef from Query.""" + # For now, we don't store raw event payload + return None + + @classmethod + def _extract_allowed_models( + cls, + query: pipeline_query.Query, + ) -> list[str] | None: + """Extract allowed model UUIDs from query.""" + model_uuids: list[str] = [] + model_uuid = getattr(query, 'use_llm_model_uuid', None) + if model_uuid: + model_uuids.append(model_uuid) + + variables = getattr(query, 'variables', None) or {} + for fallback_uuid in variables.get('_fallback_model_uuids', []) or []: + if fallback_uuid and fallback_uuid not in model_uuids: + model_uuids.append(fallback_uuid) + + return model_uuids or None + + @classmethod + def _extract_allowed_tools( + cls, + query: pipeline_query.Query, + ) -> list[str] | None: + """Extract allowed tool names from query.""" + use_funcs = getattr(query, 'use_funcs', None) + if not use_funcs: + return None + try: + tool_names = [] + for func in use_funcs: + if isinstance(func, dict): + name = func.get('name') + elif hasattr(func, 'name'): + name = func.name + else: + continue + if name: + tool_names.append(name) + return tool_names if tool_names else None + except (TypeError, AttributeError): + return None + + @classmethod + def _extract_allowed_kbs( + cls, + query: pipeline_query.Query, + ) -> list[str] | None: + """Extract allowed knowledge base UUIDs from query.""" + variables = getattr(query, 'variables', None) + if not variables: + return None + kb_uuids = variables.get('_knowledge_base_uuids') + if kb_uuids: + return kb_uuids + return None + + @classmethod + def _extract_allowed_skills( + cls, + query: pipeline_query.Query, + ) -> list[str] | None: + """Extract pipeline-visible skill names from query.""" + variables = getattr(query, 'variables', None) + if not variables or '_pipeline_bound_skills' not in variables: + return None + bound_skills = variables.get('_pipeline_bound_skills') + if bound_skills is None: + return None + if not isinstance(bound_skills, list): + return [] + return [str(skill_name) for skill_name in bound_skills if skill_name] diff --git a/src/langbot/pkg/agent/runner/registry.py b/src/langbot/pkg/agent/runner/registry.py new file mode 100644 index 000000000..1a76de81b --- /dev/null +++ b/src/langbot/pkg/agent/runner/registry.py @@ -0,0 +1,273 @@ +"""Agent runner registry for discovering and caching runner descriptors.""" + +from __future__ import annotations + +import typing +import asyncio + +from langbot_plugin.api.entities.builtin.agent_runner.manifest import ( + AgentRunnerManifest, +) + +from ...core import app +from .descriptor import AgentRunnerDescriptor +from .id import parse_runner_id, format_runner_id +from .errors import RunnerNotFoundError, RunnerNotAuthorizedError + + +class AgentRunnerRegistry: + """Registry for discovering and managing agent runners. + + Responsibilities: + - Discover runners from plugin runtime via LIST_AGENT_RUNNERS + - Validate runner manifests (kind, metadata, spec) + - Cache discovered runners for performance + - Filter runners by bound plugins + - Handle manifest errors gracefully (log warning, skip runner) + """ + + ap: app.Application + + _cache: dict[str, AgentRunnerDescriptor] | None + """Cached runner descriptors keyed by runner ID""" + + _cache_lock: asyncio.Lock + """Lock for cache refresh operations""" + + def __init__(self, ap: app.Application): + self.ap = ap + self._cache = None + self._cache_lock = asyncio.Lock() + + async def _discover_runners(self) -> dict[str, AgentRunnerDescriptor]: + """Discover runners from plugin runtime. + + Always discovers ALL runners (no bound_plugins filter). + The cache should contain unfiltered discovery results. + + Returns: + Dict of runner descriptors keyed by runner ID + """ + if not self.ap.plugin_connector.is_enable_plugin: + return {} + + runners: dict[str, AgentRunnerDescriptor] = {} + + try: + # Always list all runners (bound_plugins=None) + plugin_runners = await self.ap.plugin_connector.list_agent_runners(None) + + for runner_data in plugin_runners: + try: + descriptor = self._validate_and_build_descriptor(runner_data) + if descriptor is not None: + runners[descriptor.id] = descriptor + except Exception as e: + plugin_author = runner_data.get('plugin_author', 'unknown') + plugin_name = runner_data.get('plugin_name', 'unknown') + runner_name = runner_data.get('runner_name', 'unknown') + self.ap.logger.warning( + f'Invalid runner manifest for plugin:{plugin_author}/{plugin_name}/{runner_name}: {e}' + ) + continue + + except Exception as e: + self.ap.logger.warning(f'Failed to list agent runners from plugin runtime: {e}') + return {} + + return runners + + def _validate_and_build_descriptor(self, runner_data: dict[str, typing.Any]) -> AgentRunnerDescriptor | None: + """Validate runner manifest and build descriptor. + + Args: + runner_data: Raw runner data from plugin runtime with fields: + - plugin_author, plugin_name, runner_name + - manifest (typed AgentRunnerManifest) + + Returns: + AgentRunnerDescriptor if valid, None if invalid + """ + plugin_author = runner_data.get('plugin_author', '') + plugin_name = runner_data.get('plugin_name', '') + runner_name = runner_data.get('runner_name', '') + + if not plugin_author or not plugin_name or not runner_name: + return None + + manifest = runner_data.get('manifest', {}) + runner_id = format_runner_id( + source='plugin', + plugin_author=plugin_author, + plugin_name=plugin_name, + runner_name=runner_name, + ) + + typed_manifest = AgentRunnerManifest.model_validate(manifest) + config_schema = [ + item.model_dump(mode='json') for item in typed_manifest.config_schema + ] + + return AgentRunnerDescriptor( + id=runner_id, + source='plugin', + label=typed_manifest.label, + description=typed_manifest.description, + plugin_author=plugin_author, + plugin_name=plugin_name, + runner_name=runner_name, + plugin_version=runner_data.get('plugin_version'), + config_schema=config_schema, + capabilities=typed_manifest.capabilities, + permissions=typed_manifest.permissions, + raw_manifest=manifest, + ) + + async def refresh(self) -> None: + """Refresh runner cache. + + Always discovers ALL runners (no bound_plugins filter). + The cache contains unfiltered discovery results. + """ + async with self._cache_lock: + self._cache = await self._discover_runners() + + async def list_runners( + self, + bound_plugins: list[str] | None = None, + use_cache: bool = True, + ) -> list[AgentRunnerDescriptor]: + """List available runners. + + Args: + bound_plugins: Optional filter for bound plugins (applied locally) + use_cache: Use cached data if available + + Returns: + List of runner descriptors + """ + if use_cache and self._cache is not None: + # Filter from cache + return self._filter_runners_by_bound_plugins(self._cache, bound_plugins) + + # Discover fresh (always full list) + runners = await self._discover_runners() + + # Update cache (full list, unfiltered) + async with self._cache_lock: + self._cache = runners + + # Filter locally + return self._filter_runners_by_bound_plugins(runners, bound_plugins) + + def _filter_runners_by_bound_plugins( + self, + runners: dict[str, AgentRunnerDescriptor], + bound_plugins: list[str] | None, + ) -> list[AgentRunnerDescriptor]: + """Filter runners by bound plugins. + + Args: + runners: Dict of runner descriptors + bound_plugins: Optional filter (None means all plugins allowed) + + Returns: + Filtered list of runner descriptors + """ + if bound_plugins is None: + # All plugins allowed + return list(runners.values()) + + allowed_plugin_ids = set(bound_plugins) + filtered = [] + for descriptor in runners.values(): + plugin_id = descriptor.get_plugin_id() + if plugin_id in allowed_plugin_ids: + filtered.append(descriptor) + + return filtered + + async def get( + self, + runner_id: str, + bound_plugins: list[str] | None = None, + ) -> AgentRunnerDescriptor: + """Get a specific runner descriptor. + + Args: + runner_id: Runner ID to lookup + bound_plugins: Optional bound plugins filter + + Returns: + AgentRunnerDescriptor + + Raises: + RunnerNotFoundError: If runner not found + RunnerNotAuthorizedError: If runner not in bound plugins + """ + # Parse and validate runner ID format + try: + parse_runner_id(runner_id) + except ValueError as e: + raise RunnerNotFoundError(runner_id) from e + + # Get from cache or discover (always full list) + if self._cache is None: + await self.refresh() + + if self._cache is None: + raise RunnerNotFoundError(runner_id) + + descriptor = self._cache.get(runner_id) + if descriptor is None: + raise RunnerNotFoundError(runner_id) + + # Check authorization + if bound_plugins is not None: + plugin_id = descriptor.get_plugin_id() + if plugin_id not in bound_plugins: + raise RunnerNotAuthorizedError(runner_id, bound_plugins) + + return descriptor + + async def get_runner_metadata_for_pipeline(self) -> list[dict[str, typing.Any]]: + """Get runner metadata for pipeline configuration UI. + + Returns runner options and their config schemas for the DynamicForm. + """ + # Get all runners (no bound plugin filter for metadata listing) + runners = await self.list_runners(bound_plugins=None) + + options = [] + stages = [] + + for descriptor in runners: + config_schema = [] + for index, config_item in enumerate(descriptor.config_schema): + item = dict(config_item) + if not item.get('id'): + item_name = item.get('name') or str(index) + item['id'] = f'{descriptor.id}.{item_name}' + config_schema.append(item) + + # Add runner option + options.append( + { + 'name': descriptor.id, + 'label': descriptor.label, + 'description': descriptor.description, + } + ) + + # Add config schema as stage if not empty + if descriptor.config_schema: + stages.append( + { + 'name': descriptor.id, + 'label': descriptor.label, + 'description': descriptor.description, + 'config': config_schema, + } + ) + + return options, stages diff --git a/src/langbot/pkg/agent/runner/resource_builder.py b/src/langbot/pkg/agent/runner/resource_builder.py new file mode 100644 index 000000000..1abc3cf1c --- /dev/null +++ b/src/langbot/pkg/agent/runner/resource_builder.py @@ -0,0 +1,307 @@ +"""Agent resource builder for constructing authorized resources.""" +from __future__ import annotations + +import typing + +from ...core import app +from .descriptor import AgentRunnerDescriptor +from .context_builder import ( + AgentResources, + ModelResource, + ToolResource, + KnowledgeBaseResource, + SkillResource, + StorageResource, +) +from . import config_schema +from .host_models import AgentEventEnvelope, AgentBinding + + +class AgentResourceBuilder: + """Builder for constructing run-scoped AgentResources with permission filtering. + + Responsibilities: + - Apply manifest permissions intersected with binding resource policy + - Build models list from authorized models + - Build tools list from bound plugins/MCP servers + - Build knowledge_bases list from config + - Build storage access summary + + Note: This only builds the resource declaration. The actual proxy actions + in handler.py must still validate against ctx.resources at runtime. + + Resource field names match the plugin SDK payload: + - ModelResource: model_id, model_type, provider + - ToolResource: tool_name, tool_type, description + - KnowledgeBaseResource: kb_id, kb_name, kb_type + - SkillResource: skill_name, display_name, description + - StorageResource: plugin_storage, workspace_storage + """ + + ap: app.Application + + def __init__(self, ap: app.Application): + self.ap = ap + + async def build_resources_from_binding( + self, + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, + ) -> AgentResources: + """Build AgentResources from event and binding. + + This is the main entry point for Protocol v1. + + Args: + event: Event envelope + binding: Agent binding with resource policy + descriptor: Runner descriptor with capabilities, permissions, and config schema + + Returns: + AgentResources dict with filtered resource lists + """ + resource_policy = binding.resource_policy + runner_config = binding.runner_config + manifest_perms = descriptor.permissions + + # Build each resource category + models = await self._build_models_from_binding( + manifest_perms, resource_policy, descriptor, runner_config + ) + tools = await self._build_tools_from_binding( + manifest_perms, resource_policy, descriptor + ) + knowledge_bases = await self._build_knowledge_bases_from_binding( + manifest_perms, resource_policy, descriptor, runner_config + ) + skills = self._build_skills_from_binding( + resource_policy, descriptor + ) + storage = self._build_storage_from_binding(manifest_perms, binding) + + return { + 'models': models, + 'tools': tools, + 'knowledge_bases': knowledge_bases, + 'skills': skills, + 'storage': storage, + 'platform_capabilities': {}, # Reserved for EBA + } + + async def _build_models_from_binding( + self, + manifest_perms: typing.Any, + resource_policy: typing.Any, + descriptor: AgentRunnerDescriptor, + runner_config: dict[str, typing.Any], + ) -> list[ModelResource]: + """Build models list from binding.""" + models: list[ModelResource] = [] + seen_model_ids: set[str] = set() + + model_perms = set(manifest_perms.models) + include_llm = bool({'invoke', 'stream'} & model_perms) + include_rerank = 'rerank' in model_perms + llm_operations = [operation for operation in ('invoke', 'stream') if operation in model_perms] + if not include_llm and not include_rerank: + return models + + # Get additional model UUID grants from resource policy. + allowed_uuids = resource_policy.allowed_model_uuids + + # Add model resources from Agent/runner config schema + await self._append_config_declared_model_resources( + models=models, + seen_model_ids=seen_model_ids, + descriptor=descriptor, + runner_config=runner_config, + include_llm=include_llm, + include_rerank=include_rerank, + llm_operations=llm_operations, + ) + + # Add explicitly allowed models + if allowed_uuids and include_llm: + for model_uuid in allowed_uuids: + await self._append_llm_model_resource(models, seen_model_ids, model_uuid, llm_operations) + + return models + + async def _build_tools_from_binding( + self, + manifest_perms: typing.Any, + resource_policy: typing.Any, + descriptor: AgentRunnerDescriptor, + ) -> list[ToolResource]: + """Build tools list from binding.""" + tools: list[ToolResource] = [] + tool_perms = set(manifest_perms.tools) + if not ({'detail', 'call'} & tool_perms): + return tools + + if not config_schema.uses_host_tools(descriptor): + return tools + + # Get tool names from resource policy + allowed_names = resource_policy.allowed_tool_names + tool_operations = [operation for operation in ('detail', 'call') if operation in tool_perms] + + if allowed_names: + for tool_name in allowed_names: + tools.append({ + 'tool_name': tool_name, + 'tool_type': None, + 'description': None, + 'operations': tool_operations, + }) + + return tools + + async def _build_knowledge_bases_from_binding( + self, + manifest_perms: typing.Any, + resource_policy: typing.Any, + descriptor: AgentRunnerDescriptor, + runner_config: dict[str, typing.Any], + ) -> list[KnowledgeBaseResource]: + """Build knowledge bases list from binding.""" + kb_resources: list[KnowledgeBaseResource] = [] + kb_perms = set(manifest_perms.knowledge_bases) + if not ({'list', 'retrieve'} & kb_perms): + return kb_resources + kb_operations = [operation for operation in ('list', 'retrieve') if operation in kb_perms] + + if not config_schema.uses_host_knowledge_bases(descriptor): + return kb_resources + + # Get KB UUID grants from schema-defined config fields. + kb_uuids = config_schema.extract_knowledge_base_uuids(descriptor, runner_config) + + # Also include resource policy grants. + allowed_uuids = resource_policy.allowed_kb_uuids + if allowed_uuids: + kb_uuids = list(dict.fromkeys([*kb_uuids, *allowed_uuids])) + + for kb_uuid in kb_uuids: + try: + kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid) + if kb: + kb_resources.append({ + 'kb_id': kb_uuid, + 'kb_name': kb.get_name(), + 'kb_type': kb.knowledge_base_entity.kb_type if hasattr(kb.knowledge_base_entity, 'kb_type') else None, + 'operations': kb_operations, + }) + except Exception as e: + self.ap.logger.warning(f'Failed to build knowledge base resource {kb_uuid}: {e}') + + return kb_resources + + def _build_skills_from_binding( + self, + resource_policy: typing.Any, + descriptor: AgentRunnerDescriptor, + ) -> list[SkillResource]: + """Build pipeline-visible skill resource facts.""" + if not config_schema.supports_skill_authoring(descriptor): + return [] + + skill_mgr = getattr(self.ap, 'skill_mgr', None) + if skill_mgr is None: + return [] + + loaded_skills = getattr(skill_mgr, 'skills', {}) or {} + allowed_names = resource_policy.allowed_skill_names + if allowed_names is None: + names = sorted(loaded_skills.keys()) + else: + names = sorted(name for name in allowed_names if name in loaded_skills) + + skills: list[SkillResource] = [] + for skill_name in names: + skill_data = loaded_skills.get(skill_name) or {} + skills.append({ + 'skill_name': skill_name, + 'display_name': skill_data.get('display_name') or skill_data.get('name') or skill_name, + 'description': skill_data.get('description') or None, + }) + return skills + + def _build_storage_from_binding( + self, + manifest_perms: typing.Any, + binding: AgentBinding, + ) -> StorageResource: + """Build storage access summary from manifest and binding policy.""" + resource_policy = binding.resource_policy + storage_perms = set(manifest_perms.storage) + + return { + 'plugin_storage': 'plugin' in storage_perms and resource_policy.allow_plugin_storage, + 'workspace_storage': 'workspace' in storage_perms and resource_policy.allow_workspace_storage, + } + + async def _append_config_declared_model_resources( + self, + models: list[ModelResource], + seen_model_ids: set[str], + descriptor: AgentRunnerDescriptor, + runner_config: dict[str, typing.Any], + include_llm: bool, + include_rerank: bool, + llm_operations: list[str], + ) -> None: + """Authorize model-like values selected through DynamicForm fields.""" + for model_type, model_uuid in config_schema.iter_config_model_refs(descriptor, runner_config): + if model_type == 'llm' and include_llm: + await self._append_llm_model_resource(models, seen_model_ids, model_uuid, llm_operations) + elif model_type == 'rerank' and include_rerank: + await self._append_rerank_model_resource(models, seen_model_ids, model_uuid) + + async def _append_llm_model_resource( + self, + models: list[ModelResource], + seen_model_ids: set[str], + model_uuid: str | None, + operations: list[str], + ) -> None: + """Append an LLM model resource if it exists and has not been added.""" + if not model_uuid or model_uuid == '__none__' or model_uuid in seen_model_ids: + return + + try: + model = await self.ap.model_mgr.get_model_by_uuid(model_uuid) + if model and model.model_entity: + models.append({ + 'model_id': model_uuid, + 'model_type': getattr(model.model_entity, 'model_type', None), + 'provider': getattr(model.provider_entity, 'name', None) if hasattr(model, 'provider_entity') else None, + 'operations': operations, + }) + seen_model_ids.add(model_uuid) + except Exception as e: + self.ap.logger.warning(f'Failed to build LLM model resource {model_uuid}: {e}') + + async def _append_rerank_model_resource( + self, + models: list[ModelResource], + seen_model_ids: set[str], + model_uuid: str | None, + ) -> None: + """Append a rerank model resource if it exists and has not been added.""" + if not model_uuid or model_uuid == '__none__' or model_uuid in seen_model_ids: + return + + try: + model = await self.ap.model_mgr.get_rerank_model_by_uuid(model_uuid) + if model and model.model_entity: + models.append({ + 'model_id': model_uuid, + 'model_type': getattr(model.model_entity, 'model_type', 'rerank') or 'rerank', + 'provider': getattr(model.provider_entity, 'name', None) if hasattr(model, 'provider_entity') else None, + 'operations': ['rerank'], + }) + seen_model_ids.add(model_uuid) + except Exception as e: + self.ap.logger.warning(f'Failed to build rerank model resource {model_uuid}: {e}') diff --git a/src/langbot/pkg/agent/runner/result_normalizer.py b/src/langbot/pkg/agent/runner/result_normalizer.py new file mode 100644 index 000000000..3f4d34d92 --- /dev/null +++ b/src/langbot/pkg/agent/runner/result_normalizer.py @@ -0,0 +1,234 @@ +"""Agent result normalizer for converting AgentRunResult to Pipeline messages.""" +from __future__ import annotations + +import typing + +import pydantic +from langbot_plugin.api.entities.builtin.agent_runner.result import ( + ActionRequestedPayload, + MessageCompletedPayload, + MessageDeltaPayload, + RunCompletedPayload, + RunFailedPayload, + StateUpdatedPayload, + ToolCallCompletedPayload, + ToolCallStartedPayload, +) +from langbot_plugin.api.entities.builtin.provider import message as provider_message + +from ...core import app +from .descriptor import AgentRunnerDescriptor +from .errors import RunnerExecutionError, RunnerProtocolError + + +# Maximum size for a single result payload (prevent memory exhaustion) +MAX_RESULT_SIZE_BYTES = 1024 * 1024 # 1 MB + +STRICT_RESULT_PAYLOADS: dict[str, type[pydantic.BaseModel]] = { + 'message.delta': MessageDeltaPayload, + 'message.completed': MessageCompletedPayload, + 'tool.call.started': ToolCallStartedPayload, + 'tool.call.completed': ToolCallCompletedPayload, + 'state.updated': StateUpdatedPayload, + 'action.requested': ActionRequestedPayload, + 'run.completed': RunCompletedPayload, + 'run.failed': RunFailedPayload, +} + + +class AgentResultNormalizer: + """Normalizer for converting AgentRunResult to Pipeline messages. + + Responsibilities: + - Accept only supported result types (message.delta, message.completed, etc.) + - Map message.delta -> MessageChunk + - Map message.completed -> Message + - Map run.completed (with message) -> Message + - Handle run.failed as controlled error + - Ignore unknown types with warning + - Validate result size + - Validate message schema + + Accepted result types: + - message.delta + - message.completed + - tool.call.started + - tool.call.completed + - state.updated + - run.completed + - run.failed + - action.requested (log only, don't execute) + """ + + ap: app.Application + + def __init__(self, ap: app.Application): + self.ap = ap + + async def normalize( + self, + result_dict: dict[str, typing.Any], + descriptor: AgentRunnerDescriptor, + ) -> provider_message.Message | provider_message.MessageChunk | None: + """Normalize AgentRunResult to Message or MessageChunk. + + Args: + result_dict: Raw result dict from plugin runtime + descriptor: Runner descriptor for error context + + Returns: + Message, MessageChunk, or None (for non-message events) + + Raises: + RunnerExecutionError: On run.failed + RunnerProtocolError: On invalid result format + """ + # Validate result type + result_type = result_dict.get('type') + if not result_type: + raise RunnerProtocolError(descriptor.id, 'Missing result type') + + # Validate result size + try: + import json + result_json = json.dumps(result_dict) + if len(result_json) > MAX_RESULT_SIZE_BYTES: + self.ap.logger.warning( + f'Runner {descriptor.id} result too large ({len(result_json)} bytes), truncating' + ) + # Truncate content if possible + data = result_dict.get('data', {}) + if 'chunk' in data or 'message' in data: + content = data.get('chunk', {}).get('content', '') or data.get('message', {}).get('content', '') + if isinstance(content, str) and len(content) > 10000: + # Keep reasonable length + data['chunk'] = {'role': 'assistant', 'content': content[:10000] + '...[truncated]'} + except Exception as e: + self.ap.logger.warning(f'Failed to validate runner {descriptor.id} result size: {e}') + + # Handle each result type + data = result_dict.get('data', {}) + + if not self.validate_payload(result_type, data, descriptor): + return None + + if result_type == 'message.delta': + return self._normalize_message_delta(data, descriptor) + + elif result_type == 'message.completed': + return self._normalize_message_completed(data, descriptor) + + elif result_type == 'tool.call.started': + # Log only, don't yield to pipeline + self.ap.logger.debug( + f'Runner {descriptor.id} tool call started: {data.get("tool_name", "unknown")}' + ) + return None + + elif result_type == 'tool.call.completed': + # Log only, don't yield to pipeline + self.ap.logger.debug( + f'Runner {descriptor.id} tool call completed: {data.get("tool_name", "unknown")}' + ) + return None + + elif result_type == 'state.updated': + # Log for telemetry, don't yield to pipeline + # Orchestrator already handles the actual PersistentStateStore update. + scope = data.get('scope', 'unknown') + key = data.get('key', 'unknown') + value_repr = repr(data.get('value', '...'))[:100] # Truncate for log + self.ap.logger.debug( + f'Runner {descriptor.id} state.updated logged: scope={scope}, key={key}, value={value_repr}' + ) + return None + + elif result_type == 'run.completed': + # May include final message + if 'message' in data: + return self._normalize_message_completed(data, descriptor) + # If no message, it's just completion signal + return None + + elif result_type == 'run.failed': + error_msg = data.get('error', 'Unknown error') + error_code = data.get('code', 'unknown') + retryable = data.get('retryable', False) + raise RunnerExecutionError( + descriptor.id, + f'{error_msg} (code: {error_code})', + retryable=retryable, + ) + + elif result_type == 'action.requested': + # Reserved for EBA - log only, don't execute + self.ap.logger.info( + f'Runner {descriptor.id} requested action (not executed in current phase): ' + f'{data.get("action", "unknown")}' + ) + return None + + else: + # Unknown type - warn and ignore. + self.ap.logger.warning( + f'Runner {descriptor.id} returned unknown result type: {result_type}. ' + f'Expected supported types (message.delta, message.completed, run.completed, run.failed, etc.)' + ) + return None + + def validate_payload( + self, + result_type: str, + data: typing.Any, + descriptor: AgentRunnerDescriptor, + ) -> bool: + """Validate typed payloads that affect Host state or delivery. + + Tool-call telemetry stays intentionally loose so older runners can keep + emitting diagnostic fields. Unknown result types are handled by the + caller and are not validated here. + """ + payload_model = STRICT_RESULT_PAYLOADS.get(result_type) + if payload_model is None: + return True + + try: + payload_model.model_validate(data) + return True + except Exception as e: + self.ap.logger.warning( + f'Runner {descriptor.id} returned invalid {result_type} payload; dropping result: {e}' + ) + return False + + def _normalize_message_delta( + self, + data: dict[str, typing.Any], + descriptor: AgentRunnerDescriptor, + ) -> provider_message.MessageChunk: + """Normalize message.delta to MessageChunk.""" + chunk_data = data.get('chunk', {}) + if not chunk_data: + raise RunnerProtocolError(descriptor.id, 'message.delta missing chunk data') + + try: + chunk = provider_message.MessageChunk.model_validate(chunk_data) + return chunk + except Exception as e: + raise RunnerProtocolError(descriptor.id, f'Invalid chunk schema: {e}') + + def _normalize_message_completed( + self, + data: dict[str, typing.Any], + descriptor: AgentRunnerDescriptor, + ) -> provider_message.Message: + """Normalize message.completed to Message.""" + message_data = data.get('message', {}) + if not message_data: + raise RunnerProtocolError(descriptor.id, 'message.completed missing message data') + + try: + msg = provider_message.Message.model_validate(message_data) + return msg + except Exception as e: + raise RunnerProtocolError(descriptor.id, f'Invalid message schema: {e}') diff --git a/src/langbot/pkg/agent/runner/run_journal.py b/src/langbot/pkg/agent/runner/run_journal.py new file mode 100644 index 000000000..fdfdfed16 --- /dev/null +++ b/src/langbot/pkg/agent/runner/run_journal.py @@ -0,0 +1,412 @@ +"""Run-side effects for AgentRunner executions.""" + +from __future__ import annotations + +import typing + +from ...core import app +from .descriptor import AgentRunnerDescriptor +from .errors import RunnerProtocolError +from .host_models import AgentBinding, AgentEventEnvelope +from .persistent_state_store import PersistentStateStore, get_persistent_state_store +from .run_ledger_store import RunLedgerStore + + +class AgentRunJournal: + """Persist run events, transcript records, and state updates.""" + + ap: app.Application + + _persistent_state_store: PersistentStateStore | None + _run_ledger_store: RunLedgerStore | None + + def __init__(self, ap: app.Application): + self.ap = ap + self._persistent_state_store = None + self._run_ledger_store = None + + def _get_run_ledger_store(self) -> RunLedgerStore: + if self._run_ledger_store is None: + self._run_ledger_store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine()) + return self._run_ledger_store + + @staticmethod + def _to_plain_dict(value: typing.Any) -> dict[str, typing.Any]: + if hasattr(value, 'model_dump'): + value = value.model_dump(mode='json') + if isinstance(value, dict): + return dict(value) + return {} + + @classmethod + def _sanitize_content_item(cls, value: typing.Any) -> typing.Any: + item = cls._to_plain_dict(value) + if not item: + return value + item_type = item.get('type') + if item_type == 'image_base64' and item.get('image_base64'): + item['image_base64'] = None + item['content_redacted'] = True + elif item_type == 'file_base64' and item.get('file_base64'): + item['file_base64'] = None + item['content_redacted'] = True + return item + + @classmethod + def _sanitize_attachment_ref(cls, value: typing.Any) -> dict[str, typing.Any]: + item = cls._to_plain_dict(value) + if item.get('content'): + item['content'] = None + item['content_redacted'] = True + return item + + @classmethod + def _sanitize_contents(cls, contents: typing.Iterable[typing.Any]) -> list[typing.Any]: + return [cls._sanitize_content_item(content) for content in contents] + + @classmethod + def _sanitize_attachments(cls, attachments: typing.Iterable[typing.Any]) -> list[dict[str, typing.Any]]: + return [cls._sanitize_attachment_ref(attachment) for attachment in attachments] + + async def create_run( + self, + *, + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, + context: dict[str, typing.Any], + authorization: dict[str, typing.Any], + ) -> dict[str, typing.Any]: + """Create the Host-owned run ledger record.""" + runtime = context.get('runtime') if isinstance(context, dict) else {} + return await self._get_run_ledger_store().create_run( + run_id=context['run_id'], + event_id=event.event_id, + binding_id=binding.binding_id, + runner_id=descriptor.id, + conversation_id=event.conversation_id, + thread_id=event.thread_id, + workspace_id=event.workspace_id, + bot_id=event.bot_id, + deadline_at=runtime.get('deadline_at') if isinstance(runtime, dict) else None, + authorization=authorization, + metadata={ + 'event_type': event.event_type, + 'source': event.source, + }, + ) + + async def append_run_result( + self, + *, + result_dict: dict[str, typing.Any], + run_id: str, + sequence: int, + source: str = 'runner', + metadata: dict[str, typing.Any] | None = None, + ) -> dict[str, typing.Any]: + """Persist one AgentRunResult in the run ledger.""" + usage = result_dict.get('usage') + if hasattr(usage, 'model_dump'): + usage = usage.model_dump(mode='json') + return await self._get_run_ledger_store().append_event( + run_id=run_id, + sequence=sequence, + event_type=str(result_dict.get('type') or 'unknown'), + data=result_dict.get('data') if isinstance(result_dict.get('data'), dict) else {}, + usage=usage if isinstance(usage, dict) else None, + source=source, + metadata=metadata, + ) + + async def finalize_run( + self, + *, + run_id: str, + status: str, + status_reason: str | None = None, + usage: dict[str, typing.Any] | None = None, + metadata: dict[str, typing.Any] | None = None, + ) -> dict[str, typing.Any] | None: + """Finalize or update the Host-owned run ledger record.""" + return await self._get_run_ledger_store().finalize_run( + run_id=run_id, + status=status, + status_reason=status_reason, + usage=usage, + metadata=metadata, + ) + + async def get_run(self, run_id: str) -> dict[str, typing.Any] | None: + """Return the persisted run ledger record.""" + return await self._get_run_ledger_store().get_run(run_id) + + async def handle_state_updated_event( + self, + result_dict: dict[str, typing.Any], + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, + run_id: str | None = None, + ) -> None: + """Handle state.updated result in event-first mode.""" + data = result_dict.get('data', {}) + + result_run_id = result_dict.get('run_id') + if run_id and result_run_id and result_run_id != run_id: + raise RunnerProtocolError( + descriptor.id, + f'state.updated run_id mismatch: expected {run_id}, got {result_run_id}', + ) + + scope = data.get('scope') + if not scope: + raise RunnerProtocolError( + descriptor.id, + 'state.updated missing required field: scope', + ) + + key = data.get('key') + value = data.get('value') + + if not key: + raise RunnerProtocolError( + descriptor.id, + 'state.updated missing required field: key', + ) + + if self._persistent_state_store is None: + self._persistent_state_store = get_persistent_state_store(self.ap.persistence_mgr.get_db_engine()) + + success, error = await self._persistent_state_store.apply_update_from_event( + event=event, + binding=binding, + descriptor=descriptor, + scope=scope, + key=key, + value=value, + logger=self.ap.logger, + ) + + if success: + self.ap.logger.debug(f'Runner {descriptor.id} state.updated (event mode): scope={scope}, key={key}') + elif error: + self.ap.logger.warning(f'Runner {descriptor.id} state.updated rejected: {error}') + + async def write_event_log( + self, + event: AgentEventEnvelope, + binding: AgentBinding, + run_id: str, + runner_id: str, + metadata: dict[str, typing.Any] | None = None, + ) -> str: + """Write incoming event to EventLog.""" + import datetime + + from .event_log_store import EventLogStore + + store = EventLogStore(self.ap.persistence_mgr.get_db_engine()) + + input_summary = None + input_json = None + if event.input: + if event.input.text: + input_summary = event.input.text[:1000] + input_json = { + 'text': event.input.text, + 'contents': self._sanitize_contents(event.input.contents), + 'attachments': self._sanitize_attachments(event.input.attachments), + } + + return await store.append_event( + event_id=event.event_id, + event_type=event.event_type, + source=event.source, + bot_id=event.bot_id, + workspace_id=event.workspace_id, + conversation_id=event.conversation_id, + thread_id=event.thread_id, + actor_type=event.actor.actor_type if event.actor else None, + actor_id=event.actor.actor_id if event.actor else None, + actor_name=event.actor.actor_name if event.actor else None, + subject_type=event.subject.subject_type if event.subject else None, + subject_id=event.subject.subject_id if event.subject else None, + input_summary=input_summary, + input_json=input_json, + run_id=run_id, + runner_id=runner_id, + event_time=( + datetime.datetime.fromtimestamp(event.event_time, datetime.timezone.utc) if event.event_time else None + ), + metadata=metadata, + ) + + async def write_user_transcript( + self, + event: AgentEventEnvelope, + event_log_id: str, + ) -> None: + """Write user message to Transcript.""" + from .transcript_store import TranscriptStore + + store = TranscriptStore(self.ap.persistence_mgr.get_db_engine()) + + content = event.input.text if event.input else None + content_json = None + if event.input: + content_json = { + 'role': 'user', + 'content': self._sanitize_contents(event.input.contents) if event.input.contents else [], + } + + attachment_refs = [] + if event.input and event.input.attachments: + for a in event.input.attachments: + attachment_refs.append(self._sanitize_attachment_ref(a)) + + await store.append_transcript( + transcript_id=None, + event_id=event_log_id, + conversation_id=event.conversation_id, + role='user', + bot_id=event.bot_id, + workspace_id=event.workspace_id, + content=content, + content_json=content_json, + attachment_refs=attachment_refs if attachment_refs else None, + thread_id=event.thread_id, + item_type='message', + metadata={ + 'actor_type': event.actor.actor_type if event.actor else None, + 'actor_id': event.actor.actor_id if event.actor else None, + }, + ) + + async def write_steering_dropped_audits( + self, + items: list[dict[str, typing.Any]], + run_id: str, + runner_id: str, + *, + reason: str = 'run_ended', + ) -> None: + """Write terminal audit events for steering items left unconsumed.""" + if not items: + return + + import datetime + import uuid + + from .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.get('event'), dict) else {} + input_data = item.get('input') if isinstance(item.get('input'), dict) else {} + conversation = item.get('conversation') if isinstance(item.get('conversation'), dict) else {} + actor = item.get('actor') if isinstance(item.get('actor'), dict) else {} + subject = item.get('subject') if isinstance(item.get('subject'), dict) else {} + + text = input_data.get('text') + input_summary = text[:1000] if isinstance(text, str) and text else 'Unconsumed steering input dropped' + event_time = None + raw_event_time = event.get('event_time') + if raw_event_time: + try: + event_time = datetime.datetime.fromtimestamp( + raw_event_time, + datetime.timezone.utc, + ) + except (TypeError, ValueError, OSError): + event_time = None + + await store.append_event( + event_id=str(uuid.uuid4()), + event_type='steering.dropped', + source='host', + bot_id=conversation.get('bot_id'), + workspace_id=conversation.get('workspace_id'), + conversation_id=conversation.get('conversation_id'), + thread_id=conversation.get('thread_id'), + actor_type=actor.get('actor_type'), + actor_id=actor.get('actor_id'), + actor_name=actor.get('actor_name'), + subject_type=subject.get('subject_type'), + subject_id=subject.get('subject_id'), + input_summary=input_summary, + input_json={ + 'text': text, + 'contents': self._sanitize_contents(input_data.get('contents') or []), + 'attachments': self._sanitize_attachments(input_data.get('attachments') or []), + }, + run_id=run_id, + runner_id=runner_id, + event_time=event_time, + metadata={ + 'steering': { + 'status': 'dropped', + 'reason': reason, + 'original_event_id': event.get('event_id'), + 'claimed_run_id': item.get('claimed_run_id'), + 'claimed_runner_id': item.get('runner_id'), + 'claimed_at': item.get('claimed_at'), + }, + }, + ) + + async def write_assistant_transcript( + self, + result_dict: dict[str, typing.Any], + event: AgentEventEnvelope, + run_id: str, + runner_id: str, + ) -> None: + """Write assistant message to Transcript.""" + import uuid + + from .transcript_store import TranscriptStore + + store = TranscriptStore(self.ap.persistence_mgr.get_db_engine()) + + data = result_dict.get('data', {}) + message = data.get('message', {}) + + content = None + content_json = None + + if isinstance(message.get('content'), str): + content = message['content'] + content_json = message + elif isinstance(message.get('content'), list): + text_parts = [] + for c in message['content']: + if isinstance(c, dict) and c.get('type') == 'text': + text_parts.append(c.get('text', '')) + content = ' '.join(text_parts) if text_parts else None + content_json = { + **message, + 'content': self._sanitize_contents(message['content']), + } + + assistant_event_id = str(uuid.uuid4()) + + await store.append_transcript( + transcript_id=str(uuid.uuid4()), + event_id=assistant_event_id, + conversation_id=event.conversation_id, + role='assistant', + bot_id=event.bot_id, + workspace_id=event.workspace_id, + content=content, + content_json=content_json, + thread_id=event.thread_id, + item_type='message', + run_id=run_id, + runner_id=runner_id, + metadata={ + 'run_id': run_id, + 'runner_id': runner_id, + }, + ) diff --git a/src/langbot/pkg/agent/runner/run_ledger_store.py b/src/langbot/pkg/agent/runner/run_ledger_store.py new file mode 100644 index 000000000..c5e53c9f7 --- /dev/null +++ b/src/langbot/pkg/agent/runner/run_ledger_store.py @@ -0,0 +1,1102 @@ +"""Run ledger store for Host-owned AgentRun and AgentRunEvent records.""" + +from __future__ import annotations + +import datetime +import json +import typing +import uuid + +import sqlalchemy +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession +from sqlalchemy.orm import sessionmaker + +from ...entity.persistence.agent_run import AgentRun, AgentRunEvent, AgentRuntime + + +UTC = datetime.timezone.utc +RUN_STATUSES = {'created', 'queued', 'claimed', 'running', 'completed', 'failed', 'cancelled', 'timeout'} +TERMINAL_STATUSES = {'completed', 'failed', 'cancelled', 'timeout'} + + +def _utc_now() -> datetime.datetime: + return datetime.datetime.now(UTC) + + +def _as_utc(value: datetime.datetime | None) -> datetime.datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def _datetime_to_epoch(value: datetime.datetime | None) -> int | None: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=UTC) + else: + value = value.astimezone(UTC) + return int(value.timestamp()) + + +def _epoch_to_datetime(value: typing.Any) -> datetime.datetime | None: + if value is None: + return None + try: + return datetime.datetime.fromtimestamp(float(value), UTC) + except (TypeError, ValueError, OSError): + return None + + +def _json_dumps(value: typing.Any) -> str | None: + if value is None: + return None + return json.dumps(value) + + +def _json_loads(value: str | None, default: typing.Any) -> typing.Any: + if not value: + return default + try: + return json.loads(value) + except (TypeError, ValueError): + return default + + +def _validate_run_status(status: str) -> str: + normalized = str(status) + if normalized not in RUN_STATUSES: + raise ValueError(f'Unknown run status: {normalized}') + return normalized + + +def _claim_is_active( + run: AgentRun, + *, + runtime_id: str | None, + claim_token: str, + now: datetime.datetime, +) -> bool: + if run.status != 'claimed' or run.claim_token != claim_token: + return False + if runtime_id is not None and run.claimed_by_runtime_id != runtime_id: + return False + lease_expires_at = _as_utc(run.claim_lease_expires_at) + return lease_expires_at is not None and lease_expires_at > now + + +class RunLedgerStore: + """Store for Host-owned run lifecycle and result event facts.""" + + engine: AsyncEngine + + def __init__(self, engine: AsyncEngine): + self.engine = engine + self._session_factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async def create_run( + self, + *, + run_id: str, + event_id: str | None, + binding_id: str | None, + runner_id: str, + conversation_id: str | None = None, + thread_id: str | None = None, + workspace_id: str | None = None, + bot_id: str | None = None, + agent_id: str | None = None, + deadline_at: int | float | None = None, + authorization: dict[str, typing.Any] | None = None, + metadata: dict[str, typing.Any] | None = None, + status: str = 'running', + queue_name: str | None = None, + priority: int = 0, + requested_runtime_id: str | None = None, + ) -> dict[str, typing.Any]: + """Create a run if it does not already exist.""" + status = _validate_run_status(status) + now = _utc_now() + async with self._session_factory() as session: + existing = await self._get_run_row(session, run_id) + if existing is not None: + return self._run_to_dict(existing) + + run = AgentRun( + run_id=run_id, + event_id=event_id, + agent_id=agent_id, + binding_id=binding_id, + runner_id=runner_id, + conversation_id=conversation_id, + thread_id=thread_id, + workspace_id=workspace_id, + bot_id=bot_id, + status=status, + queue_name=queue_name, + priority=priority, + requested_runtime_id=requested_runtime_id, + created_at=now, + started_at=now if status == 'running' else None, + updated_at=now, + deadline_at=_epoch_to_datetime(deadline_at), + authorization_json=_json_dumps(authorization), + metadata_json=_json_dumps(metadata), + ) + session.add(run) + await session.commit() + return self._run_to_dict(run) + + async def claim_next_run( + self, + *, + runtime_id: str, + queue_name: str | None = None, + lease_seconds: int = 60, + runner_ids: list[str] | None = None, + conversation_id: str | None = None, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + strict_thread: bool = False, + ) -> dict[str, typing.Any] | None: + """Claim the next queued or expired-leased run for a runtime.""" + now = _utc_now() + lease_expires_at = now + datetime.timedelta(seconds=max(int(lease_seconds), 1)) + async with self._session_factory() as session: + query = sqlalchemy.select(AgentRun).where( + sqlalchemy.or_( + AgentRun.status == 'queued', + sqlalchemy.and_( + AgentRun.status == 'claimed', + AgentRun.claim_lease_expires_at.is_not(None), + AgentRun.claim_lease_expires_at <= now, + ), + ), + sqlalchemy.or_( + AgentRun.requested_runtime_id.is_(None), + AgentRun.requested_runtime_id == runtime_id, + ), + ) + if queue_name is not None: + query = query.where(AgentRun.queue_name == queue_name) + if runner_ids: + query = query.where(AgentRun.runner_id.in_(runner_ids)) + if conversation_id is not None: + query = query.where(AgentRun.conversation_id == conversation_id) + query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread) + + query = query.order_by(AgentRun.priority.desc(), AgentRun.id.asc()).limit(1).with_for_update( + skip_locked=True + ) + result = await session.execute(query) + run = result.scalars().first() + if run is None: + return None + + run.status = 'claimed' + run.claimed_by_runtime_id = runtime_id + run.claim_token = uuid.uuid4().hex + run.claim_lease_expires_at = lease_expires_at + run.dispatch_attempts = (run.dispatch_attempts or 0) + 1 + run.last_claimed_at = now + run.updated_at = now + await session.commit() + return self._run_to_dict(run, include_claim_token=True) + + async def renew_claim( + self, + *, + run_id: str, + claim_token: str, + runtime_id: str | None = None, + lease_seconds: int = 60, + ) -> dict[str, typing.Any] | None: + """Extend a current claim lease if the token still matches.""" + now = _utc_now() + async with self._session_factory() as session: + run = await self._get_run_row(session, run_id) + if run is None or not _claim_is_active(run, runtime_id=runtime_id, claim_token=claim_token, now=now): + return None + + run.claim_lease_expires_at = now + datetime.timedelta(seconds=max(int(lease_seconds), 1)) + run.updated_at = now + await session.commit() + return self._run_to_dict(run) + + async def release_claim( + self, + *, + run_id: str, + claim_token: str, + runtime_id: str | None = None, + status: str = 'queued', + status_reason: str | None = None, + ) -> dict[str, typing.Any] | None: + """Release a current claim lease if the token still matches.""" + status = _validate_run_status(status) + now = _utc_now() + async with self._session_factory() as session: + run = await self._get_run_row(session, run_id) + if run is None or not _claim_is_active(run, runtime_id=runtime_id, claim_token=claim_token, now=now): + return None + + run.status = status + run.status_reason = status_reason + run.claimed_by_runtime_id = None + run.claim_token = None + run.claim_lease_expires_at = None + run.updated_at = now + if status in TERMINAL_STATUSES: + run.finished_at = run.finished_at or now + await session.commit() + return self._run_to_dict(run) + + async def release_expired_claims( + self, + *, + now: datetime.datetime | None = None, + status: str = 'queued', + status_reason: str = 'claim lease expired', + limit: int = 100, + ) -> list[dict[str, typing.Any]]: + """Release claimed runs whose claim lease has expired.""" + status = _validate_run_status(status) + current_time = now or _utc_now() + if current_time.tzinfo is None: + current_time = current_time.replace(tzinfo=UTC) + limit = min(max(int(limit), 1), 500) + + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.select(AgentRun) + .where( + AgentRun.status == 'claimed', + AgentRun.claim_lease_expires_at.is_not(None), + AgentRun.claim_lease_expires_at <= current_time, + ) + .order_by(AgentRun.claim_lease_expires_at.asc(), AgentRun.id.asc()) + .limit(limit) + ) + runs = result.scalars().all() + for run in runs: + run.status = status + run.status_reason = status_reason + run.claimed_by_runtime_id = None + run.claim_token = None + run.claim_lease_expires_at = None + run.updated_at = current_time + if status in TERMINAL_STATUSES: + run.finished_at = run.finished_at or current_time + await session.commit() + return [self._run_to_dict(run) for run in runs] + + async def append_event( + self, + *, + run_id: str, + sequence: int, + event_type: str, + data: dict[str, typing.Any] | None = None, + usage: dict[str, typing.Any] | None = None, + source: str = 'runner', + metadata: dict[str, typing.Any] | None = None, + ) -> dict[str, typing.Any]: + """Append one run result event. + + If the same run_id + sequence already exists, the existing row is + returned. This supports retrying append calls idempotently. + """ + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.select(AgentRunEvent).where( + AgentRunEvent.run_id == run_id, + AgentRunEvent.sequence == sequence, + ) + ) + existing = result.scalars().first() + if existing is not None: + return self._event_to_dict(existing) + + row = AgentRunEvent( + run_id=run_id, + sequence=sequence, + type=event_type, + data_json=_json_dumps(data or {}), + usage_json=_json_dumps(usage), + created_at=_utc_now(), + source=source, + metadata_json=_json_dumps(metadata), + ) + session.add(row) + await session.commit() + return self._event_to_dict(row) + + async def append_audit_event( + self, + *, + run_id: str, + event_type: str, + data: dict[str, typing.Any] | None = None, + metadata: dict[str, typing.Any] | None = None, + ) -> dict[str, typing.Any] | None: + """Append a Host-authored audit event after the current max sequence.""" + async with self._session_factory() as session: + run = await self._get_run_row(session, run_id) + if run is None: + return None + + result = await session.execute( + sqlalchemy.select(sqlalchemy.func.max(AgentRunEvent.sequence)).where( + AgentRunEvent.run_id == run_id, + ) + ) + next_sequence = int(result.scalar_one_or_none() or 0) + 1 + row = AgentRunEvent( + run_id=run_id, + sequence=next_sequence, + type=event_type, + data_json=_json_dumps(data or {}), + usage_json=None, + created_at=_utc_now(), + source='host', + metadata_json=_json_dumps(metadata or {}), + ) + session.add(row) + await session.commit() + return self._event_to_dict(row) + + async def finalize_run( + self, + *, + run_id: str, + status: str, + status_reason: str | None = None, + usage: dict[str, typing.Any] | None = None, + cost: dict[str, typing.Any] | None = None, + metadata: dict[str, typing.Any] | None = None, + ) -> dict[str, typing.Any] | None: + """Update a run to a terminal or current status.""" + status = _validate_run_status(status) + now = _utc_now() + async with self._session_factory() as session: + run = await self._get_run_row(session, run_id) + if run is None: + return None + + if run.status in TERMINAL_STATUSES and run.status != status: + raise ValueError(f'Cannot transition terminal run {run_id} from {run.status} to {status}') + + run.status = status + if status_reason is not None: + run.status_reason = status_reason + run.updated_at = now + if status in TERMINAL_STATUSES: + run.finished_at = run.finished_at or now + run.claimed_by_runtime_id = None + run.claim_token = None + run.claim_lease_expires_at = None + if usage is not None: + run.usage_json = _json_dumps(usage) + if cost is not None: + run.cost_json = _json_dumps(cost) + if metadata is not None: + existing_metadata = _json_loads(run.metadata_json, {}) + if isinstance(existing_metadata, dict): + existing_metadata.update(metadata) + run.metadata_json = _json_dumps(existing_metadata) + else: + run.metadata_json = _json_dumps(metadata) + await session.commit() + return self._run_to_dict(run) + + async def validate_active_claim( + self, + *, + run_id: str, + runtime_id: str, + claim_token: str, + ) -> bool: + """Return whether a runtime currently owns an unexpired claim lease.""" + now = _utc_now() + async with self._session_factory() as session: + run = await self._get_run_row(session, run_id) + if run is None: + return False + return _claim_is_active(run, runtime_id=runtime_id, claim_token=claim_token, now=now) + + async def request_cancel( + self, + *, + run_id: str, + status_reason: str | None = None, + ) -> dict[str, typing.Any] | None: + """Record a cancellation request.""" + now = _utc_now() + async with self._session_factory() as session: + run = await self._get_run_row(session, run_id) + if run is None: + return None + run.cancel_requested_at = now + run.updated_at = now + run.status_reason = status_reason or run.status_reason + await session.commit() + return self._run_to_dict(run) + + async def get_run(self, run_id: str) -> dict[str, typing.Any] | None: + """Get one run by run_id.""" + async with self._session_factory() as session: + row = await self._get_run_row(session, run_id) + return self._run_to_dict(row) if row is not None else None + + async def register_runtime( + self, + *, + runtime_id: str, + status: str = 'online', + display_name: str | None = None, + endpoint: str | None = None, + version: str | None = None, + capabilities: dict[str, typing.Any] | None = None, + labels: dict[str, typing.Any] | None = None, + metadata: dict[str, typing.Any] | None = None, + heartbeat_deadline_seconds: int = 60, + ) -> dict[str, typing.Any]: + """Create or update a runtime registry row and record a heartbeat.""" + now = _utc_now() + async with self._session_factory() as session: + runtime = await self._get_runtime_row(session, runtime_id) + if runtime is None: + runtime = AgentRuntime(runtime_id=runtime_id, created_at=now) + session.add(runtime) + + runtime.status = status + runtime.display_name = display_name + runtime.endpoint = endpoint + runtime.version = version + runtime.capabilities_json = _json_dumps(capabilities or {}) + runtime.labels_json = _json_dumps(labels or {}) + runtime.metadata_json = _json_dumps(metadata or {}) + runtime.last_heartbeat_at = now + runtime.heartbeat_deadline_at = now + datetime.timedelta(seconds=max(int(heartbeat_deadline_seconds), 1)) + runtime.updated_at = now + await session.commit() + return self._runtime_to_dict(runtime) + + async def heartbeat_runtime( + self, + *, + runtime_id: str, + status: str = 'online', + heartbeat_deadline_seconds: int = 60, + capabilities: dict[str, typing.Any] | None = None, + labels: dict[str, typing.Any] | None = None, + metadata: dict[str, typing.Any] | None = None, + ) -> dict[str, typing.Any] | None: + """Refresh a runtime heartbeat.""" + now = _utc_now() + async with self._session_factory() as session: + runtime = await self._get_runtime_row(session, runtime_id) + if runtime is None: + return None + + runtime.status = status + runtime.last_heartbeat_at = now + runtime.heartbeat_deadline_at = now + datetime.timedelta(seconds=max(int(heartbeat_deadline_seconds), 1)) + runtime.updated_at = now + if capabilities is not None: + runtime.capabilities_json = _json_dumps(capabilities) + if labels is not None: + runtime.labels_json = _json_dumps(labels) + if metadata is not None: + existing_metadata = _json_loads(runtime.metadata_json, {}) + if isinstance(existing_metadata, dict): + existing_metadata.update(metadata) + runtime.metadata_json = _json_dumps(existing_metadata) + else: + runtime.metadata_json = _json_dumps(metadata) + await session.commit() + return self._runtime_to_dict(runtime) + + async def get_runtime(self, runtime_id: str) -> dict[str, typing.Any] | None: + """Get one runtime by runtime_id.""" + async with self._session_factory() as session: + row = await self._get_runtime_row(session, runtime_id) + return self._runtime_to_dict(row) if row is not None else None + + async def list_runtimes( + self, + *, + statuses: list[str] | None = None, + labels: dict[str, str] | None = None, + limit: int = 100, + ) -> tuple[list[dict[str, typing.Any]], int]: + """List runtime registry rows. + + Args: + statuses: Filter by status list + labels: Filter by labels (key-value pairs) + limit: Maximum number of rows to return + + Returns: + Tuple of (runtimes, total_count). + """ + limit = min(max(int(limit), 1), 500) + async with self._session_factory() as session: + # Build base query with status filter + base_query = sqlalchemy.select(AgentRuntime) + if statuses: + base_query = base_query.where(AgentRuntime.status.in_(statuses)) + + # Get total count (before label filtering) + if not labels: + # Simple case - can count directly in DB + count_query = sqlalchemy.select(sqlalchemy.func.count(AgentRuntime.id)) + if statuses: + count_query = count_query.where(AgentRuntime.status.in_(statuses)) + count_result = await session.execute(count_query) + total_count = count_result.scalar() or 0 + + # Get items + query = base_query.order_by(AgentRuntime.id.asc()).limit(limit) + result = await session.execute(query) + runtimes = [self._runtime_to_dict(row) for row in result.scalars().all()] + else: + # Need to fetch all and filter by labels in Python + query = base_query.order_by(AgentRuntime.id.asc()) + result = await session.execute(query) + all_runtimes = [self._runtime_to_dict(row) for row in result.scalars().all()] + + # Filter by labels + runtimes = [ + rt for rt in all_runtimes + if all(rt.get('labels', {}).get(k) == v for k, v in labels.items()) + ] + total_count = len(runtimes) + + # Apply limit after filtering + runtimes = runtimes[:limit] + + return runtimes, total_count + + async def mark_stale_runtimes( + self, + *, + now: datetime.datetime | None = None, + stale_status: str = 'stale', + stale_after_seconds: int | float | None = None, + ) -> list[dict[str, typing.Any]]: + """Mark runtimes stale when their heartbeat deadline has passed.""" + current_time = now or _utc_now() + if current_time.tzinfo is None: + current_time = current_time.replace(tzinfo=UTC) + stale_conditions: list[typing.Any] = [ + sqlalchemy.and_( + AgentRuntime.heartbeat_deadline_at.is_not(None), + AgentRuntime.heartbeat_deadline_at < current_time, + ) + ] + if stale_after_seconds is not None: + try: + stale_after_delta = datetime.timedelta(seconds=max(float(stale_after_seconds), 0)) + except (TypeError, ValueError): + stale_after_delta = None + if stale_after_delta is not None: + stale_conditions.append( + sqlalchemy.and_( + AgentRuntime.last_heartbeat_at.is_not(None), + AgentRuntime.last_heartbeat_at < current_time - stale_after_delta, + ) + ) + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.select(AgentRuntime).where( + sqlalchemy.or_(*stale_conditions), + AgentRuntime.status != stale_status, + ) + ) + runtimes = result.scalars().all() + for runtime in runtimes: + runtime.status = stale_status + runtime.updated_at = current_time + await session.commit() + return [self._runtime_to_dict(runtime) for runtime in runtimes] + + async def list_runs( + self, + *, + conversation_id: str | None = None, + statuses: list[str] | None = None, + before_id: int | None = None, + limit: int = 50, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + strict_thread: bool = False, + runner_id: str | None = None, + ) -> tuple[list[dict[str, typing.Any]], int | None, bool, int]: + """Page runs by scope. + + Returns: + Tuple of (items, next_cursor, has_more, total_count). + """ + limit = min(max(int(limit), 1), 100) + async with self._session_factory() as session: + # First get total count + count_query = sqlalchemy.select(sqlalchemy.func.count(AgentRun.id)) + if conversation_id is not None: + count_query = count_query.where(AgentRun.conversation_id == conversation_id) + if statuses: + count_query = count_query.where(AgentRun.status.in_(statuses)) + if runner_id is not None: + count_query = count_query.where(AgentRun.runner_id == runner_id) + count_query = self._apply_scope_filters(count_query, bot_id, workspace_id, thread_id, strict_thread) + count_result = await session.execute(count_query) + total_count = count_result.scalar() or 0 + + # Then get items + query = sqlalchemy.select(AgentRun) + if conversation_id is not None: + query = query.where(AgentRun.conversation_id == conversation_id) + if statuses: + query = query.where(AgentRun.status.in_(statuses)) + if runner_id is not None: + query = query.where(AgentRun.runner_id == runner_id) + if before_id is not None: + query = query.where(AgentRun.id < before_id) + query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread) + query = query.order_by(AgentRun.id.desc()).limit(limit + 1) + + result = await session.execute(query) + rows = result.scalars().all() + items = [self._run_to_dict(row) for row in rows[:limit]] + has_more = len(rows) > limit + next_cursor = items[-1]['id'] if items and has_more else None + + return items, next_cursor, has_more, total_count + + async def page_run_events( + self, + *, + run_id: str, + before_sequence: int | None = None, + after_sequence: int | None = None, + limit: int = 50, + direction: str = 'forward', + ) -> tuple[list[dict[str, typing.Any]], int | None, int | None, bool]: + """Page result events for one run.""" + limit = min(max(int(limit), 1), 100) + direction = direction if direction in {'forward', 'backward'} else 'forward' + async with self._session_factory() as session: + query = sqlalchemy.select(AgentRunEvent).where(AgentRunEvent.run_id == run_id) + if before_sequence is not None: + query = query.where(AgentRunEvent.sequence < before_sequence) + if after_sequence is not None: + query = query.where(AgentRunEvent.sequence > after_sequence) + + if direction == 'backward': + query = query.order_by(AgentRunEvent.sequence.desc()) + else: + query = query.order_by(AgentRunEvent.sequence.asc()) + query = query.limit(limit + 1) + + result = await session.execute(query) + rows = result.scalars().all() + items = [self._event_to_dict(row) for row in rows[:limit]] + has_more = len(rows) > limit + + if direction == 'backward': + next_cursor = items[-1]['sequence'] if items and has_more else None + prev_cursor = items[0]['sequence'] if items else None + else: + next_cursor = items[-1]['sequence'] if items and has_more else None + prev_cursor = items[0]['sequence'] if items else None + return items, next_cursor, prev_cursor, has_more + + async def _get_run_row( + self, + session: AsyncSession, + run_id: str, + ) -> AgentRun | None: + result = await session.execute(sqlalchemy.select(AgentRun).where(AgentRun.run_id == run_id)) + return result.scalars().first() + + async def _get_runtime_row( + self, + session: AsyncSession, + runtime_id: str, + ) -> AgentRuntime | None: + result = await session.execute(sqlalchemy.select(AgentRuntime).where(AgentRuntime.runtime_id == runtime_id)) + return result.scalars().first() + + def _apply_scope_filters( + self, + query: typing.Any, + bot_id: str | None, + workspace_id: str | None, + thread_id: str | None, + strict_thread: bool, + ) -> typing.Any: + if bot_id is not None: + query = query.where(AgentRun.bot_id == bot_id) + if workspace_id is not None: + query = query.where(AgentRun.workspace_id == workspace_id) + if strict_thread: + if thread_id is None: + query = query.where(AgentRun.thread_id.is_(None)) + else: + query = query.where(AgentRun.thread_id == thread_id) + return query + + def _run_to_dict(self, row: AgentRun, *, include_claim_token: bool = False) -> dict[str, typing.Any]: + data = { + 'id': row.id, + 'run_id': row.run_id, + 'event_id': row.event_id, + 'agent_id': row.agent_id, + 'binding_id': row.binding_id, + 'runner_id': row.runner_id, + 'conversation_id': row.conversation_id, + 'thread_id': row.thread_id, + 'workspace_id': row.workspace_id, + 'bot_id': row.bot_id, + 'status': row.status, + 'status_reason': row.status_reason, + 'queue_name': row.queue_name, + 'priority': row.priority, + 'requested_runtime_id': row.requested_runtime_id, + 'claimed_by_runtime_id': row.claimed_by_runtime_id, + 'claim_lease_expires_at': _datetime_to_epoch(row.claim_lease_expires_at), + 'dispatch_attempts': row.dispatch_attempts, + 'last_claimed_at': _datetime_to_epoch(row.last_claimed_at), + 'created_at': _datetime_to_epoch(row.created_at), + 'started_at': _datetime_to_epoch(row.started_at), + 'finished_at': _datetime_to_epoch(row.finished_at), + 'updated_at': _datetime_to_epoch(row.updated_at), + 'deadline_at': _datetime_to_epoch(row.deadline_at), + 'cancel_requested_at': _datetime_to_epoch(row.cancel_requested_at), + 'usage': _json_loads(row.usage_json, None), + 'cost': _json_loads(row.cost_json, None), + 'metadata': _json_loads(row.metadata_json, {}), + } + if include_claim_token: + data['claim_token'] = row.claim_token + return data + + def _runtime_to_dict(self, row: AgentRuntime) -> dict[str, typing.Any]: + return { + 'id': row.id, + 'runtime_id': row.runtime_id, + 'status': row.status, + 'display_name': row.display_name, + 'endpoint': row.endpoint, + 'version': row.version, + 'capabilities': _json_loads(row.capabilities_json, {}), + 'labels': _json_loads(row.labels_json, {}), + 'metadata': _json_loads(row.metadata_json, {}), + 'last_heartbeat_at': _datetime_to_epoch(row.last_heartbeat_at), + 'heartbeat_deadline_at': _datetime_to_epoch(row.heartbeat_deadline_at), + 'created_at': _datetime_to_epoch(row.created_at), + 'updated_at': _datetime_to_epoch(row.updated_at), + } + + def _event_to_dict(self, row: AgentRunEvent) -> dict[str, typing.Any]: + return { + 'id': row.id, + 'run_id': row.run_id, + 'sequence': row.sequence, + 'type': row.type, + 'data': _json_loads(row.data_json, {}), + 'usage': _json_loads(row.usage_json, None), + 'created_at': _datetime_to_epoch(row.created_at), + 'source': row.source, + 'metadata': _json_loads(row.metadata_json, {}), + } + + async def get_run_stats( + self, + *, + start_time: int, + end_time: int, + runner_id: str | None = None, + ) -> dict[str, typing.Any]: + """Get run statistics within a time window. + + Args: + start_time: Unix timestamp for start of window + end_time: Unix timestamp for end of window + runner_id: Optional filter by runner + + Returns: + Dict with status counts, rates, and duration stats. + """ + from sqlalchemy import func + + start_dt = _epoch_to_datetime(start_time) + end_dt = _epoch_to_datetime(end_time) + + async with self._session_factory() as session: + # Base filter for time window + base_filter = [ + AgentRun.created_at >= start_dt, + AgentRun.created_at <= end_dt, + ] + if runner_id: + base_filter.append(AgentRun.runner_id == runner_id) + + # Count by status + status_query = ( + sqlalchemy.select( + AgentRun.status, + func.count(AgentRun.id).label('count') + ) + .where(*base_filter) + .group_by(AgentRun.status) + ) + status_result = await session.execute(status_query) + status_counts = {row.status: row.count for row in status_result} + + total_count = sum(status_counts.values()) + completed_count = status_counts.get('completed', 0) + failed_count = status_counts.get('failed', 0) + status_counts.get('timeout', 0) + + # Calculate rates + window_hours = max((end_time - start_time) / 3600, 0.001) + throughput = total_count / window_hours if total_count > 0 else 0 + success_rate = completed_count / total_count if total_count > 0 else None + failure_rate = failed_count / total_count if total_count > 0 else None + + # Duration stats for completed runs - compute in Python for DB compatibility + avg_duration_seconds = None + avg_queue_wait_seconds = None + + # Fetch completed runs with timing data + timing_query = ( + sqlalchemy.select( + AgentRun.started_at, + AgentRun.finished_at, + AgentRun.created_at, + ) + .where( + AgentRun.status == 'completed', + AgentRun.started_at.is_not(None), + AgentRun.finished_at.is_not(None), + *base_filter + ) + ) + timing_result = await session.execute(timing_query) + timing_rows = timing_result.all() + + if timing_rows: + durations = [] + for row in timing_rows: + if row.finished_at and row.started_at: + delta = row.finished_at - row.started_at + durations.append(delta.total_seconds()) + if durations: + avg_duration_seconds = round(sum(durations) / len(durations), 2) + + # Queue wait time - compute in Python + queue_query = ( + sqlalchemy.select( + AgentRun.created_at, + AgentRun.started_at, + ) + .where( + AgentRun.started_at.is_not(None), + *base_filter + ) + ) + queue_result = await session.execute(queue_query) + queue_rows = queue_result.all() + + if queue_rows: + waits = [] + for row in queue_rows: + if row.started_at and row.created_at: + delta = row.started_at - row.created_at + wait_seconds = delta.total_seconds() + if wait_seconds >= 0: # Only count positive waits + waits.append(wait_seconds) + if waits: + avg_queue_wait_seconds = round(sum(waits) / len(waits), 2) + + return { + 'start_time': start_time, + 'end_time': end_time, + 'total_count': total_count, + 'created_count': status_counts.get('created', 0), + 'queued_count': status_counts.get('queued', 0), + 'claimed_count': status_counts.get('claimed', 0), + 'running_count': status_counts.get('running', 0), + 'completed_count': completed_count, + 'failed_count': status_counts.get('failed', 0), + 'cancelled_count': status_counts.get('cancelled', 0), + 'timeout_count': status_counts.get('timeout', 0), + 'throughput_per_hour': round(throughput, 2), + 'success_rate': round(success_rate, 4) if success_rate is not None else None, + 'failure_rate': round(failure_rate, 4) if failure_rate is not None else None, + 'avg_duration_seconds': avg_duration_seconds, + 'p50_duration_seconds': None, # Requires more complex calculation + 'p95_duration_seconds': None, + 'p99_duration_seconds': None, + 'avg_queue_wait_seconds': avg_queue_wait_seconds, + } + + async def get_runtime_stats(self) -> dict[str, typing.Any]: + """Get runtime registry statistics. + + Returns: + Dict with counts, heartbeat health, and capacity. + """ + from sqlalchemy import func + + now = _utc_now() + + async with self._session_factory() as session: + # Count by status + status_query = ( + sqlalchemy.select( + AgentRuntime.status, + func.count(AgentRuntime.id).label('count') + ) + .group_by(AgentRuntime.status) + ) + status_result = await session.execute(status_query) + status_counts = {row.status: row.count for row in status_result} + + total_count = sum(status_counts.values()) + online_count = status_counts.get('online', 0) + stale_count = status_counts.get('stale', 0) + + # Heartbeat age stats - compute in Python for DB compatibility + avg_heartbeat_age = None + max_heartbeat_age = None + + heartbeat_query = ( + sqlalchemy.select(AgentRuntime.last_heartbeat_at) + .where(AgentRuntime.last_heartbeat_at.is_not(None)) + ) + heartbeat_result = await session.execute(heartbeat_query) + heartbeat_rows = heartbeat_result.all() + + if heartbeat_rows: + ages = [] + for row in heartbeat_rows: + heartbeat_at = _as_utc(row.last_heartbeat_at) + if heartbeat_at: + delta = now - heartbeat_at + age_seconds = delta.total_seconds() + if age_seconds >= 0: + ages.append(age_seconds) + if ages: + avg_heartbeat_age = round(sum(ages) / len(ages), 2) + max_heartbeat_age = round(max(ages), 2) + + active_runs_query = ( + sqlalchemy.select(func.count(AgentRun.id)) + .where(AgentRun.status.in_(['running', 'claimed'])) + ) + active_runs_result = await session.execute(active_runs_query) + active_runs = active_runs_result.scalar() or 0 + claimed_runs_query = ( + sqlalchemy.select(func.count(AgentRun.id)) + .where(AgentRun.status == 'claimed') + ) + claimed_runs_result = await session.execute(claimed_runs_query) + claimed_runs = claimed_runs_result.scalar() or 0 + + return { + 'total_count': total_count, + 'online_count': online_count, + 'stale_count': stale_count, + 'avg_heartbeat_age_seconds': avg_heartbeat_age, + 'max_heartbeat_age_seconds': max_heartbeat_age, + 'active_runs': active_runs, + 'claimed_runs': claimed_runs, + } + + async def get_runner_stats( + self, + *, + start_time: int, + end_time: int, + limit: int = 50, + ) -> list[dict[str, typing.Any]]: + """Get runner-aggregated statistics. + + Args: + start_time: Unix timestamp for start of window + end_time: Unix timestamp for end of window + limit: Maximum number of runners to return + + Returns: + List of dicts with per-runner statistics. + """ + from sqlalchemy import func + + start_dt = _epoch_to_datetime(start_time) + end_dt = _epoch_to_datetime(end_time) + limit = min(max(limit, 1), 100) + + async with self._session_factory() as session: + # Aggregate runs by runner_id + query = ( + sqlalchemy.select( + AgentRun.runner_id, + func.count(AgentRun.id).label('total'), + func.sum( + sqlalchemy.case( + (AgentRun.status.in_(['queued', 'claimed', 'running']), 1), + else_=0 + ) + ).label('active'), + func.sum( + sqlalchemy.case( + (AgentRun.status == 'completed', 1), + else_=0 + ) + ).label('completed'), + func.sum( + sqlalchemy.case( + (AgentRun.status.in_(['failed', 'timeout']), 1), + else_=0 + ) + ).label('failed'), + ) + .where( + AgentRun.created_at >= start_dt, + AgentRun.created_at <= end_dt, + AgentRun.runner_id.is_not(None), + ) + .group_by(AgentRun.runner_id) + .order_by(func.count(AgentRun.id).desc()) + .limit(limit) + ) + + result = await session.execute(query) + rows = result.all() + + stats = [] + for row in rows: + runner_id = row.runner_id or 'unknown' + total = row.total or 0 + completed = row.completed or 0 + failed = row.failed or 0 + success_rate = completed / total if total > 0 else None + + stats.append({ + 'runner_id': runner_id, + 'runner_label': None, # Would need to join with runner descriptors + 'plugin_identity': None, + 'total_runs': total, + 'active_runs': row.active or 0, + 'completed_runs': completed, + 'failed_runs': failed, + 'success_rate': round(success_rate, 4) if success_rate is not None else None, + 'avg_duration_seconds': None, # Would need more complex query + }) + + return stats diff --git a/src/langbot/pkg/agent/runner/session_registry.py b/src/langbot/pkg/agent/runner/session_registry.py new file mode 100644 index 000000000..2d2a316bb --- /dev/null +++ b/src/langbot/pkg/agent/runner/session_registry.py @@ -0,0 +1,424 @@ +"""Agent run session registry for proxy action permission validation.""" +from __future__ import annotations + +import asyncio +import copy +import typing +import time +import threading + +from .context_builder import AgentResources + + +MAX_STEERING_QUEUE_ITEMS = 100 + +DEFAULT_RESOURCE_OPERATIONS: dict[str, set[str]] = { + 'model': {'invoke', 'stream', 'rerank'}, + 'tool': {'detail', 'call'}, + 'knowledge_base': {'list', 'retrieve'}, + 'skill': {'activate'}, +} + + +class AgentRunSessionStatus(typing.TypedDict): + """Status tracking for agent run session.""" + started_at: int + last_activity_at: int + + +class RunAuthorizationSnapshot(typing.TypedDict): + """Frozen authorization data for one active run. + + ResourceBuilder creates the authorized resource list once before runner + execution. Runtime proxy handlers must validate against this run-scoped + snapshot instead of recomputing resource policy. + """ + + resources: AgentResources + available_apis: dict[str, bool] + conversation_id: str | None + bot_id: str | None + workspace_id: str | None + thread_id: str | None + state_policy: dict[str, typing.Any] + state_context: dict[str, typing.Any] + authorized_ids: dict[str, set[str]] + authorized_operations: dict[str, dict[str, set[str]]] + + +SteeringQueueItem = dict[str, typing.Any] + + +class AgentRunSession(typing.TypedDict): + """Session for an active agent runner execution. + + Stored in AgentRunSessionRegistry for proxy action permission validation. + + Fields: + run_id: Unique run identifier (UUID from AgentRunContext) + runner_id: Runner descriptor ID (plugin:author/name/runner) + query_id: Host entry query ID, only present for query-based adapters + plugin_identity: Plugin identifier (author/name) of the runner + authorization: Run-scoped authorization snapshot; runtime auth truth + status: Session status tracking + """ + run_id: str + runner_id: str + query_id: int | None + plugin_identity: str # author/name + authorization: RunAuthorizationSnapshot + status: AgentRunSessionStatus + steering_queue: list[SteeringQueueItem] + + +class AgentRunSessionRegistry: + """Registry for active agent run sessions. + + Host-owned registry for tracking active AgentRunner executions. + Used by proxy actions in handler.py to validate resource access. + + Key: run_id (UUID from AgentRunContext) + Value: AgentRunSession with authorized resources + + Thread-safe via asyncio.Lock. + """ + + _sessions: dict[str, AgentRunSession] + _lock: asyncio.Lock + + def __init__(self): + self._sessions = {} + self._lock = asyncio.Lock() + + async def register( + self, + run_id: str, + runner_id: str, + query_id: int | None, + plugin_identity: str, + resources: AgentResources, + conversation_id: str | None = None, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + available_apis: dict[str, bool] | None = None, + state_policy: dict[str, typing.Any] | None = None, + state_context: dict[str, typing.Any] | None = None, + ) -> None: + """Register a new agent run session. + + Args: + run_id: Unique run identifier + runner_id: Runner descriptor ID + query_id: Host entry query ID, only present for query-based adapters + plugin_identity: Plugin identifier (author/name) + resources: Authorized resources for this run + conversation_id: Conversation ID for history/event access + bot_id: Bot UUID for history/event access + workspace_id: Workspace ID for history/event access + thread_id: Thread ID for history/event access + available_apis: Run-scoped pull APIs exposed in AgentRunContext + state_policy: State policy from binding (enable_state, state_scopes) + state_context: Context for state API (scope_keys, binding_identity, etc.) + """ + if not isinstance(plugin_identity, str) or not plugin_identity.strip(): + raise ValueError('plugin_identity is required for agent run sessions') + + now = int(time.time()) + + available_apis = copy.deepcopy(available_apis or {}) + + # Normalize state_policy to defaults if None + if state_policy is None: + state_policy = {'enable_state': True, 'state_scopes': ['conversation', 'actor']} + + # Normalize state_context to empty dict if None + state_context = state_context or {} + + resources_snapshot = copy.deepcopy(resources) + authorization: RunAuthorizationSnapshot = { + 'resources': resources_snapshot, + 'available_apis': available_apis, + 'conversation_id': conversation_id, + 'bot_id': bot_id, + 'workspace_id': workspace_id, + 'thread_id': thread_id, + 'state_policy': copy.deepcopy(state_policy), + 'state_context': copy.deepcopy(state_context), + 'authorized_ids': self._build_authorized_ids(resources_snapshot), + 'authorized_operations': self._build_authorized_operations(resources_snapshot), + } + + session: AgentRunSession = { + 'run_id': run_id, + 'runner_id': runner_id, + 'query_id': query_id, + 'plugin_identity': plugin_identity, + 'authorization': authorization, + 'status': { + 'started_at': now, + 'last_activity_at': now, + }, + 'steering_queue': [], + } + + async with self._lock: + self._sessions[run_id] = session + + def _build_authorized_ids(self, resources: AgentResources) -> dict[str, set[str]]: + """Pre-compute authorized resource IDs for O(1) lookup.""" + return { + 'model': {m.get('model_id') for m in resources.get('models', [])}, + 'tool': {t.get('tool_name') for t in resources.get('tools', [])}, + 'knowledge_base': {kb.get('kb_id') for kb in resources.get('knowledge_bases', [])}, + 'skill': {s.get('skill_name') for s in resources.get('skills', [])}, + } + + def _build_authorized_operations( + self, + resources: AgentResources, + ) -> dict[str, dict[str, set[str]]]: + """Pre-compute resource operations for runtime action validation.""" + return { + 'model': { + m.get('model_id'): self._resource_operations('model', m) + for m in resources.get('models', []) + if m.get('model_id') + }, + 'tool': { + t.get('tool_name'): self._resource_operations('tool', t) + for t in resources.get('tools', []) + if t.get('tool_name') + }, + 'knowledge_base': { + kb.get('kb_id'): self._resource_operations('knowledge_base', kb) + for kb in resources.get('knowledge_bases', []) + if kb.get('kb_id') + }, + 'skill': { + s.get('skill_name'): self._resource_operations('skill', s) + for s in resources.get('skills', []) + if s.get('skill_name') + }, + } + + @staticmethod + def _resource_operations(resource_type: str, resource: dict[str, typing.Any]) -> set[str]: + """Return explicit operations or the compatibility default for old resources.""" + operations = resource.get('operations') + if isinstance(operations, list) and operations: + return {str(operation) for operation in operations} + return set(DEFAULT_RESOURCE_OPERATIONS.get(resource_type, set())) + + async def unregister(self, run_id: str) -> AgentRunSession | None: + """Unregister an agent run session. + + Args: + run_id: Unique run identifier + + Returns: + The removed session, if one existed. Callers can inspect any + pending in-memory queues before they are discarded. + """ + async with self._lock: + return self._sessions.pop(run_id, None) + + async def get(self, run_id: str) -> AgentRunSession | None: + """Get session by run_id. + + Args: + run_id: Unique run identifier + + Returns: + AgentRunSession if found, None otherwise + """ + async with self._lock: + return self._sessions.get(run_id) + + async def update_activity(self, run_id: str) -> None: + """Update last activity timestamp for session. + + Args: + run_id: Unique run identifier + """ + async with self._lock: + if run_id in self._sessions: + self._sessions[run_id]['status']['last_activity_at'] = int(time.time()) + + async def find_steering_target( + self, + *, + conversation_id: str, + runner_id: str, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + ) -> str | None: + """Find the oldest active run that can accept steering for a conversation.""" + async with self._lock: + candidates: list[tuple[int, str]] = [] + for run_id, session in self._sessions.items(): + authorization = session['authorization'] + if session.get('runner_id') != runner_id: + continue + if authorization.get('conversation_id') != conversation_id: + continue + if authorization.get('bot_id') != bot_id: + continue + if authorization.get('workspace_id') != workspace_id: + continue + if authorization.get('thread_id') != thread_id: + continue + if not authorization.get('available_apis', {}).get('steering_pull', False): + continue + candidates.append((session['status'].get('started_at', 0), run_id)) + + if not candidates: + return None + + candidates.sort(key=lambda item: item[0]) + return candidates[0][1] + + async def enqueue_steering( + self, + run_id: str, + item: SteeringQueueItem, + ) -> bool: + """Append one steering item to an active run queue.""" + async with self._lock: + session = self._sessions.get(run_id) + if session is None: + return False + if len(session['steering_queue']) >= MAX_STEERING_QUEUE_ITEMS: + return False + session['steering_queue'].append(copy.deepcopy(item)) + session['status']['last_activity_at'] = int(time.time()) + return True + + async def pull_steering( + self, + run_id: str, + *, + mode: str = 'all', + limit: int | None = None, + ) -> list[SteeringQueueItem]: + """Pop pending steering items from a run queue.""" + async with self._lock: + session = self._sessions.get(run_id) + if session is None: + return [] + + queue = session['steering_queue'] + if not queue: + return [] + + normalized_mode = str(mode or 'all').lower() + if normalized_mode in {'one', 'one-at-a-time', 'one_at_a_time'}: + count = 1 + elif isinstance(limit, int) and limit > 0: + count = min(limit, len(queue)) + else: + count = len(queue) + + count = max(0, min(count, len(queue), 100)) + items = [copy.deepcopy(item) for item in queue[:count]] + del queue[:count] + session['status']['last_activity_at'] = int(time.time()) + return items + + def is_resource_allowed( + self, + session: AgentRunSession, + resource_type: str, + resource_id: str, + operation: str | None = None, + ) -> bool: + """Check if resource access is allowed for this session. + + Uses pre-computed authorized IDs for O(1) lookup. + + Args: + session: AgentRunSession to check + resource_type: Resource type ('model', 'tool', 'knowledge_base', 'storage') + resource_id: Resource identifier (model_id, tool_name, kb_id, 'plugin'/'workspace') + operation: Optional operation to check within the authorized resource + + Returns: + True if resource is authorized, False otherwise + """ + authorization = session['authorization'] + authorized_ids = authorization['authorized_ids'] + resources = authorization['resources'] + + if resource_type in ('model', 'tool', 'knowledge_base', 'skill'): + if resource_id not in authorized_ids.get(resource_type, set()): + return False + if operation is None: + return True + operation_map = authorization.get('authorized_operations', {}) + operations = operation_map.get(resource_type, {}).get(resource_id) + if not operations: + operations = DEFAULT_RESOURCE_OPERATIONS.get(resource_type, set()) + return operation in operations + + if resource_type == 'storage': + storage = resources.get('storage', {}) + if resource_id == 'plugin': + return storage.get('plugin_storage', False) + elif resource_id == 'workspace': + return storage.get('workspace_storage', False) + return False + + return False + + async def list_active_runs(self) -> list[AgentRunSession]: + """List all active run sessions. + + Returns: + List of active AgentRunSession dicts + """ + async with self._lock: + return list(self._sessions.values()) + + async def cleanup_stale_sessions(self, max_age_seconds: int = 3600) -> int: + """Cleanup sessions that have been inactive for too long. + + Args: + max_age_seconds: Maximum inactivity time in seconds (default 1 hour) + + Returns: + Number of sessions cleaned up + """ + now = int(time.time()) + cleaned = 0 + + async with self._lock: + stale_run_ids = [] + for run_id, session in self._sessions.items(): + last_activity = session['status'].get('last_activity_at', 0) + if now - last_activity > max_age_seconds: + stale_run_ids.append(run_id) + + for run_id in stale_run_ids: + del self._sessions[run_id] + cleaned += 1 + + return cleaned + + +# Global registry instance (singleton) +_global_registry: AgentRunSessionRegistry | None = None +_global_registry_lock = threading.Lock() + + +def get_session_registry() -> AgentRunSessionRegistry: + """Get global session registry instance (thread-safe singleton). + + Returns: + AgentRunSessionRegistry singleton + """ + global _global_registry + with _global_registry_lock: + if _global_registry is None: + _global_registry = AgentRunSessionRegistry() + return _global_registry diff --git a/src/langbot/pkg/agent/runner/state_scope.py b/src/langbot/pkg/agent/runner/state_scope.py new file mode 100644 index 000000000..f2a32bb11 --- /dev/null +++ b/src/langbot/pkg/agent/runner/state_scope.py @@ -0,0 +1,136 @@ +"""State scope key helpers for AgentRunner host-owned state.""" +from __future__ import annotations + +import hashlib +import json +import typing + +from .descriptor import AgentRunnerDescriptor +from .host_models import AgentBinding, AgentEventEnvelope + + +VALID_STATE_SCOPES = ('conversation', 'actor', 'subject', 'runner') + +STATE_KEY_ALIASES = { + 'conversation_id': 'external.conversation_id', +} + + +def normalize_state_key(key: str) -> str: + """Map accepted public aliases to protocol state keys.""" + return STATE_KEY_ALIASES.get(key, key) + + +def get_binding_identity(binding: AgentBinding) -> str: + """Return the stable binding identity used for state isolation.""" + if binding.binding_id: + return binding.binding_id + + scope = binding.scope + if scope.scope_type and scope.scope_id: + return f'{scope.scope_type}:{scope.scope_id}' + + return 'unknown_binding' + + +def _scope_hash(scope: str, parts: dict[str, typing.Any]) -> str: + """Encode state scope dimensions without separator ambiguity.""" + payload = { + 'version': 2, + 'scope': scope, + **parts, + } + raw = json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False) + return f'{scope}:v2:{hashlib.sha256(raw.encode("utf-8")).hexdigest()}' + + +def _base_scope_parts( + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, +) -> dict[str, typing.Any]: + return { + 'runner_id': descriptor.id, + 'binding_identity': get_binding_identity(binding), + 'bot_id': event.bot_id, + 'workspace_id': event.workspace_id, + } + + +def build_state_scope_key( + scope: str, + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, +) -> str | None: + """Build the storage key for one state scope. + + Returns None when the event lacks the identity required by that scope. + """ + base_parts = _base_scope_parts(event, binding, descriptor) + + if scope == 'conversation': + if not event.conversation_id: + return None + return _scope_hash(scope, { + **base_parts, + 'conversation_id': event.conversation_id, + 'thread_id': event.thread_id, + }) + + if scope == 'actor': + if not event.actor or not event.actor.actor_id: + return None + return _scope_hash(scope, { + **base_parts, + 'actor_type': event.actor.actor_type or 'user', + 'actor_id': event.actor.actor_id, + }) + + if scope == 'subject': + if not event.subject or not event.subject.subject_id: + return None + return _scope_hash(scope, { + **base_parts, + 'subject_type': event.subject.subject_type or 'unknown', + 'subject_id': event.subject.subject_id, + }) + + if scope == 'runner': + return _scope_hash(scope, base_parts) + + return None + + +def build_state_scope_keys( + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, +) -> dict[str, str]: + """Build all available scope keys for an event/binding pair.""" + scope_keys: dict[str, str] = {} + for scope in VALID_STATE_SCOPES: + scope_key = build_state_scope_key(scope, event, binding, descriptor) + if scope_key: + scope_keys[scope] = scope_key + return scope_keys + + +def build_state_context( + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, +) -> dict[str, typing.Any]: + """Build the State API context stored in the run session.""" + return { + 'scope_keys': build_state_scope_keys(event, binding, descriptor), + 'binding_identity': get_binding_identity(binding), + 'bot_id': event.bot_id, + 'workspace_id': event.workspace_id, + 'conversation_id': event.conversation_id, + 'thread_id': event.thread_id, + 'actor_type': event.actor.actor_type if event.actor else None, + 'actor_id': event.actor.actor_id if event.actor else None, + 'subject_type': event.subject.subject_type if event.subject else None, + 'subject_id': event.subject.subject_id if event.subject else None, + } diff --git a/src/langbot/pkg/agent/runner/transcript_store.py b/src/langbot/pkg/agent/runner/transcript_store.py new file mode 100644 index 000000000..bd47e690d --- /dev/null +++ b/src/langbot/pkg/agent/runner/transcript_store.py @@ -0,0 +1,426 @@ +"""Transcript store for writing and querying conversation history.""" +from __future__ import annotations + +import json +import datetime +import typing +import uuid + +import sqlalchemy +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession +from sqlalchemy.orm import sessionmaker + +from ...entity.persistence.transcript import Transcript +from langbot_plugin.api.entities.builtin.provider import message as provider_message + + +UTC = datetime.timezone.utc + + +def _utc_now() -> datetime.datetime: + return datetime.datetime.now(UTC) + + +def _datetime_to_epoch(value: datetime.datetime | None) -> int | None: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=UTC) + else: + value = value.astimezone(UTC) + return int(value.timestamp()) + + +class TranscriptStore: + """Store for Transcript records. + + Handles writing transcript items and querying them for history API. + All methods are async and use the provided database engine. + """ + + engine: AsyncEngine + + # Hard limits + MAX_CONTENT_LENGTH = 4000 + HARD_LIMIT = 100 + + def __init__(self, engine: AsyncEngine): + self.engine = engine + self._session_factory = sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False + ) + + async def append_transcript( + self, + transcript_id: str | None, + event_id: str, + conversation_id: str, + role: str, + bot_id: str | None = None, + workspace_id: str | None = None, + content: str | None = None, + content_json: dict[str, typing.Any] | None = None, + attachment_refs: list[dict[str, typing.Any]] | None = None, + thread_id: str | None = None, + item_type: str = "message", + run_id: str | None = None, + runner_id: str | None = None, + metadata: dict[str, typing.Any] | None = None, + ) -> str: + """Append a transcript item. + + Args: + transcript_id: Unique transcript ID (generated if None) + event_id: Source event ID + conversation_id: Conversation ID + role: Message role (user, assistant, system, tool) + bot_id: Bot UUID scope + workspace_id: Workspace scope + content: Text content + content_json: Full structured content + attachment_refs: Attachment references + thread_id: Thread ID + item_type: Item type + run_id: Run ID that generated this + runner_id: Runner ID that generated this + metadata: Additional metadata + + Returns: + The transcript_id + """ + if transcript_id is None: + transcript_id = str(uuid.uuid4()) + + # Truncate content if too long + if content and len(content) > self.MAX_CONTENT_LENGTH: + content = content[:self.MAX_CONTENT_LENGTH - 3] + "..." + + async with self._session_factory() as session: + item = Transcript( + transcript_id=transcript_id, + event_id=event_id, + bot_id=bot_id, + workspace_id=workspace_id, + conversation_id=conversation_id, + thread_id=thread_id, + role=role, + item_type=item_type, + content=content, + content_json=json.dumps(content_json) if content_json else None, + attachment_refs_json=json.dumps(attachment_refs) if attachment_refs else None, + seq=0, + run_id=run_id, + runner_id=runner_id, + created_at=_utc_now(), + metadata_json=json.dumps(metadata) if metadata else None, + ) + session.add(item) + await session.flush() + item.seq = item.id or await self._get_next_seq(conversation_id) + await session.commit() + + return transcript_id + + async def page_transcript( + self, + conversation_id: str, + before_seq: int | None = None, + after_seq: int | None = None, + limit: int = 50, + direction: str = "backward", + include_attachments: bool = False, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + strict_thread: bool = False, + ) -> tuple[list[dict[str, typing.Any]], int | None, int | None, bool]: + """Page through transcript items. + + Args: + conversation_id: Conversation ID + before_seq: Get items before this sequence (backward) + after_seq: Get items after this sequence (forward) + limit: Maximum items to return (capped at 100) + direction: 'backward' (older) or 'forward' (newer) + include_attachments: Include attachment refs + bot_id: Optional bot scope filter + workspace_id: Optional workspace scope filter + thread_id: Optional thread scope filter + strict_thread: When true, require thread_id equality including NULL + + Returns: + Tuple of (items, next_seq, prev_seq, has_more) + """ + limit = min(limit, self.HARD_LIMIT) + + async with self._session_factory() as session: + query = sqlalchemy.select(Transcript).where( + Transcript.conversation_id == conversation_id + ) + query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread) + + if direction == "backward" and before_seq is not None: + query = query.where(Transcript.seq < before_seq) + query = query.order_by(Transcript.seq.desc()) + elif direction == "forward" and after_seq is not None: + query = query.where(Transcript.seq > after_seq) + query = query.order_by(Transcript.seq.asc()) + else: + # Default: most recent items first (backward from latest) + query = query.order_by(Transcript.seq.desc()) + + query = query.limit(limit + 1) + + result = await session.execute(query) + rows = result.scalars().all() + + items = [self._row_to_dict(row, include_attachments) for row in rows[:limit]] + has_more = len(rows) > limit + + # Calculate cursors + next_seq = None + prev_seq = None + + if direction == "backward": + # Items are in descending order + if items: + next_seq = items[-1].get('seq') if has_more else None + prev_seq = items[0].get('seq') + else: + # Items are in ascending order + if items: + next_seq = items[-1].get('seq') if has_more else None + prev_seq = items[0].get('seq') + + return items, next_seq, prev_seq, has_more + + async def search_transcript( + self, + conversation_id: str, + query_text: str, + filters: dict[str, typing.Any] | None = None, + top_k: int = 10, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + strict_thread: bool = False, + ) -> list[dict[str, typing.Any]]: + """Search transcript items. + + Basic implementation using LIKE filtering. + + Args: + conversation_id: Conversation ID + query_text: Search query + filters: Optional filters + top_k: Maximum results + bot_id: Optional bot scope filter + workspace_id: Optional workspace scope filter + thread_id: Optional thread scope filter + strict_thread: When true, require thread_id equality including NULL + + Returns: + List of matching items + """ + async with self._session_factory() as session: + query = sqlalchemy.select(Transcript).where( + Transcript.conversation_id == conversation_id, + Transcript.content.ilike(f"%{query_text}%"), + ) + query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread) + + # Apply additional filters + if filters: + if 'roles' in filters: + query = query.where(Transcript.role.in_(filters['roles'])) + if 'item_types' in filters: + query = query.where(Transcript.item_type.in_(filters['item_types'])) + + query = query.order_by(Transcript.seq.desc()).limit(top_k) + + result = await session.execute(query) + rows = result.scalars().all() + + return [self._row_to_dict(row, include_attachments=True) for row in rows] + + async def get_latest_cursor( + self, + conversation_id: str, + ) -> str | None: + """Get the latest cursor for a conversation. + + Args: + conversation_id: Conversation ID + + Returns: + Cursor string (seq number), or None if no items + """ + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.select(Transcript.seq) + .where(Transcript.conversation_id == conversation_id) + .order_by(Transcript.seq.desc()) + .limit(1) + ) + row = result.scalars().first() + if row is None: + return None + return str(row) + + async def get_legacy_provider_messages( + self, + conversation_id: str, + limit: int = HARD_LIMIT, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + strict_thread: bool = False, + ) -> list[provider_message.Message]: + """Project Transcript rows into the legacy provider Message view. + + AgentRunner history is canonical in Transcript. This view exists for + legacy Pipeline readers such as PromptPreProcessing that still expect + query.messages. + """ + items, _, _, _ = await self.page_transcript( + conversation_id=conversation_id, + limit=limit, + direction="backward", + bot_id=bot_id, + workspace_id=workspace_id, + thread_id=thread_id, + strict_thread=strict_thread, + ) + + messages: list[provider_message.Message] = [] + for item in reversed(items): + message = self._transcript_item_to_provider_message(item) + if message is not None: + messages.append(message) + return messages + + async def has_history_before( + self, + conversation_id: str, + seq: int, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + strict_thread: bool = False, + ) -> bool: + """Check if there is history before a sequence number. + + Args: + conversation_id: Conversation ID + seq: Sequence number + + Returns: + True if there are items before + """ + async with self._session_factory() as session: + query = ( + sqlalchemy.select(sqlalchemy.func.count()) + .select_from(Transcript) + .where(Transcript.conversation_id == conversation_id, Transcript.seq < seq) + ) + query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread) + result = await session.execute(query) + count = result.scalar() + return count > 0 + + def _apply_scope_filters( + self, + query: typing.Any, + bot_id: str | None, + workspace_id: str | None, + thread_id: str | None, + strict_thread: bool, + ) -> typing.Any: + if bot_id is not None: + query = query.where(Transcript.bot_id == bot_id) + if workspace_id is not None: + query = query.where(Transcript.workspace_id == workspace_id) + if strict_thread: + if thread_id is None: + query = query.where(Transcript.thread_id.is_(None)) + else: + query = query.where(Transcript.thread_id == thread_id) + return query + + async def cleanup_transcripts_older_than( + self, + before: datetime.datetime, + ) -> int: + """Delete Transcript rows created before the supplied timestamp.""" + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.delete(Transcript).where(Transcript.created_at < before) + ) + await session.commit() + return result.rowcount or 0 + + async def _get_next_seq(self, conversation_id: str) -> int: + """Fallback next sequence number for stores that cannot expose autoincrement IDs.""" + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.select(sqlalchemy.func.max(Transcript.seq)) + .where(Transcript.conversation_id == conversation_id) + ) + max_seq = result.scalar() + return (max_seq or 0) + 1 + + def _row_to_dict( + self, + row: Transcript, + include_attachments: bool = False, + ) -> dict[str, typing.Any]: + """Convert a Transcript row to dict.""" + result = { + 'transcript_id': row.transcript_id, + 'event_id': row.event_id, + 'bot_id': row.bot_id, + 'workspace_id': row.workspace_id, + 'conversation_id': row.conversation_id, + 'thread_id': row.thread_id, + 'role': row.role, + 'item_type': row.item_type, + 'content': row.content, + 'content_json': json.loads(row.content_json) if row.content_json else None, + 'seq': row.seq, + 'cursor': str(row.seq), + 'created_at': _datetime_to_epoch(row.created_at), + 'metadata': json.loads(row.metadata_json) if row.metadata_json else {}, + } + + if include_attachments and row.attachment_refs_json: + result['attachment_refs'] = json.loads(row.attachment_refs_json) + else: + result['attachment_refs'] = [] + + return result + + def _transcript_item_to_provider_message( + self, + item: dict[str, typing.Any], + ) -> provider_message.Message | None: + """Convert one Transcript API item into a provider Message.""" + if item.get('item_type') != 'message': + return None + + role = item.get('role') + if role not in {'user', 'assistant'}: + return None + + content_json = item.get('content_json') + if isinstance(content_json, dict): + message_data = dict(content_json) + message_data['role'] = role + try: + return provider_message.Message.model_validate(message_data) + except Exception: + pass + + content = item.get('content') + if content is None: + return None + return provider_message.Message(role=role, content=content) diff --git a/src/langbot/pkg/api/http/service/model.py b/src/langbot/pkg/api/http/service/model.py index 87298c084..ff01c8834 100644 --- a/src/langbot/pkg/api/http/service/model.py +++ b/src/langbot/pkg/api/http/service/model.py @@ -7,7 +7,6 @@ from langbot_plugin.api.entities.builtin.provider import message as provider_mes from ....core import app from ....entity.persistence import model as persistence_model -from ....entity.persistence import pipeline as persistence_pipeline from ....provider.modelmgr import requester as model_requester @@ -151,23 +150,9 @@ class LLMModelsService: self.ap.model_mgr.llm_models.append(runtime_llm_model) if auto_set_to_default_pipeline: - # set the default pipeline model to this model - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( - persistence_pipeline.LegacyPipeline.is_default == True - ) - ) - pipeline = result.first() - if pipeline is not None: - model_config = pipeline.config.get('ai', {}).get('local-agent', {}).get('model', {}) - if not model_config.get('primary', ''): - pipeline_config = pipeline.config - pipeline_config['ai']['local-agent']['model'] = { - 'primary': model_data['uuid'], - 'fallbacks': [], - } - pipeline_data = {'config': pipeline_config} - await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data) + default_config_service = getattr(self.ap, 'agent_runner_default_config_service', None) + if default_config_service is not None: + await default_config_service.auto_set_default_pipeline_llm_model(model_data['uuid']) return model_data['uuid'] diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py index d7685fe43..dbe7c2dda 100644 --- a/src/langbot/pkg/api/http/service/pipeline.py +++ b/src/langbot/pkg/api/http/service/pipeline.py @@ -3,6 +3,7 @@ from __future__ import annotations import uuid import json import sqlalchemy +import typing from ....core import app from ....entity.persistence import pipeline as persistence_pipeline @@ -13,7 +14,6 @@ default_stage_order = [ 'BanSessionCheckStage', # 封禁会话检查 'PreContentFilterStage', # 内容过滤前置阶段 'PreProcessor', # 预处理器 - 'ConversationMessageTruncator', # 会话消息截断器 'RequireRateLimitOccupancy', # 请求速率限制占用 'MessageProcessor', # 处理器 'ReleaseRateLimitOccupancy', # 释放速率限制占用 @@ -30,11 +30,100 @@ class PipelineService: def __init__(self, ap: app.Application) -> None: self.ap = ap + def _get_default_values_from_schema(self, config_schema: list[dict[str, typing.Any]]) -> dict[str, typing.Any]: + """Build runner config defaults from a DynamicForm schema.""" + defaults: dict[str, typing.Any] = {} + for item in config_schema: + name = item.get('name') + if not name: + continue + if 'default' in item: + defaults[name] = item['default'] + return defaults + + async def get_default_pipeline_config(self) -> dict[str, typing.Any]: + """Get the default pipeline config, rendering runner defaults from installed plugins.""" + from ....utils import paths as path_utils + + template_path = path_utils.get_resource_path('templates/default-pipeline-config.json') + with open(template_path, 'r', encoding='utf-8') as f: + config = json.load(f) + + agent_runner_registry = getattr(self.ap, 'agent_runner_registry', None) + if agent_runner_registry is None: + return config + + try: + runners = await agent_runner_registry.list_runners(bound_plugins=None) + except Exception as e: + logger = getattr(self.ap, 'logger', None) + if logger: + logger.warning(f'Failed to load plugin agent runners for default pipeline config: {e}') + return config + + if not runners: + return config + + selected_runner = runners[0] + ai_config = config.setdefault('ai', {}) + runner_config = ai_config.setdefault('runner', {}) + runner_config['id'] = selected_runner.id + runner_config.setdefault('expire-time', 0) + + ai_config['runner_config'] = { + selected_runner.id: self._get_default_values_from_schema(selected_runner.config_schema), + } + + return config + async def get_pipeline_metadata(self) -> list[dict]: + """Get pipeline metadata with dynamically loaded plugin runners from registry""" + import copy + + # Deep copy AI metadata to avoid modifying the original + ai_metadata = copy.deepcopy(self.ap.pipeline_config_meta_ai) + + # Find the runner stage + runner_stage = None + for stage in ai_metadata.get('stages', []): + if stage.get('name') == 'runner': + runner_stage = stage + break + + if runner_stage: + # Find the runner select config (now uses 'id' field) + for config_item in runner_stage.get('config', []): + if config_item.get('name') == 'id': + # Get plugin agent runners from registry + try: + ( + runner_options, + runner_stages, + ) = await self.ap.agent_runner_registry.get_runner_metadata_for_pipeline() + + # Replace options entirely with registry options + # Only installed/available runners should be shown + config_item['options'] = runner_options + + # Use the registry order as the default order. If no runner is available, leave + # the default unset so the UI can recommend installing an AgentRunner plugin. + if runner_options and 'default' not in config_item: + config_item['default'] = runner_options[0]['name'] + + # Add corresponding stage configuration for each runner + for stage_config in runner_stages: + # Avoid duplicate stages + existing_stage_names = {s.get('name') for s in ai_metadata.get('stages', [])} + if stage_config['name'] not in existing_stage_names: + ai_metadata['stages'].append(stage_config) + + except Exception as e: + self.ap.logger.warning(f'Failed to load plugin agent runners from registry: {e}') + return [ self.ap.pipeline_config_meta_trigger, self.ap.pipeline_config_meta_safety, - self.ap.pipeline_config_meta_ai, + ai_metadata, self.ap.pipeline_config_meta_output, ] @@ -74,8 +163,6 @@ class PipelineService: return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline) async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str: - from ....utils import paths as path_utils - # Check limitation limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) max_pipelines = limitation.get('max_pipelines', -1) @@ -89,9 +176,7 @@ class PipelineService: pipeline_data['stages'] = default_stage_order.copy() pipeline_data['is_default'] = default - template_path = path_utils.get_resource_path('templates/default-pipeline-config.json') - with open(template_path, 'r', encoding='utf-8') as f: - pipeline_data['config'] = json.load(f) + pipeline_data['config'] = await self.get_default_pipeline_config() # Ensure extensions_preferences is set with enable_all_plugins and enable_all_mcp_servers=True by default if 'extensions_preferences' not in pipeline_data: @@ -113,10 +198,16 @@ class PipelineService: return pipeline_data['uuid'] async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None: + from ....agent.runner.config_migration import ConfigMigration + pipeline_data = pipeline_data.copy() for protected_field in ('uuid', 'for_version', 'stages', 'is_default'): pipeline_data.pop(protected_field, None) + # Migrate config to new format before saving + if 'config' in pipeline_data: + pipeline_data['config'] = ConfigMigration.migrate_pipeline_config(pipeline_data['config']) + await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_pipeline.LegacyPipeline) .where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid) diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index ea117b0cc..c24dc4420 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -237,12 +237,18 @@ class BoxService: if forced_template: template = forced_template else: - template = ( - (query.pipeline_config or {}) - .get('ai', {}) - .get('local-agent', {}) - .get('box-session-id-template', '{launcher_type}_{launcher_id}') + template = '{launcher_type}_{launcher_id}' + pipeline_config = query.pipeline_config or {} + ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {} + runner_selector = ai_config.get('runner', {}) if isinstance(ai_config, dict) else {} + runner_id = runner_selector.get('id') if isinstance(runner_selector, dict) else None + runner_configs = ai_config.get('runner_config', {}) if isinstance(ai_config, dict) else {} + runner_config = runner_configs.get(runner_id, {}) if isinstance(runner_configs, dict) else {} + configured_template = ( + runner_config.get('box-session-id-template') if isinstance(runner_config, dict) else None ) + if isinstance(configured_template, str) and configured_template: + template = configured_template variables = dict(query.variables or {}) launcher_type = getattr(query, 'launcher_type', None) if hasattr(launcher_type, 'value'): diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index b0adb5594..6dffe9dc7 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -4,6 +4,7 @@ import logging import asyncio import traceback import os +from typing import TYPE_CHECKING from ..platform import botmgr as im_mgr from ..platform.webhook_pusher import WebhookPusher @@ -46,6 +47,9 @@ from ..telemetry import telemetry as telemetry_module from ..survey import manager as survey_module from ..skill import manager as skill_mgr +if TYPE_CHECKING: + from ..agent.runner import AgentRunnerRegistry, AgentRunOrchestrator, AgentRunnerDefaultConfigService + class Application: """Runtime application object and context""" @@ -165,6 +169,13 @@ class Application: maintenance_service: maintenance_service.MaintenanceService = None + # Agent runner subsystem + agent_runner_registry: AgentRunnerRegistry = None + + agent_runner_default_config_service: AgentRunnerDefaultConfigService = None + + agent_run_orchestrator: AgentRunOrchestrator = None + def __init__(self): pass diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index a8d53b7b3..092bed5fd 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -39,6 +39,7 @@ from ...vector import mgr as vectordb_mgr from .. import taskmgr from ...telemetry import telemetry as telemetry_module from ...survey import manager as survey_module +from ...agent.runner import AgentRunnerRegistry, AgentRunOrchestrator, AgentRunnerDefaultConfigService @stage.stage_class('BuildAppStage') @@ -194,5 +195,15 @@ class BuildAppStage(stage.BootingStage): await plugin_connector_inst.initialize() ap.plugin_connector = plugin_connector_inst + # Initialize agent runner subsystem + agent_runner_registry_inst = AgentRunnerRegistry(ap) + ap.agent_runner_registry = agent_runner_registry_inst + + agent_runner_default_config_service_inst = AgentRunnerDefaultConfigService(ap) + ap.agent_runner_default_config_service = agent_runner_default_config_service_inst + + agent_run_orchestrator_inst = AgentRunOrchestrator(ap, agent_runner_registry_inst) + ap.agent_run_orchestrator = agent_run_orchestrator_inst + ctrl = controller.Controller(ap) ap.ctrl = ctrl diff --git a/src/langbot/pkg/entity/persistence/agent_run.py b/src/langbot/pkg/entity/persistence/agent_run.py new file mode 100644 index 000000000..e735ea692 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/agent_run.py @@ -0,0 +1,200 @@ +"""Agent run ledger persistence entities.""" + +from __future__ import annotations + +import datetime + +import sqlalchemy + +from .base import Base + + +class AgentRun(Base): + """AgentRun stores Host-owned execution lifecycle facts.""" + + __tablename__ = 'agent_run' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + """Auto-increment ID for pagination.""" + + run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True) + """Unique AgentRunner run identifier.""" + + event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Input event that triggered this run.""" + + agent_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Future Host-owned agent identifier.""" + + binding_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Binding that selected this runner.""" + + runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + """Runner descriptor ID.""" + + conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Conversation this run belongs to.""" + + thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Thread this run belongs to.""" + + workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Workspace this run belongs to.""" + + bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Bot UUID this run belongs to.""" + + status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True) + """Run lifecycle status.""" + + status_reason = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Human-readable terminal or current status reason.""" + + queue_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Host queue name this run is waiting in.""" + + priority = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0) + """Higher values are claimed before lower values within a queue.""" + + requested_runtime_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Specific runtime requested by the producer, if any.""" + + claimed_by_runtime_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Runtime that currently owns the claim lease.""" + + claim_token = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Opaque token required to renew or release the current claim.""" + + claim_lease_expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True, index=True) + """When the current claim lease expires.""" + + dispatch_attempts = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0) + """Number of times this run has been claimed for dispatch.""" + + last_claimed_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + """When this run was last claimed.""" + + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow) + """When the run record was created.""" + + started_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + """When execution started.""" + + finished_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + """When execution reached a terminal status.""" + + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow + ) + """When the run record was last updated.""" + + deadline_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + """Execution deadline if one was assigned.""" + + cancel_requested_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + """When cancellation was requested.""" + + usage_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Final or latest aggregate token usage JSON.""" + + cost_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Host-calculated cost JSON, if available.""" + + authorization_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Run-scoped authorization snapshot JSON.""" + + metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Additional metadata JSON.""" + + __table_args__ = ( + sqlalchemy.Index( + 'ix_agent_run_scope_status', 'bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'status' + ), + sqlalchemy.Index('ix_agent_run_runner_status', 'runner_id', 'status'), + sqlalchemy.Index('ix_agent_run_queue_claim', 'queue_name', 'status', 'priority', 'id'), + ) + + +class AgentRuntime(Base): + """AgentRuntime stores Host-owned runtime heartbeat registry facts.""" + + __tablename__ = 'agent_runtime' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + """Auto-increment ID.""" + + runtime_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True) + """Unique runtime or daemon identifier.""" + + status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True) + """Runtime lifecycle status.""" + + display_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Human-readable runtime display name.""" + + endpoint = sqlalchemy.Column(sqlalchemy.String(1024), nullable=True) + """Runtime endpoint, if it exposes one.""" + + version = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Runtime version string.""" + + capabilities_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Runtime capabilities JSON.""" + + labels_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Runtime labels JSON.""" + + metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Additional metadata JSON.""" + + last_heartbeat_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True, index=True) + """When the runtime last sent a heartbeat.""" + + heartbeat_deadline_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True, index=True) + """When the runtime should be considered stale.""" + + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow) + """When the runtime record was created.""" + + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow + ) + """When the runtime record was last updated.""" + + +class AgentRunEvent(Base): + """AgentRunEvent stores one result event emitted by a run.""" + + __tablename__ = 'agent_run_event' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + """Auto-increment ID.""" + + run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + """Run that produced this event.""" + + sequence = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) + """Monotonic sequence inside the run.""" + + type = sqlalchemy.Column(sqlalchemy.String(100), nullable=False, index=True) + """Result event type.""" + + data_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Result event payload JSON.""" + + usage_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Token usage JSON for this event, if provided.""" + + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow) + """When this event was persisted.""" + + source = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) + """Source that appended the event.""" + + metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Additional metadata JSON.""" + + __table_args__ = ( + sqlalchemy.UniqueConstraint('run_id', 'sequence', name='uq_agent_run_event_run_sequence'), + sqlalchemy.Index('ix_agent_run_event_run_sequence', 'run_id', 'sequence'), + ) diff --git a/src/langbot/pkg/entity/persistence/agent_runner_state.py b/src/langbot/pkg/entity/persistence/agent_runner_state.py new file mode 100644 index 000000000..93f297f4b --- /dev/null +++ b/src/langbot/pkg/entity/persistence/agent_runner_state.py @@ -0,0 +1,88 @@ +"""Agent runner state persistence entity for host-owned state.""" +from __future__ import annotations + +import sqlalchemy +import datetime + +from .base import Base + + +class AgentRunnerState(Base): + """AgentRunnerState stores host-owned state for AgentRunner protocol. + + State is: + - Host-owned: Managed by LangBot, not by plugin instances + - Scope-isolated: Separated by runner_id + binding_identity + scope + - Policy-enforced: Controlled by StatePolicy (enable_state, state_scopes) + + Scope key design: + - conversation: runner_id + binding_id + conversation_id [+ thread_id] + - actor: runner_id + binding_id + actor_type + actor_id + - subject: runner_id + binding_id + subject_type + subject_id + - runner: runner_id + binding_id + + This table is the production store for AgentRunner state. + """ + + __tablename__ = 'agent_runner_state' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + """Auto-increment ID for sequencing.""" + + # Identity + runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + """Runner descriptor ID (plugin:author/name/runner).""" + + binding_identity = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + """Binding identity for isolation (binding_id or scope_type:scope_id).""" + + scope = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True) + """State scope: 'conversation', 'actor', 'subject', or 'runner'.""" + + scope_key = sqlalchemy.Column(sqlalchemy.String(512), nullable=False) + """Full scope key for unique lookup (includes all identity parts).""" + + state_key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + """State key within scope (should use namespace prefix like external.*).""" + + value_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """State value as JSON string (size-limited by host).""" + + # Context fields for querying/filtering + bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Bot UUID if applicable.""" + + workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Workspace ID for multi-tenant.""" + + conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Conversation ID for conversation scope.""" + + thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Thread ID for thread-scoped conversation state.""" + + actor_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) + """Actor type for actor scope.""" + + actor_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Actor ID for actor scope.""" + + subject_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) + """Subject type for subject scope.""" + + subject_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Subject ID for subject scope.""" + + # Lifecycle + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow) + """When this state entry was created.""" + + updated_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) + """When this state entry was last updated.""" + + # Unique constraint: scope_key + state_key + __table_args__ = ( + sqlalchemy.UniqueConstraint('scope_key', 'state_key', name='uq_agent_runner_state_scope_key_state_key'), + sqlalchemy.Index('ix_agent_runner_state_runner_binding', 'runner_id', 'binding_identity'), + sqlalchemy.Index('ix_agent_runner_state_scope_key_lookup', 'scope_key'), + ) diff --git a/src/langbot/pkg/entity/persistence/event_log.py b/src/langbot/pkg/entity/persistence/event_log.py new file mode 100644 index 000000000..d8203f8af --- /dev/null +++ b/src/langbot/pkg/entity/persistence/event_log.py @@ -0,0 +1,85 @@ +"""EventLog persistence entity for storing auditable event facts.""" +from __future__ import annotations + +import sqlalchemy +import datetime + +from .base import Base + + +class EventLog(Base): + """EventLog stores auditable event records for AgentRunner. + + This is the fact source for events - messages, tool calls, system events, etc. + Large payloads are stored separately; this table stores references and + summaries. + """ + + __tablename__ = 'event_log' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + """Auto-increment ID for sequencing.""" + + event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True) + """Unique event identifier.""" + + event_type = sqlalchemy.Column(sqlalchemy.String(100), nullable=False, index=True) + """Event type (message.received, tool.call.started, etc.).""" + + event_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + """When the event occurred.""" + + source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) + """Event source (platform, webui, api, scheduler, system, pipeline_adapter).""" + + bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Bot UUID that handled this event.""" + + workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Workspace ID for multi-tenant deployments.""" + + conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Conversation ID this event belongs to.""" + + thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Thread ID if platform supports threads.""" + + # Actor information + actor_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) + """Actor type (user, system, runner).""" + + actor_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Actor identifier.""" + + actor_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Actor display name.""" + + # Subject information + subject_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) + """Subject type (message, tool_call, attachment, etc.).""" + + subject_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Subject identifier.""" + + # Input information + input_summary = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Brief summary of input (truncated text, max 1000 chars).""" + + input_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Full input JSON if reasonably sized (AgentInput as JSON string).""" + + # Raw event reference + raw_ref = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Reference to raw event payload stored outside the inline event row.""" + + run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Run ID that processed this event.""" + + runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Runner ID that processed this event.""" + + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow) + """When this record was created.""" + + metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Additional metadata as JSON string.""" diff --git a/src/langbot/pkg/entity/persistence/transcript.py b/src/langbot/pkg/entity/persistence/transcript.py new file mode 100644 index 000000000..5d66454e7 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/transcript.py @@ -0,0 +1,79 @@ +"""Transcript persistence entity for conversation history projection.""" +from __future__ import annotations + +import sqlalchemy +import datetime + +from .base import Base + + +class Transcript(Base): + """Transcript stores conversation-oriented message projection for history API. + + This is a projection of EventLog, optimized for agent history retrieval. + It includes message content and attachment refs, but not raw platform payloads. + """ + + __tablename__ = 'transcript' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + """Auto-increment ID for sequencing.""" + + transcript_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True) + """Unique transcript item identifier.""" + + event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + """Reference to the source event in EventLog.""" + + bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Bot UUID this item belongs to.""" + + workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Workspace this item belongs to.""" + + conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + """Conversation this item belongs to.""" + + thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Thread ID if platform supports threads.""" + + role = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) + """Message role: 'user', 'assistant', 'system', or 'tool'.""" + + item_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='message') + """Item type: 'message', 'tool_call', 'tool_result', 'system'.""" + + # Content + content = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Text content summary (may be truncated for large messages, max 4000 chars).""" + + content_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Full structured content as JSON string (Message model dump).""" + + # Attachment references + attachment_refs_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Attachment references as JSON string.""" + + # Sequence for cursor-based pagination + seq = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) + """Monotonic cursor sequence for pagination.""" + + # Context + run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Run ID that generated this item (for assistant messages).""" + + runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Runner ID that generated this item.""" + + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow) + """When this item was created.""" + + metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Additional metadata as JSON string (sender_id, platform, etc.).""" + + # Indexes + __table_args__ = ( + sqlalchemy.Index('ix_transcript_conversation_seq', 'conversation_id', 'seq'), + sqlalchemy.Index('ix_transcript_conversation_created', 'conversation_id', 'created_at'), + sqlalchemy.Index('ix_transcript_scope_seq', 'bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'seq'), + ) diff --git a/src/langbot/pkg/persistence/alembic/env.py b/src/langbot/pkg/persistence/alembic/env.py index 40543edd4..a6295ff7f 100644 --- a/src/langbot/pkg/persistence/alembic/env.py +++ b/src/langbot/pkg/persistence/alembic/env.py @@ -13,6 +13,28 @@ from sqlalchemy.engine import Connection from langbot.pkg.entity.persistence.base import Base +# Import all ORM models so they are registered with Base.metadata +# This is required for autogenerate to detect model changes +from langbot.pkg.entity.persistence import ( + agent_run, # noqa: F401 + agent_runner_state, # noqa: F401 + apikey, # noqa: F401 + bot, # noqa: F401 + bstorage, # noqa: F401 + event_log, # noqa: F401 + mcp, # noqa: F401 + metadata, # noqa: F401 + model, # noqa: F401 + monitoring, # noqa: F401 + pipeline, # noqa: F401 + plugin, # noqa: F401 + rag, # noqa: F401 + transcript, # noqa: F401 + user, # noqa: F401 + vector, # noqa: F401 + webhook, # noqa: F401 +) + target_metadata = Base.metadata diff --git a/src/langbot/pkg/persistence/alembic/versions/0005_migrate_runner_config.py b/src/langbot/pkg/persistence/alembic/versions/0005_migrate_runner_config.py new file mode 100644 index 000000000..8e7aa42b7 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0005_migrate_runner_config.py @@ -0,0 +1,88 @@ +"""Normalize AgentRunner config containers + +Revision ID: 0005_migrate_runner_config +Revises: 0005_add_llm_context_length +Create Date: 2026-05-10 +""" + +import json +import sqlalchemy as sa +from alembic import op + +from langbot.pkg.agent.runner.config_migration import ConfigMigration + +revision = '0005_migrate_runner_config' +down_revision = '0005_add_llm_context_length' +branch_labels = None +depends_on = None + +def migrate_pipeline_config(config: dict) -> dict: + """Migrate persisted pipeline config to the AgentRunner plugin shape.""" + return ConfigMigration.migrate_pipeline_config(config) + + +def _load_config(config_value): + if isinstance(config_value, dict): + return config_value + if isinstance(config_value, str): + return json.loads(config_value) + return None + + +def _update_config(conn, table_name: str, pipeline_uuid: str, migrated_config: dict) -> None: + """Write JSON config using a dialect-compatible bind.""" + config_json = json.dumps(migrated_config) + if conn.dialect.name == 'postgresql': + conn.execute( + sa.text( + f'UPDATE {table_name} ' + 'SET config = CAST(:config AS JSON) ' + 'WHERE uuid = :uuid' + ), + {'config': config_json, 'uuid': pipeline_uuid}, + ) + return + + conn.execute( + sa.text(f'UPDATE {table_name} SET config = :config WHERE uuid = :uuid'), + {'config': config_json, 'uuid': pipeline_uuid}, + ) + + +def upgrade() -> None: + """Normalize existing pipeline config containers.""" + conn = op.get_bind() + inspector = sa.inspect(conn) + + table_name = 'legacy_pipelines' + + # Check if pipeline table exists (may not exist in fresh install) + if table_name not in inspector.get_table_names(): + return + + # Get all pipelines + result = conn.execute(sa.text(f'SELECT uuid, config FROM {table_name}')) + pipelines = result.fetchall() + + for pipeline_uuid, config_json in pipelines: + if not config_json: + continue + + try: + config = _load_config(config_json) + if not isinstance(config, dict): + continue + migrated_config = migrate_pipeline_config(config) + + # Only update if config changed + if json.dumps(config, sort_keys=True) != json.dumps(migrated_config, sort_keys=True): + _update_config(conn, table_name, pipeline_uuid, migrated_config) + except Exception: + # Skip invalid configs + continue + + +def downgrade() -> None: + """Downgrade is not supported for data migration.""" + # No downgrade - keep configs in new format + pass diff --git a/src/langbot/pkg/persistence/alembic/versions/58846a8d7a81_add_event_log_and_transcript_tables.py b/src/langbot/pkg/persistence/alembic/versions/58846a8d7a81_add_event_log_and_transcript_tables.py new file mode 100644 index 000000000..378ba2c5a --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/58846a8d7a81_add_event_log_and_transcript_tables.py @@ -0,0 +1,148 @@ +"""add_event_log_and_transcript_tables + +Revision ID: 58846a8d7a81 +Revises: 0005_migrate_runner_config +Create Date: 2026-05-23 15:41:47.030841 +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers +revision = '58846a8d7a81' +down_revision = '0005_migrate_runner_config' +branch_labels = None +depends_on = None + + +def _table_exists(table_name: str) -> bool: + return table_name in sa.inspect(op.get_bind()).get_table_names() + + +def _index_exists(table_name: str, index_name: str) -> bool: + return index_name in {index['name'] for index in sa.inspect(op.get_bind()).get_indexes(table_name)} + + +def _column_exists(table_name: str, column_name: str) -> bool: + return column_name in {column['name'] for column in sa.inspect(op.get_bind()).get_columns(table_name)} + + +def _add_column_if_missing(table_name: str, column: sa.Column) -> None: + if not _table_exists(table_name) or _column_exists(table_name, column.name): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.add_column(column) + + +def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None: + if not _table_exists(table_name) or _index_exists(table_name, index_name): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.create_index(index_name, columns, unique=unique) + + +def _drop_index_if_exists(table_name: str, index_name: str) -> None: + if not _table_exists(table_name) or not _index_exists(table_name, index_name): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.drop_index(index_name) + + +def upgrade() -> None: + # Create event_log table + if not _table_exists('event_log'): + op.create_table( + 'event_log', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('event_id', sa.String(255), nullable=False, unique=True), + sa.Column('event_type', sa.String(100), nullable=False), + sa.Column('event_time', sa.DateTime(), nullable=True), + sa.Column('source', sa.String(50), nullable=False), + sa.Column('bot_id', sa.String(255), nullable=True), + sa.Column('workspace_id', sa.String(255), nullable=True), + sa.Column('conversation_id', sa.String(255), nullable=True), + sa.Column('thread_id', sa.String(255), nullable=True), + sa.Column('actor_type', sa.String(50), nullable=True), + sa.Column('actor_id', sa.String(255), nullable=True), + sa.Column('actor_name', sa.String(255), nullable=True), + sa.Column('subject_type', sa.String(50), nullable=True), + sa.Column('subject_id', sa.String(255), nullable=True), + sa.Column('input_summary', sa.Text(), nullable=True), + sa.Column('input_json', sa.Text(), nullable=True), + sa.Column('raw_ref', sa.String(255), nullable=True), + sa.Column('run_id', sa.String(255), nullable=True), + sa.Column('runner_id', sa.String(255), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')), + sa.Column('metadata_json', sa.Text(), nullable=True), + ) + + # Create indexes for event_log + _create_index_if_missing('event_log', 'ix_event_log_event_id', ['event_id'], unique=True) + _create_index_if_missing('event_log', 'ix_event_log_event_type', ['event_type']) + _create_index_if_missing('event_log', 'ix_event_log_bot_id', ['bot_id']) + _create_index_if_missing('event_log', 'ix_event_log_conversation_id', ['conversation_id']) + _create_index_if_missing('event_log', 'ix_event_log_run_id', ['run_id']) + + # Create transcript table + if not _table_exists('transcript'): + op.create_table( + 'transcript', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('transcript_id', sa.String(255), nullable=False, unique=True), + sa.Column('event_id', sa.String(255), nullable=False), + sa.Column('bot_id', sa.String(255), nullable=True), + sa.Column('workspace_id', sa.String(255), nullable=True), + sa.Column('conversation_id', sa.String(255), nullable=False), + sa.Column('thread_id', sa.String(255), nullable=True), + sa.Column('role', sa.String(50), nullable=False), + sa.Column('item_type', sa.String(50), nullable=False, server_default='message'), + sa.Column('content', sa.Text(), nullable=True), + sa.Column('content_json', sa.Text(), nullable=True), + sa.Column('attachment_refs_json', sa.Text(), nullable=True), + sa.Column('seq', sa.Integer(), nullable=False), + sa.Column('run_id', sa.String(255), nullable=True), + sa.Column('runner_id', sa.String(255), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')), + sa.Column('metadata_json', sa.Text(), nullable=True), + ) + else: + _add_column_if_missing('transcript', sa.Column('bot_id', sa.String(255), nullable=True)) + _add_column_if_missing('transcript', sa.Column('workspace_id', sa.String(255), nullable=True)) + + # Create indexes for transcript + _create_index_if_missing('transcript', 'ix_transcript_transcript_id', ['transcript_id'], unique=True) + _create_index_if_missing('transcript', 'ix_transcript_event_id', ['event_id']) + _create_index_if_missing('transcript', 'ix_transcript_bot_id', ['bot_id']) + _create_index_if_missing('transcript', 'ix_transcript_conversation_id', ['conversation_id']) + _create_index_if_missing('transcript', 'ix_transcript_conversation_seq', ['conversation_id', 'seq']) + _create_index_if_missing('transcript', 'ix_transcript_conversation_created', ['conversation_id', 'created_at']) + _create_index_if_missing( + 'transcript', + 'ix_transcript_scope_seq', + ['bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'seq'], + ) + _create_index_if_missing('transcript', 'ix_transcript_run_id', ['run_id']) + + +def downgrade() -> None: + # Drop transcript table + _drop_index_if_exists('transcript', 'ix_transcript_run_id') + _drop_index_if_exists('transcript', 'ix_transcript_scope_seq') + _drop_index_if_exists('transcript', 'ix_transcript_conversation_created') + _drop_index_if_exists('transcript', 'ix_transcript_conversation_seq') + _drop_index_if_exists('transcript', 'ix_transcript_conversation_id') + _drop_index_if_exists('transcript', 'ix_transcript_bot_id') + _drop_index_if_exists('transcript', 'ix_transcript_event_id') + _drop_index_if_exists('transcript', 'ix_transcript_transcript_id') + + if _table_exists('transcript'): + op.drop_table('transcript') + + # Drop event_log table + _drop_index_if_exists('event_log', 'ix_event_log_run_id') + _drop_index_if_exists('event_log', 'ix_event_log_conversation_id') + _drop_index_if_exists('event_log', 'ix_event_log_bot_id') + _drop_index_if_exists('event_log', 'ix_event_log_event_type') + _drop_index_if_exists('event_log', 'ix_event_log_event_id') + + if _table_exists('event_log'): + op.drop_table('event_log') diff --git a/src/langbot/pkg/persistence/alembic/versions/6dfd3dd7f0c7_add_agent_runner_state_table_for_host_.py b/src/langbot/pkg/persistence/alembic/versions/6dfd3dd7f0c7_add_agent_runner_state_table_for_host_.py new file mode 100644 index 000000000..1682acdda --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/6dfd3dd7f0c7_add_agent_runner_state_table_for_host_.py @@ -0,0 +1,94 @@ +# Alembic script.py.mako — template for auto-generated revisions +"""add agent_runner_state table for host-owned persistent state + +Revision ID: 6dfd3dd7f0c7 +Revises: 58846a8d7a81 +Create Date: 2026-05-23 19:49:08.529110 +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers +revision = '6dfd3dd7f0c7' +down_revision = '58846a8d7a81' +branch_labels = None +depends_on = None + + +def _table_exists(table_name: str) -> bool: + return table_name in sa.inspect(op.get_bind()).get_table_names() + + +def _index_exists(table_name: str, index_name: str) -> bool: + return index_name in {index['name'] for index in sa.inspect(op.get_bind()).get_indexes(table_name)} + + +def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None: + if not _table_exists(table_name) or _index_exists(table_name, index_name): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.create_index(index_name, columns, unique=unique) + + +def _drop_index_if_exists(table_name: str, index_name: str) -> None: + if not _table_exists(table_name) or not _index_exists(table_name, index_name): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.drop_index(index_name) + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + if not _table_exists('agent_runner_state'): + op.create_table('agent_runner_state', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('runner_id', sa.String(length=255), nullable=False), + sa.Column('binding_identity', sa.String(length=255), nullable=False), + sa.Column('scope', sa.String(length=50), nullable=False), + sa.Column('scope_key', sa.String(length=512), nullable=False), + sa.Column('state_key', sa.String(length=255), nullable=False), + sa.Column('value_json', sa.Text(), nullable=True), + sa.Column('bot_id', sa.String(length=255), nullable=True), + sa.Column('workspace_id', sa.String(length=255), nullable=True), + sa.Column('conversation_id', sa.String(length=255), nullable=True), + sa.Column('thread_id', sa.String(length=255), nullable=True), + sa.Column('actor_type', sa.String(length=50), nullable=True), + sa.Column('actor_id', sa.String(length=255), nullable=True), + sa.Column('subject_type', sa.String(length=50), nullable=True), + sa.Column('subject_id', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('scope_key', 'state_key', name='uq_agent_runner_state_scope_key_state_key') + ) + _create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_actor_id', ['actor_id']) + _create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_binding_identity', ['binding_identity']) + _create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_bot_id', ['bot_id']) + _create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_conversation_id', ['conversation_id']) + _create_index_if_missing( + 'agent_runner_state', + 'ix_agent_runner_state_runner_binding', + ['runner_id', 'binding_identity'], + ) + _create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_runner_id', ['runner_id']) + _create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_scope', ['scope']) + _create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_scope_key_lookup', ['scope_key']) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + _drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_scope_key_lookup') + _drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_scope') + _drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_runner_id') + _drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_runner_binding') + _drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_conversation_id') + _drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_bot_id') + _drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_binding_identity') + _drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_actor_id') + + if _table_exists('agent_runner_state'): + op.drop_table('agent_runner_state') + # ### end Alembic commands ### diff --git a/src/langbot/pkg/persistence/alembic/versions/7b2c1d9e4f30_add_transcript_scope_columns.py b/src/langbot/pkg/persistence/alembic/versions/7b2c1d9e4f30_add_transcript_scope_columns.py new file mode 100644 index 000000000..99da93c0e --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/7b2c1d9e4f30_add_transcript_scope_columns.py @@ -0,0 +1,78 @@ +"""add transcript scope columns + +Revision ID: 7b2c1d9e4f30 +Revises: 6dfd3dd7f0c7 +Create Date: 2026-06-12 +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7b2c1d9e4f30' +down_revision = '6dfd3dd7f0c7' +branch_labels = None +depends_on = None + + +def _table_exists(table_name: str) -> bool: + return table_name in sa.inspect(op.get_bind()).get_table_names() + + +def _column_exists(table_name: str, column_name: str) -> bool: + return column_name in {column['name'] for column in sa.inspect(op.get_bind()).get_columns(table_name)} + + +def _index_exists(table_name: str, index_name: str) -> bool: + return index_name in {index['name'] for index in sa.inspect(op.get_bind()).get_indexes(table_name)} + + +def _add_column_if_missing(table_name: str, column: sa.Column) -> None: + if not _table_exists(table_name) or _column_exists(table_name, column.name): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.add_column(column) + + +def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None: + if not _table_exists(table_name) or _index_exists(table_name, index_name): + return + existing_columns = {column['name'] for column in sa.inspect(op.get_bind()).get_columns(table_name)} + if not set(columns).issubset(existing_columns): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.create_index(index_name, columns) + + +def _drop_index_if_exists(table_name: str, index_name: str) -> None: + if not _table_exists(table_name) or not _index_exists(table_name, index_name): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.drop_index(index_name) + + +def upgrade() -> None: + _add_column_if_missing('transcript', sa.Column('bot_id', sa.String(255), nullable=True)) + _add_column_if_missing('transcript', sa.Column('workspace_id', sa.String(255), nullable=True)) + _create_index_if_missing('transcript', 'ix_transcript_bot_id', ['bot_id']) + _create_index_if_missing( + 'transcript', + 'ix_transcript_scope_seq', + ['bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'seq'], + ) + _drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_scope_key') + _create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_scope_key_lookup', ['scope_key']) + + +def downgrade() -> None: + _drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_scope_key_lookup') + _create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_scope_key', ['scope_key']) + _drop_index_if_exists('transcript', 'ix_transcript_scope_seq') + _drop_index_if_exists('transcript', 'ix_transcript_bot_id') + if not _table_exists('transcript'): + return + existing_columns = {column['name'] for column in sa.inspect(op.get_bind()).get_columns('transcript')} + with op.batch_alter_table('transcript', schema=None) as batch_op: + if 'workspace_id' in existing_columns: + batch_op.drop_column('workspace_id') + if 'bot_id' in existing_columns: + batch_op.drop_column('bot_id') diff --git a/src/langbot/pkg/persistence/alembic/versions/8d3a1f2c4b6e_add_agent_run_ledger.py b/src/langbot/pkg/persistence/alembic/versions/8d3a1f2c4b6e_add_agent_run_ledger.py new file mode 100644 index 000000000..88773c1b1 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/8d3a1f2c4b6e_add_agent_run_ledger.py @@ -0,0 +1,202 @@ +"""add agent run ledger + +Revision ID: 8d3a1f2c4b6e +Revises: 7b2c1d9e4f30 +Create Date: 2026-06-15 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = '8d3a1f2c4b6e' +down_revision = '7b2c1d9e4f30' +branch_labels = None +depends_on = None + + +def _table_exists(table_name: str) -> bool: + return table_name in sa.inspect(op.get_bind()).get_table_names() + + +def _index_exists(table_name: str, index_name: str) -> bool: + return index_name in {index['name'] for index in sa.inspect(op.get_bind()).get_indexes(table_name)} + + +def _column_exists(table_name: str, column_name: str) -> bool: + if not _table_exists(table_name): + return False + return column_name in {column['name'] for column in sa.inspect(op.get_bind()).get_columns(table_name)} + + +def _add_column_if_missing(table_name: str, column: sa.Column) -> None: + if not _table_exists(table_name) or _column_exists(table_name, column.name): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.add_column(column) + + +def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None: + if not _table_exists(table_name) or _index_exists(table_name, index_name): + return + existing_columns = {column['name'] for column in sa.inspect(op.get_bind()).get_columns(table_name)} + if not set(columns).issubset(existing_columns): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.create_index(index_name, columns, unique=unique) + + +def _drop_index_if_exists(table_name: str, index_name: str) -> None: + if not _table_exists(table_name) or not _index_exists(table_name, index_name): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.drop_index(index_name) + + +def upgrade() -> None: + if not _table_exists('agent_run'): + op.create_table( + 'agent_run', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('run_id', sa.String(255), nullable=False, unique=True), + sa.Column('event_id', sa.String(255), nullable=True), + sa.Column('agent_id', sa.String(255), nullable=True), + sa.Column('binding_id', sa.String(255), nullable=True), + sa.Column('runner_id', sa.String(255), nullable=False), + sa.Column('conversation_id', sa.String(255), nullable=True), + sa.Column('thread_id', sa.String(255), nullable=True), + sa.Column('workspace_id', sa.String(255), nullable=True), + sa.Column('bot_id', sa.String(255), nullable=True), + sa.Column('status', sa.String(50), nullable=False), + sa.Column('status_reason', sa.Text(), nullable=True), + sa.Column('queue_name', sa.String(255), nullable=True), + sa.Column('priority', sa.Integer(), nullable=False, server_default='0'), + sa.Column('requested_runtime_id', sa.String(255), nullable=True), + sa.Column('claimed_by_runtime_id', sa.String(255), nullable=True), + sa.Column('claim_token', sa.String(255), nullable=True), + sa.Column('claim_lease_expires_at', sa.DateTime(), nullable=True), + sa.Column('dispatch_attempts', sa.Integer(), nullable=False, server_default='0'), + sa.Column('last_claimed_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')), + sa.Column('started_at', sa.DateTime(), nullable=True), + sa.Column('finished_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')), + sa.Column('deadline_at', sa.DateTime(), nullable=True), + sa.Column('cancel_requested_at', sa.DateTime(), nullable=True), + sa.Column('usage_json', sa.Text(), nullable=True), + sa.Column('cost_json', sa.Text(), nullable=True), + sa.Column('authorization_json', sa.Text(), nullable=True), + sa.Column('metadata_json', sa.Text(), nullable=True), + ) + else: + _add_column_if_missing('agent_run', sa.Column('queue_name', sa.String(255), nullable=True)) + _add_column_if_missing( + 'agent_run', sa.Column('priority', sa.Integer(), nullable=False, server_default='0') + ) + _add_column_if_missing('agent_run', sa.Column('requested_runtime_id', sa.String(255), nullable=True)) + _add_column_if_missing('agent_run', sa.Column('claimed_by_runtime_id', sa.String(255), nullable=True)) + _add_column_if_missing('agent_run', sa.Column('claim_token', sa.String(255), nullable=True)) + _add_column_if_missing('agent_run', sa.Column('claim_lease_expires_at', sa.DateTime(), nullable=True)) + _add_column_if_missing( + 'agent_run', sa.Column('dispatch_attempts', sa.Integer(), nullable=False, server_default='0') + ) + _add_column_if_missing('agent_run', sa.Column('last_claimed_at', sa.DateTime(), nullable=True)) + + _create_index_if_missing('agent_run', 'ix_agent_run_run_id', ['run_id'], unique=True) + _create_index_if_missing('agent_run', 'ix_agent_run_event_id', ['event_id']) + _create_index_if_missing('agent_run', 'ix_agent_run_binding_id', ['binding_id']) + _create_index_if_missing('agent_run', 'ix_agent_run_runner_id', ['runner_id']) + _create_index_if_missing('agent_run', 'ix_agent_run_conversation_id', ['conversation_id']) + _create_index_if_missing('agent_run', 'ix_agent_run_bot_id', ['bot_id']) + _create_index_if_missing('agent_run', 'ix_agent_run_status', ['status']) + _create_index_if_missing('agent_run', 'ix_agent_run_queue_name', ['queue_name']) + _create_index_if_missing('agent_run', 'ix_agent_run_requested_runtime_id', ['requested_runtime_id']) + _create_index_if_missing('agent_run', 'ix_agent_run_claimed_by_runtime_id', ['claimed_by_runtime_id']) + _create_index_if_missing('agent_run', 'ix_agent_run_claim_token', ['claim_token']) + _create_index_if_missing('agent_run', 'ix_agent_run_claim_lease_expires_at', ['claim_lease_expires_at']) + _create_index_if_missing( + 'agent_run', + 'ix_agent_run_scope_status', + ['bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'status'], + ) + _create_index_if_missing('agent_run', 'ix_agent_run_runner_status', ['runner_id', 'status']) + _create_index_if_missing('agent_run', 'ix_agent_run_queue_claim', ['queue_name', 'status', 'priority', 'id']) + + if not _table_exists('agent_run_event'): + op.create_table( + 'agent_run_event', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('run_id', sa.String(255), nullable=False), + sa.Column('sequence', sa.Integer(), nullable=False), + sa.Column('type', sa.String(100), nullable=False), + sa.Column('data_json', sa.Text(), nullable=True), + sa.Column('usage_json', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')), + sa.Column('source', sa.String(50), nullable=True), + sa.Column('metadata_json', sa.Text(), nullable=True), + sa.UniqueConstraint('run_id', 'sequence', name='uq_agent_run_event_run_sequence'), + ) + + _create_index_if_missing('agent_run_event', 'ix_agent_run_event_run_id', ['run_id']) + _create_index_if_missing('agent_run_event', 'ix_agent_run_event_type', ['type']) + _create_index_if_missing( + 'agent_run_event', + 'ix_agent_run_event_run_sequence', + ['run_id', 'sequence'], + ) + + if not _table_exists('agent_runtime'): + op.create_table( + 'agent_runtime', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('runtime_id', sa.String(255), nullable=False, unique=True), + sa.Column('status', sa.String(50), nullable=False), + sa.Column('display_name', sa.String(255), nullable=True), + sa.Column('endpoint', sa.String(1024), nullable=True), + sa.Column('version', sa.String(255), nullable=True), + sa.Column('capabilities_json', sa.Text(), nullable=True), + sa.Column('labels_json', sa.Text(), nullable=True), + sa.Column('metadata_json', sa.Text(), nullable=True), + sa.Column('last_heartbeat_at', sa.DateTime(), nullable=True), + sa.Column('heartbeat_deadline_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')), + ) + + _create_index_if_missing('agent_runtime', 'ix_agent_runtime_runtime_id', ['runtime_id'], unique=True) + _create_index_if_missing('agent_runtime', 'ix_agent_runtime_status', ['status']) + _create_index_if_missing('agent_runtime', 'ix_agent_runtime_last_heartbeat_at', ['last_heartbeat_at']) + _create_index_if_missing('agent_runtime', 'ix_agent_runtime_heartbeat_deadline_at', ['heartbeat_deadline_at']) + + +def downgrade() -> None: + _drop_index_if_exists('agent_runtime', 'ix_agent_runtime_heartbeat_deadline_at') + _drop_index_if_exists('agent_runtime', 'ix_agent_runtime_last_heartbeat_at') + _drop_index_if_exists('agent_runtime', 'ix_agent_runtime_status') + _drop_index_if_exists('agent_runtime', 'ix_agent_runtime_runtime_id') + if _table_exists('agent_runtime'): + op.drop_table('agent_runtime') + + _drop_index_if_exists('agent_run_event', 'ix_agent_run_event_run_sequence') + _drop_index_if_exists('agent_run_event', 'ix_agent_run_event_type') + _drop_index_if_exists('agent_run_event', 'ix_agent_run_event_run_id') + if _table_exists('agent_run_event'): + op.drop_table('agent_run_event') + + _drop_index_if_exists('agent_run', 'ix_agent_run_queue_claim') + _drop_index_if_exists('agent_run', 'ix_agent_run_claim_lease_expires_at') + _drop_index_if_exists('agent_run', 'ix_agent_run_claim_token') + _drop_index_if_exists('agent_run', 'ix_agent_run_claimed_by_runtime_id') + _drop_index_if_exists('agent_run', 'ix_agent_run_requested_runtime_id') + _drop_index_if_exists('agent_run', 'ix_agent_run_queue_name') + _drop_index_if_exists('agent_run', 'ix_agent_run_runner_status') + _drop_index_if_exists('agent_run', 'ix_agent_run_scope_status') + _drop_index_if_exists('agent_run', 'ix_agent_run_status') + _drop_index_if_exists('agent_run', 'ix_agent_run_bot_id') + _drop_index_if_exists('agent_run', 'ix_agent_run_conversation_id') + _drop_index_if_exists('agent_run', 'ix_agent_run_runner_id') + _drop_index_if_exists('agent_run', 'ix_agent_run_binding_id') + _drop_index_if_exists('agent_run', 'ix_agent_run_event_id') + _drop_index_if_exists('agent_run', 'ix_agent_run_run_id') + if _table_exists('agent_run'): + op.drop_table('agent_run') diff --git a/src/langbot/pkg/persistence/migrations/dbm001_migrate_v3_config.py b/src/langbot/pkg/persistence/migrations/dbm001_migrate_v3_config.py index 55e63fff4..195583626 100644 --- a/src/langbot/pkg/persistence/migrations/dbm001_migrate_v3_config.py +++ b/src/langbot/pkg/persistence/migrations/dbm001_migrate_v3_config.py @@ -11,6 +11,7 @@ from ...entity.persistence import ( pipeline as persistence_pipeline, bot as persistence_bot, ) +from ...agent.runner.config_migration import LEGACY_RUNNER_ID_MAP @migration.migration_class(1) @@ -114,21 +115,28 @@ class DBMigrateV3Config(migration.DBMigration): pipeline_config = default_pipeline['config'] # ai - pipeline_config['ai']['runner'] = { - 'runner': self.ap.provider_cfg.data['runner'], + ai_config = pipeline_config.setdefault('ai', {}) + runner_name = self.ap.provider_cfg.data['runner'] + runner_id = LEGACY_RUNNER_ID_MAP.get(runner_name, '') + ai_config['runner'] = { + 'id': runner_id, } - pipeline_config['ai']['local-agent']['model'] = model_uuid - pipeline_config['ai']['local-agent']['max-round'] = self.ap.pipeline_cfg.data['msg-truncate']['round'][ - 'max-round' - ] + runner_configs = ai_config.setdefault('runner_config', {}) - pipeline_config['ai']['local-agent']['prompt'] = [ + local_agent_runner_id = LEGACY_RUNNER_ID_MAP['local-agent'] + local_agent_config = runner_configs.setdefault(local_agent_runner_id, {}) + local_agent_config['model'] = { + 'primary': model_uuid, + 'fallbacks': [], + } + + local_agent_config['prompt'] = [ { 'role': 'system', 'content': self.ap.provider_cfg.data['prompt']['default'], } ] - pipeline_config['ai']['dify-service-api'] = { + runner_configs[LEGACY_RUNNER_ID_MAP['dify-service-api']] = { 'base-url': self.ap.provider_cfg.data['dify-service-api']['base-url'], 'app-type': self.ap.provider_cfg.data['dify-service-api']['app-type'], 'api-key': self.ap.provider_cfg.data['dify-service-api'][ @@ -139,7 +147,7 @@ class DBMigrateV3Config(migration.DBMigration): self.ap.provider_cfg.data['dify-service-api']['app-type'] ]['timeout'], } - pipeline_config['ai']['dashscope-app-api'] = { + runner_configs[LEGACY_RUNNER_ID_MAP['dashscope-app-api']] = { 'app-type': self.ap.provider_cfg.data['dashscope-app-api']['app-type'], 'api-key': self.ap.provider_cfg.data['dashscope-app-api']['api-key'], 'references_quote': self.ap.provider_cfg.data['dashscope-app-api'][ diff --git a/src/langbot/pkg/pipeline/controller.py b/src/langbot/pkg/pipeline/controller.py index 09d18a582..15af3d21a 100644 --- a/src/langbot/pkg/pipeline/controller.py +++ b/src/langbot/pkg/pipeline/controller.py @@ -21,11 +21,45 @@ class Controller: self.ap = ap self.semaphore = asyncio.Semaphore(self.ap.instance_config.data['concurrency']['pipeline']) + async def _try_claim_steering_before_session_slot( + self, + query: pipeline_query.Query, + ) -> bool: + """Claim steering while the normal per-session slot is still busy. + + Follow-up input must be claimed before it waits behind the session + semaphore; otherwise the active run can finish before the query reaches + ChatMessageHandler.try_claim_steering_from_query. + """ + try: + pipeline_uuid = query.pipeline_uuid + if not pipeline_uuid: + return False + + pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid) + if not pipeline: + return False + + session = await self.ap.sess_mgr.get_session(query) + query.session = session + query.pipeline_config = pipeline.pipeline_entity.config + query.variables['_pipeline_bound_plugins'] = pipeline.bound_plugins + query.variables['_pipeline_bound_mcp_servers'] = pipeline.bound_mcp_servers + + return await self.ap.agent_run_orchestrator.try_claim_steering_from_query(query) + except Exception as exc: + self.ap.logger.warning( + f'Failed to claim query {query.query_id} as steering input: {exc}', + exc_info=True, + ) + return False + async def consumer(self): """事件处理循环""" try: while True: selected_query: pipeline_query.Query = None + claimed_steering_query: pipeline_query.Query = None # 取请求 async with self.ap.query_pool: @@ -36,6 +70,13 @@ class Controller: # Debug logging removed from tight loop to prevent excessive log generation # that can cause memory overflow in high-traffic scenarios + if session._semaphore.locked(): + if await self._try_claim_steering_before_session_slot(query): + claimed_steering_query = query + self.ap.logger.debug(f'Claimed query {query.query_id} as steering before session slot') + break + continue + if not session._semaphore.locked(): selected_query = query await session._semaphore.acquire() @@ -44,7 +85,12 @@ class Controller: break - if selected_query: # 找到了 + if claimed_steering_query: + queries.remove(claimed_steering_query) + self.ap.query_pool.cached_queries.pop(claimed_steering_query.query_id, None) + self.ap.query_pool.condition.notify_all() + continue + elif selected_query: # 找到了 queries.remove(selected_query) else: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限 await self.ap.query_pool.condition.wait() diff --git a/src/langbot/pkg/pipeline/msgtrun/__init__.py b/src/langbot/pkg/pipeline/msgtrun/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/langbot/pkg/pipeline/msgtrun/msgtrun.py b/src/langbot/pkg/pipeline/msgtrun/msgtrun.py deleted file mode 100644 index 00a9bfbf2..000000000 --- a/src/langbot/pkg/pipeline/msgtrun/msgtrun.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -from .. import stage, entities -from . import truncator -from ...utils import importutil -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -from . import truncators - -importutil.import_modules_in_pkg(truncators) - - -@stage.stage_class('ConversationMessageTruncator') -class ConversationMessageTruncator(stage.PipelineStage): - """Conversation message truncator - - Used to truncate the conversation message chain to adapt to the LLM message length limit. - """ - - trun: truncator.Truncator - - async def initialize(self, pipeline_config: dict): - use_method = 'round' - - for trun in truncator.preregistered_truncators: - if trun.name == use_method: - self.trun = trun(self.ap) - break - else: - raise ValueError(f'Unknown truncator: {use_method}') - - async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult: - """处理""" - query = await self.trun.truncate(query) - - return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) diff --git a/src/langbot/pkg/pipeline/msgtrun/truncator.py b/src/langbot/pkg/pipeline/msgtrun/truncator.py deleted file mode 100644 index 180982d3c..000000000 --- a/src/langbot/pkg/pipeline/msgtrun/truncator.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -import typing -import abc - -from ...core import app -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query - -preregistered_truncators: list[typing.Type[Truncator]] = [] - - -def truncator_class( - name: str, -) -> typing.Callable[[typing.Type[Truncator]], typing.Type[Truncator]]: - """截断器类装饰器 - - Args: - name (str): 截断器名称 - - Returns: - typing.Callable[[typing.Type[Truncator]], typing.Type[Truncator]]: 装饰器 - """ - - def decorator(cls: typing.Type[Truncator]) -> typing.Type[Truncator]: - assert issubclass(cls, Truncator) - - cls.name = name - - preregistered_truncators.append(cls) - - return cls - - return decorator - - -class Truncator(abc.ABC): - """消息截断器基类""" - - name: str - - ap: app.Application - - def __init__(self, ap: app.Application): - self.ap = ap - - async def initialize(self): - pass - - @abc.abstractmethod - async def truncate(self, query: pipeline_query.Query) -> pipeline_query.Query: - """截断 - - 一般只需要操作query.messages,也可以扩展操作query.prompt, query.user_message。 - 请勿操作其他字段。 - """ - pass diff --git a/src/langbot/pkg/pipeline/msgtrun/truncators/__init__.py b/src/langbot/pkg/pipeline/msgtrun/truncators/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/langbot/pkg/pipeline/msgtrun/truncators/round.py b/src/langbot/pkg/pipeline/msgtrun/truncators/round.py deleted file mode 100644 index 400706b67..000000000 --- a/src/langbot/pkg/pipeline/msgtrun/truncators/round.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from .. import truncator -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query - - -@truncator.truncator_class('round') -class RoundTruncator(truncator.Truncator): - """Truncate the conversation message chain to adapt to the LLM message length limit.""" - - async def truncate(self, query: pipeline_query.Query) -> pipeline_query.Query: - """截断""" - max_round = query.pipeline_config['ai']['local-agent']['max-round'] - - temp_messages = [] - - current_round = 0 - - # Traverse from back to front - for msg in query.messages[::-1]: - if current_round < max_round: - temp_messages.append(msg) - if msg.role == 'user': - current_round += 1 - else: - break - - query.messages = temp_messages[::-1] - - return query diff --git a/src/langbot/pkg/pipeline/pipelinemgr.py b/src/langbot/pkg/pipeline/pipelinemgr.py index 1426fe3de..b494acc23 100644 --- a/src/langbot/pkg/pipeline/pipelinemgr.py +++ b/src/langbot/pkg/pipeline/pipelinemgr.py @@ -28,7 +28,6 @@ from . import ( wrapper, preproc, ratelimit, - msgtrun, ) importutil.import_modules_in_pkgs( @@ -42,7 +41,6 @@ importutil.import_modules_in_pkgs( wrapper, preproc, ratelimit, - msgtrun, ] ) @@ -278,8 +276,10 @@ class RuntimePipeline: # Get runner name from pipeline config runner_name = None - if query.pipeline_config and 'ai' in query.pipeline_config and 'runner' in query.pipeline_config['ai']: - runner_name = query.pipeline_config['ai']['runner'].get('runner') + if query.pipeline_config: + from ..agent.runner.config_migration import ConfigMigration + + runner_name = ConfigMigration.resolve_runner_id(query.pipeline_config) # Record query start and store message_id message_id = '' @@ -438,6 +438,9 @@ class PipelineManager: # initialize stage containers according to pipeline_entity.stages stage_containers: list[StageInstContainer] = [] for stage_name in pipeline_entity.stages: + if stage_name not in self.stage_dict: + self.ap.logger.warning(f'Pipeline stage {stage_name} is not registered; skipping') + continue stage_containers.append(StageInstContainer(inst_name=stage_name, inst=self.stage_dict[stage_name](self.ap))) for stage_container in stage_containers: diff --git a/src/langbot/pkg/pipeline/preproc/preproc.py b/src/langbot/pkg/pipeline/preproc/preproc.py index 84e9070c8..1ded09a00 100644 --- a/src/langbot/pkg/pipeline/preproc/preproc.py +++ b/src/langbot/pkg/pipeline/preproc/preproc.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime +import typing from .. import stage, entities from langbot_plugin.api.entities.builtin.provider import message as provider_message @@ -9,6 +10,15 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.platform.events as platform_events +from ...agent.runner.descriptor import AgentRunnerDescriptor +from ...agent.runner.config_migration import ConfigMigration +from ...agent.runner import config_schema + + +DEFAULT_PROMPT_CONFIG = [ + {'role': 'system', 'content': 'You are a helpful assistant.'}, +] + @stage.stage_class('PreProcessor') class PreProcessor(stage.PipelineStage): @@ -25,55 +35,170 @@ class PreProcessor(stage.PipelineStage): - use_funcs """ + async def _get_runner_descriptor( + self, + runner_id: str | None, + bound_plugins: list[str] | None, + ) -> AgentRunnerDescriptor | None: + if not runner_id: + return None + + registry = getattr(self.ap, 'agent_runner_registry', None) + if registry is None: + return None + + try: + return await registry.get(runner_id, bound_plugins) + except Exception as e: + self.ap.logger.debug(f'Unable to load AgentRunner descriptor for {runner_id}: {e}') + return None + + async def _resolve_llm_model( + self, + primary_uuid: str, + ) -> typing.Any | None: + if primary_uuid in config_schema.NONE_SENTINELS: + return None + try: + return await self.ap.model_mgr.get_model_by_uuid(primary_uuid) + except ValueError: + self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured') + return None + + async def _resolve_fallback_models(self, fallback_uuids: list[str]) -> list[str]: + valid_fallbacks = [] + for fallback_uuid in fallback_uuids: + if fallback_uuid in config_schema.NONE_SENTINELS: + continue + try: + await self.ap.model_mgr.get_model_by_uuid(fallback_uuid) + valid_fallbacks.append(fallback_uuid) + except ValueError: + self.ap.logger.warning(f'Fallback model {fallback_uuid} not found, skipping') + return valid_fallbacks + + def _runner_accepts_multimodal_input(self, descriptor: AgentRunnerDescriptor | None) -> bool: + if descriptor is None: + return True + return descriptor.capabilities.multimodal_input + + def _model_supports_vision(self, llm_model: typing.Any | None) -> bool: + if not llm_model: + return False + abilities = getattr(getattr(llm_model, 'model_entity', None), 'abilities', []) + return 'vision' in (abilities or []) + + def _should_keep_image_inputs( + self, + descriptor: AgentRunnerDescriptor | None, + uses_host_models: bool, + llm_model: typing.Any | None, + ) -> bool: + if not self._runner_accepts_multimodal_input(descriptor): + return False + if uses_host_models: + return self._model_supports_vision(llm_model) + return True + + def _strip_images_from_history(self, query: pipeline_query.Query) -> None: + for msg in query.messages: + if isinstance(msg.content, list): + msg.content = [elem for elem in msg.content if elem.type != 'image_url'] + + def _has_declared_db_engine(self) -> bool: + persistence_mgr = getattr(self.ap, 'persistence_mgr', None) + if persistence_mgr is None: + return False + if 'get_db_engine' in getattr(persistence_mgr, '__dict__', {}): + return True + return hasattr(type(persistence_mgr), 'get_db_engine') + + async def _load_agent_runner_history_messages( + self, + runner_id: str | None, + conversation_uuid: str | None, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + ) -> list[provider_message.Message] | None: + if not runner_id or not conversation_uuid or not self._has_declared_db_engine(): + return None + + try: + from ...agent.runner.transcript_store import TranscriptStore + + store = TranscriptStore(self.ap.persistence_mgr.get_db_engine()) + messages = await store.get_legacy_provider_messages( + str(conversation_uuid), + bot_id=bot_id, + workspace_id=workspace_id, + thread_id=thread_id, + strict_thread=True, + ) + except Exception as e: + self.ap.logger.warning( + f'Unable to load Transcript history view for conversation {conversation_uuid}: {e}' + ) + return None + + return messages or None + + async def _resolve_history_messages( + self, + runner_id: str | None, + conversation: typing.Any, + bot_id: str | None = None, + workspace_id: str | None = None, + ) -> list[provider_message.Message]: + transcript_messages = await self._load_agent_runner_history_messages( + runner_id, + getattr(conversation, 'uuid', None), + bot_id=bot_id, + workspace_id=workspace_id, + thread_id=getattr(conversation, 'thread_id', None), + ) + if transcript_messages is not None: + return transcript_messages + return conversation.messages.copy() + async def process( self, query: pipeline_query.Query, stage_inst_name: str, ) -> entities.StageProcessResult: """Process""" - selected_runner = query.pipeline_config['ai']['runner']['runner'] - include_skill_authoring = ( - selected_runner == 'local-agent' and getattr(self.ap, 'skill_service', None) is not None - ) + # Resolve runner ID from the current ai.runner.id shape. + runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config) + + # Get runner config from ai.runner_config[runner_id]. + runner_config = ConfigMigration.resolve_runner_config(query.pipeline_config, runner_id) if runner_id else {} + query.variables = query.variables or {} + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) + descriptor = await self._get_runner_descriptor(runner_id, bound_plugins) session = await self.ap.sess_mgr.get_session(query) - # When not local-agent, llm_model is None + 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 selected_runner == 'local-agent': - # Read model config — new format is { primary: str, fallbacks: [str] }, - # but handle legacy plain string for backward compatibility - model_config = query.pipeline_config['ai']['local-agent'].get('model', {}) - if isinstance(model_config, str): - # Legacy format: plain UUID string - primary_uuid = model_config - fallback_uuids = [] - else: - primary_uuid = model_config.get('primary', '') - fallback_uuids = model_config.get('fallbacks', []) + if uses_host_models: + primary_uuid, fallback_uuids = config_schema.extract_model_selection(descriptor, runner_config) + llm_model = await self._resolve_llm_model(primary_uuid) + valid_fallbacks = await self._resolve_fallback_models(fallback_uuids) + if valid_fallbacks: + query.variables['_fallback_model_uuids'] = valid_fallbacks - if primary_uuid: - try: - llm_model = await self.ap.model_mgr.get_model_by_uuid(primary_uuid) - except ValueError: - self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured') - - # Resolve fallback model UUIDs - if fallback_uuids: - valid_fallbacks = [] - for fb_uuid in fallback_uuids: - try: - await self.ap.model_mgr.get_model_by_uuid(fb_uuid) - valid_fallbacks.append(fb_uuid) - except ValueError: - self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping') - if valid_fallbacks: - query.variables['_fallback_model_uuids'] = valid_fallbacks + prompt_config = config_schema.extract_prompt_config(descriptor, runner_config, DEFAULT_PROMPT_CONFIG) conversation = await self.ap.sess_mgr.get_conversation( query, session, - query.pipeline_config['ai']['local-agent']['prompt'], + prompt_config, query.pipeline_uuid, query.bot_uuid, ) @@ -82,7 +207,7 @@ class PreProcessor(stage.PipelineStage): # been idle for longer than the configured conversation expire time. # The idle window is measured from the last preprocess/update time, not # from the conversation creation time. - conversation_expire_time = query.pipeline_config.get('ai', {}).get('runner', {}).get('expire-time', None) + conversation_expire_time = ConfigMigration.get_expire_time(query.pipeline_config) now = datetime.datetime.now() if conversation_expire_time is not None and conversation_expire_time > 0: last_update_time = getattr(conversation, 'update_time', None) or getattr(conversation, 'create_time', None) @@ -99,20 +224,21 @@ class PreProcessor(stage.PipelineStage): # time instead of the first message/creation time. conversation.update_time = now - # 设置query + # Attach resolved session state to the query. query.session = session query.prompt = conversation.prompt.copy() - query.messages = conversation.messages.copy() + query.messages = await self._resolve_history_messages( + runner_id, + conversation, + bot_id=query.bot_uuid, + ) - if selected_runner == 'local-agent': + if uses_host_models: query.use_funcs = [] if llm_model: query.use_llm_model_uuid = llm_model.model_entity.uuid - if 'func_call' in (llm_model.model_entity.abilities or []): - # Get bound plugins and MCP servers for filtering tools - bound_plugins = query.variables.get('_pipeline_bound_plugins', None) - bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) + if uses_host_tools and 'func_call' in (llm_model.model_entity.abilities or []): query.use_funcs = await self.ap.tool_mgr.get_all_tools( bound_plugins, bound_mcp_servers, @@ -125,14 +251,22 @@ class PreProcessor(stage.PipelineStage): # If primary model doesn't support func_call but fallback models exist, # load tools anyway since fallback models may support them - if not query.use_funcs and query.variables.get('_fallback_model_uuids'): - bound_plugins = query.variables.get('_pipeline_bound_plugins', None) - bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) + if uses_host_tools and not query.use_funcs and query.variables.get('_fallback_model_uuids'): 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}') + self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}') + self.ap.logger.debug(f'Use funcs: {query.use_funcs}') sender_name = '' @@ -157,32 +291,25 @@ class PreProcessor(stage.PipelineStage): } query.variables.update(variables) - # Check if this model supports vision, if not, remove all images - # TODO this checking should be performed in runner, and in this stage, the image should be reserved - if selected_runner == 'local-agent' and llm_model and 'vision' not in (llm_model.model_entity.abilities or []): - for msg in query.messages: - if isinstance(msg.content, list): - for me in msg.content: - if me.type == 'image_url': - msg.content.remove(me) + keep_image_inputs = self._should_keep_image_inputs(descriptor, uses_host_models, llm_model) + if not keep_image_inputs: + self._strip_images_from_history(query) content_list: list[provider_message.ContentElement] = [] plain_text = '' - quote_msg = query.pipeline_config['trigger'].get('misc', '').get('combine-quote-message') + quote_msg = query.pipeline_config['trigger'].get('misc', {}).get('combine-quote-message', False) for me in query.message_chain: if isinstance(me, platform_message.Plain): content_list.append(provider_message.ContentElement.from_text(me.text)) plain_text += me.text elif isinstance(me, platform_message.Image): - if selected_runner != 'local-agent' or ( - llm_model and 'vision' in (llm_model.model_entity.abilities or []) - ): + if keep_image_inputs: if me.base64 is not None: content_list.append(provider_message.ContentElement.from_image_base64(me.base64)) elif isinstance(me, platform_message.Voice): - # 转成文件链接,让下游 runner 上传到目标模型 + # Convert voice input into file content for downstream model upload. if me.base64: content_list.append(provider_message.ContentElement.from_file_base64(me.base64, 'voice.silk')) elif me.url: @@ -197,9 +324,7 @@ class PreProcessor(stage.PipelineStage): if isinstance(msg, platform_message.Plain): content_list.append(provider_message.ContentElement.from_text(msg.text)) elif isinstance(msg, platform_message.Image): - if selected_runner != 'local-agent' or ( - llm_model and 'vision' in (llm_model.model_entity.abilities or []) - ): + if keep_image_inputs: if msg.base64 is not None: content_list.append(provider_message.ContentElement.from_image_base64(msg.base64)) elif isinstance(msg, platform_message.File): @@ -219,16 +344,14 @@ class PreProcessor(stage.PipelineStage): query.user_message = provider_message.Message(role='user', content=content_list) - # Extract knowledge base UUIDs into query variables so plugins can modify them - # during PromptPreProcessing before the runner performs retrieval. - kb_uuids = query.pipeline_config['ai']['local-agent'].get('knowledge-bases', []) - if not kb_uuids: - old_kb_uuid = query.pipeline_config['ai']['local-agent'].get('knowledge-base', '') - if old_kb_uuid and old_kb_uuid != '__none__': - kb_uuids = [old_kb_uuid] - query.variables['_knowledge_base_uuids'] = list(kb_uuids) + # Extract configured KB UUIDs into query variables so PromptPreProcessing + # plugins can still adjust the authorized retrieval set before run_agent. + query.variables['_knowledge_base_uuids'] = config_schema.extract_knowledge_base_uuids( + descriptor, + runner_config, + ) - # =========== 触发事件 PromptPreProcessing + # Emit PromptPreProcessing before the runner receives the query. event = events.PromptPreProcessing( session_name=f'{query.session.launcher_type.value}_{query.session.launcher_id}', @@ -244,19 +367,7 @@ class PreProcessor(stage.PipelineStage): query.prompt.messages = event_ctx.event.default_prompt query.messages = event_ctx.event.prompt - # =========== Skill awareness for the local-agent runner =========== - # The actual activation goes through the ``activate`` Tool Call so the - # LLM doesn't see full SKILL.md instructions until it commits to a - # skill (Claude Code's progressive disclosure). But the LLM still has - # to KNOW which skills exist to make that choice, so we: - # 1. resolve the pipeline's bound skills and stash them in - # ``query.variables['_pipeline_bound_skills']`` for downstream - # visibility checks (skill loader, native exec workdir); - # 2. inject a short ``Available Skills`` index (name + description - # only) into the system prompt. The contributor's original PR - # relied on this injection; without it the LLM never discovers - # the skills are there and just calls native tools instead. - if selected_runner == 'local-agent' and self.ap.skill_mgr: + if include_skill_authoring and 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) @@ -268,43 +379,4 @@ class PreProcessor(stage.PipelineStage): query.variables['_pipeline_bound_skills'] = bound_skills - skill_addition = self.ap.skill_mgr.build_skill_aware_prompt_addition( - bound_skills=bound_skills, - ) - if skill_addition: - # Append to the first system message; create one if the - # prompt has none. Handles both plain-string and - # content-element (list) message bodies. - if query.prompt.messages and query.prompt.messages[0].role == 'system': - head = query.prompt.messages[0] - if isinstance(head.content, str): - head.content = head.content + skill_addition - elif isinstance(head.content, list): - appended = False - for ce in head.content: - if getattr(ce, 'type', None) == 'text': - ce.text = (ce.text or '') + skill_addition - appended = True - break - if not appended: - head.content.append(provider_message.ContentElement(type='text', text=skill_addition)) - else: - query.prompt.messages.insert( - 0, - provider_message.Message(role='system', content=skill_addition.strip()), - ) - self.ap.logger.debug( - f'Skill index injected into system prompt: ' - f'pipeline={query.pipeline_uuid} ' - f'bound_skills={bound_skills or "all"} ' - f'loaded_skills={len(self.ap.skill_mgr.skills)}' - ) - else: - self.ap.logger.debug( - f'No skills available for prompt injection: ' - f'pipeline={query.pipeline_uuid} ' - f'loaded_skills={len(self.ap.skill_mgr.skills)} ' - f'bound_skills={bound_skills}' - ) - return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) diff --git a/src/langbot/pkg/pipeline/process/handlers/chat.py b/src/langbot/pkg/pipeline/process/handlers/chat.py index 488e19216..7a3f755fb 100644 --- a/src/langbot/pkg/pipeline/process/handlers/chat.py +++ b/src/langbot/pkg/pipeline/process/handlers/chat.py @@ -9,30 +9,36 @@ from datetime import datetime from .. import handler from ... import entities -from ....provider import runner as runner_module import langbot_plugin.api.entities.events as events -from ....utils import importutil, constants, runner as runner_utils +from ....agent.runner.config_migration import ConfigMigration +from ....agent.runner import config_schema +from ....utils import constants, runner as runner_utils from ....telemetry import features as telemetry_features -from ....provider import runners import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.provider.message as provider_message -importutil.import_modules_in_pkg(runners) +DEFAULT_PROMPT_CONFIG = [ + {'role': 'system', 'content': 'You are a helpful assistant.'}, +] class ChatMessageHandler(handler.MessageHandler): + """Chat message handler using AgentRunOrchestrator. + + This handler delegates all runner execution to the agent_run_orchestrator, + which resolves runner ID, builds context, invokes plugin runtime, + and normalizes results. + """ + async def handle( self, query: pipeline_query.Query, ) -> typing.AsyncGenerator[entities.StageProcessResult, None]: - """处理""" - # 调API - # 生成器 - - # 触发插件事件 + """Handle chat message by delegating to AgentRunOrchestrator.""" + # Trigger plugin event event_class = ( events.PersonNormalMessageReceived if query.launcher_type == provider_session.LauncherTypes.PERSON @@ -53,7 +59,7 @@ class ChatMessageHandler(handler.MessageHandler): bound_plugins = query.variables.get('_pipeline_bound_plugins', None) event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) - is_create_card = False # 判断下是否需要创建流式卡片 + is_create_card = False # Track if streaming card was created if event_ctx.is_prevented_default(): if event_ctx.event.reply_message_chain is not None: @@ -79,40 +85,51 @@ class ChatMessageHandler(handler.MessageHandler): text_length = 0 try: - is_stream = await query.adapter.is_stream_output_supported() - except AttributeError: - is_stream = False - - try: - for r in runner_module.preregistered_runners: - if r.name == query.pipeline_config['ai']['runner']['runner']: - runner = r(self.ap, query.pipeline_config) - break - else: - raise ValueError(f'Request Runner not found: {query.pipeline_config["ai"]["runner"]["runner"]}') # Mark start time for telemetry start_ts = time.time() - if is_stream: - resp_message_id = uuid.uuid4() - chunk_count = 0 # Track streaming chunks to reduce excessive logging + try_claim_steering = getattr( + self.ap.agent_run_orchestrator, + 'try_claim_steering_from_query', + None, + ) + if try_claim_steering and await try_claim_steering(query): + yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query) + return - async for result in runner.run(query): - result.resp_message_id = str(resp_message_id) + try: + is_stream = await query.adapter.is_stream_output_supported() + except AttributeError: + is_stream = False + + # Create a single resp_message_id for the entire streaming response + resp_message_id = uuid.uuid4() + chunk_count = 0 + + # Use AgentRunOrchestrator to run the agent + # This replaces direct runner lookup and PluginAgentRunnerWrapper + async for result in self.ap.agent_run_orchestrator.run_from_query(query): + result.resp_message_id = str(resp_message_id) + + # For streaming mode, pop previous response before adding new chunk + # This allows incremental card updates + if is_stream: if query.resp_messages: query.resp_messages.pop() if query.resp_message_chain: query.resp_message_chain.pop() - # 此时连接外部 AI 服务正常,创建卡片 - if not is_create_card: # 只有不是第一次才创建卡片 + + # Create streaming card on first result (connection established) + if not is_create_card: await query.adapter.create_message_card(str(resp_message_id), query.message_event) is_create_card = True - query.resp_messages.append(result) + query.resp_messages.append(result) + + if is_stream: chunk_count += 1 - # Only log every 10th chunk to reduce excessive logging during streaming - # This prevents memory overflow from thousands of log entries per conversation - # First chunk uses INFO level to confirm connection establishment + # Only log every 10th chunk to reduce excessive logging during streaming. + # First chunk uses INFO level to confirm connection establishment. if chunk_count == 1: summary = self.format_result_log(result) if summary is not None: @@ -123,46 +140,59 @@ class ChatMessageHandler(handler.MessageHandler): self.ap.logger.debug( f'Conversation({query.query_id}) Streaming chunk {chunk_count}: {self.cut_str(result.readable_str())}' ) - - if result.content is not None: - text_length += len(result.content) - - yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) - - # Log final summary after streaming completes - self.ap.logger.info( - f'Conversation({query.query_id}) Streaming completed: {chunk_count} chunks, {text_length} chars' - ) - - else: - async for result in runner.run(query): - query.resp_messages.append(result) - + else: summary = self.format_result_log(result) if summary is not None: self.ap.logger.info(f'Conversation({query.query_id}) Response: {summary}') - if result.content is not None: - text_length += len(result.content) + if result.content is not None: + text_length += len(result.content) - yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) - query.session.using_conversation.messages.append(query.user_message) + # Log final summary after streaming completes + if is_stream: + self.ap.logger.info( + f'Conversation({query.query_id}) Streaming completed: {chunk_count} chunks, {text_length} chars' + ) + + # Keep a conversation object available for downstream legacy + # readers, but do not mirror AgentRunner history into + # conversation.messages. TranscriptStore is the canonical + # history source for this path. + await self._ensure_conversation_for_history(query) - query.session.using_conversation.messages.extend(query.resp_messages) except Exception as e: + # Import orchestrator errors for specific handling + from ....agent.runner.errors import ( + RunnerNotFoundError, + RunnerNotAuthorizedError, + RunnerExecutionError, + ) + error_info = f'{traceback.format_exc()}' self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}') - traceback.print_exc() - exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint') + # Handle specific runner errors with appropriate messages + if isinstance(e, RunnerNotFoundError): + user_notice = f'Agent runner not found: {e.runner_id}' + elif isinstance(e, RunnerNotAuthorizedError): + user_notice = 'Agent runner not authorized for this pipeline' + elif isinstance(e, RunnerExecutionError): + if e.retryable: + user_notice = 'Agent runner temporarily unavailable. Please try again.' + else: + user_notice = 'Agent runner execution failed.' + else: + # Use existing exception handling + exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint') - if exception_handling == 'show-error': - user_notice = f'{e}' - elif exception_handling == 'show-hint': - user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.') - else: # hide - user_notice = None + if exception_handling == 'show-error': + user_notice = f'{e}' + elif exception_handling == 'show-hint': + user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.') + else: # hide + user_notice = None yield entities.StageProcessResult( result_type=entities.ResultType.INTERRUPT, @@ -172,7 +202,7 @@ class ChatMessageHandler(handler.MessageHandler): debug_notice=traceback.format_exc(), ) finally: - # Telemetry reporting: collect minimal per-query execution info and send asynchronously + # Telemetry reporting try: end_ts = time.time() duration_ms = None @@ -180,16 +210,14 @@ class ChatMessageHandler(handler.MessageHandler): duration_ms = int((end_ts - start_ts) * 1000) adapter_name = query.adapter.__class__.__name__ if hasattr(query, 'adapter') else None - runner_name = ( - query.pipeline_config.get('ai', {}).get('runner', {}).get('runner') - if query.pipeline_config - else None - ) - # Model name if using localagent + # Use orchestrator to resolve runner ID for telemetry + runner_name = self.ap.agent_run_orchestrator.resolve_runner_id_for_telemetry(query) + + # Model name if available model_name = None try: - if runner_name == 'local-agent' and getattr(query, 'use_llm_model_uuid', None): + if getattr(query, 'use_llm_model_uuid', None): m = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid) if m and getattr(m, 'model_entity', None): model_name = getattr(m.model_entity, 'name', None) @@ -199,7 +227,7 @@ class ChatMessageHandler(handler.MessageHandler): pipeline_plugins = query.variables.get('_pipeline_bound_plugins', None) runner_category = runner_utils.get_runner_category_from_runner( - runner_name, runner, query.pipeline_config + runner_name, None, query.pipeline_config ) # Feature usage collected during query processing (tool calls, @@ -223,7 +251,6 @@ class ChatMessageHandler(handler.MessageHandler): 'timestamp': datetime.utcnow().isoformat(), } - # Send telemetry asynchronously and do not block pipeline via app's telemetry manager await self.ap.telemetry.start_send_task(payload) # Trigger survey events on successful non-WebSocket responses @@ -233,5 +260,70 @@ class ChatMessageHandler(handler.MessageHandler): # Counts toward the bot_response_success_100 milestone event await self.ap.survey.record_bot_response_success() except Exception as ex: - # Ensure telemetry issues do not affect normal flow self.ap.logger.warning(f'Failed to send telemetry: {ex}') + + async def _ensure_conversation_for_history( + self, + query: pipeline_query.Query, + ) -> provider_session.Conversation: + session = getattr(query, 'session', None) + conversation = getattr(session, 'using_conversation', None) + if conversation is not None: + return conversation + + if session is None or getattr(self.ap, 'sess_mgr', None) is None: + raise RuntimeError('Conversation is not available for history update') + + prompt_config = await self._build_history_prompt_config(query) + conversation = await self.ap.sess_mgr.get_conversation( + query, + session, + prompt_config, + query.pipeline_uuid, + query.bot_uuid, + ) + if conversation is None: + raise RuntimeError('Conversation manager did not return a conversation') + + if getattr(session, 'using_conversation', None) is None: + session.using_conversation = conversation + return conversation + + async def _build_history_prompt_config( + self, + query: pipeline_query.Query, + ) -> list[dict[str, typing.Any]]: + prompt_messages = getattr(getattr(query, 'prompt', None), 'messages', None) + if prompt_messages: + prompt_config = [] + for message in prompt_messages: + if hasattr(message, 'model_dump'): + prompt_config.append(message.model_dump(mode='python')) + elif isinstance(message, dict): + prompt_config.append(message) + if prompt_config: + return prompt_config + + runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config) + runner_config = ConfigMigration.resolve_runner_config(query.pipeline_config, runner_id) if runner_id else {} + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + descriptor = await self._get_runner_descriptor(runner_id, bound_plugins) + return config_schema.extract_prompt_config(descriptor, runner_config, DEFAULT_PROMPT_CONFIG) + + async def _get_runner_descriptor( + self, + runner_id: str | None, + bound_plugins: list[str] | None, + ) -> typing.Any | None: + if not runner_id: + return None + + registry = getattr(self.ap, 'agent_runner_registry', None) + if registry is None: + return None + + try: + return await registry.get(runner_id, bound_plugins) + except Exception as e: + self.ap.logger.debug(f'Unable to load AgentRunner descriptor for {runner_id}: {e}') + return None diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 12413e49d..039f02f05 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -187,6 +187,15 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): async def initialize_plugins(self): pass + async def _refresh_agent_runner_registry(self) -> None: + registry = getattr(self.ap, 'agent_runner_registry', None) + if registry is None: + return + try: + await registry.refresh() + except Exception as e: + self.ap.logger.warning(f'Failed to refresh agent runner registry: {e}') + async def ping_plugin_runtime(self): if not hasattr(self, 'handler'): raise PluginRuntimeNotConnectedError('Plugin runtime is not connected') @@ -550,6 +559,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): task_context.metadata.update(metadata) await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context) + await self._refresh_agent_runner_registry() async def upgrade_plugin( self, @@ -568,6 +578,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): if task_context is not None: task_context.trace(trace) + await self._refresh_agent_runner_registry() + async def delete_plugin( self, plugin_author: str, @@ -592,6 +604,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): task_context.trace('Cleaning up plugin configuration and storage...') await self.handler.cleanup_plugin_data(plugin_author, plugin_name) + await self._refresh_agent_runner_registry() + async def list_plugins(self, component_kinds: list[str] | None = None) -> list[dict[str, Any]]: """List plugins, optionally filtered by component kinds. @@ -792,6 +806,53 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): yield cmd_ret + # AgentRunner methods + async def list_agent_runners(self, bound_plugins: list[str] | None = None) -> list[dict[str, Any]]: + """List all available AgentRunner components. + + Returns list of dicts with plugin_author, plugin_name, runner_name, manifest, etc. + """ + if not self.is_enable_plugin: + return [] + + runners_data = await self.handler.list_agent_runners(include_plugins=bound_plugins) + return runners_data + + async def run_agent( + self, + plugin_author: str, + plugin_name: str, + runner_name: str, + context: dict[str, Any], + ) -> typing.AsyncGenerator[dict[str, Any], None]: + """Run an AgentRunner from a plugin. + + Args: + plugin_author: Plugin author + plugin_name: Plugin name + runner_name: AgentRunner component name + context: AgentRunContext as dict + + Yields: + AgentRunResult dicts + """ + if not self.is_enable_plugin: + # Return a protocol-level failure result. + yield { + 'type': 'run.failed', + 'data': { + 'error': 'Plugin system is disabled', + 'code': 'plugin.disabled', + 'retryable': False, + }, + } + return + + gen = self.handler.run_agent(plugin_author, plugin_name, runner_name, context) + + async for ret in gen: + yield ret + async def retrieve_knowledge( self, plugin_author: str, diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index 2c4217910..8c7c55bbe 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -1,8 +1,10 @@ from __future__ import annotations import typing -from typing import Any +from typing import Any, Union import base64 +import json +import time import traceback import sqlalchemy @@ -21,9 +23,116 @@ import langbot_plugin.api.entities.builtin.resource.tool as resource_tool from ..entity.persistence import plugin as persistence_plugin from ..entity.persistence import bstorage as persistence_bstorage +from ..provider.modelmgr import requester as model_requester from ..core import app 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 def _make_rag_error_response(error: Exception, error_type: str, **extra_context) -> handler.ActionResponse: @@ -40,6 +149,593 @@ def _make_rag_error_response(error: Exception, error_type: str, **extra_context) return handler.ActionResponse.error(message=message) +def _pop_query_llm_usage(query: Any) -> dict[str, Any] | None: + """Read provider usage stashed on a query by RuntimeProvider.""" + if query is None or not getattr(query, 'variables', None): + return None + usage = query.variables.pop(model_requester.LLM_USAGE_QUERY_VARIABLE, None) + if usage is None: + return None + if isinstance(usage, dict): + return dict(usage) + 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 _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): + return [] + return list( + dict.fromkeys(value for value in values if isinstance(value, str) and value not in config_schema.NONE_SENTINELS) + ) + + +async def _get_pipeline_knowledge_base_uuids(ap: app.Application, query: Any) -> list[str]: + """Resolve pipeline-scoped KBs from preprocessed variables or runner schema.""" + variables = getattr(query, 'variables', {}) or {} + if '_knowledge_base_uuids' in variables: + return _normalize_uuid_list(variables.get('_knowledge_base_uuids')) + + pipeline_config = getattr(query, 'pipeline_config', None) + if not pipeline_config: + return [] + + runner_id = ConfigMigration.resolve_runner_id(pipeline_config) + if not runner_id: + return [] + + runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id) + registry = getattr(ap, 'agent_runner_registry', None) + if registry is None: + return [] + + bound_plugins = variables.get('_pipeline_bound_plugins') + try: + descriptor = await registry.get(runner_id, bound_plugins) + except Exception as e: + ap.logger.warning(f'Failed to load AgentRunner descriptor for knowledge-base scope: {e}') + return [] + + return config_schema.extract_knowledge_base_uuids(descriptor, runner_config) + + +async def _validate_run_authorization( + run_id: str, + resource_type: str, + resource_id: str, + ap: app.Application, + caller_plugin_identity: str | None = None, + operation: str | None = None, +) -> Union[tuple[None, handler.ActionResponse], tuple[Any, None]]: + """Validate run_id authorization for a resource access. + + Common validation logic for INVOKE_LLM, INVOKE_LLM_STREAM, CALL_TOOL, + RETRIEVE_KNOWLEDGE_BASE, RETRIEVE_KNOWLEDGE, and storage actions. + + Args: + run_id: The run_id to validate. + resource_type: Resource type ('model', 'tool', 'knowledge_base', 'storage'). + resource_id: Resource identifier (model_uuid, tool_name, kb_id, 'plugin'/'workspace'). + ap: Application instance for logging. + caller_plugin_identity: Plugin identity (author/name) of the caller. + Required when the run session is bound to a plugin identity. + operation: Optional resource operation required by the runtime action. + + Returns: + Tuple of (session, None) if validation passes. + Tuple of (None, error_response) if validation fails. + """ + session_registry = get_session_registry() + session = await session_registry.get(run_id) + if not session: + ap.logger.warning(f'{resource_type.upper()}: run_id {run_id} not found in session registry') + 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'{resource_type.upper()}: 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'{resource_type.upper()}: 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: caller {caller_plugin_identity} is not authorized for run_id {run_id}', + ) + + if not session_registry.is_resource_allowed(session, resource_type, resource_id, operation): + ap.logger.warning( + f'{resource_type.upper()}: {resource_id} operation {operation or "*"} not allowed for run_id {run_id}' + ) + operation_suffix = f' for operation {operation}' if operation else '' + return None, handler.ActionResponse.error( + message=f'{resource_type} {resource_id} is not authorized{operation_suffix} for this agent run', + ) + + return session, None + + +def _get_cached_query(ap: app.Application, query_id: int | None) -> Any | None: + """Return a cached Query for query-based runtime actions when available.""" + if query_id is None: + return None + + try: + return ap.query_pool.cached_queries.get(query_id) + except Exception: + return None + + +def _resolve_action_query(data: dict[str, Any], session: Any | None, ap: app.Application) -> Any | None: + """Resolve the current Query from internal run state or query-based action payload.""" + query_id = None + if session: + query_id = session.get('query_id') + if query_id is None: + query_id = data.get('query_id') + query = _get_cached_query(ap, query_id) + if query is not None and session is not None: + object.__setattr__(query, '_agent_run_session', session) + return query + + +def _resolve_remove_think(data: dict[str, Any], query: Any | None) -> bool: + """Resolve remove-think using explicit action override, then pipeline config.""" + if 'remove_think' in data: + return bool(data.get('remove_think')) + + if query and getattr(query, 'pipeline_config', None): + return bool(query.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False)) + + return False + + +def _merge_model_extra_args(model: Any, call_extra_args: Any) -> dict[str, Any]: + """Merge persisted model extra_args with action-level overrides.""" + merged: dict[str, Any] = {} + + model_extra_args = getattr(getattr(model, 'model_entity', None), 'extra_args', None) + if isinstance(model_extra_args, dict): + merged.update(model_extra_args) + if isinstance(call_extra_args, dict): + merged.update(call_extra_args) + + return merged + + class RuntimeConnectionHandler(handler.Handler): """Runtime connection handler""" @@ -324,11 +1020,26 @@ class RuntimeConnectionHandler(handler.Handler): @self.action(PluginToRuntimeAction.INVOKE_LLM) async def invoke_llm(data: dict[str, Any]) -> handler.ActionResponse: - """Invoke llm""" + """Invoke llm + + For AgentRunner calls: requires run_id and validates model_uuid against session.resources.models. + For regular plugin calls: no run_id, unrestricted access (backward compatibility). + """ llm_model_uuid = data['llm_model_uuid'] messages = data['messages'] funcs = data.get('funcs', []) extra_args = data.get('extra_args', {}) + run_id = data.get('run_id') # Optional: present for AgentRunner calls + caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + session = None + + # Permission validation for AgentRunner calls + if run_id: + session, error = await _validate_run_authorization( + run_id, 'model', llm_model_uuid, self.ap, caller_plugin_identity, operation='invoke' + ) + if error: + return error llm_model = await self.ap.model_mgr.get_model_by_uuid(llm_model_uuid) if llm_model is None: @@ -345,28 +1056,220 @@ class RuntimeConnectionHandler(handler.Handler): pass funcs_obj = [resource_tool.LLMTool.model_validate({**func, 'func': _placeholder_func}) for func in funcs] + query = _resolve_action_query(data, session, self.ap) + effective_extra_args = _merge_model_extra_args(llm_model, extra_args) + remove_think = _resolve_remove_think(data, query) + effective_funcs = funcs_obj if 'func_call' in (llm_model.model_entity.abilities or []) else [] result = await llm_model.provider.invoke_llm( - query=None, + query=query, model=llm_model, messages=messages_obj, - funcs=funcs_obj, - extra_args=extra_args, + funcs=effective_funcs, + extra_args=effective_extra_args, + remove_think=remove_think, ) + usage = None + if isinstance(result, tuple): + result, usage = result + if usage is None: + usage = _pop_query_llm_usage(query) + + response_data = { + 'message': result.model_dump(), + } + if usage is not None: + response_data['usage'] = usage + return handler.ActionResponse.success( - data={ - 'message': result.model_dump(), - }, + data=response_data, ) + @self.action(PluginToRuntimeAction.INVOKE_LLM_STREAM) + async def invoke_llm_stream(data: dict[str, Any]): + """Invoke llm with streaming response + + For AgentRunner calls: requires run_id and validates model_uuid against session.resources.models. + For regular plugin calls: no run_id, unrestricted access (backward compatibility). + """ + llm_model_uuid = data['llm_model_uuid'] + messages = data['messages'] + funcs = data.get('funcs', []) + extra_args = data.get('extra_args', {}) + run_id = data.get('run_id') # Optional: present for AgentRunner calls + caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + session = None + + # Permission validation for AgentRunner calls + if run_id: + session, error = await _validate_run_authorization( + run_id, 'model', llm_model_uuid, self.ap, caller_plugin_identity, operation='stream' + ) + if error: + yield error + return + + llm_model = await self.ap.model_mgr.get_model_by_uuid(llm_model_uuid) + if llm_model is None: + yield handler.ActionResponse.error( + message=f'LLM model with llm_model_uuid {llm_model_uuid} not found', + ) + return + + messages_obj = [provider_message.Message.model_validate(message) for message in messages] + + # The func field is excluded during model_dump() in plugin side + # but required by LLMTool validation on Host. + async def _placeholder_func(**kwargs): + pass + + funcs_obj = [resource_tool.LLMTool.model_validate({**func, 'func': _placeholder_func}) for func in funcs] + query = _resolve_action_query(data, session, self.ap) + effective_extra_args = _merge_model_extra_args(llm_model, extra_args) + remove_think = _resolve_remove_think(data, query) + effective_funcs = funcs_obj if 'func_call' in (llm_model.model_entity.abilities or []) else [] + + async for chunk in llm_model.provider.invoke_llm_stream( + query=query, + model=llm_model, + messages=messages_obj, + funcs=effective_funcs, + extra_args=effective_extra_args, + remove_think=remove_think, + ): + if chunk is None: + continue + yield handler.ActionResponse.success( + data={ + 'chunk': chunk.model_dump(), + }, + ) + usage = _pop_query_llm_usage(query) + if usage is not None: + yield handler.ActionResponse.success( + data={ + 'usage': usage, + }, + ) + + @self.action(PluginToRuntimeAction.CALL_TOOL) + async def call_tool(data: dict[str, Any]) -> handler.ActionResponse: + """Call a tool + + For AgentRunner calls: requires run_id and validates tool_name against session.resources.tools. + For regular plugin calls: no run_id, unrestricted access (backward compatibility). + """ + tool_name = data['tool_name'] + run_id = data.get('run_id') # Optional: present for AgentRunner calls + caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + session = None + is_agent_runner_call = bool(run_id) + + if is_agent_runner_call: + if 'parameters' not in data: + return handler.ActionResponse.error( + message='parameters is required for AgentRunner tool calls', + ) + parameters = data.get('parameters') or {} + else: + parameters = data.get('tool_parameters') or {} + + # Permission validation for AgentRunner calls + if run_id: + session, error = await _validate_run_authorization( + run_id, 'tool', tool_name, self.ap, caller_plugin_identity, operation='call' + ) + if error: + return error + + # Convert session_data to Session object (simplified) + # In real implementation, you would reconstruct the full session + # For now, we'll call the tool manager's execute method + try: + query = _resolve_action_query(data, session, self.ap) + result = await self.ap.tool_mgr.execute_func_call( + name=tool_name, + parameters=parameters, + query=query, + ) + if is_agent_runner_call: + return handler.ActionResponse.success(data={'result': result}) + return handler.ActionResponse.success(data={'tool_response': result}) + except Exception as e: + traceback.print_exc() + return handler.ActionResponse.error( + message=f'Failed to execute tool {tool_name}: {e}', + ) + + @self.action(PluginToRuntimeAction.GET_TOOL_DETAIL) + async def get_tool_detail(data: dict[str, Any]) -> handler.ActionResponse: + """Get tool detail for LLM function calling. + + For AgentRunner calls: requires run_id and validates tool_name against session.resources.tools. + For regular plugin calls: no run_id, unrestricted access (backward compatibility). + + Returns tool manifest including name, description, and parameters schema. + """ + tool_name = data['tool_name'] + run_id = data.get('run_id') # Optional: present for AgentRunner calls + caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + + # Permission validation for AgentRunner calls + if run_id: + session, error = await _validate_run_authorization( + run_id, 'tool', tool_name, self.ap, caller_plugin_identity, operation='detail' + ) + if error: + return error + + try: + tool = await self.ap.tool_mgr.get_tool_by_name(tool_name) + if tool 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() + return handler.ActionResponse.error( + message=f'Failed to get tool detail for {tool_name}: {e}', + ) + + # ================= Binary Storage Handlers ================= + # Permission validation: + # - For AgentRunner calls (with run_id): validates storage permission via session_registry + # - For regular plugin calls (no run_id): unrestricted access (backward compatibility) + # - Plugin storage: inherent isolation via owner = plugin identity (set by SDK runtime) + # - Workspace storage: requires ctx.resources.storage.workspace_storage for AgentRunner + @self.action(RuntimeToLangBotAction.SET_BINARY_STORAGE) async def set_binary_storage(data: dict[str, Any]) -> handler.ActionResponse: - """Set binary storage""" + """Set binary storage + + For AgentRunner calls: validates storage permission via session_registry. + For regular plugin calls: unrestricted access (backward compatibility). + """ key = data['key'] owner_type = data['owner_type'] owner = data['owner'] value = base64.b64decode(data['value_base64']) + run_id = data.get('run_id') # Optional: present for AgentRunner calls + caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + + # Permission validation for AgentRunner calls + if run_id: + # Determine storage type from owner_type + storage_type = owner_type # 'plugin' or 'workspace' + session, error = await _validate_run_authorization( + run_id, 'storage', storage_type, self.ap, caller_plugin_identity + ) + if error: + return error + max_value_bytes = ( self.ap.instance_config.data.get('plugin', {}) .get('binary_storage', {}) @@ -416,10 +1319,25 @@ class RuntimeConnectionHandler(handler.Handler): @self.action(RuntimeToLangBotAction.GET_BINARY_STORAGE) async def get_binary_storage(data: dict[str, Any]) -> handler.ActionResponse: - """Get binary storage""" + """Get binary storage + + For AgentRunner calls: validates storage permission via session_registry. + For regular plugin calls: unrestricted access (backward compatibility). + """ key = data['key'] owner_type = data['owner_type'] owner = data['owner'] + run_id = data.get('run_id') # Optional: present for AgentRunner calls + caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + + # Permission validation for AgentRunner calls + if run_id: + storage_type = owner_type + session, error = await _validate_run_authorization( + run_id, 'storage', storage_type, self.ap, caller_plugin_identity + ) + if error: + return error result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_bstorage.BinaryStorage) @@ -442,10 +1360,25 @@ class RuntimeConnectionHandler(handler.Handler): @self.action(RuntimeToLangBotAction.DELETE_BINARY_STORAGE) async def delete_binary_storage(data: dict[str, Any]) -> handler.ActionResponse: - """Delete binary storage""" + """Delete binary storage + + For AgentRunner calls: validates storage permission via session_registry. + For regular plugin calls: unrestricted access (backward compatibility). + """ key = data['key'] owner_type = data['owner_type'] owner = data['owner'] + run_id = data.get('run_id') # Optional: present for AgentRunner calls + caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + + # Permission validation for AgentRunner calls + if run_id: + storage_type = owner_type + session, error = await _validate_run_authorization( + run_id, 'storage', storage_type, self.ap, caller_plugin_identity + ) + if error: + return error await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_bstorage.BinaryStorage) @@ -460,9 +1393,24 @@ class RuntimeConnectionHandler(handler.Handler): @self.action(RuntimeToLangBotAction.GET_BINARY_STORAGE_KEYS) async def get_binary_storage_keys(data: dict[str, Any]) -> handler.ActionResponse: - """Get binary storage keys""" + """Get binary storage keys + + For AgentRunner calls: validates storage permission via session_registry. + For regular plugin calls: unrestricted access (backward compatibility). + """ owner_type = data['owner_type'] owner = data['owner'] + run_id = data.get('run_id') # Optional: present for AgentRunner calls + caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + + # Permission validation for AgentRunner calls + if run_id: + storage_type = owner_type + session, error = await _validate_run_authorization( + run_id, 'storage', storage_type, self.ap, caller_plugin_identity + ) + if error: + return error result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_bstorage.BinaryStorage.key) @@ -478,7 +1426,11 @@ class RuntimeConnectionHandler(handler.Handler): @self.action(PluginToRuntimeAction.GET_CONFIG_FILE) async def get_config_file(data: dict[str, Any]) -> handler.ActionResponse: - """Get a config file by file key""" + """Get a config file by file key + + Regular plugin config files are still host storage files. AgentRunner + file access goes through sandbox tools, not this action. + """ file_key = data['file_key'] try: @@ -516,11 +1468,20 @@ class RuntimeConnectionHandler(handler.Handler): @self.action(PluginToRuntimeAction.INVOKE_RERANK) async def invoke_rerank(data: dict[str, Any]) -> handler.ActionResponse: + """Invoke rerank model, with run-scoped authorization for agent runner calls.""" + run_id = data.get('run_id') rerank_model_uuid = data['rerank_model_uuid'] query = data['query'] documents = data['documents'] top_k = data.get('top_k') - extra_args = data.get('extra_args', {}) + caller_plugin_identity = data.get('caller_plugin_identity') + + if run_id: + _, error = await _validate_run_authorization( + run_id, 'model', rerank_model_uuid, self.ap, caller_plugin_identity, operation='rerank' + ) + if error: + return error try: rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(rerank_model_uuid) @@ -530,11 +1491,12 @@ class RuntimeConnectionHandler(handler.Handler): ) try: + documents_capped = documents[:64] scores = await rerank_model.provider.invoke_rerank( model=rerank_model, query=query, - documents=documents[:64], - extra_args=extra_args, + documents=documents_capped, + extra_args=_merge_model_extra_args(rerank_model, data.get('extra_args', {})), ) scored = sorted(scores, key=lambda x: x.get('relevance_score', 0), reverse=True) if top_k is not None: @@ -676,11 +1638,27 @@ class RuntimeConnectionHandler(handler.Handler): @self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE) async def retrieve_knowledge(data: dict[str, Any]) -> handler.ActionResponse: - """Retrieve documents from any knowledge base (unrestricted).""" + """Retrieve documents from any knowledge base. + + For AgentRunner calls: requires run_id and validates kb_id against session.resources.knowledge_bases. + For regular plugin calls: no run_id, unrestricted access (backward compatibility). + + Note: SDK AgentRunAPIProxy.retrieve_knowledge calls this action with run_id. + """ kb_id = data['kb_id'] query_text = data['query_text'] top_k = data.get('top_k', 5) - filters = data.get('filters', {}) + filters = data.get('filters') or {} + run_id = data.get('run_id') # Optional: present for AgentRunner calls + caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + + # Permission validation for AgentRunner calls + if run_id: + session, error = await _validate_run_authorization( + run_id, 'knowledge_base', kb_id, self.ap, caller_plugin_identity, operation='retrieve' + ) + if error: + return error kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_id) if not kb: @@ -713,15 +1691,7 @@ class RuntimeConnectionHandler(handler.Handler): query = self.ap.query_pool.cached_queries[query_id] - kb_uuids = [] - if query.pipeline_config: - local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {}) - kb_uuids = local_agent_config.get('knowledge-bases', []) - # Backward compatibility - if not kb_uuids: - old_kb_uuid = local_agent_config.get('knowledge-base', '') - if old_kb_uuid and old_kb_uuid != '__none__': - kb_uuids = [old_kb_uuid] + kb_uuids = await _get_pipeline_knowledge_base_uuids(self.ap, query) knowledge_bases = [] for kb_uuid in kb_uuids: @@ -739,34 +1709,49 @@ class RuntimeConnectionHandler(handler.Handler): @self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE) async def retrieve_knowledge_base(data: dict[str, Any]) -> handler.ActionResponse: - """Retrieve documents from a knowledge base within the pipeline's scope.""" - query_id = data['query_id'] + """Retrieve documents from a knowledge base within the current run or query scope. + + For AgentRunner calls: requires run_id and validates kb_id against session.resources.knowledge_bases. + For regular plugin calls: no run_id, validates against pipeline's configured knowledge bases. + + Note: This action has dual validation paths: + - AgentRunner: uses session_registry for permission check + - Regular plugin: uses ConfigMigration.resolve_runner_config for pipeline-level check + """ kb_id = data['kb_id'] query_text = data['query_text'] top_k = data.get('top_k', 5) - filters = data.get('filters', {}) + filters = data.get('filters') or {} + run_id = data.get('run_id') # Optional: present for AgentRunner calls + caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + session = None + query = None - if query_id not in self.ap.query_pool.cached_queries: - return handler.ActionResponse.error( - message=f'Query with query_id {query_id} not found', + # Permission validation for AgentRunner calls + if run_id: + session, error = await _validate_run_authorization( + run_id, 'knowledge_base', kb_id, self.ap, caller_plugin_identity, operation='retrieve' ) + if error: + return error + query = _resolve_action_query(data, session, self.ap) + else: + query_id = data['query_id'] + if query_id not in self.ap.query_pool.cached_queries: + return handler.ActionResponse.error( + message=f'Query with query_id {query_id} not found', + ) - query = self.ap.query_pool.cached_queries[query_id] + query = self.ap.query_pool.cached_queries[query_id] - # Validate kb_id is in pipeline's allowed list - allowed_kb_uuids = [] - if query.pipeline_config: - local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {}) - allowed_kb_uuids = local_agent_config.get('knowledge-bases', []) - if not allowed_kb_uuids: - old_kb_uuid = local_agent_config.get('knowledge-base', '') - if old_kb_uuid and old_kb_uuid != '__none__': - allowed_kb_uuids = [old_kb_uuid] + # Regular plugin call: validate against the runner binding's + # schema-defined KB selectors or the preprocessed query scope. + allowed_kb_uuids = await _get_pipeline_knowledge_base_uuids(self.ap, query) - if kb_id not in allowed_kb_uuids: - return handler.ActionResponse.error( - message=f'Knowledge base {kb_id} is not configured for this pipeline', - ) + if kb_id not in allowed_kb_uuids: + return handler.ActionResponse.error( + message=f'Knowledge base {kb_id} is not configured for this pipeline', + ) kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_id) if not kb: @@ -775,22 +1760,1802 @@ class RuntimeConnectionHandler(handler.Handler): ) try: - session_name = f'{query.session.launcher_type.value}_{query.session.launcher_id}' + settings: dict[str, Any] = { + 'top_k': top_k, + 'filters': filters, + } + if query is not None: + session_name = f'{query.session.launcher_type.value}_{query.session.launcher_id}' + settings.update( + { + 'session_name': session_name, + 'bot_uuid': query.bot_uuid or '', + 'sender_id': str(query.sender_id), + } + ) entries = await kb.retrieve( query_text, - settings={ - 'top_k': top_k, - 'filters': filters, - 'session_name': session_name, - 'bot_uuid': query.bot_uuid or '', - 'sender_id': str(query.sender_id), - }, + settings=settings, ) results = [entry.model_dump(mode='json') for entry in entries] return handler.ActionResponse.success(data={'results': results}) except Exception as e: return _make_rag_error_response(e, 'RetrievalError', kb_id=kb_id) + # ================= Agent History/Event APIs ================= + + @self.action(PluginToRuntimeAction.GET_PROMPT) + async def get_prompt(data: dict[str, Any]) -> handler.ActionResponse: + """Return the current run's effective prompt after PromptPreProcessing.""" + run_id = data.get('run_id') + 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, + 'Get prompt', + api_capability='prompt_get', + ) + if error: + return error + + query = _resolve_action_query(data, session, self.ap) + if query is None: + return handler.ActionResponse.error( + message=f'Query for run_id {run_id} not found or expired', + ) + + prompt = getattr(query, 'prompt', None) + messages = getattr(prompt, 'messages', []) or [] + return handler.ActionResponse.success( + data={ + 'prompt': [ + message.model_dump(mode='json') if hasattr(message, 'model_dump') else message + for message in messages + ], + } + ) + + @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}') + @self.action(CommonAction.PING) async def ping(data: dict[str, Any]) -> handler.ActionResponse: """Ping""" @@ -935,6 +3700,66 @@ class RuntimeConnectionHandler(handler.Handler): return result['tools'] + async def list_agent_runners(self, include_plugins: list[str] | None = None) -> list[dict[str, Any]]: + """List agent runners from plugin runtime. + + Returns list of dicts with: + - plugin_author + - plugin_name + - runner_name + - manifest + """ + result = await self.call_action( + LangBotToRuntimeAction.LIST_AGENT_RUNNERS, + { + 'include_plugins': include_plugins, + }, + timeout=20, + ) + + return result['runners'] + + async def run_agent( + self, + plugin_author: str, + plugin_name: str, + runner_name: str, + context: dict[str, Any], + ) -> typing.AsyncGenerator[dict[str, Any], None]: + """Run an AgentRunner component. + + Yields AgentRunResult dicts. + """ + timeout = self._get_runner_action_timeout(context) + gen = self.call_action_generator( + LangBotToRuntimeAction.RUN_AGENT, + { + 'plugin_author': plugin_author, + 'plugin_name': plugin_name, + 'runner_name': runner_name, + 'context': context, + }, + timeout=timeout, + ) + + async for ret in gen: + yield ret + + def _get_runner_action_timeout(self, context: dict[str, Any]) -> float: + """Use the run deadline as the transport idle timeout when available.""" + try: + import time + + deadline_at = (context.get('runtime') or {}).get('deadline_at') + if deadline_at is None: + return 300 + remaining = float(deadline_at) - time.time() + if remaining <= 0: + return 0.001 + return max(remaining + 1.0, 0.001) + except (TypeError, ValueError): + return 300 + async def get_plugin_icon(self, plugin_author: str, plugin_name: str) -> dict[str, Any]: """Get plugin icon""" result = await self.call_action( diff --git a/src/langbot/pkg/provider/runner.py b/src/langbot/pkg/provider/runner.py deleted file mode 100644 index 987b3a0e9..000000000 --- a/src/langbot/pkg/provider/runner.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -import abc -import typing -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from ..core import app - import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query - import langbot_plugin.api.entities.builtin.provider.message as provider_message - - -preregistered_runners: list[typing.Type[RequestRunner]] = [] - - -def runner_class(name: str): - """注册一个请求运行器""" - - def decorator(cls: typing.Type[RequestRunner]) -> typing.Type[RequestRunner]: - cls.name = name - preregistered_runners.append(cls) - return cls - - return decorator - - -class RequestRunner(abc.ABC): - """请求运行器""" - - name: str = None - - ap: app.Application - - pipeline_config: dict - - def __init__(self, ap: app.Application, pipeline_config: dict): - self.ap = ap - self.pipeline_config = pipeline_config - - @abc.abstractmethod - async def run( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]: - """运行请求""" - pass diff --git a/src/langbot/pkg/provider/runners/__init__.py b/src/langbot/pkg/provider/runners/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/langbot/pkg/provider/runners/cozeapi.py b/src/langbot/pkg/provider/runners/cozeapi.py deleted file mode 100644 index 26980f81e..000000000 --- a/src/langbot/pkg/provider/runners/cozeapi.py +++ /dev/null @@ -1,288 +0,0 @@ -from __future__ import annotations - -import typing -import json -import base64 - -from langbot.pkg.provider import runner -from langbot.pkg.core import app -import langbot_plugin.api.entities.builtin.provider.message as provider_message -from langbot.pkg.utils import image -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -from langbot.libs.coze_server_api.client import AsyncCozeAPIClient - - -@runner.runner_class('coze-api') -class CozeAPIRunner(runner.RequestRunner): - """Coze API 对话请求器""" - - def __init__(self, ap: app.Application, pipeline_config: dict): - self.pipeline_config = pipeline_config - self.ap = ap - self.agent_token = pipeline_config['ai']['coze-api']['api-key'] - self.bot_id = pipeline_config['ai']['coze-api'].get('bot-id') - self.chat_timeout = pipeline_config['ai']['coze-api'].get('timeout') - self.auto_save_history = pipeline_config['ai']['coze-api'].get('auto_save_history') - self.api_base = pipeline_config['ai']['coze-api'].get('api-base') - - self.coze = AsyncCozeAPIClient(self.agent_token, self.api_base) - - def _process_thinking_content( - self, - content: str, - ) -> tuple[str, str]: - """处理思维链内容 - - Args: - content: 原始内容 - Returns: - (处理后的内容, 提取的思维链内容) - """ - remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False) - thinking_content = '' - # 从 content 中提取 标签内容 - if content and '' in content and '' in content: - import re - - think_pattern = r'(.*?)' - think_matches = re.findall(think_pattern, content, re.DOTALL) - if think_matches: - thinking_content = '\n'.join(think_matches) - # 移除 content 中的 标签 - content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip() - - # 根据 remove_think 参数决定是否保留思维链 - if remove_think: - return content, '' - else: - # 如果有思维链内容,将其以 格式添加到 content 开头 - if thinking_content: - content = f'\n{thinking_content}\n\n{content}'.strip() - return content, thinking_content - - async def _preprocess_user_message(self, query: pipeline_query.Query) -> list[dict]: - """预处理用户消息,转换为Coze消息格式 - - Returns: - list[dict]: Coze消息列表 - """ - messages = [] - - if isinstance(query.user_message.content, list): - # 多模态消息处理 - content_parts = [] - - for ce in query.user_message.content: - if ce.type == 'text': - content_parts.append({'type': 'text', 'text': ce.text}) - elif ce.type == 'image_base64': - image_b64, image_format = await image.extract_b64_and_format(ce.image_base64) - file_bytes = base64.b64decode(image_b64) - file_id = await self._get_file_id(file_bytes) - content_parts.append({'type': 'image', 'file_id': file_id}) - elif ce.type == 'file': - # 处理文件,上传到Coze - file_id = await self._get_file_id(ce.file) - content_parts.append({'type': 'file', 'file_id': file_id}) - - # 创建多模态消息 - if content_parts: - messages.append( - { - 'role': 'user', - 'content': json.dumps(content_parts), - 'content_type': 'object_string', - 'meta_data': None, - } - ) - - elif isinstance(query.user_message.content, str): - # 纯文本消息 - messages.append( - {'role': 'user', 'content': query.user_message.content, 'content_type': 'text', 'meta_data': None} - ) - - return messages - - async def _get_file_id(self, file) -> str: - """上传文件到Coze服务 - Args: - file: 文件 - Returns: - str: 文件ID - """ - file_id = await self.coze.upload(file=file) - return file_id - - async def _chat_messages( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """调用聊天助手(非流式) - - 注意:由于cozepy没有提供非流式API,这里使用流式API并在结束后一次性返回完整内容 - """ - user_id = f'{query.launcher_type.value}_{query.launcher_id}' - - # 预处理用户消息 - additional_messages = await self._preprocess_user_message(query) - - # 获取会话ID - conversation_id = None - - # 收集完整内容 - full_content = '' - full_reasoning = '' - - try: - # 调用Coze API流式接口 - async for chunk in self.coze.chat_messages( - bot_id=self.bot_id, - user_id=user_id, - additional_messages=additional_messages, - conversation_id=conversation_id, - timeout=self.chat_timeout, - auto_save_history=self.auto_save_history, - stream=True, - ): - self.ap.logger.debug(f'coze-chat-stream: {chunk}') - - event_type = chunk.get('event') - data = chunk.get('data', {}) - # Removed debug print statement to avoid cluttering logs in production - - if event_type == 'conversation.message.delta': - # 收集内容 - if 'content' in data: - full_content += data.get('content', '') - - # 收集推理内容(如果有) - if 'reasoning_content' in data: - full_reasoning += data.get('reasoning_content', '') - - elif event_type.split('.')[-1] == 'done': # 本地部署coze时,结束event不为done - # 保存会话ID - if 'conversation_id' in data: - conversation_id = data.get('conversation_id') - - elif event_type == 'error': - # 处理错误 - error_msg = f'Coze API错误: {data.get("message", "未知错误")}' - yield provider_message.Message( - role='assistant', - content=error_msg, - ) - return - - # 处理思维链内容 - content, thinking_content = self._process_thinking_content(full_content) - if full_reasoning: - remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False) - if not remove_think: - content = f'\n{full_reasoning}\n\n{content}'.strip() - - # 一次性返回完整内容 - yield provider_message.Message( - role='assistant', - content=content, - ) - - # 保存会话ID - if conversation_id and query.session.using_conversation: - query.session.using_conversation.uuid = conversation_id - - except Exception as e: - self.ap.logger.error(f'Coze API错误: {str(e)}') - yield provider_message.Message( - role='assistant', - content=f'Coze API调用失败: {str(e)}', - ) - - async def _chat_messages_chunk( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: - """调用聊天助手(流式)""" - user_id = f'{query.launcher_type.value}_{query.launcher_id}' - - # 预处理用户消息 - additional_messages = await self._preprocess_user_message(query) - - # 获取会话ID - conversation_id = None - - start_reasoning = False - stop_reasoning = False - message_idx = 1 - is_final = False - full_content = '' - remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False) - - try: - # 调用Coze API流式接口 - async for chunk in self.coze.chat_messages( - bot_id=self.bot_id, - user_id=user_id, - additional_messages=additional_messages, - conversation_id=conversation_id, - timeout=self.chat_timeout, - auto_save_history=self.auto_save_history, - stream=True, - ): - self.ap.logger.debug(f'coze-chat-stream-chunk: {chunk}') - - event_type = chunk.get('event') - data = chunk.get('data', {}) - content = '' - - if event_type == 'conversation.message.delta': - message_idx += 1 - # 处理内容增量 - if 'reasoning_content' in data and not remove_think: - reasoning_content = data.get('reasoning_content', '') - if reasoning_content and not start_reasoning: - content = '\n' - start_reasoning = True - content += reasoning_content - - if 'content' in data: - if data.get('content', ''): - content += data.get('content', '') - if not stop_reasoning and start_reasoning: - content = f'\n{content}' - stop_reasoning = True - - elif event_type.split('.')[-1] == 'done': # 本地部署coze时,结束event不为done - # 保存会话ID - if 'conversation_id' in data: - conversation_id = data.get('conversation_id') - if query.session.using_conversation: - query.session.using_conversation.uuid = conversation_id - is_final = True - - elif event_type == 'error': - # 处理错误 - error_msg = f'Coze API错误: {data.get("message", "未知错误")}' - yield provider_message.MessageChunk(role='assistant', content=error_msg, finish_reason='error') - return - full_content += content - if message_idx % 8 == 0 or is_final: - if full_content: - yield provider_message.MessageChunk(role='assistant', content=full_content, is_final=is_final) - - except Exception as e: - self.ap.logger.error(f'Coze API流式调用错误: {str(e)}') - yield provider_message.MessageChunk( - role='assistant', content=f'Coze API流式调用失败: {str(e)}', finish_reason='error' - ) - - async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: - """运行""" - msg_seq = 0 - if await query.adapter.is_stream_output_supported(): - async for msg in self._chat_messages_chunk(query): - if isinstance(msg, provider_message.MessageChunk): - msg_seq += 1 - msg.msg_sequence = msg_seq - yield msg - else: - async for msg in self._chat_messages(query): - yield msg diff --git a/src/langbot/pkg/provider/runners/dashscopeapi.py b/src/langbot/pkg/provider/runners/dashscopeapi.py deleted file mode 100644 index a2c593ccc..000000000 --- a/src/langbot/pkg/provider/runners/dashscopeapi.py +++ /dev/null @@ -1,355 +0,0 @@ -from __future__ import annotations - -import typing -import re - -import dashscope - -from .. import runner -from ...core import app -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.provider.message as provider_message - - -class DashscopeAPIError(Exception): - """Dashscope API 请求失败""" - - def __init__(self, message: str): - self.message = message - super().__init__(self.message) - - -@runner.runner_class('dashscope-app-api') -class DashScopeAPIRunner(runner.RequestRunner): - "阿里云百炼DashsscopeAPI对话请求器" - - # 运行器内部使用的配置 - app_type: str # 应用类型 - app_id: str # 应用ID - api_key: str # API Key - references_quote: ( - str # 引用资料提示(当展示回答来源功能开启时,这个变量会作为引用资料名前的提示,可在provider.json中配置) - ) - - def __init__(self, ap: app.Application, pipeline_config: dict): - """初始化""" - self.ap = ap - self.pipeline_config = pipeline_config - - valid_app_types = ['agent', 'workflow'] - self.app_type = self.pipeline_config['ai']['dashscope-app-api']['app-type'] - # 检查配置文件中使用的应用类型是否支持 - if self.app_type not in valid_app_types: - raise DashscopeAPIError(f'不支持的 Dashscope 应用类型: {self.app_type}') - - # 初始化Dashscope 参数配置 - self.app_id = self.pipeline_config['ai']['dashscope-app-api']['app-id'] - self.api_key = self.pipeline_config['ai']['dashscope-app-api']['api-key'] - self.references_quote = self.pipeline_config['ai']['dashscope-app-api']['references_quote'] - - def _replace_references(self, text, references_dict): - """阿里云百炼平台的自定义应用支持资料引用,此函数可以将引用标签替换为参考资料""" - - # 匹配 [index_id] 形式的字符串 - pattern = re.compile(r'\[(.*?)\]') - - def replacement(match): - # 获取引用编号 - ref_key = match.group(1) - if ref_key in references_dict: - # 如果有对应的参考资料按照provider.json中的reference_quote返回提示,来自哪个参考资料文件 - return f'({self.references_quote} {references_dict[ref_key]})' - else: - # 如果没有对应的参考资料,保留原样 - return match.group(0) - - # 使用 re.sub() 进行替换 - return pattern.sub(replacement, text) - - async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[str]]: - """预处理用户消息,提取纯文本,阿里云提供的上传文件方法过于复杂,暂不支持上传文件(包括图片)""" - plain_text = '' - image_ids = [] - if isinstance(query.user_message.content, list): - for ce in query.user_message.content: - if ce.type == 'text': - plain_text += ce.text - # 暂时不支持上传图片,保留代码以便后续扩展 - # elif ce.type == "image_base64": - # image_b64, image_format = await image.extract_b64_and_format(ce.image_base64) - # file_bytes = base64.b64decode(image_b64) - # file = ("img.png", file_bytes, f"image/{image_format}") - # file_upload_resp = await self.dify_client.upload_file( - # file, - # f"{query.session.launcher_type.value}_{query.session.launcher_id}", - # ) - # image_id = file_upload_resp["id"] - # image_ids.append(image_id) - elif isinstance(query.user_message.content, str): - plain_text = query.user_message.content - - return plain_text, image_ids - - async def _agent_messages( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """Dashscope 智能体对话请求""" - - # 局部变量 - chunk = None # 流式传输的块 - pending_content = '' # 待处理的Agent输出内容 - references_dict = {} # 用于存储引用编号和对应的参考资料 - plain_text = '' # 用户输入的纯文本信息 - image_ids = [] # 用户输入的图片ID列表 (暂不支持) - - think_start = False - think_end = False - - plain_text, image_ids = await self._preprocess_user_message(query) - has_thoughts = True # 获取思考过程 - remove_think = self.pipeline_config['output'].get('misc', {}).get('remove-think') - if remove_think: - has_thoughts = False - # 发送对话请求 - response = dashscope.Application.call( - api_key=self.api_key, # 智能体应用的API Key - app_id=self.app_id, # 智能体应用的ID - prompt=plain_text, # 用户输入的文本信息 - stream=True, # 流式输出 - incremental_output=True, # 增量输出,使用流式输出需要开启增量输出 - session_id=query.session.using_conversation.uuid, # 会话ID用于,多轮对话 - enable_thinking=has_thoughts, - has_thoughts=has_thoughts, - # rag_options={ # 主要用于文件交互,暂不支持 - # "session_file_ids": ["FILE_ID1"], # FILE_ID1 替换为实际的临时文件ID,逗号隔开多个 - # } - ) - idx_chunk = 0 - try: - is_stream = await query.adapter.is_stream_output_supported() - - except AttributeError: - is_stream = False - if is_stream: - for chunk in response: - if chunk.get('status_code') != 200: - raise DashscopeAPIError( - f'Dashscope API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} ' - ) - if not chunk: - continue - idx_chunk += 1 - # 获取流式传输的output - stream_output = chunk.get('output', {}) - stream_think = stream_output.get('thoughts') or [] - if stream_think and stream_think[0].get('thought'): - if not think_start: - think_start = True - pending_content += f'\n{stream_think[0].get("thought")}' - else: - # 继续输出 reasoning_content - pending_content += stream_think[0].get('thought') - elif think_start and (not stream_think or stream_think[0].get('thought') == '') and not think_end: - think_end = True - pending_content += '\n\n' - if stream_output.get('text') is not None: - pending_content += stream_output.get('text') - # 是否是流式最后一个chunk - is_final = False if stream_output.get('finish_reason', False) == 'null' else True - - # 获取模型传出的参考资料列表 - references_dict_list = stream_output.get('doc_references', []) - - # 从模型传出的参考资料信息中提取用于替换的字典 - if references_dict_list is not None: - for doc in references_dict_list: - if doc.get('index_id') is not None: - references_dict[doc.get('index_id')] = doc.get('doc_name') - - # 将参考资料替换到文本中 - pending_content = self._replace_references(pending_content, references_dict) - - if idx_chunk % 8 == 0 or is_final: - yield provider_message.MessageChunk( - role='assistant', - content=pending_content, - is_final=is_final, - ) - # 保存当前会话的session_id用于下次对话的语境 - query.session.using_conversation.uuid = stream_output.get('session_id') - else: - for chunk in response: - if chunk.get('status_code') != 200: - raise DashscopeAPIError( - f'Dashscope API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} ' - ) - if not chunk: - continue - idx_chunk += 1 - # 获取流式传输的output - stream_output = chunk.get('output', {}) - stream_think = stream_output.get('thoughts') or [] - if stream_think and stream_think[0].get('thought'): - if not think_start: - think_start = True - pending_content += f'\n{stream_think[0].get("thought")}' - else: - # 继续输出 reasoning_content - pending_content += stream_think[0].get('thought') - elif think_start and (not stream_think or stream_think[0].get('thought') == '') and not think_end: - think_end = True - pending_content += '\n\n' - if stream_output.get('text') is not None: - pending_content += stream_output.get('text') - - # 保存当前会话的session_id用于下次对话的语境 - query.session.using_conversation.uuid = stream_output.get('session_id') - - # 获取模型传出的参考资料列表 - references_dict_list = stream_output.get('doc_references', []) - - # 从模型传出的参考资料信息中提取用于替换的字典 - if references_dict_list is not None: - for doc in references_dict_list: - if doc.get('index_id') is not None: - references_dict[doc.get('index_id')] = doc.get('doc_name') - - # 将参考资料替换到文本中 - pending_content = self._replace_references(pending_content, references_dict) - - yield provider_message.Message( - role='assistant', - content=pending_content, - ) - - async def _workflow_messages( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """Dashscope 工作流对话请求""" - - # 局部变量 - chunk = None # 流式传输的块 - pending_content = '' # 待处理的Agent输出内容 - references_dict = {} # 用于存储引用编号和对应的参考资料 - plain_text = '' # 用户输入的纯文本信息 - image_ids = [] # 用户输入的图片ID列表 (暂不支持) - - plain_text, image_ids = await self._preprocess_user_message(query) - - biz_params = {} - biz_params.update(query.variables) - - # 发送对话请求 - response = dashscope.Application.call( - api_key=self.api_key, # 智能体应用的API Key - app_id=self.app_id, # 智能体应用的ID - prompt=plain_text, # 用户输入的文本信息 - stream=True, # 流式输出 - incremental_output=True, # 增量输出,使用流式输出需要开启增量输出 - session_id=query.session.using_conversation.uuid, # 会话ID用于,多轮对话 - biz_params=biz_params, # 工作流应用的自定义输入参数传递 - flow_stream_mode='message_format', # 消息模式,输出/结束节点的流式结果 - # rag_options={ # 主要用于文件交互,暂不支持 - # "session_file_ids": ["FILE_ID1"], # FILE_ID1 替换为实际的临时文件ID,逗号隔开多个 - # } - ) - - # 处理API返回的流式输出 - try: - is_stream = await query.adapter.is_stream_output_supported() - - except AttributeError: - is_stream = False - idx_chunk = 0 - if is_stream: - for chunk in response: - if chunk.get('status_code') != 200: - raise DashscopeAPIError( - f'Dashscope API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} ' - ) - if not chunk: - continue - idx_chunk += 1 - # 获取流式传输的output - stream_output = chunk.get('output', {}) - if stream_output.get('workflow_message') is not None: - pending_content += stream_output.get('workflow_message').get('message').get('content') - # if stream_output.get('text') is not None: - # pending_content += stream_output.get('text') - - is_final = False if stream_output.get('finish_reason', False) == 'null' else True - - # 获取模型传出的参考资料列表 - references_dict_list = stream_output.get('doc_references', []) - - # 从模型传出的参考资料信息中提取用于替换的字典 - if references_dict_list is not None: - for doc in references_dict_list: - if doc.get('index_id') is not None: - references_dict[doc.get('index_id')] = doc.get('doc_name') - - # 将参考资料替换到文本中 - pending_content = self._replace_references(pending_content, references_dict) - if idx_chunk % 8 == 0 or is_final: - yield provider_message.MessageChunk( - role='assistant', - content=pending_content, - is_final=is_final, - ) - - # 保存当前会话的session_id用于下次对话的语境 - query.session.using_conversation.uuid = stream_output.get('session_id') - - else: - for chunk in response: - if chunk.get('status_code') != 200: - raise DashscopeAPIError( - f'Dashscope API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} ' - ) - if not chunk: - continue - - # 获取流式传输的output - stream_output = chunk.get('output', {}) - if stream_output.get('text') is not None: - pending_content += stream_output.get('text') - - is_final = False if stream_output.get('finish_reason', False) == 'null' else True - - # 保存当前会话的session_id用于下次对话的语境 - query.session.using_conversation.uuid = stream_output.get('session_id') - - # 获取模型传出的参考资料列表 - references_dict_list = stream_output.get('doc_references', []) - - # 从模型传出的参考资料信息中提取用于替换的字典 - if references_dict_list is not None: - for doc in references_dict_list: - if doc.get('index_id') is not None: - references_dict[doc.get('index_id')] = doc.get('doc_name') - - # 将参考资料替换到文本中 - pending_content = self._replace_references(pending_content, references_dict) - - yield provider_message.Message( - role='assistant', - content=pending_content, - ) - - async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: - """运行""" - msg_seq = 0 - if self.app_type == 'agent': - async for msg in self._agent_messages(query): - if isinstance(msg, provider_message.MessageChunk): - msg_seq += 1 - msg.msg_sequence = msg_seq - yield msg - elif self.app_type == 'workflow': - async for msg in self._workflow_messages(query): - if isinstance(msg, provider_message.MessageChunk): - msg_seq += 1 - msg.msg_sequence = msg_seq - yield msg - else: - raise DashscopeAPIError(f'不支持的 Dashscope 应用类型: {self.app_type}') diff --git a/src/langbot/pkg/provider/runners/deerflowapi.py b/src/langbot/pkg/provider/runners/deerflowapi.py deleted file mode 100644 index 79c77126e..000000000 --- a/src/langbot/pkg/provider/runners/deerflowapi.py +++ /dev/null @@ -1,511 +0,0 @@ -"""DeerFlow LangGraph API Runner - -参考 astrbot 的 deerflow_agent_runner 实现,适配 LangBot 的 Runner 接口。 - -特点: -- 使用 LangGraph HTTP API 接入 deer-flow 后端 -- 自动管理 thread_id(按 session 隔离) -- 支持 SSE 流式响应解析 -- 支持 streaming/非流式两种输出 -- 处理 values / messages-tuple / custom 三种事件 -""" - -from __future__ import annotations - -import asyncio -import hashlib -import json -import typing -from collections import deque -from dataclasses import dataclass, field - - -from langbot.pkg.provider import runner -from langbot.pkg.core import app -import langbot_plugin.api.entities.builtin.provider.message as provider_message -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -from langbot.libs.deerflow_api import client, errors, stream_utils - - -_MAX_VALUES_HISTORY = 200 - - -@dataclass -class _StreamState: - """流式状态跟踪""" - - latest_text: str = '' - prev_text_for_streaming: str = '' - clarification_text: str = '' - task_failures: list[str] = field(default_factory=list) - seen_message_ids: set[str] = field(default_factory=set) - seen_message_order: deque[str] = field(default_factory=deque) - no_id_message_fingerprints: dict[int, str] = field(default_factory=dict) - baseline_initialized: bool = False - has_values_text: bool = False - run_values_messages: list[dict[str, typing.Any]] = field(default_factory=list) - timed_out: bool = False - - -@runner.runner_class('deerflow-api') -class DeerFlowAPIRunner(runner.RequestRunner): - """DeerFlow LangGraph API 对话请求器""" - - deerflow_client: client.AsyncDeerFlowClient - - def __init__(self, ap: app.Application, pipeline_config: dict): - super().__init__(ap, pipeline_config) - - cfg = self.pipeline_config['ai']['deerflow-api'] - - api_base = cfg.get('api-base', '').strip() - if not api_base or not api_base.startswith(('http://', 'https://')): - raise errors.DeerFlowAPIError( - message='DeerFlow API Base URL 格式错误,必须以 http:// 或 https:// 开头', - ) - - self.api_base = api_base - self.api_key = cfg.get('api-key', '') - self.auth_header = cfg.get('auth-header', '') - self.assistant_id = cfg.get('assistant-id', 'lead_agent') - self.model_name = cfg.get('model-name', '') - self.thinking_enabled = bool(cfg.get('thinking-enabled', False)) - self.plan_mode = bool(cfg.get('plan-mode', False)) - self.subagent_enabled = bool(cfg.get('subagent-enabled', False)) - self.max_concurrent_subagents = int(cfg.get('max-concurrent-subagents', 3)) - self.timeout = int(cfg.get('timeout', 300)) - self.recursion_limit = int(cfg.get('recursion-limit', 1000)) - - self.deerflow_client = client.AsyncDeerFlowClient( - api_base=self.api_base, - api_key=self.api_key, - auth_header=self.auth_header, - ) - - # ------------------------------------------------------------------ - # 辅助方法 - # ------------------------------------------------------------------ - - def _fingerprint_message(self, message: dict[str, typing.Any]) -> str: - try: - raw = json.dumps(message, sort_keys=True, ensure_ascii=False, default=str) - except (TypeError, ValueError): - raw = repr(message) - return hashlib.sha1(raw.encode('utf-8', errors='ignore')).hexdigest() - - def _remember_seen_message_id(self, state: _StreamState, msg_id: str) -> None: - if not msg_id or msg_id in state.seen_message_ids: - return - state.seen_message_ids.add(msg_id) - state.seen_message_order.append(msg_id) - while len(state.seen_message_order) > _MAX_VALUES_HISTORY: - dropped = state.seen_message_order.popleft() - state.seen_message_ids.discard(dropped) - - def _extract_new_messages_from_values( - self, - values_messages: list[typing.Any], - state: _StreamState, - ) -> list[dict[str, typing.Any]]: - new_messages: list[dict[str, typing.Any]] = [] - no_id_indexes_seen: set[int] = set() - for idx, msg in enumerate(values_messages): - if not isinstance(msg, dict): - continue - msg_id = stream_utils.get_message_id(msg) - if msg_id: - if msg_id in state.seen_message_ids: - continue - self._remember_seen_message_id(state, msg_id) - new_messages.append(msg) - continue - - no_id_indexes_seen.add(idx) - fp = self._fingerprint_message(msg) - if state.no_id_message_fingerprints.get(idx) == fp: - continue - state.no_id_message_fingerprints[idx] = fp - new_messages.append(msg) - - for idx in list(state.no_id_message_fingerprints.keys()): - if idx not in no_id_indexes_seen: - state.no_id_message_fingerprints.pop(idx, None) - return new_messages - - # ------------------------------------------------------------------ - # 用户输入处理 - # ------------------------------------------------------------------ - - def _build_user_content( - self, - prompt: str, - image_urls: list[str], - ) -> typing.Any: - """构建 LangGraph 兼容的 user content(支持多模态)""" - if not image_urls: - return prompt - - content: list[dict[str, typing.Any]] = [] - if prompt: - content.append({'type': 'text', 'text': prompt}) - for url in image_urls: - if not isinstance(url, str): - continue - url = url.strip() - if not url: - continue - if url.startswith(('http://', 'https://', 'data:')): - content.append({'type': 'image_url', 'image_url': {'url': url}}) - return content if content else prompt - - def _preprocess_user_message( - self, - query: pipeline_query.Query, - ) -> tuple[str, list[str]]: - """提取用户消息的纯文本与图片 URL 列表""" - plain_text = '' - image_urls: list[str] = [] - - if isinstance(query.user_message.content, str): - plain_text = query.user_message.content - elif isinstance(query.user_message.content, list): - for ce in query.user_message.content: - if ce.type == 'text': - plain_text += ce.text - elif ce.type == 'image_base64': - # 转换为 data URI 形式 - b64 = getattr(ce, 'image_base64', '') - if b64: - if not b64.startswith('data:'): - b64 = f'data:image/png;base64,{b64}' - image_urls.append(b64) - elif ce.type == 'image_url': - url = getattr(ce, 'image_url', '') - if url: - image_urls.append(url) - - return plain_text, image_urls - - # ------------------------------------------------------------------ - # 请求构造 - # ------------------------------------------------------------------ - - def _build_messages( - self, - prompt: str, - image_urls: list[str], - system_prompt: str = '', - ) -> list[dict[str, typing.Any]]: - messages: list[dict[str, typing.Any]] = [] - if system_prompt: - messages.append({'role': 'system', 'content': system_prompt}) - messages.append( - { - 'role': 'user', - 'content': self._build_user_content(prompt, image_urls), - } - ) - return messages - - def _build_runtime_configurable(self, thread_id: str) -> dict[str, typing.Any]: - cfg: dict[str, typing.Any] = { - 'thread_id': thread_id, - 'thinking_enabled': self.thinking_enabled, - 'is_plan_mode': self.plan_mode, - 'subagent_enabled': self.subagent_enabled, - } - if self.subagent_enabled: - cfg['max_concurrent_subagents'] = self.max_concurrent_subagents - if self.model_name: - cfg['model_name'] = self.model_name - return cfg - - def _build_payload( - self, - thread_id: str, - prompt: str, - image_urls: list[str], - system_prompt: str = '', - ) -> dict[str, typing.Any]: - runtime_configurable = self._build_runtime_configurable(thread_id) - return { - 'assistant_id': self.assistant_id, - 'input': { - 'messages': self._build_messages(prompt, image_urls, system_prompt), - }, - 'stream_mode': ['values', 'messages-tuple', 'custom'], - # DeerFlow 2.0 从 config.configurable 读取运行时覆盖 - # 同时保留 context 字段做向后兼容 - 'context': dict(runtime_configurable), - 'config': { - 'recursion_limit': self.recursion_limit, - 'configurable': runtime_configurable, - }, - } - - # ------------------------------------------------------------------ - # Session/Thread 管理 - # ------------------------------------------------------------------ - - async def _ensure_thread_id(self, query: pipeline_query.Query) -> str: - """从 query.session 取/创建 deerflow thread_id - - LangBot 使用 `query.session.using_conversation.uuid` 持久化 conversation id, - 我们复用这个字段存储 deerflow thread_id(与 Dify Runner 同样做法)。 - """ - thread_id = query.session.using_conversation.uuid or '' - if thread_id: - return thread_id - - thread = await self.deerflow_client.create_thread(timeout=min(30, self.timeout)) - thread_id = thread.get('thread_id', '') - if not thread_id: - raise errors.DeerFlowAPIError(message=f'DeerFlow create thread 返回数据缺少 thread_id: {thread}') - - query.session.using_conversation.uuid = thread_id - return thread_id - - # ------------------------------------------------------------------ - # 流式事件处理 - # ------------------------------------------------------------------ - - def _handle_values_event( - self, - data: typing.Any, - state: _StreamState, - ) -> str | None: - """处理 values 事件,返回新的完整文本(增量基础上的全量)""" - values_messages = stream_utils.extract_messages_from_values_data(data) - if not values_messages: - return None - - new_messages: list[dict[str, typing.Any]] = [] - if not state.baseline_initialized: - state.baseline_initialized = True - for idx, msg in enumerate(values_messages): - if not isinstance(msg, dict): - continue - new_messages.append(msg) - msg_id = stream_utils.get_message_id(msg) - if msg_id: - self._remember_seen_message_id(state, msg_id) - continue - state.no_id_message_fingerprints[idx] = self._fingerprint_message(msg) - else: - new_messages = self._extract_new_messages_from_values(values_messages, state) - - latest_text = '' - if new_messages: - state.run_values_messages.extend(new_messages) - if len(state.run_values_messages) > _MAX_VALUES_HISTORY: - state.run_values_messages = state.run_values_messages[-_MAX_VALUES_HISTORY:] - latest_text = stream_utils.extract_latest_ai_text(state.run_values_messages) - if latest_text: - state.has_values_text = True - latest_clarification = stream_utils.extract_latest_clarification_text( - state.run_values_messages, - ) - if latest_clarification: - state.clarification_text = latest_clarification - - return latest_text or None - - def _handle_message_event( - self, - data: typing.Any, - state: _StreamState, - ) -> str | None: - """处理 messages-tuple 事件,返回增量文本 - - 当 values 事件已经提供完整文本时,跳过 messages-tuple 的增量 - """ - delta = stream_utils.extract_ai_delta_from_event_data(data) - if delta and not state.has_values_text: - state.latest_text += delta - return delta - - maybe_clar = stream_utils.extract_clarification_from_event_data(data) - if maybe_clar: - state.clarification_text = maybe_clar - return None - - def _build_final_text(self, state: _StreamState) -> str: - """构建最终输出文本""" - if state.clarification_text: - return state.clarification_text - - # 优先使用最后一条 AI message 的文本 - latest_ai = stream_utils.extract_latest_ai_message(state.run_values_messages) - if latest_ai: - text = stream_utils.extract_text(latest_ai.get('content')) - if text: - if state.timed_out: - text += f'\n\nDeerFlow stream 在 {self.timeout}s 后超时,返回部分结果。' - return text - - if state.latest_text: - text = state.latest_text - if state.timed_out: - text += f'\n\nDeerFlow stream 在 {self.timeout}s 后超时,返回部分结果。' - return text - - # 提取任务失败信息作兜底 - failure_text = stream_utils.build_task_failure_summary(state.task_failures) - if failure_text: - return failure_text - - return 'DeerFlow 返回空响应' - - # ------------------------------------------------------------------ - # 主流程 - # ------------------------------------------------------------------ - - async def _stream_messages_chunk( - self, - query: pipeline_query.Query, - ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: - """流式输出生成器""" - plain_text, image_urls = self._preprocess_user_message(query) - - system_prompt = '' - # LangBot 的 pipeline 通常通过 prompt-preprocess 已注入 system prompt - # 这里保持空,让 prompt-preprocess 的内容作为 user message 一并送给 deerflow - - thread_id = await self._ensure_thread_id(query) - payload = self._build_payload( - thread_id=thread_id, - prompt=plain_text or 'continue', - image_urls=image_urls, - system_prompt=system_prompt, - ) - - state = _StreamState() - prev_text = '' - message_idx = 0 - - try: - async for event in self.deerflow_client.stream_run( - thread_id=thread_id, - payload=payload, - timeout=self.timeout, - ): - event_type = event.get('event') - data = event.get('data') - - if event_type == 'values': - new_full = self._handle_values_event(data, state) - if new_full and new_full != prev_text: - delta = new_full[len(prev_text) :] if new_full.startswith(prev_text) else new_full - prev_text = new_full - if delta: - message_idx += 1 - yield provider_message.MessageChunk( - role='assistant', - content=new_full, - is_final=False, - ) - continue - - if event_type in {'messages-tuple', 'messages', 'message'}: - delta = self._handle_message_event(data, state) - if delta: - prev_text = state.latest_text - message_idx += 1 - yield provider_message.MessageChunk( - role='assistant', - content=prev_text, - is_final=False, - ) - continue - - if event_type == 'custom': - state.task_failures.extend( - stream_utils.extract_task_failures_from_custom_event(data), - ) - continue - - if event_type == 'error': - raise errors.DeerFlowAPIError(message=f'DeerFlow stream error event: {data}') - - if event_type == 'end': - break - except (asyncio.TimeoutError, TimeoutError): - self.ap.logger.warning(f'DeerFlow stream timed out after {self.timeout}s for thread_id={thread_id}') - state.timed_out = True - - # 最终消息 - final_text = self._build_final_text(state) - yield provider_message.MessageChunk( - role='assistant', - content=final_text, - is_final=True, - ) - - async def _messages( - self, - query: pipeline_query.Query, - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """非流式聚合输出""" - plain_text, image_urls = self._preprocess_user_message(query) - - thread_id = await self._ensure_thread_id(query) - payload = self._build_payload( - thread_id=thread_id, - prompt=plain_text or 'continue', - image_urls=image_urls, - ) - - state = _StreamState() - - try: - async for event in self.deerflow_client.stream_run( - thread_id=thread_id, - payload=payload, - timeout=self.timeout, - ): - event_type = event.get('event') - data = event.get('data') - - if event_type == 'values': - self._handle_values_event(data, state) - continue - - if event_type in {'messages-tuple', 'messages', 'message'}: - self._handle_message_event(data, state) - continue - - if event_type == 'custom': - state.task_failures.extend( - stream_utils.extract_task_failures_from_custom_event(data), - ) - continue - - if event_type == 'error': - raise errors.DeerFlowAPIError(message=f'DeerFlow stream error event: {data}') - - if event_type == 'end': - break - except (asyncio.TimeoutError, TimeoutError): - self.ap.logger.warning(f'DeerFlow stream timed out after {self.timeout}s for thread_id={thread_id}') - state.timed_out = True - - final_text = self._build_final_text(state) - yield provider_message.Message( - role='assistant', - content=final_text, - ) - - async def run( - self, - query: pipeline_query.Query, - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """主入口:根据 adapter 是否支持流式输出,选择流式或非流式""" - if await query.adapter.is_stream_output_supported(): - msg_idx = 0 - async for msg in self._stream_messages_chunk(query): - msg_idx += 1 - msg.msg_sequence = msg_idx - yield msg - else: - async for msg in self._messages(query): - yield msg diff --git a/src/langbot/pkg/provider/runners/difysvapi.py b/src/langbot/pkg/provider/runners/difysvapi.py deleted file mode 100644 index 039bf33ad..000000000 --- a/src/langbot/pkg/provider/runners/difysvapi.py +++ /dev/null @@ -1,775 +0,0 @@ -from __future__ import annotations - -import typing -import json -import uuid -import base64 -import mimetypes - - -from langbot.pkg.provider import runner -from langbot.pkg.core import app -import langbot_plugin.api.entities.builtin.provider.message as provider_message -from langbot.pkg.utils import image -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -from langbot.libs.dify_service_api.v1 import client, errors -import httpx - - -@runner.runner_class('dify-service-api') -class DifyServiceAPIRunner(runner.RequestRunner): - """Dify Service API 对话请求器""" - - dify_client: client.AsyncDifyServiceClient - - def __init__(self, ap: app.Application, pipeline_config: dict): - self.ap = ap - self.pipeline_config = pipeline_config - - valid_app_types = ['chat', 'agent', 'workflow'] - if self.pipeline_config['ai']['dify-service-api']['app-type'] not in valid_app_types: - raise errors.DifyAPIError( - f'不支持的 Dify 应用类型: {self.pipeline_config["ai"]["dify-service-api"]["app-type"]}' - ) - - api_key = self.pipeline_config['ai']['dify-service-api']['api-key'] - - self.dify_client = client.AsyncDifyServiceClient( - api_key=api_key, - base_url=self.pipeline_config['ai']['dify-service-api']['base-url'], - ) - - def _process_thinking_content( - self, - content: str, - ) -> tuple[str, str]: - """处理思维链内容 - - Args: - content: 原始内容 - Returns: - (处理后的内容, 提取的思维链内容) - """ - remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') - thinking_content = '' - # 从 content 中提取 标签内容 - if content and '' in content and '' in content: - import re - - think_pattern = r'(.*?)' - think_matches = re.findall(think_pattern, content, re.DOTALL) - if think_matches: - thinking_content = '\n'.join(think_matches) - # 移除 content 中的 标签 - content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip() - - # 3. 根据 remove_think 参数决定是否保留思维链 - if remove_think: - return content, '' - else: - # 如果有思维链内容,将其以 格式添加到 content 开头 - if thinking_content: - content = f'\n{thinking_content}\n\n{content}'.strip() - return content, thinking_content - - def _extract_dify_text_output(self, value: typing.Any) -> str: - """Extract text content from Dify output payload.""" - if value is None: - return '' - if isinstance(value, dict): - content = value.get('content') - if isinstance(content, str): - return content - return json.dumps(value, ensure_ascii=False) - if isinstance(value, str): - text = value.strip() - if not text: - return '' - try: - parsed = json.loads(text) - except json.JSONDecodeError: - return value - if isinstance(parsed, dict) and isinstance(parsed.get('content'), str): - return parsed['content'] - return value - return str(value) - - async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[dict]]: - """预处理用户消息,提取纯文本,并将图片/文件上传到 Dify 服务 - - Returns: - tuple[str, list[dict]]: 纯文本和上传后的文件描述(包含 type 与 id) - """ - plain_text = '' - upload_files: list[dict] = [] - user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}' - - async def upload_file_bytes(file_name: str, file_bytes: bytes, content_type: str) -> str: - file_name = file_name or 'file' - content_type = content_type or 'application/octet-stream' - file = (file_name, file_bytes, content_type) - resp = await self.dify_client.upload_file(file, user_tag) - return resp['id'] - - async def download_file(file_url: str) -> tuple[bytes, str]: - """Download file from url (supports data url).""" - - async with httpx.AsyncClient() as client_session: - resp = await client_session.get(file_url) - resp.raise_for_status() - content_type = ( - resp.headers.get('content-type') or mimetypes.guess_type(file_url)[0] or 'application/octet-stream' - ) - return resp.content, content_type - - def _detect_file_type(content_type: str) -> str: - """Map MIME to dify file type.""" - if content_type and content_type.startswith('image/'): - return 'image' - if content_type and content_type.startswith('audio/'): - return 'audio' - if content_type and content_type.startswith('video/'): - return 'video' - return 'document' - - if isinstance(query.user_message.content, list): - for ce in query.user_message.content: - if ce.type == 'text': - plain_text += ce.text - elif ce.type == 'image_base64': - image_b64, image_format = await image.extract_b64_and_format(ce.image_base64) - file_bytes = base64.b64decode(image_b64) - image_id = await upload_file_bytes(f'img.{image_format}', file_bytes, f'image/{image_format}') - upload_files.append({'type': 'image', 'id': image_id}) - elif ce.type == 'file_url': - file_url = getattr(ce, 'file_url', None) - file_name = getattr(ce, 'file_name', None) or 'file' - try: - file_bytes, content_type = await download_file(file_url) - file_id = await upload_file_bytes(file_name, file_bytes, content_type) - file_type = _detect_file_type(content_type) - upload_files.append({'type': file_type, 'id': file_id}) - except Exception as e: - self.ap.logger.warning(f'dify file upload failed: {e}') - elif ce.type == 'file_base64': - file_name = getattr(ce, 'file_name', None) or 'file' - - header, b64_data = ce.file_base64.split(',', 1) - content_type = 'application/octet-stream' - if ';' in header: - content_type = header.split(';')[0][5:] or content_type - file_bytes = base64.b64decode(b64_data) - file_id = await upload_file_bytes(file_name, file_bytes, content_type) - file_type = _detect_file_type(content_type) - upload_files.append({'type': file_type, 'id': file_id}) - - elif isinstance(query.user_message.content, str): - plain_text = query.user_message.content - - plain_text = plain_text if plain_text else self.pipeline_config['ai']['dify-service-api']['base-prompt'] - - return plain_text, upload_files - - async def _chat_messages( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """调用聊天助手""" - cov_id = query.session.using_conversation.uuid or None - query.variables['conversation_id'] = cov_id - - plain_text, upload_files = await self._preprocess_user_message(query) - - files = [ - { - 'type': f['type'], - 'transfer_method': 'local_file', - 'upload_file_id': f['id'], - } - for f in upload_files - ] - - mode = 'basic' # 标记是基础编排还是工作流编排 - - basic_mode_pending_chunk = '' - - inputs = {} - - inputs.update(query.variables) - - chunk = None # 初始化chunk变量,防止在没有响应时引用错误 - - async for chunk in self.dify_client.chat_messages( - inputs=inputs, - query=plain_text, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - conversation_id=cov_id, - files=files, - timeout=120, - ): - self.ap.logger.debug('dify-chat-chunk: ' + str(chunk)) - - if chunk['event'] == 'workflow_started': - mode = 'workflow' - - if mode == 'workflow': - if chunk['event'] == 'node_finished': - if chunk['data']['node_type'] == 'answer': - answer = self._extract_dify_text_output(chunk['data']['outputs'].get('answer')) - content, _ = self._process_thinking_content(answer) - - yield provider_message.Message( - role='assistant', - content=content, - ) - elif mode == 'basic': - if chunk['event'] == 'message': - basic_mode_pending_chunk += chunk['answer'] - elif chunk['event'] == 'message_end': - content, _ = self._process_thinking_content(basic_mode_pending_chunk) - yield provider_message.Message( - role='assistant', - content=content, - ) - basic_mode_pending_chunk = '' - - if chunk is None: - raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') - - query.session.using_conversation.uuid = chunk['conversation_id'] - - async def _agent_chat_messages( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """调用聊天助手""" - cov_id = query.session.using_conversation.uuid or None - query.variables['conversation_id'] = cov_id - - plain_text, upload_files = await self._preprocess_user_message(query) - - files = [ - { - 'type': f['type'], - 'transfer_method': 'local_file', - 'upload_file_id': f['id'], - } - for f in upload_files - ] - - ignored_events = [] - - inputs = {} - - inputs.update(query.variables) - - pending_agent_message = '' - - chunk = None # 初始化chunk变量,防止在没有响应时引用错误 - - async for chunk in self.dify_client.chat_messages( - inputs=inputs, - query=plain_text, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - response_mode='streaming', - conversation_id=cov_id, - files=files, - timeout=120, - ): - self.ap.logger.debug('dify-agent-chunk: ' + str(chunk)) - - if chunk['event'] in ignored_events: - continue - - if chunk['event'] == 'agent_message' or chunk['event'] == 'message': - pending_agent_message += chunk['answer'] - else: - if pending_agent_message.strip() != '': - pending_agent_message = pending_agent_message.replace('Action:', '') - content, _ = self._process_thinking_content(pending_agent_message) - yield provider_message.Message( - role='assistant', - content=content, - ) - pending_agent_message = '' - - if chunk['event'] == 'agent_thought': - if chunk['tool'] != '' and chunk['observation'] != '': # 工具调用结果,跳过 - continue - - if chunk['tool']: - msg = provider_message.Message( - role='assistant', - tool_calls=[ - provider_message.ToolCall( - id=chunk['id'], - type='function', - function=provider_message.FunctionCall( - name=chunk['tool'], - arguments=json.dumps({}), - ), - ) - ], - ) - yield msg - if chunk['event'] == 'message_file': - if chunk['type'] == 'image' and chunk['belongs_to'] == 'assistant': - # 检查URL是否已经是完整的连接 - if chunk['url'].startswith('http://') or chunk['url'].startswith('https://'): - image_url = chunk['url'] - else: - base_url = self.dify_client.base_url - - if base_url.endswith('/v1'): - base_url = base_url[:-3] - - image_url = base_url + chunk['url'] - - yield provider_message.Message( - role='assistant', - content=[provider_message.ContentElement.from_image_url(image_url)], - ) - if chunk['event'] == 'error': - raise errors.DifyAPIError('dify 服务错误: ' + chunk['message']) - - if chunk is None: - raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') - - query.session.using_conversation.uuid = chunk['conversation_id'] - - async def _workflow_messages( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """调用工作流""" - - if not query.session.using_conversation.uuid: - query.session.using_conversation.uuid = str(uuid.uuid4()) - - query.variables['conversation_id'] = query.session.using_conversation.uuid - - plain_text, upload_files = await self._preprocess_user_message(query) - - files = [ - { - 'type': f['type'], - 'transfer_method': 'local_file', - 'upload_file_id': f['id'], - } - for f in upload_files - ] - - ignored_events = ['text_chunk', 'workflow_started'] - - inputs = { # these variables are legacy variables, we need to keep them for compatibility - 'langbot_user_message_text': plain_text, - 'langbot_session_id': query.variables['session_id'], - 'langbot_conversation_id': query.variables['conversation_id'], - 'langbot_msg_create_time': query.variables['msg_create_time'], - } - - inputs.update(query.variables) - - async for chunk in self.dify_client.workflow_run( - inputs=inputs, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - files=files, - timeout=120, - ): - self.ap.logger.debug('dify-workflow-chunk: ' + str(chunk)) - if chunk['event'] in ignored_events: - continue - - if chunk['event'] == 'node_started': - if chunk['data']['node_type'] == 'start' or chunk['data']['node_type'] == 'end': - continue - - msg = provider_message.Message( - role='assistant', - content=None, - tool_calls=[ - provider_message.ToolCall( - id=chunk['data']['node_id'], - type='function', - function=provider_message.FunctionCall( - name=chunk['data']['title'], - arguments=json.dumps({}), - ), - ) - ], - ) - - yield msg - - elif chunk['event'] == 'workflow_finished': - if chunk['data']['error']: - raise errors.DifyAPIError(chunk['data']['error']) - content, _ = self._process_thinking_content(chunk['data']['outputs']['summary']) - - msg = provider_message.Message( - role='assistant', - content=content, - ) - - yield msg - - async def _chat_messages_chunk( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: - """调用聊天助手""" - cov_id = query.session.using_conversation.uuid or None - query.variables['conversation_id'] = cov_id - - plain_text, upload_files = await self._preprocess_user_message(query) - - files = [ - { - 'type': f['type'], - 'transfer_method': 'local_file', - 'upload_file_id': f['id'], - } - for f in upload_files - ] - - mode = 'basic' - basic_mode_pending_chunk = '' - - inputs = {} - - inputs.update(query.variables) - message_idx = 0 - - chunk = None # 初始化chunk变量,防止在没有响应时引用错误 - - is_final = False - think_start = False - think_end = False - yielded_final = False - - remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') - - async for chunk in self.dify_client.chat_messages( - inputs=inputs, - query=plain_text, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - conversation_id=cov_id, - files=files, - timeout=120, - ): - self.ap.logger.debug('dify-chat-chunk: ' + str(chunk)) - - if chunk['event'] == 'workflow_started': - mode = 'workflow' - elif chunk['event'] in ('node_started', 'node_finished', 'workflow_finished'): - # Some Dify deployments may omit workflow_started in streamed chunks. - mode = 'workflow' - - if chunk['event'] == 'message': - message_idx += 1 - if remove_think: - if '' in chunk['answer'] and not think_start: - think_start = True - continue - if '' in chunk['answer'] and not think_end: - import re - - content = re.sub(r'^\n', '', chunk['answer']) - basic_mode_pending_chunk += content - think_end = True - elif think_end: - basic_mode_pending_chunk += chunk['answer'] - if think_start: - continue - - else: - basic_mode_pending_chunk += chunk['answer'] - - if chunk['event'] == 'message_end': - is_final = True - elif chunk['event'] == 'workflow_finished': - is_final = True - if chunk['data'].get('error'): - raise errors.DifyAPIError(chunk['data']['error']) - - if mode == 'workflow' and chunk['event'] == 'node_finished': - if chunk['data'].get('node_type') == 'answer': - answer = self._extract_dify_text_output(chunk['data'].get('outputs', {}).get('answer')) - if answer: - basic_mode_pending_chunk = answer - - if ( - not yielded_final - and (is_final or message_idx % 8 == 0) - and (basic_mode_pending_chunk != '' or is_final) - ): - # content, _ = self._process_thinking_content(basic_mode_pending_chunk) - yield provider_message.MessageChunk( - role='assistant', - content=basic_mode_pending_chunk, - is_final=is_final, - ) - if is_final: - yielded_final = True - - if chunk is None: - raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') - - query.session.using_conversation.uuid = chunk['conversation_id'] - - async def _agent_chat_messages_chunk( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: - """调用聊天助手""" - cov_id = query.session.using_conversation.uuid or None - query.variables['conversation_id'] = cov_id - - plain_text, upload_files = await self._preprocess_user_message(query) - - files = [ - { - 'type': f['type'], - 'transfer_method': 'local_file', - 'upload_file_id': f['id'], - } - for f in upload_files - ] - - ignored_events = [] - - inputs = {} - - inputs.update(query.variables) - - pending_agent_message = '' - - chunk = None # 初始化chunk变量,防止在没有响应时引用错误 - message_idx = 0 - is_final = False - think_start = False - think_end = False - - remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') - - async for chunk in self.dify_client.chat_messages( - inputs=inputs, - query=plain_text, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - response_mode='streaming', - conversation_id=cov_id, - files=files, - timeout=120, - ): - self.ap.logger.debug('dify-agent-chunk: ' + str(chunk)) - - if chunk['event'] in ignored_events: - continue - - if chunk['event'] == 'agent_message': - message_idx += 1 - if remove_think: - if '' in chunk['answer'] and not think_start: - think_start = True - continue - if '' in chunk['answer'] and not think_end: - import re - - content = re.sub(r'^\n', '', chunk['answer']) - pending_agent_message += content - think_end = True - elif think_end or not think_start: - pending_agent_message += chunk['answer'] - if think_start and not think_end: - continue - - else: - pending_agent_message += chunk['answer'] - elif chunk['event'] == 'message_end': - is_final = True - else: - if chunk['event'] == 'agent_thought': - if chunk['tool'] != '' and chunk['observation'] != '': # 工具调用结果,跳过 - continue - message_idx += 1 - if chunk['tool']: - msg = provider_message.MessageChunk( - role='assistant', - tool_calls=[ - provider_message.ToolCall( - id=chunk['id'], - type='function', - function=provider_message.FunctionCall( - name=chunk['tool'], - arguments=json.dumps({}), - ), - ) - ], - ) - yield msg - if chunk['event'] == 'message_file': - message_idx += 1 - if chunk['type'] == 'image' and chunk['belongs_to'] == 'assistant': - # 检查URL是否已经是完整的连接 - if chunk['url'].startswith('http://') or chunk['url'].startswith('https://'): - image_url = chunk['url'] - else: - base_url = self.dify_client.base_url - - if base_url.endswith('/v1'): - base_url = base_url[:-3] - - image_url = base_url + chunk['url'] - - yield provider_message.MessageChunk( - role='assistant', - content=[provider_message.ContentElement.from_image_url(image_url)], - is_final=is_final, - ) - - if chunk['event'] == 'error': - raise errors.DifyAPIError('dify 服务错误: ' + chunk['message']) - if message_idx % 8 == 0 or is_final: - yield provider_message.MessageChunk( - role='assistant', - content=pending_agent_message, - is_final=is_final, - ) - - if chunk is None: - raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') - - query.session.using_conversation.uuid = chunk['conversation_id'] - - async def _workflow_messages_chunk( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: - """调用工作流""" - - if not query.session.using_conversation.uuid: - query.session.using_conversation.uuid = str(uuid.uuid4()) - - query.variables['conversation_id'] = query.session.using_conversation.uuid - - plain_text, upload_files = await self._preprocess_user_message(query) - - files = [ - { - 'type': f['type'], - 'transfer_method': 'local_file', - 'upload_file_id': f['id'], - } - for f in upload_files - ] - - ignored_events = ['workflow_started'] - - inputs = { # these variables are legacy variables, we need to keep them for compatibility - 'langbot_user_message_text': plain_text, - 'langbot_session_id': query.variables['session_id'], - 'langbot_conversation_id': query.variables['conversation_id'], - 'langbot_msg_create_time': query.variables['msg_create_time'], - } - - inputs.update(query.variables) - messsage_idx = 0 - is_final = False - think_start = False - think_end = False - workflow_contents = '' - - remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') - async for chunk in self.dify_client.workflow_run( - inputs=inputs, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - files=files, - timeout=120, - ): - self.ap.logger.debug('dify-workflow-chunk: ' + str(chunk)) - if chunk['event'] in ignored_events: - continue - if chunk['event'] == 'workflow_finished': - is_final = True - if chunk['data']['error']: - raise errors.DifyAPIError(chunk['data']['error']) - - if chunk['event'] == 'text_chunk': - messsage_idx += 1 - if remove_think: - if '' in chunk['data']['text'] and not think_start: - think_start = True - continue - if '' in chunk['data']['text'] and not think_end: - import re - - content = re.sub(r'^\n', '', chunk['data']['text']) - workflow_contents += content - think_end = True - elif think_end: - workflow_contents += chunk['data']['text'] - if think_start: - continue - - else: - workflow_contents += chunk['data']['text'] - - if chunk['event'] == 'node_started': - if chunk['data']['node_type'] == 'start' or chunk['data']['node_type'] == 'end': - continue - messsage_idx += 1 - msg = provider_message.MessageChunk( - role='assistant', - content=None, - tool_calls=[ - provider_message.ToolCall( - id=chunk['data']['node_id'], - type='function', - function=provider_message.FunctionCall( - name=chunk['data']['title'], - arguments=json.dumps({}), - ), - ) - ], - ) - - yield msg - - if messsage_idx % 8 == 0 or is_final: - yield provider_message.MessageChunk( - role='assistant', - content=workflow_contents, - is_final=is_final, - ) - - async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: - """运行请求""" - if await query.adapter.is_stream_output_supported(): - msg_idx = 0 - if self.pipeline_config['ai']['dify-service-api']['app-type'] == 'chat': - async for msg in self._chat_messages_chunk(query): - msg_idx += 1 - msg.msg_sequence = msg_idx - yield msg - elif self.pipeline_config['ai']['dify-service-api']['app-type'] == 'agent': - async for msg in self._agent_chat_messages_chunk(query): - msg_idx += 1 - msg.msg_sequence = msg_idx - yield msg - elif self.pipeline_config['ai']['dify-service-api']['app-type'] == 'workflow': - async for msg in self._workflow_messages_chunk(query): - msg_idx += 1 - msg.msg_sequence = msg_idx - yield msg - else: - raise errors.DifyAPIError( - f'不支持的 Dify 应用类型: {self.pipeline_config["ai"]["dify-service-api"]["app-type"]}' - ) - else: - if self.pipeline_config['ai']['dify-service-api']['app-type'] == 'chat': - async for msg in self._chat_messages(query): - yield msg - elif self.pipeline_config['ai']['dify-service-api']['app-type'] == 'agent': - async for msg in self._agent_chat_messages(query): - yield msg - elif self.pipeline_config['ai']['dify-service-api']['app-type'] == 'workflow': - async for msg in self._workflow_messages(query): - yield msg - else: - raise errors.DifyAPIError( - f'不支持的 Dify 应用类型: {self.pipeline_config["ai"]["dify-service-api"]["app-type"]}' - ) diff --git a/src/langbot/pkg/provider/runners/langflowapi.py b/src/langbot/pkg/provider/runners/langflowapi.py deleted file mode 100644 index 8995476d3..000000000 --- a/src/langbot/pkg/provider/runners/langflowapi.py +++ /dev/null @@ -1,180 +0,0 @@ -from __future__ import annotations - -import typing -import json -import httpx -import uuid -import traceback - -from .. import runner -from ...core import app -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.provider.message as provider_message - - -@runner.runner_class('langflow-api') -class LangflowAPIRunner(runner.RequestRunner): - """Langflow API 对话请求器""" - - def __init__(self, ap: app.Application, pipeline_config: dict): - self.ap = ap - self.pipeline_config = pipeline_config - - async def _build_request_payload(self, query: pipeline_query.Query) -> dict: - """构建请求负载 - - Args: - query: 用户查询对象 - - Returns: - dict: 请求负载 - """ - # 获取用户消息文本 - user_message_text = '' - if isinstance(query.user_message.content, str): - user_message_text = query.user_message.content - elif isinstance(query.user_message.content, list): - for item in query.user_message.content: - if item.type == 'text': - user_message_text += item.text - - # 从配置中获取 input_type 和 output_type,如果未配置则使用默认值 - input_type = self.pipeline_config['ai']['langflow-api'].get('input_type', 'chat') - output_type = self.pipeline_config['ai']['langflow-api'].get('output_type', 'chat') - - # 构建基本负载 - payload = { - 'output_type': output_type, - 'input_type': input_type, - 'input_value': user_message_text, - 'session_id': str(uuid.uuid4()), - } - - # 如果配置中有tweaks,则添加到负载中 - tweaks = json.loads(self.pipeline_config['ai']['langflow-api'].get('tweaks')) - if tweaks: - payload['tweaks'] = tweaks - - return payload - - async def run( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]: - """运行请求 - - Args: - query: 用户查询对象 - - Yields: - Message: 回复消息 - """ - # 检查是否支持流式输出 - is_stream = False - try: - is_stream = await query.adapter.is_stream_output_supported() - except AttributeError: - is_stream = False - - # 从配置中获取API参数 - base_url = self.pipeline_config['ai']['langflow-api']['base-url'] - api_key = self.pipeline_config['ai']['langflow-api']['api-key'] - flow_id = self.pipeline_config['ai']['langflow-api']['flow-id'] - - # 构建API URL - url = f'{base_url.rstrip("/")}/api/v1/run/{flow_id}' - - # 构建请求负载 - payload = await self._build_request_payload(query) - - # 设置请求头 - headers = {'Content-Type': 'application/json', 'x-api-key': api_key} - - # 发送请求 - async with httpx.AsyncClient() as client: - if is_stream: - # 流式请求 - async with client.stream('POST', url, json=payload, headers=headers, timeout=120.0) as response: - response.raise_for_status() - - accumulated_content = '' - message_count = 0 - - async for line in response.aiter_lines(): - data_str = line - - if data_str.startswith('data: '): - data_str = data_str[6:] # 移除 "data: " 前缀 - - try: - data = json.loads(data_str) - - # 提取消息内容 - message_text = '' - if 'outputs' in data and len(data['outputs']) > 0: - output = data['outputs'][0] - if 'outputs' in output and len(output['outputs']) > 0: - inner_output = output['outputs'][0] - if 'outputs' in inner_output and 'message' in inner_output['outputs']: - message_data = inner_output['outputs']['message'] - if 'message' in message_data: - message_text = message_data['message'] - - # 如果没有找到消息,尝试其他可能的路径 - if not message_text and 'messages' in data: - messages = data['messages'] - if messages and len(messages) > 0: - message_text = messages[0].get('message', '') - - if message_text: - # 更新累积内容 - accumulated_content = message_text - message_count += 1 - - # 每8条消息或有新内容时生成一个chunk - if message_count % 8 == 0 or len(message_text) > 0: - yield provider_message.MessageChunk( - role='assistant', content=accumulated_content, is_final=False - ) - except json.JSONDecodeError: - # 如果不是JSON,跳过这一行 - traceback.print_exc() - continue - - # 发送最终消息 - yield provider_message.MessageChunk(role='assistant', content=accumulated_content, is_final=True) - else: - # 非流式请求 - response = await client.post(url, json=payload, headers=headers, timeout=120.0) - response.raise_for_status() - - # 解析响应 - response_data = response.json() - - # 提取消息内容 - # 根据Langflow API文档,响应结构可能在outputs[0].outputs[0].outputs.message.message中 - message_text = '' - if 'outputs' in response_data and len(response_data['outputs']) > 0: - output = response_data['outputs'][0] - if 'outputs' in output and len(output['outputs']) > 0: - inner_output = output['outputs'][0] - if 'outputs' in inner_output and 'message' in inner_output['outputs']: - message_data = inner_output['outputs']['message'] - if 'message' in message_data: - message_text = message_data['message'] - - # 如果没有找到消息,尝试其他可能的路径 - if not message_text and 'messages' in response_data: - messages = response_data['messages'] - if messages and len(messages) > 0: - message_text = messages[0].get('message', '') - - # 如果仍然没有找到消息,返回完整响应的字符串表示 - if not message_text: - message_text = json.dumps(response_data, ensure_ascii=False, indent=2) - - # 生成回复消息 - if is_stream: - yield provider_message.MessageChunk(role='assistant', content=message_text, is_final=True) - else: - reply_message = provider_message.Message(role='assistant', content=message_text) - yield reply_message diff --git a/src/langbot/pkg/provider/runners/localagent.py b/src/langbot/pkg/provider/runners/localagent.py deleted file mode 100644 index 482d03493..000000000 --- a/src/langbot/pkg/provider/runners/localagent.py +++ /dev/null @@ -1,587 +0,0 @@ -from __future__ import annotations - -import json -import copy -import typing -from .. import runner -from ...telemetry import features as telemetry_features -from ..modelmgr import requester as modelmgr_requester -from ..tools.loaders.native import EXEC_TOOL_NAME -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.provider.message as provider_message -import langbot_plugin.api.entities.builtin.rag.context as rag_context - - -rag_combined_prompt_template = """ -The following are relevant context entries retrieved from the knowledge base. -Please use them to answer the user's message. -Respond in the same language as the user's input. - - -{rag_context} - - - -{user_message} - -""" - -SANDBOX_EXEC_TOOL_NAME = 'sandbox_exec' -SANDBOX_EXEC_SYSTEM_GUIDANCE = ( - 'When sandbox_exec is available, use it for exact calculations, statistics, structured data parsing, ' - 'and code execution instead of estimating mentally. If the user provides numbers, tables, CSV-like text, ' - 'JSON, or other data and asks for a computed answer, prefer running a short Python script in sandbox_exec ' - 'and then answer from the tool result.' -) - - -# Hard cap on tool-call rounds within a single agent turn. A looping or -# adversarial model can otherwise emit tool calls indefinitely (each potentially -# a sandbox exec), yielding a non-terminating request and runaway cost. Set -# generously so it never interrupts legitimate multi-step agentic workflows. -MAX_TOOL_CALL_ROUNDS = 128 - - -def _model_has_ability(model: modelmgr_requester.RuntimeLLMModel, ability: str) -> bool: - return ability in (model.model_entity.abilities or []) - - -class _StreamAccumulator: - """Accumulate streamed content and fragmented OpenAI-style tool calls.""" - - def __init__(self, msg_sequence: int = 0, initial_content: str | None = None): - self.tool_calls_map: dict[str, provider_message.ToolCall] = {} - self.msg_idx = 0 - self.accumulated_content = initial_content or '' - self.last_role = 'assistant' - self.msg_sequence = msg_sequence - - def add(self, msg: provider_message.MessageChunk) -> provider_message.MessageChunk | None: - self.msg_idx += 1 - - if msg.role: - self.last_role = msg.role - - if msg.content: - self.accumulated_content += msg.content - - if msg.tool_calls: - for tool_call in msg.tool_calls: - if tool_call.id not in self.tool_calls_map: - self.tool_calls_map[tool_call.id] = provider_message.ToolCall( - id=tool_call.id, - type=tool_call.type, - function=provider_message.FunctionCall( - name=tool_call.function.name if tool_call.function else '', - arguments='', - ), - ) - if tool_call.function and tool_call.function.arguments: - self.tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments - - if self.msg_idx % 8 == 0 or msg.is_final: - self.msg_sequence += 1 - return provider_message.MessageChunk( - role=self.last_role, - content=self.accumulated_content, - tool_calls=list(self.tool_calls_map.values()) if (self.tool_calls_map and msg.is_final) else None, - is_final=msg.is_final, - msg_sequence=self.msg_sequence, - ) - - return None - - def final_message(self) -> provider_message.MessageChunk: - return provider_message.MessageChunk( - role=self.last_role, - content=self.accumulated_content, - tool_calls=list(self.tool_calls_map.values()) if self.tool_calls_map else None, - msg_sequence=self.msg_sequence, - ) - - -@runner.runner_class('local-agent') -class LocalAgentRunner(runner.RequestRunner): - """Local agent request runner""" - - async def _inject_inbound_attachments( - self, - query: pipeline_query.Query, - user_message: provider_message.Message, - ) -> None: - """Persist inbound attachments into the sandbox and tell the model. - - No-op when the box service is unavailable or there are no attachments. - On success, appends an extra text ContentElement to the user message - listing the in-sandbox paths and the outbox convention, and stashes the - descriptors in ``query.variables['_sandbox_inbound_attachments']``. - """ - box_service = getattr(self.ap, 'box_service', None) - if box_service is None or not getattr(box_service, 'available', False): - return - try: - attachments = await box_service.materialize_inbound_attachments(query) - except Exception as e: # never break the chat turn over attachment IO - self.ap.logger.warning(f'Inbound attachment materialization failed: {e}') - return - if not attachments: - return - - query.variables['_sandbox_inbound_attachments'] = attachments - - lines = [ - 'The user sent attachments. They have been saved into the sandbox and are ' - 'available to the exec/read/write tools at these paths:' - ] - for att in attachments: - lines.append(f'- {att["type"]}: {att["path"]} ({att["size"]} bytes)') - outbox_dir = f'{box_service.OUTBOX_MOUNT_DIR}/{query.query_id}' - lines.append( - 'If you produce any file (image, audio, document, etc.) that should be sent ' - f'back to the user, write it into {outbox_dir}/ (create the directory if ' - 'needed). Every file placed there will be delivered to the user automatically.' - ) - note = '\n'.join(lines) - - # Voice/File attachments are now available to the agent via the sandbox - # (exec/read/write tools). Their raw bytes must NOT be forwarded to the - # chat model as multimodal content: providers reject non-image file - # parts ("Invalid user message ... ensure all user messages are valid - # OpenAI chat completion messages"). Strip those content elements and - # rely on the sandbox-path note instead. Images are kept so vision - # models can still see them. - _model_unsafe_types = {'file_base64', 'file_url'} - if isinstance(user_message.content, list): - user_message.content = [ - ce for ce in user_message.content if getattr(ce, 'type', None) not in _model_unsafe_types - ] - - if isinstance(user_message.content, str): - user_message.content = [ - provider_message.ContentElement.from_text(user_message.content), - provider_message.ContentElement.from_text(note), - ] - elif isinstance(user_message.content, list): - user_message.content.append(provider_message.ContentElement.from_text(note)) - else: - user_message.content = [provider_message.ContentElement.from_text(note)] - - def _build_request_messages( - self, - query: pipeline_query.Query, - user_message: provider_message.Message, - ) -> list[provider_message.Message]: - req_messages = query.prompt.messages.copy() + query.messages.copy() - - if any(getattr(tool, 'name', None) == EXEC_TOOL_NAME for tool in query.use_funcs or []): - req_messages.append( - provider_message.Message( - role='system', - content=self.ap.box_service.get_system_guidance(query.query_id), - ) - ) - - req_messages.append(user_message) - return req_messages - - async def _get_model_candidates( - self, - query: pipeline_query.Query, - ) -> list[modelmgr_requester.RuntimeLLMModel]: - """Build ordered list of models to try: primary model + fallback models.""" - candidates = [] - - # Primary model - if query.use_llm_model_uuid: - try: - primary = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid) - candidates.append(primary) - except ValueError: - self.ap.logger.warning(f'Primary model {query.use_llm_model_uuid} not found') - - # Fallback models - fallback_uuids = (query.variables or {}).get('_fallback_model_uuids', []) - for fb_uuid in fallback_uuids: - try: - fb_model = await self.ap.model_mgr.get_model_by_uuid(fb_uuid) - candidates.append(fb_model) - except ValueError: - self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping') - - return candidates - - async def _invoke_with_fallback( - self, - query: pipeline_query.Query, - candidates: list[modelmgr_requester.RuntimeLLMModel], - messages: list, - funcs: list, - remove_think: bool, - ) -> tuple[provider_message.Message, modelmgr_requester.RuntimeLLMModel]: - """Try non-streaming invocation with sequential fallback. Returns (message, model_used).""" - last_error = None - for model in candidates: - try: - msg = await model.provider.invoke_llm( - query, - model, - messages, - funcs if _model_has_ability(model, 'func_call') else [], - extra_args=model.model_entity.extra_args, - remove_think=remove_think, - ) - return msg, model - except Exception as e: - last_error = e - self.ap.logger.warning(f'Model {model.model_entity.name} failed: {e}, trying next fallback...') - raise last_error or RuntimeError('No model candidates available') - - async def _invoke_stream_with_fallback( - self, - query: pipeline_query.Query, - candidates: list[modelmgr_requester.RuntimeLLMModel], - messages: list, - funcs: list, - remove_think: bool, - ) -> tuple[typing.AsyncGenerator, modelmgr_requester.RuntimeLLMModel]: - """Try streaming invocation with sequential fallback. Returns (stream_generator, model_used). - - Fallback is only possible before any chunks have been yielded to the client. - Once streaming starts, the model is committed. - """ - last_error = None - for model in candidates: - try: - stream = model.provider.invoke_llm_stream( - query, - model, - messages, - funcs if _model_has_ability(model, 'func_call') else [], - extra_args=model.model_entity.extra_args, - remove_think=remove_think, - ) - # Attempt to get the first chunk to verify the stream works - first_chunk = await stream.__anext__() - - async def _chain_stream(first, rest): - yield first - async for chunk in rest: - yield chunk - - return _chain_stream(first_chunk, stream), model - except StopAsyncIteration: - # Empty stream — treat as success (model returned nothing) - async def _empty_stream(): - return - yield # make it a generator - - return _empty_stream(), model - except Exception as e: - last_error = e - self.ap.logger.warning(f'Model {model.model_entity.name} stream failed: {e}, trying next fallback...') - raise last_error or RuntimeError('No model candidates available') - - async def run( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]: - """Run request""" - pending_tool_calls = [] - initial_response_emitted = False - - # Get knowledge bases list from query variables (set by PreProcessor, - # may have been modified by plugins during PromptPreProcessing) - kb_uuids = query.variables.get('_knowledge_base_uuids', []) - - user_message = copy.deepcopy(query.user_message) - - # Materialize inbound attachments (images / voices / files) into the - # sandbox so the agent's exec/read/write tools can operate on the real - # bytes — not just the multimodal copy the model sees. The exact - # in-sandbox paths are announced to the model as a system note. - await self._inject_inbound_attachments(query, user_message) - - user_message_text = '' - - if isinstance(user_message.content, str): - user_message_text = user_message.content - elif isinstance(user_message.content, list): - for ce in user_message.content: - if ce.type == 'text': - user_message_text += ce.text - break - - if kb_uuids and user_message_text: - # only support text for now - all_results: list[rag_context.RetrievalResultEntry] = [] - - kb_engine_plugins: set[str] = set() - - # Retrieve from each knowledge base - for kb_uuid in kb_uuids: - kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid) - - if not kb: - self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping') - continue - - try: - engine_plugin_id = kb.get_knowledge_engine_plugin_id() or 'builtin' - except Exception: - engine_plugin_id = 'builtin' - kb_engine_plugins.add(engine_plugin_id) - - result = await kb.retrieve( - user_message_text, - settings={ - 'bot_uuid': query.bot_uuid or '', - 'sender_id': str(query.sender_id), - 'session_name': f'{query.session.launcher_type.value}_{query.session.launcher_id}', - }, - ) - - if result: - all_results.extend(result) - - # Telemetry: knowledge base usage (counts and engine categories only) - telemetry_features.set_value( - query, - 'kb', - { - 'kb_count': len(kb_uuids), - 'engine_plugins': sorted(kb_engine_plugins), - 'retrieved_entries': len(all_results), - }, - ) - - # Rerank step: re-score results using a rerank model if configured - local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {}) - rerank_model_uuid = local_agent_config.get('rerank-model', '') - if rerank_model_uuid == '__none__': - rerank_model_uuid = '' - self.ap.logger.info( - f'Rerank config: model_uuid={rerank_model_uuid!r}, ' - f'results={len(all_results)}, ' - f'local_agent_keys={list(local_agent_config.keys())}' - ) - if all_results and rerank_model_uuid: - try: - rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(rerank_model_uuid) - rerank_top_k = int(local_agent_config.get('rerank-top-k', 5)) - - doc_texts = [] - for entry in all_results: - text = ' '.join(c.text for c in entry.content if c.type == 'text' and c.text) - doc_texts.append(text) - - doc_texts_capped = doc_texts[:64] - scores = await rerank_model.provider.invoke_rerank( - model=rerank_model, - query=user_message_text, - documents=doc_texts_capped, - ) - - scored = sorted(scores, key=lambda x: x.get('relevance_score', 0), reverse=True) - top_indices = [s['index'] for s in scored[:rerank_top_k] if s['index'] < len(all_results)] - all_results = [all_results[i] for i in top_indices] - - self.ap.logger.info( - f'Rerank complete: {len(doc_texts)} docs reranked -> top {len(all_results)} kept (top_k={rerank_top_k})' - ) - except ValueError: - self.ap.logger.warning(f'Rerank model {rerank_model_uuid} not found, skipping rerank') - except Exception as e: - self.ap.logger.warning(f'Rerank failed, using original order: {e}') - - final_user_message_text = '' - - if all_results: - texts = [] - idx = 1 - for entry in all_results: - for content in entry.content: - if content.type == 'text' and content.text is not None: - texts.append(f'[{idx}] {content.text}') - idx += 1 - rag_context_text = '\n\n'.join(texts) - final_user_message_text = rag_combined_prompt_template.format( - rag_context=rag_context_text, user_message=user_message_text - ) - - else: - final_user_message_text = user_message_text - - self.ap.logger.debug(f'Final user message text: {final_user_message_text}') - - for ce in user_message.content: - if ce.type == 'text': - ce.text = final_user_message_text - break - - req_messages = self._build_request_messages(query, user_message) - - try: - is_stream = await query.adapter.is_stream_output_supported() - except AttributeError: - is_stream = False - - remove_think = query.pipeline_config['output'].get('misc', '').get('remove-think') - - # Build ordered candidate list (primary + fallbacks) - candidates = await self._get_model_candidates(query) - if not candidates: - raise RuntimeError('No LLM model configured for local-agent runner') - - self.ap.logger.debug( - f'localagent req: query={query.query_id} req_messages={req_messages} ' - f'candidates={[m.model_entity.name for m in candidates]}' - ) - - if not is_stream: - # Non-streaming: invoke with fallback - msg, use_llm_model = await self._invoke_with_fallback( - query, - candidates, - req_messages, - query.use_funcs, - remove_think, - ) - final_msg = msg - else: - # Streaming: invoke with fallback - stream_accumulator = _StreamAccumulator(msg_sequence=1) - - stream_src, use_llm_model = await self._invoke_stream_with_fallback( - query, - candidates, - req_messages, - query.use_funcs, - remove_think, - ) - async for msg in stream_src: - chunk = stream_accumulator.add(msg) - if chunk: - yield chunk - initial_response_emitted = True - - final_msg = stream_accumulator.final_message() - - pending_tool_calls = final_msg.tool_calls - first_content = final_msg.content - if isinstance(final_msg, provider_message.MessageChunk): - first_end_sequence = final_msg.msg_sequence - - if not is_stream: - yield final_msg - elif not initial_response_emitted: - yield final_msg - initial_response_emitted = True - - req_messages.append(final_msg) - - # Once a model succeeds, commit to it for the tool call loop - # (no fallback mid-conversation — different models may interpret tool results differently) - tool_call_round = 0 - while pending_tool_calls: - tool_call_round += 1 - telemetry_features.set_value(query, 'tool_call_rounds', tool_call_round) - if tool_call_round > MAX_TOOL_CALL_ROUNDS: - self.ap.logger.warning( - f'Tool-call loop reached the {MAX_TOOL_CALL_ROUNDS}-round cap ' - f'(query_id={query.query_id}); stopping to avoid a non-terminating request.' - ) - break - for tool_call in pending_tool_calls: - try: - func = tool_call.function - - if func.arguments: - parameters = json.loads(func.arguments) - else: - parameters = {} - - func_ret = await self.ap.tool_mgr.execute_func_call(func.name, parameters, query=query) - - # Handle return value content - tool_content = None - if ( - isinstance(func_ret, list) - and len(func_ret) > 0 - and isinstance(func_ret[0], provider_message.ContentElement) - ): - tool_content = func_ret - else: - tool_content = json.dumps(func_ret, ensure_ascii=False) - - if is_stream: - msg = provider_message.MessageChunk( - role='tool', - content=tool_content, - tool_call_id=tool_call.id, - ) - else: - msg = provider_message.Message( - role='tool', - content=tool_content, - tool_call_id=tool_call.id, - ) - - yield msg - - req_messages.append(msg) - except Exception as e: - if is_stream: - err_msg = provider_message.MessageChunk( - role='tool', - content=f'err: {e}', - tool_call_id=tool_call.id, - is_final=True, - ) - else: - err_msg = provider_message.Message(role='tool', content=f'err: {e}', tool_call_id=tool_call.id) - - yield err_msg - - req_messages.append(err_msg) - - self.ap.logger.debug( - f'localagent req: query={query.query_id} req_messages={req_messages} ' - f'use_llm_model={use_llm_model.model_entity.name}' - ) - - if is_stream: - stream_accumulator = _StreamAccumulator( - msg_sequence=first_end_sequence, - initial_content=first_content, - ) - - tool_stream_src = use_llm_model.provider.invoke_llm_stream( - query, - use_llm_model, - req_messages, - query.use_funcs if _model_has_ability(use_llm_model, 'func_call') else [], - extra_args=use_llm_model.model_entity.extra_args, - remove_think=remove_think, - ) - async for msg in tool_stream_src: - chunk = stream_accumulator.add(msg) - if chunk: - yield chunk - - final_msg = stream_accumulator.final_message() - else: - # Non-streaming: use committed model directly (no fallback in tool loop) - msg = await use_llm_model.provider.invoke_llm( - query, - use_llm_model, - req_messages, - query.use_funcs if _model_has_ability(use_llm_model, 'func_call') else [], - extra_args=use_llm_model.model_entity.extra_args, - remove_think=remove_think, - ) - - yield msg - final_msg = msg - - pending_tool_calls = final_msg.tool_calls - - req_messages.append(final_msg) diff --git a/src/langbot/pkg/provider/runners/n8nsvapi.py b/src/langbot/pkg/provider/runners/n8nsvapi.py deleted file mode 100644 index 543fd7ef9..000000000 --- a/src/langbot/pkg/provider/runners/n8nsvapi.py +++ /dev/null @@ -1,277 +0,0 @@ -from __future__ import annotations - -import typing -import json -import uuid -import aiohttp - -from langbot.pkg.utils import httpclient - -from .. import runner -from ...core import app -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.provider.message as provider_message - - -class N8nAPIError(Exception): - """N8n API 请求失败""" - - def __init__(self, message: str): - self.message = message - super().__init__(self.message) - - -@runner.runner_class('n8n-service-api') -class N8nServiceAPIRunner(runner.RequestRunner): - """N8n Service API 工作流请求器""" - - def __init__(self, ap: app.Application, pipeline_config: dict): - self.ap = ap - self.pipeline_config = pipeline_config - - # 获取webhook URL - self.webhook_url = self.pipeline_config['ai']['n8n-service-api']['webhook-url'] - - # 获取超时设置,默认为120秒 - self.timeout = self.pipeline_config['ai']['n8n-service-api'].get('timeout', 120) - - # 获取输出键名,默认为response - self.output_key = self.pipeline_config['ai']['n8n-service-api'].get('output-key', 'response') - - # 获取认证类型,默认为none - self.auth_type = self.pipeline_config['ai']['n8n-service-api'].get('auth-type', 'none') - - # 根据认证类型获取相应的认证信息 - if self.auth_type == 'basic': - self.basic_username = self.pipeline_config['ai']['n8n-service-api'].get('basic-username', '') - self.basic_password = self.pipeline_config['ai']['n8n-service-api'].get('basic-password', '') - elif self.auth_type == 'jwt': - self.jwt_secret = self.pipeline_config['ai']['n8n-service-api'].get('jwt-secret', '') - self.jwt_algorithm = self.pipeline_config['ai']['n8n-service-api'].get('jwt-algorithm', 'HS256') - elif self.auth_type == 'header': - self.header_name = self.pipeline_config['ai']['n8n-service-api'].get('header-name', '') - self.header_value = self.pipeline_config['ai']['n8n-service-api'].get('header-value', '') - - async def _preprocess_user_message(self, query: pipeline_query.Query) -> str: - """预处理用户消息,提取纯文本 - - Returns: - str: 纯文本消息 - """ - plain_text = '' - - if isinstance(query.user_message.content, list): - for ce in query.user_message.content: - if ce.type == 'text': - plain_text += ce.text - # 注意:n8n webhook目前不支持直接处理图片,如需支持可在此扩展 - elif isinstance(query.user_message.content, str): - plain_text = query.user_message.content - - return plain_text - - async def _process_response( - self, response: aiohttp.ClientResponse - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """处理响应——支持流式格式和普通 JSON 格式""" - full_content = '' - full_text = '' - chunk_idx = 0 - is_final = False - message_idx = 0 - - buffer = '' - decoder = json.JSONDecoder() - - async for raw_chunk in response.content.iter_chunked(1024): - if not raw_chunk: - continue - - try: - # 将 bytes 解码为字符串(容忍错误) - if isinstance(raw_chunk, (bytes, bytearray)): - chunk_str = raw_chunk.decode('utf-8', errors='replace') - else: - chunk_str = str(raw_chunk) - - full_text += chunk_str - buffer += chunk_str - - # 尝试从 buffer 中循环解析出 JSON 对象(处理多个对象或部分对象) - while buffer: - buffer = buffer.lstrip() - if not buffer: - break - try: - obj, idx = decoder.raw_decode(buffer) - buffer = buffer[idx:] - - if not isinstance(obj, dict): - # 忽略非字典类型的顶级 JSON - continue - - if obj.get('type') == 'item' and 'content' in obj: - chunk_idx += 1 - content = obj['content'] - full_content += content - elif obj.get('type') == 'end': - is_final = True - - if is_final or (chunk_idx > 0 and chunk_idx % 8 == 0): - message_idx += 1 - yield provider_message.MessageChunk( - role='assistant', - content=full_content, - is_final=is_final, - msg_sequence=message_idx, - ) - except json.JSONDecodeError: - # buffer 末尾可能是一个不完整的 JSON,等待更多数据 - break - except Exception as e: - # 记录解析失败并继续接收后续 chunk - try: - preview = chunk_str[:200] - except Exception: - preview = '' - self.ap.logger.warning(f'Failed to process chunk: {e}; chunk preview: {preview}') - - # 流结束后,尝试解析残余 buffer - if buffer: - try: - buffer = buffer.strip() - if buffer: - obj, _ = decoder.raw_decode(buffer) - if isinstance(obj, dict): - if obj.get('type') == 'item' and 'content' in obj: - chunk_idx += 1 - full_content += obj['content'] - elif obj.get('type') == 'end': - is_final = True - message_idx += 1 - yield provider_message.MessageChunk( - role='assistant', - content=full_content, - is_final=is_final, - msg_sequence=message_idx, - ) - except Exception as e: - preview = buffer[:200] - self.ap.logger.warning(f'Failed to parse remaining buffer: {e}; buffer preview: {preview}') - - # n8n 返回普通 JSON 格式(无任何流式 type:item 内容) - if chunk_idx == 0: - output_content = '' - try: - response_data = json.loads(full_text.strip()) - if isinstance(response_data, dict): - if self.output_key in response_data: - output_content = response_data[self.output_key] - else: - output_content = json.dumps(response_data, ensure_ascii=False) - else: - output_content = full_text - except json.JSONDecodeError: - output_content = full_text - self.ap.logger.debug(f'n8n webhook response (non-stream): {full_text[:200]}') - yield provider_message.MessageChunk( - role='assistant', - content=output_content, - is_final=True, - msg_sequence=message_idx + 1, - ) - - async def _call_webhook(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: - """调用n8n webhook""" - # 生成会话ID(如果不存在) - if not query.session.using_conversation.uuid: - query.session.using_conversation.uuid = str(uuid.uuid4()) - - # Keep query variables in sync with the generated/new conversation id. - # query.variables is later merged into payload and would otherwise - # overwrite the generated conversation_id with the stale preprocessor - # value (usually None for a new conversation). - query.variables['conversation_id'] = query.session.using_conversation.uuid - - # 预处理用户消息 - plain_text = await self._preprocess_user_message(query) - - # 准备请求数据 - payload = { - # 基本消息内容 - 'chatInput': plain_text, # 考虑到之前用户直接用的message model这里添加新键 - 'message': plain_text, - 'user_message_text': plain_text, - 'conversation_id': query.session.using_conversation.uuid, - 'session_id': query.variables.get('session_id', ''), - 'user_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}', - 'msg_create_time': query.variables.get('msg_create_time', ''), - } - - # 添加所有变量到payload - payload.update(query.variables) - - try: - is_stream = await query.adapter.is_stream_output_supported() - except AttributeError: - is_stream = False - - try: - # 准备请求头和认证信息 - headers = {} - auth = None - - # 根据认证类型设置相应的认证信息 - if self.auth_type == 'basic': - # 使用Basic认证 - auth = aiohttp.BasicAuth(self.basic_username, self.basic_password) - self.ap.logger.debug(f'using basic auth: {self.basic_username}') - elif self.auth_type == 'jwt': - # 使用JWT认证 - import jwt - import time - - # 创建JWT令牌 - payload_jwt = { - 'exp': int(time.time()) + 3600, # 1小时过期 - 'iat': int(time.time()), - 'sub': 'n8n-webhook', - } - token = jwt.encode(payload_jwt, self.jwt_secret, algorithm=self.jwt_algorithm) - - # 添加到Authorization头 - headers['Authorization'] = f'Bearer {token}' - self.ap.logger.debug('using jwt auth') - elif self.auth_type == 'header': - # 使用自定义请求头认证 - headers[self.header_name] = self.header_value - self.ap.logger.debug(f'using header auth: {self.header_name}') - else: - self.ap.logger.debug('no auth') - - # 调用webhook - session = httpclient.get_session() - async with session.post( - self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout - ) as response: - if response.status != 200: - error_text = await response.text() - self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}') - raise Exception(f'n8n webhook call failed: {response.status}, {error_text}') - - async for chunk in self._process_response(response): - if is_stream: - yield chunk - elif chunk.is_final: - yield provider_message.Message( - role='assistant', - content=chunk.content, - ) - except Exception as e: - self.ap.logger.error(f'n8n webhook call exception: {str(e)}') - raise N8nAPIError(f'n8n webhook call exception: {str(e)}') - - async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: - """运行请求""" - async for msg in self._call_webhook(query): - yield msg diff --git a/src/langbot/pkg/provider/runners/tboxapi.py b/src/langbot/pkg/provider/runners/tboxapi.py deleted file mode 100644 index 0fb22a642..000000000 --- a/src/langbot/pkg/provider/runners/tboxapi.py +++ /dev/null @@ -1,202 +0,0 @@ -from __future__ import annotations - -import typing -import json -import base64 -import tempfile -import os - -from tboxsdk.tbox import TboxClient -from tboxsdk.model.file import File, FileType - -from .. import runner -from ...core import app -from ...utils import image -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.provider.message as provider_message - - -class TboxAPIError(Exception): - """TBox API 请求失败""" - - def __init__(self, message: str): - self.message = message - super().__init__(self.message) - - -@runner.runner_class('tbox-app-api') -class TboxAPIRunner(runner.RequestRunner): - "蚂蚁百宝箱API对话请求器" - - # 运行器内部使用的配置 - app_id: str # 蚂蚁百宝箱平台中的应用ID - api_key: str # 在蚂蚁百宝箱平台中申请的令牌 - - def __init__(self, ap: app.Application, pipeline_config: dict): - """初始化""" - self.ap = ap - self.pipeline_config = pipeline_config - - # 初始化Tbox 参数配置 - self.app_id = self.pipeline_config['ai']['tbox-app-api']['app-id'] - self.api_key = self.pipeline_config['ai']['tbox-app-api']['api-key'] - - # 初始化Tbox client - self.tbox_client = TboxClient(authorization=self.api_key) - - async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[str]]: - """预处理用户消息,提取纯文本,并将图片上传到 Tbox 服务 - - Returns: - tuple[str, list[str]]: 纯文本和图片的 Tbox 文件ID - """ - plain_text = '' - image_ids = [] - - if isinstance(query.user_message.content, list): - for ce in query.user_message.content: - if ce.type == 'text': - plain_text += ce.text - elif ce.type == 'image_base64': - image_b64, image_format = await image.extract_b64_and_format(ce.image_base64) - # 创建临时文件 - file_bytes = base64.b64decode(image_b64) - try: - with tempfile.NamedTemporaryFile(suffix=f'.{image_format}', delete=False) as tmp_file: - tmp_file.write(file_bytes) - tmp_file_path = tmp_file.name - file_upload_resp = self.tbox_client.upload_file(tmp_file_path) - image_id = file_upload_resp.get('data', '') - image_ids.append(image_id) - finally: - # 清理临时文件 - if os.path.exists(tmp_file_path): - os.unlink(tmp_file_path) - elif isinstance(query.user_message.content, str): - plain_text = query.user_message.content - - return plain_text, image_ids - - async def _agent_messages( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """TBox 智能体对话请求""" - - plain_text, image_ids = await self._preprocess_user_message(query) - remove_think = self.pipeline_config['output'].get('misc', {}).get('remove-think') - - try: - is_stream = await query.adapter.is_stream_output_supported() - except AttributeError: - is_stream = False - - # 获取Tbox的conversation_id - conversation_id = query.session.using_conversation.uuid or None - - files = None - if image_ids: - files = [File(file_id=image_id, type=FileType.IMAGE) for image_id in image_ids] - - # 发送对话请求 - response = self.tbox_client.chat( - app_id=self.app_id, # Tbox中智能体应用的ID - user_id=query.bot_uuid, # 用户ID - query=plain_text, # 用户输入的文本信息 - stream=is_stream, # 是否流式输出 - conversation_id=conversation_id, # 会话ID,为None时Tbox会自动创建一个新会话 - files=files, # 图片内容 - ) - - if is_stream: - # 解析Tbox流式输出内容,并发送给上游 - for chunk in self._process_stream_message(response, query, remove_think): - yield chunk - else: - message = self._process_non_stream_message(response, query, remove_think) - yield provider_message.Message( - role='assistant', - content=message, - ) - - def _process_non_stream_message(self, response: typing.Dict, query: pipeline_query.Query, remove_think: bool): - if response.get('errorCode') != '0': - raise TboxAPIError(f'Tbox API 请求失败: {response.get("errorMsg", "")}') - payload = response.get('data', {}) - conversation_id = payload.get('conversationId', '') - query.session.using_conversation.uuid = conversation_id - thinking_content = payload.get('reasoningContent', []) - result = '' - if thinking_content and not remove_think: - result += f'\n{thinking_content[0].get("text", "")}\n\n' - content = payload.get('result', []) - if content: - result += content[0].get('chunk', '') - return result - - def _process_stream_message( - self, response: typing.Generator[dict], query: pipeline_query.Query, remove_think: bool - ): - idx_msg = 0 - pending_content = '' - conversation_id = None - think_start = False - think_end = False - for chunk in response: - if chunk.get('type', '') == 'chunk': - """ - Tbox返回的消息内容chunk结构 - {'lane': 'default', 'payload': {'conversationId': '20250918tBI947065406', 'messageId': '20250918TB1f53230954', 'text': '️'}, 'type': 'chunk'} - """ - # 如果包含思考过程,拼接 - if think_start and not think_end: - pending_content += '\n\n' - think_end = True - - payload = chunk.get('payload', {}) - if not conversation_id: - conversation_id = payload.get('conversationId') - query.session.using_conversation.uuid = conversation_id - if payload.get('text'): - idx_msg += 1 - pending_content += payload.get('text') - elif chunk.get('type', '') == 'thinking' and not remove_think: - """ - Tbox返回的思考过程chunk结构 - {'payload': '{"ext_data":{"text":"日期"},"event":"flow.node.llm.thinking","entity":{"node_type":"text-completion","execute_id":"6","group_id":0,"parent_execute_id":"6","node_name":"模型推理","node_id":"TC_5u6gl0"}}', 'type': 'thinking'} - """ - payload = json.loads(chunk.get('payload', '{}')) - if payload.get('ext_data', {}).get('text'): - idx_msg += 1 - content = payload.get('ext_data', {}).get('text') - if not think_start: - think_start = True - pending_content += f'\n{content}' - else: - pending_content += content - elif chunk.get('type', '') == 'error': - raise TboxAPIError( - f'Tbox API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} ' - ) - - if idx_msg % 8 == 0: - yield provider_message.MessageChunk( - role='assistant', - content=pending_content, - is_final=False, - ) - - # Tbox不返回END事件,默认发一个最终消息 - yield provider_message.MessageChunk( - role='assistant', - content=pending_content, - is_final=True, - ) - - async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: - """运行""" - msg_seq = 0 - async for msg in self._agent_messages(query): - if isinstance(msg, provider_message.MessageChunk): - msg_seq += 1 - msg.msg_sequence = msg_seq - yield msg diff --git a/src/langbot/pkg/provider/runners/weknoraapi.py b/src/langbot/pkg/provider/runners/weknoraapi.py deleted file mode 100644 index 9d46eebb7..000000000 --- a/src/langbot/pkg/provider/runners/weknoraapi.py +++ /dev/null @@ -1,351 +0,0 @@ -from __future__ import annotations - -import typing -import json - - -from langbot.pkg.provider import runner -from langbot.pkg.core import app -import langbot_plugin.api.entities.builtin.provider.message as provider_message -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -from langbot.libs.weknora_api import client, errors - - -@runner.runner_class('weknora-api') -class WeKnoraAPIRunner(runner.RequestRunner): - """WeKnora API 对话请求器""" - - weknora_client: client.AsyncWeKnoraClient - - def __init__(self, ap: app.Application, pipeline_config: dict): - super().__init__(ap, pipeline_config) - - valid_app_types = ['chat', 'agent'] - if self.pipeline_config['ai']['weknora-api']['app-type'] not in valid_app_types: - raise errors.WeKnoraAPIError( - f'不支持的 WeKnora 应用类型: {self.pipeline_config["ai"]["weknora-api"]["app-type"]}' - ) - - api_key = self.pipeline_config['ai']['weknora-api'].get('api-key', '').strip() - if not api_key: - raise errors.WeKnoraAPIError( - 'WeKnora API Key 未配置,请在流水线的 WeKnora API 配置中填入 API Key ' - '(从 WeKnora 前端 设置 → API Keys 生成)' - ) - - base_url = self.pipeline_config['ai']['weknora-api'].get('base-url', '').strip() - if not base_url: - raise errors.WeKnoraAPIError('WeKnora Base URL 未配置,请填入服务器地址,例如 http://localhost:8080/api/v1') - - self.weknora_client = client.AsyncWeKnoraClient( - api_key=api_key, - base_url=base_url, - ) - - async def _extract_plain_text(self, query: pipeline_query.Query) -> str: - """从用户消息中提取纯文本内容""" - plain_text = '' - if isinstance(query.user_message.content, str): - plain_text = query.user_message.content - elif isinstance(query.user_message.content, list): - for ce in query.user_message.content: - if ce.type == 'text': - plain_text += ce.text - - if not plain_text: - plain_text = self.pipeline_config['ai']['weknora-api'].get('base-prompt', '') - - return plain_text - - async def _ensure_session(self, query: pipeline_query.Query) -> str: - """确保会话存在,如果不存在则创建""" - session_id = query.session.using_conversation.uuid or '' - - if not session_id: - user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}' - session_id = await self.weknora_client.create_session(title=f'IM Chat - {user_tag}') - query.session.using_conversation.uuid = session_id - - return session_id - - async def _agent_chat_messages( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """调用 Agent 智能对话(非流式聚合输出)""" - session_id = await self._ensure_session(query) - plain_text = await self._extract_plain_text(query) - user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}' - - config = self.pipeline_config['ai']['weknora-api'] - agent_id = config.get('agent-id', 'builtin-smart-reasoning') - knowledge_base_ids = config.get('knowledge-base-ids', []) - web_search_enabled = config.get('web-search-enabled', False) - timeout = config.get('timeout', 120) - - full_answer = '' - chunk = None - - async for chunk in self.weknora_client.agent_chat( - session_id=session_id, - query=plain_text, - user=user_tag, - agent_id=agent_id, - knowledge_base_ids=knowledge_base_ids, - web_search_enabled=web_search_enabled, - timeout=timeout, - ): - self.ap.logger.debug('weknora-agent-chunk: ' + str(chunk)) - - response_type = chunk.get('response_type', '') - content = chunk.get('content', '') - - if response_type == 'tool_call': - # 工具调用 - tool_data = chunk.get('data', {}) - tool_name = tool_data.get('tool_name', '') - if tool_name: - yield provider_message.Message( - role='assistant', - tool_calls=[ - provider_message.ToolCall( - id=chunk.get('id', ''), - type='function', - function=provider_message.FunctionCall( - name=tool_name, - arguments=json.dumps(tool_data.get('arguments', {})), - ), - ) - ], - ) - - elif response_type == 'answer': - if content: - full_answer += content - - elif response_type == 'error': - raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}') - - if chunk is None: - raise errors.WeKnoraAPIError('WeKnora API 没有返回任何响应,请检查网络连接和API配置') - - if full_answer: - yield provider_message.Message( - role='assistant', - content=full_answer, - ) - - async def _chat_messages( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """调用知识库 RAG 问答(非流式聚合输出)""" - session_id = await self._ensure_session(query) - plain_text = await self._extract_plain_text(query) - user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}' - - config = self.pipeline_config['ai']['weknora-api'] - agent_id = config.get('agent-id', 'builtin-quick-answer') - knowledge_base_ids = config.get('knowledge-base-ids', []) - timeout = config.get('timeout', 120) - - full_answer = '' - chunk = None - - async for chunk in self.weknora_client.knowledge_chat( - session_id=session_id, - query=plain_text, - user=user_tag, - agent_id=agent_id, - knowledge_base_ids=knowledge_base_ids, - timeout=timeout, - ): - self.ap.logger.debug('weknora-chat-chunk: ' + str(chunk)) - - response_type = chunk.get('response_type', '') - content = chunk.get('content', '') - - if response_type == 'answer': - if content: - full_answer += content - - elif response_type == 'error': - raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}') - - if chunk is None: - raise errors.WeKnoraAPIError('WeKnora API 没有返回任何响应,请检查网络连接和API配置') - - if full_answer: - yield provider_message.Message( - role='assistant', - content=full_answer, - ) - - async def _agent_chat_messages_chunk( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: - """调用 Agent 智能对话(流式输出)""" - session_id = await self._ensure_session(query) - plain_text = await self._extract_plain_text(query) - user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}' - - config = self.pipeline_config['ai']['weknora-api'] - agent_id = config.get('agent-id', 'builtin-smart-reasoning') - knowledge_base_ids = config.get('knowledge-base-ids', []) - web_search_enabled = config.get('web-search-enabled', False) - timeout = config.get('timeout', 120) - - pending_answer = '' - message_idx = 0 - is_final = False - chunk = None - - async for chunk in self.weknora_client.agent_chat( - session_id=session_id, - query=plain_text, - user=user_tag, - agent_id=agent_id, - knowledge_base_ids=knowledge_base_ids, - web_search_enabled=web_search_enabled, - timeout=timeout, - ): - self.ap.logger.debug('weknora-agent-chunk: ' + str(chunk)) - - response_type = chunk.get('response_type', '') - content = chunk.get('content', '') - done = chunk.get('done', False) - - if response_type == 'tool_call': - tool_data = chunk.get('data', {}) - tool_name = tool_data.get('tool_name', '') - if tool_name: - message_idx += 1 - yield provider_message.MessageChunk( - role='assistant', - tool_calls=[ - provider_message.ToolCall( - id=chunk.get('id', ''), - type='function', - function=provider_message.FunctionCall( - name=tool_name, - arguments=json.dumps(tool_data.get('arguments', {})), - ), - ) - ], - ) - - elif response_type == 'answer': - message_idx += 1 - if content: - pending_answer += content - - if done: - is_final = True - - # 每 8 个 chunk 输出一次,或最终输出 - if message_idx % 8 == 0 or is_final: - yield provider_message.MessageChunk( - role='assistant', - content=pending_answer, - is_final=is_final, - ) - - elif response_type == 'error': - raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}') - - if chunk is None: - raise errors.WeKnoraAPIError('WeKnora API 没有返回任何响应,请检查网络连接和API配置') - - # 确保最终消息已发出 - if not is_final and pending_answer: - yield provider_message.MessageChunk( - role='assistant', - content=pending_answer, - is_final=True, - ) - - async def _chat_messages_chunk( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: - """调用知识库 RAG 问答(流式输出)""" - session_id = await self._ensure_session(query) - plain_text = await self._extract_plain_text(query) - user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}' - - config = self.pipeline_config['ai']['weknora-api'] - agent_id = config.get('agent-id', 'builtin-quick-answer') - knowledge_base_ids = config.get('knowledge-base-ids', []) - timeout = config.get('timeout', 120) - - pending_answer = '' - message_idx = 0 - is_final = False - chunk = None - - async for chunk in self.weknora_client.knowledge_chat( - session_id=session_id, - query=plain_text, - user=user_tag, - agent_id=agent_id, - knowledge_base_ids=knowledge_base_ids, - timeout=timeout, - ): - self.ap.logger.debug('weknora-chat-chunk: ' + str(chunk)) - - response_type = chunk.get('response_type', '') - content = chunk.get('content', '') - done = chunk.get('done', False) - - if response_type == 'answer': - message_idx += 1 - if content: - pending_answer += content - - if done: - is_final = True - - if message_idx % 8 == 0 or is_final: - yield provider_message.MessageChunk( - role='assistant', - content=pending_answer, - is_final=is_final, - ) - - elif response_type == 'error': - raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}') - - if chunk is None: - raise errors.WeKnoraAPIError('WeKnora API 没有返回任何响应,请检查网络连接和API配置') - - if not is_final and pending_answer: - yield provider_message.MessageChunk( - role='assistant', - content=pending_answer, - is_final=True, - ) - - async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: - """运行请求""" - app_type = self.pipeline_config['ai']['weknora-api']['app-type'] - - if await query.adapter.is_stream_output_supported(): - msg_idx = 0 - if app_type == 'agent': - async for msg in self._agent_chat_messages_chunk(query): - msg_idx += 1 - msg.msg_sequence = msg_idx - yield msg - elif app_type == 'chat': - async for msg in self._chat_messages_chunk(query): - msg_idx += 1 - msg.msg_sequence = msg_idx - yield msg - else: - raise errors.WeKnoraAPIError(f'不支持的 WeKnora 应用类型: {app_type}') - else: - if app_type == 'agent': - async for msg in self._agent_chat_messages(query): - yield msg - elif app_type == 'chat': - async for msg in self._chat_messages(query): - yield msg - else: - raise errors.WeKnoraAPIError(f'不支持的 WeKnora 应用类型: {app_type}') diff --git a/src/langbot/pkg/provider/tools/loaders/skill.py b/src/langbot/pkg/provider/tools/loaders/skill.py index b62f3e7d5..7de439caa 100644 --- a/src/langbot/pkg/provider/tools/loaders/skill.py +++ b/src/langbot/pkg/provider/tools/loaders/skill.py @@ -10,6 +10,7 @@ if typing.TYPE_CHECKING: from langbot_plugin.api.entities.events import pipeline_query ACTIVATED_SKILLS_KEY = '_activated_skills' +ACTIVATED_SKILL_NAMES_STATE_KEY = 'host.activated_skills' PIPELINE_BOUND_SKILLS_KEY = '_pipeline_bound_skills' SKILL_MOUNT_PREFIX = '/workspace/.skills' _SKILL_MOUNT_PATTERN = re.compile(r'/workspace/\.skills/([A-Za-z0-9_-]+)') @@ -111,6 +112,29 @@ def restore_activated_skills( return restored +def restore_activated_skills_from_state( + ap: app.Application, + query: pipeline_query.Query, + state: dict[str, dict[str, typing.Any]], +) -> list[str]: + """Restore persisted activated skill names into Query variables. + + The state value stores names only. Full skill metadata is rebuilt from the + current pipeline-visible skill cache so removed or unbound skills remain + unavailable to native exec/write/edit. + """ + conversation_state = state.get('conversation', {}) if isinstance(state, dict) else {} + skill_names = normalize_skill_names(conversation_state.get(ACTIVATED_SKILL_NAMES_STATE_KEY)) + restored: list[str] = [] + for skill_name in skill_names: + skill_data = get_visible_skill(ap, query, skill_name) + if skill_data is None: + continue + register_activated_skill(query, skill_data) + restored.append(skill_name) + return restored + + def 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/loaders/skill_authoring.py b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py index d53721785..e6c5ecef5 100644 --- a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py +++ b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py @@ -92,6 +92,7 @@ class SkillToolLoader(loader.ToolLoader): # Register activated skill for sandbox mount path resolution skill_loader.register_activated_skill(query, skill_data) + await skill_loader.persist_activated_skill(self.ap, query, skill_name) # Return SKILL.md content as Tool Result (injects into context) instructions = skill_data.get('instructions', '') diff --git a/src/langbot/pkg/skill/manager.py b/src/langbot/pkg/skill/manager.py index ddb2125c3..48839a861 100644 --- a/src/langbot/pkg/skill/manager.py +++ b/src/langbot/pkg/skill/manager.py @@ -93,50 +93,3 @@ class SkillManager: def get_skill_by_name(self, name: str) -> dict | None: """Get skill data by name.""" return self.skills.get(name) - - def get_skill_index(self, bound_skills: list[str] | None = None) -> str: - """Render the pipeline-visible skills as a short ``name: description`` - index suitable for the system prompt. - - ``bound_skills`` follows the same convention as - ``query.variables['_pipeline_bound_skills']``: ``None`` means every - loaded skill is exposed; an explicit list filters to that subset. - Returns an empty string when no skills are visible. - """ - lines: list[str] = [] - for skill in self.skills.values(): - name = skill.get('name') - if not name: - continue - if bound_skills is not None and name not in bound_skills: - continue - display = skill.get('display_name') or name - description = (skill.get('description') or '').strip().replace('\n', ' ') - lines.append(f'- {name} ({display}): {description}') - - if not lines: - return '' - return 'Available Skills:\n' + '\n'.join(lines) - - def build_skill_aware_prompt_addition(self, bound_skills: list[str] | None = None) -> str: - """Build the system-prompt addendum that makes the LLM aware of the - pipeline-visible skills. - - Only metadata (name + description) is injected — the full SKILL.md is - loaded later via the ``activate`` Tool Call, protecting KV cache and - matching Claude Code's progressive disclosure pattern. Returns an - empty string when no skills are visible (no prompt change at all). - """ - skill_index = self.get_skill_index(bound_skills) - if not skill_index: - return '' - return ( - '\n\n' - f'{skill_index}\n\n' - "When the user's request clearly matches one or more skills " - 'based on their descriptions above, call the `activate` tool with ' - 'the skill name to load its full instructions. Only the name and ' - 'description are visible here; the actual instructions arrive as ' - 'the tool result. If no skill is a clear match, respond normally ' - 'without activating any skill.' - ) diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 24874fa6a..e03a53991 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -113,6 +113,19 @@ plugin: binary_storage: # Max bytes for a single plugin binary storage value max_value_bytes: 10485760 +agent_runner: + # Host-level admin permissions for trusted control plugins. These plugins + # can use existing plugin action handlers to inspect or manage AgentRunner + # infrastructure across runner/plugin boundaries. Keep empty unless you + # fully trust the plugin identity. + # + # Example: + # admin_plugins: + # - identity: langbot/agent-runner-control + # permissions: + # - agent_run:admin + # - runtime:admin + admin_plugins: [] monitoring: auto_cleanup: # Enable automatic cleanup of expired monitoring records diff --git a/src/langbot/templates/default-pipeline-config.json b/src/langbot/templates/default-pipeline-config.json index 78e2ec958..23666ab03 100644 --- a/src/langbot/templates/default-pipeline-config.json +++ b/src/langbot/templates/default-pipeline-config.json @@ -38,58 +38,10 @@ }, "ai": { "runner": { - "runner": "local-agent", + "id": "", "expire-time": 0 }, - "local-agent": { - "model": { - "primary": "", - "fallbacks": [] - }, - "max-round": 10, - "prompt": [ - { - "role": "system", - "content": "You are a helpful assistant. When tools are available, use them for exact calculations, data processing, and code execution instead of guessing. Unless the user explicitly asks for code or a script, return the result directly instead of printing the generated code." - } - ], - "knowledge-bases": [], - "box-session-id-template": "{launcher_type}_{launcher_id}", - "rerank-model": "", - "rerank-top-k": 5 - }, - "dify-service-api": { - "base-url": "https://api.dify.ai/v1", - "app-type": "chat", - "api-key": "your-api-key", - "timeout": 30 - }, - "dashscope-app-api": { - "app-type": "agent", - "api-key": "your-api-key", - "app-id": "your-app-id", - "references-quote": "参考资料来自:" - }, - "n8n-service-api": { - "webhook-url": "http://your-n8n-webhook-url", - "auth-type": "none", - "basic-username": "", - "basic-password": "", - "jwt-secret": "", - "jwt-algorithm": "HS256", - "header-name": "", - "header-value": "", - "timeout": 120, - "output-key": "response" - }, - "langflow-api": { - "base-url": "http://localhost:7860", - "api-key": "your-api-key", - "flow-id": "your-flow-id", - "input-type": "chat", - "output-type": "chat", - "tweaks": "{}" - } + "runner_config": {} }, "output": { "long-text-processing": { diff --git a/src/langbot/templates/legacy/pipeline.json b/src/langbot/templates/legacy/pipeline.json index eb57f0232..5b8fc9c9d 100644 --- a/src/langbot/templates/legacy/pipeline.json +++ b/src/langbot/templates/legacy/pipeline.json @@ -34,11 +34,5 @@ "limit": 60 } } - }, - "msg-truncate": { - "method": "round", - "round": { - "max-round": 10 - } } -} \ No newline at end of file +} diff --git a/src/langbot/templates/metadata/pipeline/ai.yaml b/src/langbot/templates/metadata/pipeline/ai.yaml index 00c041c75..f169ccb0e 100644 --- a/src/langbot/templates/metadata/pipeline/ai.yaml +++ b/src/langbot/templates/metadata/pipeline/ai.yaml @@ -11,50 +11,13 @@ stages: en_US: Strategy to call AI to process messages zh_Hans: 调用 AI 处理消息的方式 config: - - name: runner + - name: id label: en_US: Runner zh_Hans: 运行器 type: select required: true - default: local-agent - options: - - name: local-agent - label: - en_US: Local Agent - zh_Hans: 内置 Agent - - name: dify-service-api - label: - en_US: Dify Service API - zh_Hans: Dify 服务 API - - name: n8n-service-api - label: - en_US: n8n Workflow API - zh_Hans: n8n 工作流 API - - name: coze-api - label: - en_US: Coze API - zh_Hans: 扣子 API - - name: tbox-app-api - label: - en_US: Tbox App API - zh_Hans: 蚂蚁百宝箱平台 API - - name: dashscope-app-api - label: - en_US: Aliyun Dashscope App API - zh_Hans: 阿里云百炼平台 API - - name: langflow-api - label: - en_US: Langflow API - zh_Hans: Langflow API - - name: weknora-api - label: - en_US: WeKnora API - zh_Hans: WeKnora API - - name: deerflow-api - label: - en_US: DeerFlow API - zh_Hans: DeerFlow API + # Options and default are dynamically populated from AgentRunnerRegistry - name: expire-time label: en_US: Conversation expire time (seconds) @@ -75,802 +38,6 @@ stages: type: integer required: true default: 0 - - name: local-agent - label: - en_US: Local Agent - zh_Hans: 内置 Agent - description: - en_US: Configure the embedded agent of the pipeline - zh_Hans: 配置内置 Agent - config: - - name: model - label: - en_US: Model - zh_Hans: 模型 - type: model-fallback-selector - required: true - default: - primary: '' - fallbacks: [] - - name: max-round - label: - en_US: Max Round - zh_Hans: 最大回合数 - description: - en_US: The maximum number of previous messages that the agent can remember - zh_Hans: 最大前文消息回合数 - type: integer - required: true - default: 10 - show_if: - field: __system.is_wizard - operator: neq - value: true - - name: prompt - label: - en_US: Prompt - zh_Hans: 提示词 - description: - en_US: The prompt of the agent - zh_Hans: 除非您了解消息结构,否则请只使用 system 单提示词 - type: prompt-editor - required: true - default: - - role: system - content: "You are a helpful assistant." - - name: knowledge-bases - label: - en_US: Knowledge Bases - zh_Hans: 知识库 - description: - en_US: Configure the knowledge bases to use for the agent, if not selected, the agent will directly use the LLM to reply - zh_Hans: 配置用于提升回复质量的知识库,若不选择,则直接使用大模型回复 - type: knowledge-base-multi-selector - required: false - default: [] - show_if: - field: __system.is_wizard - operator: neq - value: true - - name: box-session-id-template - label: - en_US: Sandbox Scope - zh_Hans: 沙箱作用域 - zh_Hant: 沙箱作用域 - ja_JP: サンドボックススコープ - vi_VN: Phạm vi Sandbox - th_TH: ขอบเขต Sandbox - es_ES: Alcance del Sandbox - ru_RU: Область песочницы - description: - en_US: Determines how sandbox environments are shared across messages. - zh_Hans: 决定沙箱环境在不同消息间的共享方式。 - zh_Hant: 決定沙箱環境在不同訊息間的共享方式。 - ja_JP: メッセージ間でサンドボックス環境を共有する方法を決定します。 - vi_VN: Xác định cách chia sẻ môi trường sandbox giữa các tin nhắn. - th_TH: กำหนดวิธีแชร์สภาพแวดล้อม Sandbox ระหว่างข้อความ - es_ES: Determina cómo se comparten los entornos sandbox entre mensajes. - ru_RU: Определяет, как песочницы используются совместно между сообщениями. - disable_if: - field: __system.box_scope_editable - operator: eq - value: false - disabled_tooltip: - en_US: >- - Sandbox scope can't be changed: either the Box sandbox is disabled - or unavailable (enable it in config.yaml with box.enabled = true and - ensure the runtime is reachable), or this deployment pins all - pipelines to a fixed scope. - zh_Hans: "无法修改沙箱作用域:Box 沙箱已禁用或不可用(请在配置中启用 box.enabled = true 并确认运行时连接正常),或本部署已将所有流水线固定为统一作用域。" - zh_Hant: "無法修改沙箱作用域:Box 沙箱已停用或無法使用(請在設定中啟用 box.enabled = true 並確認執行時連線正常),或本部署已將所有流水線固定為統一作用域。" - ja_JP: "サンドボックススコープを変更できません:Box サンドボックスが無効/利用不可(設定で box.enabled = true にしてランタイム接続を確認)、またはこのデプロイがすべてのパイプラインを固定スコープに制限しています。" - vi_VN: "Không thể thay đổi phạm vi sandbox:Box sandbox bị tắt hoặc không khả dụng (bật box.enabled = true và đảm bảo runtime hoạt động), hoặc bản triển khai này cố định mọi pipeline về một phạm vi." - th_TH: "ไม่สามารถเปลี่ยนขอบเขต Sandbox:Box sandbox ถูกปิดหรือไม่พร้อมใช้งาน (เปิด box.enabled = true และตรวจสอบรันไทม์) หรือการ deploy นี้ล็อกทุก pipeline ไว้ที่ขอบเขตเดียว" - es_ES: "No se puede cambiar el alcance del sandbox: el sandbox de Box está desactivado o no disponible (actívelo con box.enabled = true y verifique el runtime), o este despliegue fija todas las pipelines a un alcance único." - ru_RU: "Невозможно изменить область песочницы: песочница Box отключена или недоступна (включите box.enabled = true и проверьте среду выполнения), либо это развёртывание фиксирует единую область для всех конвейеров." - type: select - required: false - default: "{launcher_type}_{launcher_id}" - options: - - name: "{global}" - label: - en_US: Global (shared by all) - zh_Hans: 全局(所有人共享) - zh_Hant: 全域(所有人共用) - ja_JP: グローバル(全員共有) - vi_VN: Toàn cục (chia sẻ cho tất cả) - th_TH: ทั่วไป (แชร์ทั้งหมด) - es_ES: Global (compartido por todos) - ru_RU: Глобальный (общий для всех) - - name: "{launcher_type}_{launcher_id}" - label: - en_US: Per chat (Recommended) - zh_Hans: 每个会话(推荐) - zh_Hant: 每個會話(推薦) - ja_JP: チャットごと(推奨) - vi_VN: Mỗi cuộc trò chuyện (Khuyến nghị) - th_TH: ต่อแชท (แนะนำ) - es_ES: Por chat (Recomendado) - ru_RU: По чату (Рекомендуется) - - name: "{launcher_type}_{launcher_id}_{sender_id}" - label: - en_US: Per user in chat - zh_Hans: 会话中每个用户 - zh_Hant: 會話中每個用戶 - ja_JP: チャット内のユーザーごと - vi_VN: Mỗi người dùng trong cuộc trò chuyện - th_TH: ต่อผู้ใช้ในแชท - es_ES: Por usuario en chat - ru_RU: По пользователю в чате - - name: "{launcher_type}_{launcher_id}_{conversation_id}" - label: - en_US: Per conversation context - zh_Hans: 每个对话上下文 - zh_Hant: 每個對話上下文 - ja_JP: 会話コンテキストごと - vi_VN: Mỗi ngữ cảnh hội thoại - th_TH: ต่อบริบทการสนทนา - es_ES: Por contexto de conversación - ru_RU: По контексту разговора - - name: "{query_id}" - label: - en_US: Per message (isolated) - zh_Hans: 每条消息(完全隔离) - zh_Hant: 每條訊息(完全隔離) - ja_JP: メッセージごと(隔離) - vi_VN: Mỗi tin nhắn (cách ly) - th_TH: ต่อข้อความ (แยกส่วน) - es_ES: Por mensaje (aislado) - ru_RU: По сообщению (изолированно) - show_if: - field: __system.is_wizard - operator: neq - value: true - - name: rerank-model - label: - en_US: Rerank Model - zh_Hans: 重排序模型 - description: - en_US: Optional rerank model to improve retrieval quality by re-scoring retrieved chunks - zh_Hans: 可选的重排序模型,通过重新评分检索结果来提升检索质量 - type: rerank-model-selector - required: false - default: '' - show_if: - field: knowledge-bases - operator: neq - value: [] - - name: rerank-top-k - label: - en_US: Rerank Top K - zh_Hans: 重排序保留数量 - description: - en_US: Number of top results to keep after reranking - zh_Hans: 重排序后保留的最相关结果数量 - type: integer - required: false - default: 5 - show_if: - field: rerank-model - operator: neq - value: '' - - name: dify-service-api - label: - en_US: Dify Service API - zh_Hans: Dify 服务 API - description: - en_US: Configure the Dify service API of the pipeline - zh_Hans: 配置 Dify 服务 API - config: - - name: base-url - label: - en_US: Base URL - zh_Hans: 基础 URL - type: string - required: true - options: - - name: 'https://api.dify.ai/v1' - label: - en_US: Dify Cloud - zh_Hans: Dify 云服务 - default: 'https://api.dify.ai/v1' - - name: base-prompt - label: - en_US: Base PROMPT - zh_Hans: 基础提示词 - description: - en_US: When Dify receives a message with empty input (only images), it will pass this default prompt into it. - zh_Hans: 当 Dify 接收到输入文字为空(仅图片)的消息时,传入该默认提示词 - type: string - required: true - default: "When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image." - - name: app-type - label: - en_US: App Type - zh_Hans: 应用类型 - type: select - required: true - default: chat - options: - - name: chat - label: - en_US: Chat - zh_Hans: 聊天(包括Chatflow) - - name: agent - label: - en_US: Agent - zh_Hans: Agent - - name: workflow - label: - en_US: Workflow - zh_Hans: 工作流 - - name: api-key - label: - en_US: API Key - zh_Hans: API 密钥 - type: string - required: true - default: 'your-api-key' - - name: n8n-service-api - label: - en_US: n8n Workflow API - zh_Hans: n8n 工作流 API - description: - en_US: Configure the n8n workflow API of the pipeline - zh_Hans: 配置 n8n 工作流 API - config: - - name: webhook-url - label: - en_US: Webhook URL - zh_Hans: Webhook URL - description: - en_US: The webhook URL of the n8n workflow - zh_Hans: n8n 工作流的 webhook URL - type: string - required: true - default: 'http://your-n8n-webhook-url' - - name: auth-type - label: - en_US: Authentication Type - zh_Hans: 认证类型 - description: - en_US: The authentication type for the webhook call - zh_Hans: webhook 调用的认证类型 - type: select - required: true - default: 'none' - options: - - name: 'none' - label: - en_US: None - zh_Hans: 无认证 - - name: 'basic' - label: - en_US: Basic Auth - zh_Hans: 基本认证 - - name: 'jwt' - label: - en_US: JWT - zh_Hans: JWT认证 - - name: 'header' - label: - en_US: Header Auth - zh_Hans: 请求头认证 - - name: basic-username - label: - en_US: Username - zh_Hans: 用户名 - description: - en_US: The username for Basic Auth - zh_Hans: 基本认证的用户名 - type: string - required: false - default: '' - show_if: - field: auth-type - operator: eq - value: 'basic' - - name: basic-password - label: - en_US: Password - zh_Hans: 密码 - description: - en_US: The password for Basic Auth - zh_Hans: 基本认证的密码 - type: string - required: false - default: '' - show_if: - field: auth-type - operator: eq - value: 'basic' - - name: jwt-secret - label: - en_US: Secret - zh_Hans: 密钥 - description: - en_US: The secret for JWT authentication - zh_Hans: JWT认证的密钥 - type: string - required: false - default: '' - show_if: - field: auth-type - operator: eq - value: 'jwt' - - name: jwt-algorithm - label: - en_US: Algorithm - zh_Hans: 算法 - description: - en_US: The algorithm for JWT authentication - zh_Hans: JWT认证的算法 - type: string - required: false - default: 'HS256' - show_if: - field: auth-type - operator: eq - value: 'jwt' - - name: header-name - label: - en_US: Header Name - zh_Hans: 请求头名称 - description: - en_US: The header name for Header Auth - zh_Hans: 请求头认证的名称 - type: string - required: false - default: '' - show_if: - field: auth-type - operator: eq - value: 'header' - - name: header-value - label: - en_US: Header Value - zh_Hans: 请求头值 - description: - en_US: The header value for Header Auth - zh_Hans: 请求头认证的值 - type: string - required: false - default: '' - show_if: - field: auth-type - operator: eq - value: 'header' - - name: timeout - label: - en_US: Timeout - zh_Hans: 超时时间 - description: - en_US: The timeout in seconds for the webhook call - zh_Hans: webhook 调用的超时时间(秒) - type: integer - required: false - default: 120 - - name: output-key - label: - en_US: Output Key - zh_Hans: 输出键名 - description: - en_US: The key name of the output in the webhook response - zh_Hans: webhook 响应中输出内容的键名 - type: string - required: false - default: 'response' - - name: coze-api - label: - en_US: coze API - zh_Hans: 扣子 API - description: - en_US: Configure the Coze API of the pipeline - zh_Hans: 配置Coze API - config: - - name: api-key - label: - en_US: API Key - zh_Hans: API 密钥 - description: - en_US: The API key for the Coze server - zh_Hans: Coze服务器的 API 密钥 - type: string - required: true - default: '' - - name: bot-id - label: - en_US: Bot ID - zh_Hans: 机器人 ID - description: - en_US: The ID of the bot to run - zh_Hans: 要运行的机器人 ID - type: string - required: true - default: '' - - name: api-base - label: - en_US: API Base URL - zh_Hans: API 基础 URL - description: - en_US: The base URL for the Coze API, please use https://api.coze.com for global Coze edition(coze.com). - zh_Hans: Coze API 的基础 URL,请使用 https://api.coze.com 用于全球 Coze 版(coze.com) - type: string - options: - - name: 'https://api.coze.cn' - label: - en_US: Coze China - zh_Hans: Coze 中国版 - - name: 'https://api.coze.com' - label: - en_US: Coze Global - zh_Hans: Coze 全球版 - default: "https://api.coze.cn" - - name: auto-save-history - label: - en_US: Auto Save History - zh_Hans: 自动保存历史 - description: - en_US: Whether to automatically save conversation history - zh_Hans: 是否自动保存对话历史 - type: boolean - default: true - - name: timeout - label: - en_US: Request Timeout - zh_Hans: 请求超时 - description: - en_US: Timeout in seconds for API requests - zh_Hans: API 请求超时时间(秒) - type: number - default: 120 - - name: tbox-app-api - label: - en_US: Tbox App API - zh_Hans: 蚂蚁百宝箱平台 API - description: - en_US: Configure the Tbox App API of the pipeline - zh_Hans: 配置蚂蚁百宝箱平台 API - config: - - name: api-key - label: - en_US: API Key - zh_Hans: API 密钥 - type: string - required: true - default: '' - - name: app-id - label: - en_US: App ID - zh_Hans: 应用 ID - type: string - required: true - default: '' - - name: dashscope-app-api - label: - en_US: Aliyun Dashscope App API - zh_Hans: 阿里云百炼平台 API - description: - en_US: Configure the Aliyun Dashscope App API of the pipeline - zh_Hans: 配置阿里云百炼平台 API - config: - - name: app-type - label: - en_US: App Type - zh_Hans: 应用类型 - type: select - required: true - default: agent - options: - - name: agent - label: - en_US: Agent - zh_Hans: Agent - - name: workflow - label: - en_US: Workflow - zh_Hans: 工作流 - - name: api-key - label: - en_US: API Key - zh_Hans: API 密钥 - type: string - required: true - default: 'your-api-key' - - name: app-id - label: - en_US: App ID - zh_Hans: 应用 ID - type: string - required: true - default: 'your-app-id' - - name: references_quote - label: - en_US: References Quote - zh_Hans: 引用文本 - description: - en_US: The text prompt when the references are included - zh_Hans: 包含引用资料时的文本提示 - type: string - required: false - default: '参考资料来自:' - - name: langflow-api - label: - en_US: Langflow API - zh_Hans: Langflow API - description: - en_US: Configure the Langflow API of the pipeline, call the Langflow flow through the `Simplified Run Flow` interface - zh_Hans: 配置 Langflow API,通过 `Simplified Run Flow` 接口调用 Langflow 的流程 - config: - - name: base-url - label: - en_US: Base URL - zh_Hans: 基础 URL - description: - en_US: The base URL of the Langflow server - zh_Hans: Langflow 服务器的基础 URL - type: string - required: true - default: 'http://localhost:7860' - - name: api-key - label: - en_US: API Key - zh_Hans: API 密钥 - description: - en_US: The API key for the Langflow server - zh_Hans: Langflow 服务器的 API 密钥 - type: string - required: true - default: 'your-api-key' - - name: flow-id - label: - en_US: Flow ID - zh_Hans: 流程 ID - description: - en_US: The ID of the flow to run - zh_Hans: 要运行的流程 ID - type: string - required: true - default: 'your-flow-id' - - name: input-type - label: - en_US: Input Type - zh_Hans: 输入类型 - description: - en_US: The input type for the flow - zh_Hans: 流程的输入类型 - type: string - required: false - default: 'chat' - - name: output-type - label: - en_US: Output Type - zh_Hans: 输出类型 - description: - en_US: The output type for the flow - zh_Hans: 流程的输出类型 - type: string - required: false - default: 'chat' - - name: tweaks - label: - en_US: Tweaks - zh_Hans: 调整参数 - description: - en_US: Optional tweaks to apply to the flow - zh_Hans: 可选的流程调整参数 - type: json - required: false - default: '{}' - - name: weknora-api - label: - en_US: WeKnora API - zh_Hans: WeKnora API - description: - en_US: Configure the WeKnora API of the pipeline - zh_Hans: 配置 WeKnora API - config: - - name: base-url - label: - en_US: Base URL - zh_Hans: 基础 URL - description: - en_US: The base URL of the WeKnora server (with /api/v1) - zh_Hans: WeKnora 服务器的基础 URL(包含 /api/v1) - type: string - required: true - default: 'http://localhost:8080/api/v1' - - name: api-key - label: - en_US: API Key - zh_Hans: API 密钥 - description: - en_US: The API key for WeKnora, generated from WeKnora frontend Settings → API Keys - zh_Hans: WeKnora 的 API 密钥,从 WeKnora 前端 设置 → API Keys 生成 - type: string - required: true - default: '' - - name: app-type - label: - en_US: App Type - zh_Hans: 应用类型 - type: select - required: true - default: agent - options: - - name: agent - label: - en_US: Agent (Smart Reasoning) - zh_Hans: Agent(智能推理) - - name: chat - label: - en_US: Chat (Knowledge Base RAG) - zh_Hans: 聊天(知识库 RAG) - - name: agent-id - label: - en_US: Agent ID - zh_Hans: 智能体 ID - description: - en_US: The Agent ID to use. Built-in agents include builtin-quick-answer, builtin-smart-reasoning, builtin-data-analyst - zh_Hans: 要使用的智能体 ID。内置智能体:builtin-quick-answer、builtin-smart-reasoning、builtin-data-analyst - type: string - required: true - default: 'builtin-smart-reasoning' - - name: knowledge-base-ids - label: - en_US: Knowledge Base IDs - zh_Hans: 知识库 ID 列表 - description: - en_US: List of WeKnora knowledge base IDs to use (one per line) - zh_Hans: 要使用的 WeKnora 知识库 ID 列表(每行一个) - type: array - required: false - default: [] - - name: web-search-enabled - label: - en_US: Enable Web Search - zh_Hans: 启用网络搜索 - description: - en_US: Whether to enable web search in agent mode - zh_Hans: 在 Agent 模式下是否启用网络搜索 - type: boolean - required: false - default: false - - name: timeout - label: - en_US: Timeout - zh_Hans: 超时时间 - description: - en_US: Request timeout in seconds - zh_Hans: 请求超时时间(秒) - type: integer - required: false - default: 120 - - name: base-prompt - label: - en_US: Base Prompt - zh_Hans: 基础提示词 - description: - en_US: Default prompt when user message is empty (e.g. only images) - zh_Hans: 当用户消息为空(例如仅图片)时使用的默认提示词 - type: string - required: false - default: '请回答用户的问题。' - - name: deerflow-api - label: - en_US: DeerFlow API - zh_Hans: DeerFlow API - description: - en_US: Configure the DeerFlow LangGraph API of the pipeline - zh_Hans: 配置 DeerFlow LangGraph API - config: - - name: api-base - label: - en_US: API Base URL - zh_Hans: API 基础 URL - description: - en_US: The base URL of the DeerFlow server (e.g. http://127.0.0.1:2026) - zh_Hans: DeerFlow 服务器的基础 URL(例如 http://127.0.0.1:2026) - type: string - required: true - default: 'http://127.0.0.1:2026' - - name: api-key - label: - en_US: API Key - zh_Hans: API 密钥 - description: - en_US: Optional API key for DeerFlow (leave empty if not required) - zh_Hans: DeerFlow 的 API 密钥(如果不需要可留空) - type: string - required: false - default: '' - - name: auth-header - label: - en_US: Auth Header Name - zh_Hans: 鉴权请求头名称 - description: - en_US: Custom auth header name. Leave empty to use "x-api-key" - zh_Hans: 自定义鉴权请求头名称,留空则使用 "x-api-key" - type: string - required: false - default: '' - - name: assistant-id - label: - en_US: Assistant ID - zh_Hans: 助手 ID - description: - en_US: The DeerFlow assistant/graph id (default lead_agent) - zh_Hans: DeerFlow 助手/图 ID(默认 lead_agent) - type: string - required: true - default: 'lead_agent' - - name: model-name - label: - en_US: Model Name - zh_Hans: 模型名称 - description: - en_US: Optional model override forwarded to DeerFlow configurable - zh_Hans: 可选的模型名称覆盖,会作为 configurable 转发给 DeerFlow - type: string - required: false - default: '' - - name: thinking-enabled - label: - en_US: Enable Thinking - zh_Hans: 启用思考 - description: - en_US: Whether to enable DeerFlow thinking mode - zh_Hans: 是否启用 DeerFlow 思考模式 - type: boolean - required: false - default: false - - name: plan-mode - label: - en_US: Plan Mode - zh_Hans: 规划模式 - description: - en_US: Whether to enable DeerFlow plan mode - zh_Hans: 是否启用 DeerFlow 规划模式 - type: boolean - required: false - default: false - - name: subagent-enabled - label: - en_US: Enable Subagents - zh_Hans: 启用子代理 - description: - en_US: Whether to enable parallel subagent execution - zh_Hans: 是否启用并行子代理执行 - type: boolean - required: false - default: false - - name: max-concurrent-subagents - label: - en_US: Max Concurrent Subagents - zh_Hans: 最大并发子代理数 - description: - en_US: Maximum number of concurrent subagents (only effective when subagents are enabled) - zh_Hans: 最大并发子代理数(仅在启用子代理时生效) - type: integer - required: false - default: 3 - - name: timeout - label: - en_US: Timeout - zh_Hans: 超时时间 - description: - en_US: Request timeout in seconds (DeerFlow runs may take a long time) - zh_Hans: 请求超时时间(秒),DeerFlow 运行可能耗时较长 - type: integer - required: false - default: 300 - - name: recursion-limit - label: - en_US: Recursion Limit - zh_Hans: 递归上限 - description: - en_US: LangGraph recursion limit for a single run - zh_Hans: 单次运行的 LangGraph 递归上限 - type: integer - required: false - default: 1000 + # Runner config stages are dynamically added from AgentRunnerRegistry + # Each plugin runner's config schema is added as a separate stage + # The stage name matches the runner id for frontend matching \ No newline at end of file diff --git a/tests/factories/app.py b/tests/factories/app.py index d1edf56a2..9b316ffe4 100644 --- a/tests/factories/app.py +++ b/tests/factories/app.py @@ -122,11 +122,9 @@ class FakeApp: return cmd_mgr def _create_mock_skill_mgr(self): - """Mock SkillManager that returns no skill index addition by default.""" + """Mock SkillManager with no loaded skills by default.""" skill_mgr = Mock() skill_mgr.skills = {} - skill_mgr.build_skill_aware_prompt_addition = Mock(return_value='') - skill_mgr.get_skill_index = Mock(return_value=[]) return skill_mgr def _create_mock_pipeline_service(self): diff --git a/tests/unit_tests/COVERAGE_EXCLUSIONS.md b/tests/unit_tests/COVERAGE_EXCLUSIONS.md index 1e3f28cec..40365f245 100644 --- a/tests/unit_tests/COVERAGE_EXCLUSIONS.md +++ b/tests/unit_tests/COVERAGE_EXCLUSIONS.md @@ -18,14 +18,7 @@ - **测试方式**: 需要 mock HTTP 响应或使用 fake LLM server - **状态**: 后续可补充 mock HTTP 测试 -### 3. Agent Runner (`provider/runners/`) -- **路径**: `src/langbot/pkg/provider/runners/` -- **模块**: cozeapi, difysvapi, n8nsvapi, langflowapi, dashscopeapi, localagent, tboxapi -- **排除原因**: 需要真实 Agent 平台(Coze、Dify、n8n 等)的 API 连接 -- **测试方式**: 需要 mock Agent 平台响应 -- **状态**: 后续可补充 mock 测试 - -### 4. 向量数据库 (`vector/vdbs/`) +### 3. 向量数据库 (`vector/vdbs/`) - **路径**: `src/langbot/pkg/vector/vdbs/` - **模块**: chroma, milvus, pgvector, qdrant, seekdb - **排除原因**: 需要真实向量数据库实例运行 @@ -42,7 +35,7 @@ # 排除外部适配器后计算覆盖率 pytest tests/unit_tests/ --cov=langbot.pkg \ --cov-fail-under=0 \ - -o "cov_exclude_patterns=platform/sources/*,provider/modelmgr/requesters/*,provider/runners/*,vector/vdbs/*" + -o "cov_exclude_patterns=platform/sources/*,provider/modelmgr/requesters/*,vector/vdbs/*" ``` ### 当前覆盖率(排除后) @@ -77,15 +70,11 @@ pytest tests/unit_tests/ --cov=langbot.pkg \ - 使用 `httpx` mock 测试 API 响应解析 - 测试重试逻辑、错误处理 -2. **`provider/runners/`** (优先级:中) - - Mock Agent 平台响应 - - 测试 session 管理、错误处理 - -3. **`platform/sources/`** (优先级:低) +2. **`platform/sources/`** (优先级:低) - Mock 平台 webhook 事件 - 测试消息解析、事件处理 -4. **`vector/vdbs/`** (优先级:低) +3. **`vector/vdbs/`** (优先级:低) - Mock 向量数据库操作 - 测试 CRUD、查询逻辑 @@ -176,4 +165,4 @@ tests/unit_tests/ | `core` | **28%** | 1289 | 🔄 需补充 app 启动 | | `persistence` | **24%** | 1099 | 🔄 需补充 mgr | -外部适配器测试需要 mock 环境或集成测试,不属于纯单元测试范畴。 \ No newline at end of file +外部适配器测试需要 mock 环境或集成测试,不属于纯单元测试范畴。 diff --git a/tests/unit_tests/agent/__init__.py b/tests/unit_tests/agent/__init__.py new file mode 100644 index 000000000..ba10b285b --- /dev/null +++ b/tests/unit_tests/agent/__init__.py @@ -0,0 +1,2 @@ +"""Tests for agent runner subsystem.""" +from __future__ import annotations \ No newline at end of file diff --git a/tests/unit_tests/agent/conftest.py b/tests/unit_tests/agent/conftest.py new file mode 100644 index 000000000..a55dccf1c --- /dev/null +++ b/tests/unit_tests/agent/conftest.py @@ -0,0 +1,122 @@ +"""Shared test fixtures for agent runner tests.""" +from __future__ import annotations + +import typing + + +def make_resources( + models: list[dict] | None = None, + tools: list[dict] | None = None, + knowledge_bases: list[dict] | None = None, + skills: list[dict] | None = None, + storage: dict | None = None, +) -> dict[str, typing.Any]: + """Create a minimal AgentResources dict for testing. + + Args: + models: List of model dicts with 'model_id' key + tools: List of tool dicts with 'tool_name' key + knowledge_bases: List of KB dicts with 'kb_id' key + skills: List of skill dicts with 'skill_name' key + storage: Storage permissions dict + Returns: + AgentResources dict with all required fields + """ + return { + 'models': models or [], + 'tools': tools or [], + 'knowledge_bases': knowledge_bases or [], + 'skills': skills or [], + 'storage': storage or {'plugin_storage': False, 'workspace_storage': False}, + 'platform_capabilities': {}, + } + + +def make_session( + run_id: str = 'test-run-id', + runner_id: str = 'plugin:test/test-runner/default', + query_id: int | None = 1, + plugin_identity: str = 'test/test-runner', + resources: dict | None = None, + conversation_id: str | None = None, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + available_apis: dict[str, bool] | None = None, + state_policy: dict[str, typing.Any] | None = None, + state_context: dict[str, typing.Any] | None = None, +) -> dict[str, typing.Any]: + """Create a minimal AgentRunSession dict for testing. + + Args: + run_id: Unique run identifier + runner_id: Runner descriptor ID + query_id: Host entry query ID + plugin_identity: Plugin identifier (author/name) + resources: AgentResources dict (uses make_resources() default if None) + + Returns: + AgentRunSession dict with run-scoped authorization snapshot + """ + import time + now = int(time.time()) + res = resources if resources is not None else make_resources() + apis = available_apis if available_apis is not None else {} + policy = ( + state_policy + if state_policy is not None + else {'enable_state': True, 'state_scopes': ['conversation', 'actor']} + ) + context = state_context if state_context is not None else {} + + authorized_ids: dict[str, set[str]] = { + 'model': {m.get('model_id') for m in res.get('models', [])}, + 'tool': {t.get('tool_name') for t in res.get('tools', [])}, + 'knowledge_base': {kb.get('kb_id') for kb in res.get('knowledge_bases', [])}, + 'skill': {s.get('skill_name') for s in res.get('skills', [])}, + } + authorized_operations: dict[str, dict[str, set[str]]] = { + 'model': { + m.get('model_id'): set(m.get('operations') or ['invoke', 'stream', 'rerank']) + for m in res.get('models', []) + if m.get('model_id') + }, + 'tool': { + t.get('tool_name'): set(t.get('operations') or ['detail', 'call']) + for t in res.get('tools', []) + if t.get('tool_name') + }, + 'knowledge_base': { + kb.get('kb_id'): set(kb.get('operations') or ['list', 'retrieve']) + for kb in res.get('knowledge_bases', []) + if kb.get('kb_id') + }, + 'skill': { + s.get('skill_name'): set(s.get('operations') or ['activate']) + for s in res.get('skills', []) + if s.get('skill_name') + }, + } + + return { + 'run_id': run_id, + 'runner_id': runner_id, + 'query_id': query_id, + 'plugin_identity': plugin_identity, + 'authorization': { + 'resources': res, + 'available_apis': apis, + 'conversation_id': conversation_id, + 'bot_id': bot_id, + 'workspace_id': workspace_id, + 'thread_id': thread_id, + 'state_policy': policy, + 'state_context': context, + 'authorized_ids': authorized_ids, + 'authorized_operations': authorized_operations, + }, + 'status': { + 'started_at': now, + 'last_activity_at': now, + }, + } diff --git a/tests/unit_tests/agent/test_chat_handler.py b/tests/unit_tests/agent/test_chat_handler.py new file mode 100644 index 000000000..88e3b4f2a --- /dev/null +++ b/tests/unit_tests/agent/test_chat_handler.py @@ -0,0 +1,608 @@ +"""Tests for ChatMessageHandler behavior with AgentRunOrchestrator. + +Tests focus on: +- Streaming mode behavior (single resp_message_id, pop/append pattern) +- Non-streaming mode behavior (no pop) +- Orchestrator invocation +- Error handling for RunnerNotFoundError, RunnerExecutionError + +Avoids circular imports by using proper import structure. +""" +from __future__ import annotations + +import uuid +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from langbot.pkg.agent.runner.errors import ( + RunnerNotFoundError, + RunnerExecutionError, + RunnerNotAuthorizedError, +) +from langbot.pkg.agent.runner.config_migration import ConfigMigration + + +# Define mock classes in dependency order (no forward references needed) + +class MockLauncherType: + value = 'person' + + +class MockConversation: + def __init__(self): + self.uuid = 'conv-uuid' + self.messages = [] + + +class MockMessage: + role = 'user' + content = 'Hello' + + +class MockAdapter: + is_stream = False + + async def is_stream_output_supported(self): + return self.is_stream + + async def create_message_card(self, resp_message_id, message_event): + pass + + +class MockSession: + launcher_type = MockLauncherType() + launcher_id = 'user123' + + def __init__(self): + self.using_conversation = MockConversation() + + +class MockQuery: + """Mock Query for testing.""" + def __init__(self): + self.query_id = 1 + self.launcher_type = MockLauncherType() + self.launcher_id = 'user123' + self.sender_id = 'user123' + self.bot_uuid = 'bot-uuid' + self.pipeline_uuid = 'pipeline-uuid' + self.pipeline_config = { + 'ai': { + 'runner': { + 'id': 'plugin:langbot/local-agent/default', + }, + 'runner_config': {}, + }, + 'output': { + 'misc': { + 'exception-handling': 'show-hint', + 'failure-hint': 'Request failed.', + }, + }, + } + self.variables = {} + self.session = MockSession() + self.user_message = MockMessage() + self.messages = [] + self.resp_messages = [] + self.resp_message_chain = None + self.adapter = MockAdapter() + self.message_event = MagicMock() + self.message_chain = MagicMock() + + +class MockMessageChunk: + """Mock MessageChunk for testing.""" + def __init__(self, content, resp_message_id=None): + self.role = 'assistant' + self.content = content + self.resp_message_id = resp_message_id + self.tool_calls = [] + self.is_final = False + + def readable_str(self): + return self.content + + +class MockEventContext: + """Mock event context for testing.""" + def __init__(self, prevented=False, reply_message_chain=None, user_message_alter=None): + self._prevented = prevented + self.event = MagicMock() + self.event.reply_message_chain = reply_message_chain + self.event.user_message_alter = user_message_alter + + def is_prevented_default(self): + return self._prevented + + +class MockAgentRunOrchestrator: + """Mock AgentRunOrchestrator for testing.""" + def __init__(self, chunks=None, error=None): + self._chunks = chunks or [] + self._error = error + + async def run_from_query(self, query): + """Async generator that yields chunks or raises error.""" + if self._error: + raise self._error + for chunk in self._chunks: + yield chunk + + async def try_claim_steering_from_query(self, query): + return False + + def resolve_runner_id_for_telemetry(self, query): + return 'plugin:langbot/local-agent/default' + + +class MockApplication: + """Mock Application for testing.""" + def __init__(self, orchestrator=None): + self.agent_run_orchestrator = orchestrator or MockAgentRunOrchestrator() + self.logger = MagicMock() + self.logger.info = MagicMock() + self.logger.debug = MagicMock() + self.logger.warning = MagicMock() + self.logger.error = MagicMock() + + # Mock plugin_connector + self.plugin_connector = MagicMock() + self.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext()) + + # Mock telemetry + self.telemetry = MagicMock() + self.telemetry.start_send_task = AsyncMock() + + # Mock survey + self.survey = MagicMock() + self.survey.trigger_event = AsyncMock() + + # Mock model_mgr + self.model_mgr = MagicMock() + self.model_mgr.get_model_by_uuid = AsyncMock(return_value=None) + + # Mock sess_mgr + self.sess_mgr = MagicMock() + self.sess_mgr.get_conversation = AsyncMock() + + +class TestStreamingBehavior: + """Tests for streaming mode behavior.""" + + def test_single_resp_message_id_for_streaming(self): + """Streaming mode should use single resp_message_id for entire response.""" + # Simulate the streaming logic: resp_message_id created outside loop + resp_message_id = uuid.uuid4() + + chunks = ['Hello', ' World', '!'] + resp_messages = [] + + for chunk in chunks: + result = MockMessageChunk(chunk) + result.resp_message_id = str(resp_message_id) + + # Pop old chunk (streaming behavior) + if resp_messages: + resp_messages.pop() + resp_messages.append(result) + + # All chunks should have same resp_message_id + assert len(resp_messages) == 1 # Only last chunk remains after pop/append + assert resp_messages[0].resp_message_id == str(resp_message_id) + + def test_pop_before_append_in_streaming(self): + """Streaming mode should pop old chunk before appending new.""" + resp_message_id = uuid.uuid4() + resp_messages = [] + + # First chunk - no pop + chunk1 = MockMessageChunk('Hello') + chunk1.resp_message_id = str(resp_message_id) + resp_messages.append(chunk1) + assert len(resp_messages) == 1 + + # Second chunk - pop first, then append + if resp_messages: + resp_messages.pop() + chunk2 = MockMessageChunk('Hello World') + chunk2.resp_message_id = str(resp_message_id) + resp_messages.append(chunk2) + assert len(resp_messages) == 1 + assert resp_messages[0].content == 'Hello World' + + def test_non_streaming_no_pop(self): + """Non-streaming mode should NOT pop previous responses.""" + resp_messages = [] + + # First message + msg1 = MockMessageChunk('Response 1') + resp_messages.append(msg1) + assert len(resp_messages) == 1 + + # Second message - should NOT pop in non-streaming + msg2 = MockMessageChunk('Response 2') + resp_messages.append(msg2) + assert len(resp_messages) == 2 + + +class TestConfigMigrationInChatHandler: + """Tests for ConfigMigration usage in chat handler context.""" + + def test_resolve_runner_id_from_pipeline_config(self): + """Chat handler should use ConfigMigration to resolve runner ID.""" + pipeline_config = { + 'ai': { + 'runner': { + 'id': 'plugin:langbot/local-agent/default', + }, + }, + } + + runner_id = ConfigMigration.resolve_runner_id(pipeline_config) + assert runner_id == 'plugin:langbot/local-agent/default' + + def test_resolve_runner_id_from_old_format(self): + """ConfigMigration resolves old runner aliases for compatibility.""" + pipeline_config = { + 'ai': { + 'runner': { + 'runner': 'local-agent', + }, + }, + } + + runner_id = ConfigMigration.resolve_runner_id(pipeline_config) + assert runner_id == 'plugin:langbot/local-agent/default' + + +class TestErrorHandling: + """Tests for orchestrator error handling.""" + + def test_runner_not_found_error_properties(self): + """RunnerNotFoundError should have runner_id property.""" + error = RunnerNotFoundError('plugin:notexist/unknown/default') + assert error.runner_id == 'plugin:notexist/unknown/default' + assert 'not found' in str(error) + + def test_runner_execution_error_retryable(self): + """RunnerExecutionError should have retryable property.""" + error = RunnerExecutionError( + 'plugin:langbot/local-agent/default', + 'Upstream timeout', + retryable=True, + ) + assert error.runner_id == 'plugin:langbot/local-agent/default' + assert error.retryable is True + assert 'timeout' in str(error) + + def test_runner_execution_error_not_retryable(self): + """RunnerExecutionError can be non-retryable.""" + error = RunnerExecutionError( + 'plugin:langbot/local-agent/default', + 'Configuration error', + retryable=False, + ) + assert error.retryable is False + + def test_runner_not_authorized_error_properties(self): + """RunnerNotAuthorizedError should have bound_plugins property.""" + error = RunnerNotAuthorizedError( + 'plugin:langbot/local-agent/default', + ['langbot/dify-agent'], + ) + assert error.runner_id == 'plugin:langbot/local-agent/default' + assert error.bound_plugins == ['langbot/dify-agent'] + + +class TestChatHandlerImports: + """Test that chat handler can be imported without circular import.""" + + def test_import_chat_handler_module(self): + """Import chat handler module should work.""" + # This test verifies the import works without circular dependency + from langbot.pkg.pipeline.process.handlers import chat + assert chat.ChatMessageHandler is not None + + def test_chat_handler_class_exists(self): + """ChatMessageHandler class should be defined.""" + from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler + assert ChatMessageHandler.__name__ == 'ChatMessageHandler' + + def test_chat_handler_has_handle_method(self): + """ChatMessageHandler should have async generator handle method.""" + from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler + assert hasattr(ChatMessageHandler, 'handle') + # handle returns AsyncGenerator, so check for async generator function + import inspect + assert inspect.isasyncgenfunction(ChatMessageHandler.handle) + + +class TestChatHandlerAsyncBehavior: + """Real async tests for ChatMessageHandler.handle() with mocked orchestrator.""" + + @pytest.mark.asyncio + async def test_streaming_single_resp_message_id(self): + """Streaming mode: all chunks should have same resp_message_id.""" + from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler + from langbot.pkg.pipeline import entities + + # Create chunks for streaming + chunks = [ + MockMessageChunk('Hello'), + MockMessageChunk('Hello World'), + MockMessageChunk('Hello World!'), + ] + + orchestrator = MockAgentRunOrchestrator(chunks=chunks) + mock_ap = MockApplication(orchestrator=orchestrator) + + # Mock event context to not prevent default + event_ctx = MockEventContext(prevented=False) + mock_ap.plugin_connector.emit_event = AsyncMock(return_value=event_ctx) + + query = MockQuery() + query.adapter.is_stream = True # Enable streaming mode + + handler = ChatMessageHandler(mock_ap) + + # Mock event creation and StageProcessResult to bypass pydantic validation + mock_event = MagicMock() + mock_event.return_value = MagicMock() + + def make_result(*args, **kwargs): + return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE)) + + with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \ + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result): + mock_events_module.PersonNormalMessageReceived = mock_event + mock_events_module.GroupNormalMessageReceived = mock_event + + results = [] + async for result in handler.handle(query): + results.append(result) + + # Verify single resp_message_id + resp_ids = [msg.resp_message_id for msg in query.resp_messages if hasattr(msg, 'resp_message_id')] + assert len(set(resp_ids)) == 1 # All same ID + + # Verify pop/append pattern: only last chunk remains + assert len(query.resp_messages) == 1 + assert query.resp_messages[0].content == 'Hello World!' + + @pytest.mark.asyncio + async def test_non_streaming_no_pop(self): + """Non-streaming mode: all chunks should remain.""" + from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler + from langbot.pkg.pipeline import entities + + chunks = [ + MockMessageChunk('Response 1'), + MockMessageChunk('Response 2'), + ] + + orchestrator = MockAgentRunOrchestrator(chunks=chunks) + mock_ap = MockApplication(orchestrator=orchestrator) + mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False)) + + query = MockQuery() + query.adapter.is_stream = False # Disable streaming mode + + handler = ChatMessageHandler(mock_ap) + + mock_event = MagicMock() + mock_event.return_value = MagicMock() + + def make_result(*args, **kwargs): + return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE)) + + with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \ + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result): + mock_events_module.PersonNormalMessageReceived = mock_event + mock_events_module.GroupNormalMessageReceived = mock_event + + results = [] + async for result in handler.handle(query): + results.append(result) + + # No pop: all chunks should remain + assert len(query.resp_messages) == 2 + assert query.resp_messages[0].content == 'Response 1' + assert query.resp_messages[1].content == 'Response 2' + + @pytest.mark.asyncio + async def test_agent_turn_recreates_conversation_if_tool_resets_it(self): + """Agent turn bookkeeping should tolerate CREATE_NEW_CONVERSATION during runner execution.""" + from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler + from langbot.pkg.pipeline import entities + + response = MockMessageChunk('Tool response') + new_conversation = MockConversation() + + class ResetConversationOrchestrator(MockAgentRunOrchestrator): + async def run_from_query(self, query): + query.session.using_conversation = None + yield response + + mock_ap = MockApplication(orchestrator=ResetConversationOrchestrator()) + mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False)) + mock_ap.sess_mgr.get_conversation = AsyncMock(return_value=new_conversation) + + query = MockQuery() + query.adapter.is_stream = False + + handler = ChatMessageHandler(mock_ap) + + mock_event = MagicMock() + mock_event.return_value = MagicMock() + + def make_result(*args, **kwargs): + return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE)) + + with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \ + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result): + mock_events_module.PersonNormalMessageReceived = mock_event + mock_events_module.GroupNormalMessageReceived = mock_event + + results = [] + async for result in handler.handle(query): + results.append(result) + + assert len(results) == 1 + assert results[0].result_type == entities.ResultType.CONTINUE + mock_ap.sess_mgr.get_conversation.assert_awaited_once() + assert query.session.using_conversation is new_conversation + assert new_conversation.messages == [] + + @pytest.mark.asyncio + async def test_runner_not_found_error(self): + """Handler should catch RunnerNotFoundError and return INTERRUPT.""" + from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler + from langbot.pkg.pipeline import entities + + orchestrator = MockAgentRunOrchestrator( + error=RunnerNotFoundError('plugin:notexist/unknown/default') + ) + mock_ap = MockApplication(orchestrator=orchestrator) + mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False)) + + query = MockQuery() + + handler = ChatMessageHandler(mock_ap) + + mock_event = MagicMock() + mock_event.return_value = MagicMock() + + def make_result(*args, **kwargs): + return MagicMock( + result_type=kwargs.get('result_type'), + user_notice=kwargs.get('user_notice'), + ) + + with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \ + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result): + mock_events_module.PersonNormalMessageReceived = mock_event + mock_events_module.GroupNormalMessageReceived = mock_event + + results = [] + async for result in handler.handle(query): + results.append(result) + + # Should return INTERRUPT with user_notice + assert len(results) == 1 + assert results[0].result_type == entities.ResultType.INTERRUPT + assert 'not found' in results[0].user_notice + + @pytest.mark.asyncio + async def test_runner_not_authorized_error(self): + """Handler should catch RunnerNotAuthorizedError and return INTERRUPT.""" + from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler + from langbot.pkg.pipeline import entities + + orchestrator = MockAgentRunOrchestrator( + error=RunnerNotAuthorizedError('plugin:langbot/local-agent/default', ['other/plugin']) + ) + mock_ap = MockApplication(orchestrator=orchestrator) + mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False)) + + query = MockQuery() + + handler = ChatMessageHandler(mock_ap) + + mock_event = MagicMock() + mock_event.return_value = MagicMock() + + def make_result(*args, **kwargs): + return MagicMock( + result_type=kwargs.get('result_type'), + user_notice=kwargs.get('user_notice'), + ) + + with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \ + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result): + mock_events_module.PersonNormalMessageReceived = mock_event + mock_events_module.GroupNormalMessageReceived = mock_event + + results = [] + async for result in handler.handle(query): + results.append(result) + + assert len(results) == 1 + assert results[0].result_type == entities.ResultType.INTERRUPT + assert 'not authorized' in results[0].user_notice + + @pytest.mark.asyncio + async def test_runner_execution_error_retryable(self): + """Handler should catch retryable RunnerExecutionError.""" + from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler + from langbot.pkg.pipeline import entities + + orchestrator = MockAgentRunOrchestrator( + error=RunnerExecutionError('plugin:langbot/local-agent/default', 'timeout', retryable=True) + ) + mock_ap = MockApplication(orchestrator=orchestrator) + mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False)) + + query = MockQuery() + + handler = ChatMessageHandler(mock_ap) + + mock_event = MagicMock() + mock_event.return_value = MagicMock() + + def make_result(*args, **kwargs): + return MagicMock( + result_type=kwargs.get('result_type'), + user_notice=kwargs.get('user_notice'), + ) + + with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \ + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result): + mock_events_module.PersonNormalMessageReceived = mock_event + mock_events_module.GroupNormalMessageReceived = mock_event + + results = [] + async for result in handler.handle(query): + results.append(result) + + assert len(results) == 1 + assert results[0].result_type == entities.ResultType.INTERRUPT + assert 'temporarily unavailable' in results[0].user_notice + + @pytest.mark.asyncio + async def test_prevented_default_with_reply(self): + """When event prevented default with reply, use reply message.""" + from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler + from langbot.pkg.pipeline import entities + + # Mock reply message chain + reply_chain = MockMessageChunk('Reply from plugin') + + mock_ap = MockApplication() + mock_ap.plugin_connector.emit_event = AsyncMock( + return_value=MockEventContext(prevented=True, reply_message_chain=reply_chain) + ) + + query = MockQuery() + + handler = ChatMessageHandler(mock_ap) + + mock_event = MagicMock() + mock_event.return_value = MagicMock() + + def make_result(*args, **kwargs): + return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE)) + + with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \ + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result): + mock_events_module.PersonNormalMessageReceived = mock_event + mock_events_module.GroupNormalMessageReceived = mock_event + + results = [] + async for result in handler.handle(query): + results.append(result) + + # Should return CONTINUE with reply message + assert len(results) == 1 + assert results[0].result_type == entities.ResultType.CONTINUE + assert len(query.resp_messages) == 1 diff --git a/tests/unit_tests/agent/test_config_migration.py b/tests/unit_tests/agent/test_config_migration.py new file mode 100644 index 000000000..aec7e8e4a --- /dev/null +++ b/tests/unit_tests/agent/test_config_migration.py @@ -0,0 +1,257 @@ +"""Tests for current AgentRunner config helpers.""" + +from __future__ import annotations + +from langbot.pkg.agent.runner.config_migration import ConfigMigration + + +class TestResolveRunnerId: + """Tests for ConfigMigration.resolve_runner_id.""" + + def test_resolve_current_runner_id(self): + pipeline_config = { + 'ai': { + 'runner': { + 'id': 'plugin:langbot/local-agent/default', + }, + }, + } + + runner_id = ConfigMigration.resolve_runner_id(pipeline_config) + assert runner_id == 'plugin:langbot/local-agent/default' + + def test_resolves_old_runner_field(self): + pipeline_config = { + 'ai': { + 'runner': { + 'runner': 'local-agent', + }, + }, + } + + runner_id = ConfigMigration.resolve_runner_id(pipeline_config) + assert runner_id == 'plugin:langbot/local-agent/default' + + def test_resolves_deerflow_and_weknora_legacy_runner_fields(self): + assert ( + ConfigMigration.resolve_runner_id( + { + 'ai': { + 'runner': { + 'runner': 'deerflow-api', + }, + }, + } + ) + == 'plugin:langbot/deerflow-agent/default' + ) + assert ( + ConfigMigration.resolve_runner_id( + { + 'ai': { + 'runner': { + 'runner': 'weknora-api', + }, + }, + } + ) + == 'plugin:langbot/weknora-agent/default' + ) + + def test_resolve_no_runner_config(self): + runner_id = ConfigMigration.resolve_runner_id({}) + assert runner_id is None + + +class TestResolveRunnerConfig: + """Tests for ConfigMigration.resolve_runner_config.""" + + def test_resolve_current_config(self): + pipeline_config = { + 'ai': { + 'runner_config': { + 'plugin:langbot/local-agent/default': { + 'model': 'uuid-123', + 'custom_option': 10, + }, + }, + }, + } + + config = ConfigMigration.resolve_runner_config( + pipeline_config, + 'plugin:langbot/local-agent/default', + ) + assert config == {'model': 'uuid-123', 'custom_option': 10} + + def test_reads_old_runner_block(self): + pipeline_config = { + 'ai': { + 'local-agent': { + 'model': 'uuid-123', + }, + }, + } + + config = ConfigMigration.resolve_runner_config( + pipeline_config, + 'plugin:langbot/local-agent/default', + ) + assert config == {'model': {'primary': 'uuid-123', 'fallbacks': []}} + + def test_reads_deerflow_and_weknora_legacy_runner_blocks(self): + pipeline_config = { + 'ai': { + 'deerflow-api': { + 'api-base': 'http://127.0.0.1:2026', + 'assistant-id': 'lead_agent', + }, + 'weknora-api': { + 'base-url': 'http://localhost:8080/api/v1', + 'app-type': 'agent', + }, + }, + } + + deerflow_config = ConfigMigration.resolve_runner_config( + pipeline_config, + 'plugin:langbot/deerflow-agent/default', + ) + weknora_config = ConfigMigration.resolve_runner_config( + pipeline_config, + 'plugin:langbot/weknora-agent/default', + ) + + assert deerflow_config == { + 'api-base': 'http://127.0.0.1:2026', + 'assistant-id': 'lead_agent', + } + assert weknora_config == { + 'base-url': 'http://localhost:8080/api/v1', + 'app-type': 'agent', + } + + def test_resolve_no_config(self): + config = ConfigMigration.resolve_runner_config( + {}, + 'plugin:langbot/local-agent/default', + ) + assert config == {} + + +class TestGetExpireTime: + """Tests for ConfigMigration.get_expire_time.""" + + def test_get_expire_time_zero(self): + pipeline_config = { + 'ai': { + 'runner': { + 'expire-time': 0, + }, + }, + } + + expire_time = ConfigMigration.get_expire_time(pipeline_config) + assert expire_time == 0 + + def test_get_expire_time_positive(self): + pipeline_config = { + 'ai': { + 'runner': { + 'expire-time': 3600, + }, + }, + } + + expire_time = ConfigMigration.get_expire_time(pipeline_config) + assert expire_time == 3600 + + def test_get_expire_time_default(self): + expire_time = ConfigMigration.get_expire_time({}) + assert expire_time == 0 + + +class TestNormalizePipelineConfig: + """Tests for ConfigMigration.migrate_pipeline_config.""" + + def test_normalizes_current_containers(self): + config = {'ai': {}} + + migrated = ConfigMigration.migrate_pipeline_config(config) + + assert migrated == {'ai': {'runner': {}, 'runner_config': {}}} + + def test_preserves_current_config(self): + config = { + 'ai': { + 'runner': {'id': 'plugin:test/my-runner/default'}, + 'runner_config': { + 'plugin:test/my-runner/default': {'custom-option': 20}, + }, + }, + } + + migrated = ConfigMigration.migrate_pipeline_config(config) + + assert migrated['ai']['runner']['id'] == 'plugin:test/my-runner/default' + assert migrated['ai']['runner_config']['plugin:test/my-runner/default']['custom-option'] == 20 + + def test_migrates_old_runner_blocks(self): + config = { + 'ai': { + 'runner': {'runner': 'local-agent'}, + 'local-agent': {'model': 'old-model', 'knowledge-base': 'kb_1'}, + }, + } + + migrated = ConfigMigration.migrate_pipeline_config(config) + + assert migrated['ai']['runner']['id'] == 'plugin:langbot/local-agent/default' + assert 'runner' not in migrated['ai']['runner'] + assert 'local-agent' not in migrated['ai'] + assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default'] == { + 'model': {'primary': 'old-model', 'fallbacks': []}, + 'knowledge-bases': ['kb_1'], + } + + def test_migrates_deerflow_legacy_runner_block(self): + config = { + 'ai': { + 'runner': {'runner': 'deerflow-api'}, + 'deerflow-api': { + 'api-base': 'http://127.0.0.1:2026', + 'assistant-id': 'lead_agent', + }, + }, + } + + migrated = ConfigMigration.migrate_pipeline_config(config) + + assert migrated['ai']['runner']['id'] == 'plugin:langbot/deerflow-agent/default' + assert 'runner' not in migrated['ai']['runner'] + assert 'deerflow-api' not in migrated['ai'] + assert migrated['ai']['runner_config']['plugin:langbot/deerflow-agent/default'] == { + 'api-base': 'http://127.0.0.1:2026', + 'assistant-id': 'lead_agent', + } + + def test_migrates_weknora_legacy_runner_block(self): + config = { + 'ai': { + 'runner': {'runner': 'weknora-api'}, + 'weknora-api': { + 'base-url': 'http://localhost:8080/api/v1', + 'app-type': 'agent', + }, + }, + } + + migrated = ConfigMigration.migrate_pipeline_config(config) + + assert migrated['ai']['runner']['id'] == 'plugin:langbot/weknora-agent/default' + assert 'runner' not in migrated['ai']['runner'] + assert 'weknora-api' not in migrated['ai'] + assert migrated['ai']['runner_config']['plugin:langbot/weknora-agent/default'] == { + 'base-url': 'http://localhost:8080/api/v1', + 'app-type': 'agent', + } diff --git a/tests/unit_tests/agent/test_config_migration_full.py b/tests/unit_tests/agent/test_config_migration_full.py new file mode 100644 index 000000000..ecff0ff5a --- /dev/null +++ b/tests/unit_tests/agent/test_config_migration_full.py @@ -0,0 +1,131 @@ +"""Tests for persisted AgentRunner config shape.""" + +from __future__ import annotations + +import json + +from langbot.pkg.agent.runner.config_migration import ConfigMigration + + +class TestMigratePipelineConfig: + """Tests for ConfigMigration.migrate_pipeline_config.""" + + def test_current_format_config_stays_unchanged(self): + config = { + 'ai': { + 'runner': { + 'id': 'plugin:langbot/local-agent/default', + 'expire-time': 0, + }, + 'runner_config': { + 'plugin:langbot/local-agent/default': { + 'model': {'primary': '', 'fallbacks': []}, + 'custom-option': 10, + }, + }, + }, + } + + migrated = ConfigMigration.migrate_pipeline_config(config) + + assert migrated['ai']['runner']['id'] == 'plugin:langbot/local-agent/default' + assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default']['custom-option'] == 10 + + def test_old_runner_field_is_mapped(self): + config = { + 'ai': { + 'runner': { + 'runner': 'local-agent', + 'expire-time': 3600, + }, + 'local-agent': { + 'model': 'old-model', + }, + }, + } + + migrated = ConfigMigration.migrate_pipeline_config(config) + + assert migrated['ai']['runner'] == { + 'expire-time': 3600, + 'id': 'plugin:langbot/local-agent/default', + } + assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default'] == { + 'model': {'primary': 'old-model', 'fallbacks': []}, + } + assert 'local-agent' not in migrated['ai'] + + def test_empty_config_is_unchanged(self): + config = {} + migrated = ConfigMigration.migrate_pipeline_config(config) + assert migrated == {} + + def test_config_without_ai_section_is_unchanged(self): + config = {'trigger': {}} + migrated = ConfigMigration.migrate_pipeline_config(config) + assert migrated == {'trigger': {}} + + +class TestDefaultPipelineConfig: + """Tests for default-pipeline-config.json format.""" + + def test_default_config_is_current_format(self): + from langbot.pkg.utils import paths as path_utils + + template_path = path_utils.get_resource_path('templates/default-pipeline-config.json') + with open(template_path, 'r', encoding='utf-8') as f: + config = json.load(f) + + assert 'ai' in config + assert 'runner' in config['ai'] + assert 'id' in config['ai']['runner'] + assert config['ai']['runner']['id'] == '' + assert 'runner_config' in config['ai'] + assert config['ai']['runner_config'] == {} + assert 'local-agent' not in config['ai'] + + +class TestResolveRunnerId: + """Tests for current runner id resolution.""" + + def test_resolve_current_id(self): + config = { + 'ai': { + 'runner': {'id': 'plugin:test/my-runner/default'}, + }, + } + runner_id = ConfigMigration.resolve_runner_id(config) + assert runner_id == 'plugin:test/my-runner/default' + + def test_old_runner_field_is_mapped(self): + config = { + 'ai': { + 'runner': {'runner': 'local-agent'}, + }, + } + runner_id = ConfigMigration.resolve_runner_id(config) + assert runner_id == 'plugin:langbot/local-agent/default' + + +class TestResolveRunnerConfig: + """Tests for runtime runner config resolution.""" + + def test_resolve_current_config(self): + config = { + 'ai': { + 'runner_config': { + 'plugin:langbot/local-agent/default': {'custom-option': 20}, + }, + }, + } + runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot/local-agent/default') + assert runner_config['custom-option'] == 20 + + def test_old_runner_block_is_read(self): + config = { + 'ai': { + 'local-agent': {'custom-option': 20}, + }, + } + runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot/local-agent/default') + assert runner_config == {'custom-option': 20} diff --git a/tests/unit_tests/agent/test_context_builder_params_state.py b/tests/unit_tests/agent/test_context_builder_params_state.py new file mode 100644 index 000000000..05e868eb5 --- /dev/null +++ b/tests/unit_tests/agent/test_context_builder_params_state.py @@ -0,0 +1,162 @@ +"""Tests for Query entry adapter params packaging.""" +from __future__ import annotations + +from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter + + +class TestBuildParams: + """Tests for QueryEntryAdapter.build_params filtering.""" + + def test_params_empty_when_no_variables(self): + query = type('Query', (), {'variables': None})() + assert QueryEntryAdapter.build_params(query) == {} + + def test_params_filters_underscore_prefix(self): + query = type('Query', (), { + 'variables': { + '_internal_var': 'should_be_excluded', + '_pipeline_bound_plugins': ['a/b'], + '_monitoring_bot_name': 'Bot', + 'public_var': 'should_be_included', + }, + })() + + params = QueryEntryAdapter.build_params(query) + assert '_internal_var' not in params + assert '_pipeline_bound_plugins' not in params + assert '_monitoring_bot_name' not in params + assert params['public_var'] == 'should_be_included' + + def test_params_filters_sensitive_naming(self): + query = type('Query', (), { + 'variables': { + 'api_key': 'secret123', + 'API_KEY': 'secret456', + 'token': 'tok123', + 'secret': 'sec123', + 'password': 'pass123', + 'credential': 'cred123', + 'user_api_key': 'should_be_excluded', + 'user_secret_key': 'should_be_excluded', + 'my_token_value': 'should_be_excluded', + 'user_password_hash': 'should_be_excluded', + 'public_name': 'should_be_included', + 'safe_value': 'should_be_included', + }, + })() + + params = QueryEntryAdapter.build_params(query) + assert 'api_key' not in params + assert 'API_KEY' not in params + assert 'token' not in params + assert 'secret' not in params + assert 'password' not in params + assert 'credential' not in params + assert 'user_api_key' not in params + assert 'user_secret_key' not in params + assert 'my_token_value' not in params + assert 'user_password_hash' not in params + assert 'public_name' in params + assert 'safe_value' in params + + def test_params_keeps_common_public_vars(self): + query = type('Query', (), { + 'variables': { + 'launcher_type': 'telegram', + 'launcher_id': 'group_123', + 'sender_id': 'user_001', + 'session_id': 'sess_abc', + 'msg_create_time': 1234567890, + 'group_name': 'Tech Group', + 'sender_name': 'John', + 'user_message_text': 'Hello world', + }, + })() + + params = QueryEntryAdapter.build_params(query) + assert params['launcher_type'] == 'telegram' + assert params['launcher_id'] == 'group_123' + assert params['sender_id'] == 'user_001' + assert params['session_id'] == 'sess_abc' + assert params['msg_create_time'] == 1234567890 + assert params['group_name'] == 'Tech Group' + assert params['sender_name'] == 'John' + assert params['user_message_text'] == 'Hello world' + + def test_params_filters_non_json_serializable(self): + class CustomObject: + pass + + query = type('Query', (), { + 'variables': { + 'string_value': 'hello', + 'int_value': 42, + 'float_value': 3.14, + 'bool_value': True, + 'null_value': None, + 'list_value': ['a', 'b', 'c'], + 'dict_value': {'nested': 'value'}, + 'custom_object': CustomObject(), + }, + })() + + params = QueryEntryAdapter.build_params(query) + assert 'string_value' in params + assert 'int_value' in params + assert 'float_value' in params + assert 'bool_value' in params + assert 'null_value' in params + assert 'list_value' in params + assert 'dict_value' in params + assert 'custom_object' not in params + + def test_params_filters_nested_non_serializable(self): + class CustomObject: + pass + + query = type('Query', (), { + 'variables': { + 'nested_list_with_bad': ['a', CustomObject(), 'c'], + 'nested_dict_with_bad': {'good': 'value', 'bad': CustomObject()}, + 'good_nested_list': ['a', ['b', 'c']], + 'good_nested_dict': {'outer': {'inner': 'value'}}, + }, + })() + + params = QueryEntryAdapter.build_params(query) + assert 'nested_list_with_bad' not in params + assert 'nested_dict_with_bad' not in params + assert 'good_nested_list' in params + assert 'good_nested_dict' in params + + def test_is_json_serializable_primitives_and_collections(self): + assert QueryEntryAdapter.is_json_serializable(None) is True + assert QueryEntryAdapter.is_json_serializable('string') is True + assert QueryEntryAdapter.is_json_serializable(42) is True + assert QueryEntryAdapter.is_json_serializable(['a', 'b']) is True + assert QueryEntryAdapter.is_json_serializable({'key': 'value'}) is True + assert QueryEntryAdapter.is_json_serializable((1, 2, 3)) is True + + def test_is_json_serializable_rejects_sets_and_objects(self): + class CustomObject: + pass + + assert QueryEntryAdapter.is_json_serializable(CustomObject()) is False + assert QueryEntryAdapter.is_json_serializable({1, 2, 3}) is False + assert QueryEntryAdapter.is_json_serializable([1, {2, 3}]) is False + assert QueryEntryAdapter.is_json_serializable({'key': {1, 2}}) is False + + +class TestBuildAdapterContext: + """Tests for QueryEntryAdapter.build_adapter_context.""" + + def test_adapter_context_does_not_push_prompt(self): + query = type('Query', (), { + 'variables': {}, + 'query_id': 123, + 'prompt': object(), + })() + + context = QueryEntryAdapter.build_adapter_context(query, binding=None) + + assert context == {'params': {}, 'query_id': 123} diff --git a/tests/unit_tests/agent/test_context_builder_state.py b/tests/unit_tests/agent/test_context_builder_state.py new file mode 100644 index 000000000..9c9f17cf1 --- /dev/null +++ b/tests/unit_tests/agent/test_context_builder_state.py @@ -0,0 +1,361 @@ +"""Tests for ContextAccess.state determination in AgentRunContextBuilder. + +Tests focus on: +- Event-first mode: state=True when enable_state=True and state_scopes non-empty +- Event-first mode: state=False when enable_state=False +- Legacy Query mode: state=False (no persistent state API) +""" +from __future__ import annotations + +import pytest +from unittest.mock import MagicMock + +from langbot.pkg.agent.runner.context_builder import AgentRunContextBuilder +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor +from langbot.pkg.agent.runner.host_models import AgentEventEnvelope, AgentBinding, BindingScope, StatePolicy +from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + + +class MockApplication: + """Mock Application for testing.""" + def __init__(self): + self.logger = MagicMock() + self.persistence_mgr = MagicMock() + self.persistence_mgr.get_db_engine = MagicMock() + + +def make_descriptor( + permissions: dict | None = None, +) -> AgentRunnerDescriptor: + return AgentRunnerDescriptor( + id='plugin:test/runner/default', + source='plugin', + label={'en_US': 'Test Runner'}, + plugin_author='test', + plugin_name='runner', + runner_name='default', + permissions=permissions + if permissions is not None + else { + 'history': ['page', 'search'], + 'events': ['get', 'page'], + 'storage': ['plugin'], + }, + ) + + +class TestContextAccessStateDetermination: + """Tests for ContextAccess.state field determination - real calls to _build_context_access.""" + + @pytest.fixture + def mock_app(self): + """Create mock application.""" + return MockApplication() + + @pytest.fixture + def mock_event(self): + """Create mock event envelope.""" + return AgentEventEnvelope( + event_id='evt_001', + event_type='message.received', + event_time=1234567890, + source='test', + bot_id='bot_001', + workspace_id='ws_001', + conversation_id='conv_001', + thread_id=None, + actor=ActorContext(actor_type='user', actor_id='user_001'), + subject=None, + input=AgentInput(text='hello', contents=[], attachments=[]), + delivery=DeliveryContext(surface='test', supports_streaming=True), + ) + + @pytest.fixture + def mock_descriptor(self): + """Create mock runner descriptor.""" + return make_descriptor() + + @pytest.mark.asyncio + async def test_enable_state_true_with_scopes_sets_state_true(self, mock_app, mock_event, mock_descriptor): + """ContextAccess.state=True when enable_state=True and state_scopes non-empty.""" + # Create binding with state enabled and non-empty scopes + binding = AgentBinding( + binding_id='binding_001', + runner_id='plugin:test/runner/default', + scope=BindingScope(scope_type='agent', scope_id='conv_001'), + state_policy=StatePolicy( + enable_state=True, + state_scopes=['conversation', 'actor'], + ), + ) + + builder = AgentRunContextBuilder(mock_app) + + # Real call to _build_context_access + context_access = await builder._build_context_access(mock_event, mock_descriptor, binding) + + # Verify state=True based on binding.state_policy + assert context_access['available_apis']['state'] is True + + @pytest.mark.asyncio + async def test_enable_state_false_sets_state_false(self, mock_app, mock_event, mock_descriptor): + """ContextAccess.state=False when enable_state=False.""" + binding = AgentBinding( + binding_id='binding_001', + runner_id='plugin:test/runner/default', + scope=BindingScope(scope_type='agent', scope_id='conv_001'), + state_policy=StatePolicy( + enable_state=False, + state_scopes=[], + ), + ) + + builder = AgentRunContextBuilder(mock_app) + + # Real call + context_access = await builder._build_context_access(mock_event, mock_descriptor, binding) + + # Verify state=False + assert context_access['available_apis']['state'] is False + + @pytest.mark.asyncio + async def test_enable_state_true_empty_scopes_sets_state_false(self, mock_app, mock_event, mock_descriptor): + """ContextAccess.state=False when enable_state=True but state_scopes empty.""" + binding = AgentBinding( + binding_id='binding_001', + runner_id='plugin:test/runner/default', + scope=BindingScope(scope_type='agent', scope_id='conv_001'), + state_policy=StatePolicy( + enable_state=True, + state_scopes=[], # Empty scopes - state not available + ), + ) + + builder = AgentRunContextBuilder(mock_app) + + # Real call + context_access = await builder._build_context_access(mock_event, mock_descriptor, binding) + + # Verify state=False (empty scopes means state not available) + assert context_access['available_apis']['state'] is False + + @pytest.mark.asyncio + async def test_no_binding_sets_state_false(self, mock_app, mock_event, mock_descriptor): + """ContextAccess.state=False when no binding is provided.""" + builder = AgentRunContextBuilder(mock_app) + + # Real call without binding + context_access = await builder._build_context_access(mock_event, mock_descriptor, binding=None) + + # Verify state=False (no binding = no state policy = state disabled) + assert context_access['available_apis']['state'] is False + + @pytest.mark.asyncio + async def test_runner_scope_available_without_conversation(self, mock_app, mock_descriptor): + """State API with runner scope is available even without conversation_id.""" + mock_event = AgentEventEnvelope( + event_id='evt_002', + event_type='message.received', + event_time=1234567890, + source='test', + bot_id='bot_001', + workspace_id='ws_001', + conversation_id=None, # No conversation + thread_id=None, + actor=ActorContext(actor_type='user', actor_id='user_001'), + subject=None, + input=AgentInput(text='hello', contents=[], attachments=[]), + delivery=DeliveryContext(surface='test', supports_streaming=True), + ) + + binding = AgentBinding( + binding_id='binding_002', + runner_id='plugin:test/runner/default', + scope=BindingScope(scope_type='workspace', scope_id='ws_001'), + state_policy=StatePolicy( + enable_state=True, + state_scopes=['runner'], # Runner scope doesn't need conversation_id + ), + ) + + builder = AgentRunContextBuilder(mock_app) + + # Real call + context_access = await builder._build_context_access(mock_event, mock_descriptor, binding) + + # State should be True because runner scope is enabled + assert context_access['available_apis']['state'] is True + + @pytest.mark.asyncio + async def test_multiple_scopes_all_available(self, mock_app, mock_event, mock_descriptor): + """State API with multiple scopes enabled.""" + binding = AgentBinding( + binding_id='binding_003', + runner_id='plugin:test/runner/default', + scope=BindingScope(scope_type='agent', scope_id='conv_001'), + state_policy=StatePolicy( + enable_state=True, + state_scopes=['conversation', 'actor', 'subject', 'runner'], + ), + ) + + builder = AgentRunContextBuilder(mock_app) + + # Real call + context_access = await builder._build_context_access(mock_event, mock_descriptor, binding) + + # State should be True with all scopes enabled + assert context_access['available_apis']['state'] is True + + +class TestStatePolicyFromBinding: + """Tests for state_policy extraction from binding.""" + + def test_state_policy_structure(self): + """State policy has correct structure.""" + policy = StatePolicy( + enable_state=True, + state_scopes=['conversation', 'actor', 'subject', 'runner'], + ) + + assert policy.enable_state is True + assert len(policy.state_scopes) == 4 + assert 'conversation' in policy.state_scopes + + def test_state_policy_disabled(self): + """State policy can be disabled.""" + policy = StatePolicy( + enable_state=False, + state_scopes=[], + ) + + assert policy.enable_state is False + assert len(policy.state_scopes) == 0 + + +class TestBindingWithStatePolicy: + """Tests for binding with state_policy.""" + + def test_binding_contains_state_policy(self): + """Binding contains state_policy field.""" + binding = AgentBinding( + binding_id='binding_001', + runner_id='plugin:test/runner/default', + scope=BindingScope(scope_type='agent', scope_id='conv_001'), + state_policy=StatePolicy( + enable_state=True, + state_scopes=['conversation'], + ), + ) + + assert binding.state_policy is not None + assert binding.state_policy.enable_state is True + + +class TestContextAccessOtherAPIs: + """Tests for other available_apis fields based on run scope.""" + + @pytest.fixture + def mock_app(self): + """Create mock application.""" + return MockApplication() + + @pytest.mark.asyncio + async def test_history_apis_enabled_with_conversation(self, mock_app): + """History APIs are available when the run has a conversation scope.""" + mock_event = MagicMock() + mock_event.conversation_id = 'conv_001' + mock_event.thread_id = None + mock_descriptor = make_descriptor() + + binding = AgentBinding( + binding_id='binding_001', + runner_id='plugin:test/runner/default', + scope=BindingScope(scope_type='agent', scope_id='conv_001'), + state_policy=StatePolicy(enable_state=False, state_scopes=[]), + ) + + builder = AgentRunContextBuilder(mock_app) + + # Real call + context_access = await builder._build_context_access(mock_event, mock_descriptor, binding) + + assert context_access['available_apis']['prompt_get'] is False + assert context_access['available_apis']['history_page'] is True + assert context_access['available_apis']['history_search'] is True + + @pytest.mark.asyncio + async def test_event_apis_enabled_by_default(self, mock_app): + """Event APIs are available based on current run scope.""" + mock_event = MagicMock() + mock_event.conversation_id = 'conv_001' + mock_event.thread_id = None + mock_descriptor = make_descriptor() + + binding = AgentBinding( + binding_id='binding_001', + runner_id='plugin:test/runner/default', + scope=BindingScope(scope_type='agent', scope_id='conv_001'), + state_policy=StatePolicy(enable_state=False, state_scopes=[]), + ) + + builder = AgentRunContextBuilder(mock_app) + + # Real call + context_access = await builder._build_context_access(mock_event, mock_descriptor, binding) + + assert context_access['available_apis']['event_get'] is True + assert context_access['available_apis']['event_page'] is True + + @pytest.mark.asyncio + async def test_conversation_required_apis_disabled_without_conversation(self, mock_app): + """Conversation-scoped APIs are disabled when the run has no conversation.""" + mock_event = MagicMock() + mock_event.conversation_id = None + mock_event.thread_id = None + mock_descriptor = make_descriptor() + + binding = AgentBinding( + binding_id='binding_001', + runner_id='plugin:test/runner/default', + scope=BindingScope(scope_type='agent', scope_id='conv_001'), + state_policy=StatePolicy(enable_state=False, state_scopes=[]), + ) + + builder = AgentRunContextBuilder(mock_app) + + # Real call + context_access = await builder._build_context_access(mock_event, mock_descriptor, binding) + + assert context_access['available_apis']['history_page'] is False + assert context_access['available_apis']['history_search'] is False + assert context_access['available_apis']['event_get'] is True + assert context_access['available_apis']['event_page'] is False + assert context_access['available_apis']['state'] is False + + @pytest.mark.asyncio + async def test_manifest_permissions_disable_context_apis(self, mock_app): + """Pull APIs are disabled when manifest permissions omit them.""" + mock_event = MagicMock() + mock_event.conversation_id = 'conv_001' + mock_event.thread_id = None + mock_descriptor = make_descriptor(permissions={}) + + binding = AgentBinding( + binding_id='binding_001', + runner_id='plugin:test/runner/default', + scope=BindingScope(scope_type='agent', scope_id='conv_001'), + state_policy=StatePolicy(enable_state=False, state_scopes=[]), + ) + + builder = AgentRunContextBuilder(mock_app) + + context_access = await builder._build_context_access(mock_event, mock_descriptor, binding) + + assert context_access['available_apis']['history_page'] is False + assert context_access['available_apis']['history_search'] is False + assert context_access['available_apis']['event_get'] is False + assert context_access['available_apis']['event_page'] is False + assert context_access['available_apis']['storage'] is False diff --git a/tests/unit_tests/agent/test_context_validation.py b/tests/unit_tests/agent/test_context_validation.py new file mode 100644 index 000000000..5188aefda --- /dev/null +++ b/tests/unit_tests/agent/test_context_validation.py @@ -0,0 +1,428 @@ +"""Test that LangBot context builder output validates against SDK AgentRunContext.""" +from __future__ import annotations + +import pytest +from types import SimpleNamespace +from unittest.mock import MagicMock, AsyncMock, patch + +# SDK imports for validation +from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext +from langbot_plugin.api.entities.builtin.agent_runner.event import AgentEventContext +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext +from langbot_plugin.api.entities.builtin.agent_runner.context_access import ContextAccess +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources +from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext + +# LangBot imports +from langbot.pkg.agent.runner.context_builder import ( + AgentRunContextBuilder, + AgentResources as BuilderResources, +) +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor +from langbot.pkg.agent.runner.host_models import AgentEventEnvelope, AgentBinding, BindingScope +from langbot.pkg.core import app + + +class TestContextValidation: + """Test that context builder output validates against SDK AgentRunContext.""" + + def _make_mock_app(self): + """Create a mock application.""" + mock_app = MagicMock(spec=app.Application) + mock_app.ver_mgr = MagicMock() + mock_app.ver_mgr.get_current_version = MagicMock(return_value="1.0.0") + mock_app.persistence_mgr = MagicMock() + mock_app.persistence_mgr.get_db_engine = MagicMock() + mock_app.logger = MagicMock() + return mock_app + + def _make_event_envelope(self) -> AgentEventEnvelope: + """Create a test event envelope.""" + from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext + from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput as EventInput + from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + + return AgentEventEnvelope( + event_id="evt_1", + event_type="message.received", + event_time=1700000000, + source="platform", + source_event_type="platform.message", + bot_id="bot_1", + workspace_id="workspace_1", + conversation_id="conv_1", + thread_id=None, + actor=ActorContext( + actor_type="user", + actor_id="user_1", + actor_name="Test User", + ), + subject=None, + input=EventInput(text="Hello world"), + delivery=DeliveryContext(surface="test"), + data={"platform_event_id": "source_evt_1"}, + ) + + def _make_binding(self) -> AgentBinding: + """Create a test binding.""" + return AgentBinding( + binding_id="binding_1", + scope=BindingScope(scope_type="agent", scope_id="pipeline_1"), + event_types=["message.received"], + runner_id="plugin:test/plugin/runner", + runner_config={"timeout": 300}, + agent_id="pipeline_1", + enabled=True, + ) + + def _make_resources(self) -> BuilderResources: + """Create test resources.""" + return { + 'models': [], + 'tools': [], + 'knowledge_bases': [], + 'skills': [], + 'files': [], + 'storage': {'plugin_storage': True, 'workspace_storage': True}, + 'platform_capabilities': {}, + } + + def _make_descriptor(self): + """Create a mock runner descriptor.""" + return AgentRunnerDescriptor( + id="plugin:test/plugin/runner", + source="plugin", + label={"en_US": "Test Runner"}, + plugin_author="test", + plugin_name="plugin", + runner_name="runner", + permissions={ + "history": ["page", "search"], + "events": ["get", "page"], + "storage": ["plugin", "workspace"], + }, + ) + + @pytest.mark.asyncio + async def test_build_context_from_event_validates(self): + """Test that build_context_from_event output validates against SDK AgentRunContext.""" + mock_app = self._make_mock_app() + builder = AgentRunContextBuilder(mock_app) + + event = self._make_event_envelope() + binding = self._make_binding() + resources = self._make_resources() + descriptor = self._make_descriptor() + + # Mock persistent state store to return empty state snapshot + with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store: + mock_store = AsyncMock() + mock_store.build_snapshot_from_event = AsyncMock(return_value={ + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + }) + mock_get_store.return_value = mock_store + + # Build context + context_dict = await builder.build_context_from_event( + event=event, + binding=binding, + descriptor=descriptor, + resources=resources, + ) + + # Validate it can be parsed by SDK AgentRunContext + # This will raise ValidationError if invalid + validated = AgentRunContext.model_validate(context_dict) + + # Verify required fields + assert validated.run_id is not None + assert validated.event is not None + assert isinstance(validated.event, AgentEventContext) + assert validated.delivery is not None + assert isinstance(validated.delivery, DeliveryContext) + assert validated.context is not None + assert isinstance(validated.context, ContextAccess) + assert validated.input is not None + assert isinstance(validated.input, AgentInput) + assert validated.resources is not None + assert isinstance(validated.resources, AgentResources) + assert validated.runtime is not None + assert isinstance(validated.runtime, AgentRuntimeContext) + assert "protocol_version" not in validated.runtime.model_dump() + assert "sdk_protocol_version" not in validated.runtime.model_dump() + assert "sdk_protocol_version" not in context_dict["runtime"] + + # Verify event context + assert validated.event.event_id == "evt_1" + assert validated.event.event_type == "message.received" + assert validated.event.source == "platform" + assert validated.event.source_event_type == "platform.message" + assert validated.event.data == {"platform_event_id": "source_evt_1"} + + # Verify conversation context uses SDK field names + assert validated.conversation is not None + assert validated.conversation.bot_id == "bot_1" + assert validated.conversation.workspace_id == "workspace_1" + + # Verify delivery context + assert validated.delivery.surface == "test" + + # Verify input + assert validated.input.text == "Hello world" + + @pytest.mark.asyncio + async def test_build_context_from_event_populates_model_context_window(self): + """Runtime metadata should expose the selected LLM model context window.""" + mock_app = self._make_mock_app() + mock_app.model_mgr = MagicMock() + mock_app.model_mgr.get_model_by_uuid = AsyncMock( + return_value=SimpleNamespace( + model_entity=SimpleNamespace(context_length=128000), + ) + ) + builder = AgentRunContextBuilder(mock_app) + + event = self._make_event_envelope() + binding = self._make_binding() + resources = self._make_resources() + resources['models'] = [ + { + 'model_id': 'rerank-model', + 'model_type': 'rerank', + 'provider': 'test-provider', + 'operations': ['rerank'], + }, + { + 'model_id': 'llm-model', + 'model_type': 'llm', + 'provider': 'test-provider', + 'operations': ['invoke', 'stream'], + }, + ] + descriptor = self._make_descriptor() + + with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store: + mock_store = AsyncMock() + mock_store.build_snapshot_from_event = AsyncMock(return_value={ + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + }) + mock_get_store.return_value = mock_store + + context_dict = await builder.build_context_from_event( + event=event, + binding=binding, + descriptor=descriptor, + resources=resources, + ) + + assert context_dict['runtime']['metadata']['model_context_window_tokens'] == 128000 + mock_app.model_mgr.get_model_by_uuid.assert_awaited_once_with('llm-model') + + @pytest.mark.asyncio + async def test_model_context_window_uses_primary_llm_only(self): + """Fallback model windows should not replace missing primary model metadata.""" + mock_app = self._make_mock_app() + mock_app.model_mgr = MagicMock() + mock_app.model_mgr.get_model_by_uuid = AsyncMock( + return_value=SimpleNamespace( + model_entity=SimpleNamespace(context_length=None), + ) + ) + builder = AgentRunContextBuilder(mock_app) + resources = self._make_resources() + resources['models'] = [ + { + 'model_id': 'primary-model', + 'model_type': 'llm', + 'provider': 'test-provider', + 'operations': ['invoke', 'stream'], + }, + { + 'model_id': 'fallback-model', + 'model_type': 'llm', + 'provider': 'test-provider', + 'operations': ['invoke', 'stream'], + }, + ] + + assert await builder._build_model_context_window_tokens(resources) is None + mock_app.model_mgr.get_model_by_uuid.assert_awaited_once_with('primary-model') + + @pytest.mark.asyncio + async def test_build_context_preserves_subject_data_for_non_message_events(self): + """Non-message EBA events keep subject.data instead of relying on message text.""" + from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext, SubjectContext + from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput as EventInput + from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + + mock_app = self._make_mock_app() + builder = AgentRunContextBuilder(mock_app) + event = AgentEventEnvelope( + event_id="evt_recall_1", + event_type="message.recalled", + event_time=1700000001, + source="platform", + source_event_type="platform.message.recall", + bot_id="bot_1", + workspace_id="workspace_1", + conversation_id="conv_1", + actor=ActorContext(actor_type="user", actor_id="user_1"), + subject=SubjectContext( + subject_type="message", + subject_id="message_1", + data={"recalled_message_id": "message_1", "reason": "user_recall"}, + ), + input=EventInput(text=None), + delivery=DeliveryContext(surface="test"), + data={"source_event_id": "source_recall_1"}, + ) + binding = self._make_binding() + binding.event_types = ["message.recalled"] + resources = self._make_resources() + descriptor = self._make_descriptor() + + with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store: + mock_store = AsyncMock() + mock_store.build_snapshot_from_event = AsyncMock(return_value={ + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + }) + mock_get_store.return_value = mock_store + + context_dict = await builder.build_context_from_event( + event=event, + binding=binding, + descriptor=descriptor, + resources=resources, + ) + + validated = AgentRunContext.model_validate(context_dict) + + assert validated.event.event_type == "message.recalled" + assert validated.input.text is None + assert validated.subject is not None + assert validated.subject.subject_type == "message" + assert validated.subject.subject_id == "message_1" + assert validated.subject.data == {"recalled_message_id": "message_1", "reason": "user_recall"} + + @pytest.mark.asyncio + async def test_build_context_from_event_has_no_legacy_top_level_fields(self): + """Test that build_context_from_event does NOT have top-level messages/prompt/params.""" + mock_app = self._make_mock_app() + builder = AgentRunContextBuilder(mock_app) + + event = self._make_event_envelope() + binding = self._make_binding() + resources = self._make_resources() + descriptor = self._make_descriptor() + + # Mock persistent state store to return empty state snapshot + with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store: + mock_store = AsyncMock() + mock_store.build_snapshot_from_event = AsyncMock(return_value={ + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + }) + mock_get_store.return_value = mock_store + + context_dict = await builder.build_context_from_event( + event=event, + binding=binding, + descriptor=descriptor, + resources=resources, + ) + + # Protocol v1 does NOT have these as core fields + assert 'messages' not in context_dict, "messages should not be top-level in Protocol v1" + assert 'prompt' not in context_dict, "prompt should not be top-level in Protocol v1" + assert 'params' not in context_dict, "params should not be top-level in Protocol v1" + + # Protocol v1 DOES have these + assert 'delivery' in context_dict, "delivery is REQUIRED in Protocol v1" + assert 'context' in context_dict, "context (ContextAccess) is REQUIRED in Protocol v1" + assert 'bootstrap' not in context_dict, "Host must not inline bootstrap/history windows" + assert 'adapter' in context_dict, "adapter should exist" + assert 'metadata' in context_dict, "metadata should exist" + + @pytest.mark.asyncio + async def test_build_context_from_event_event_is_not_none(self): + """Test that event field is NOT None in Protocol v1.""" + mock_app = self._make_mock_app() + builder = AgentRunContextBuilder(mock_app) + + event = self._make_event_envelope() + binding = self._make_binding() + resources = self._make_resources() + descriptor = self._make_descriptor() + + # Mock persistent state store to return empty state snapshot + with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store: + mock_store = AsyncMock() + mock_store.build_snapshot_from_event = AsyncMock(return_value={ + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + }) + mock_get_store.return_value = mock_store + + context_dict = await builder.build_context_from_event( + event=event, + binding=binding, + descriptor=descriptor, + resources=resources, + ) + + # event is REQUIRED in Protocol v1 + assert context_dict.get('event') is not None, "event is REQUIRED for Protocol v1" + + # Validate + validated = AgentRunContext.model_validate(context_dict) + assert validated.event is not None + + @pytest.mark.asyncio + async def test_build_context_from_event_delivery_is_not_none(self): + """Test that delivery field is NOT None in Protocol v1.""" + mock_app = self._make_mock_app() + builder = AgentRunContextBuilder(mock_app) + + event = self._make_event_envelope() + binding = self._make_binding() + resources = self._make_resources() + descriptor = self._make_descriptor() + + # Mock persistent state store to return empty state snapshot + with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store: + mock_store = AsyncMock() + mock_store.build_snapshot_from_event = AsyncMock(return_value={ + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + }) + mock_get_store.return_value = mock_store + + context_dict = await builder.build_context_from_event( + event=event, + binding=binding, + descriptor=descriptor, + resources=resources, + ) + + # delivery is REQUIRED in Protocol v1 + assert context_dict.get('delivery') is not None, "delivery is REQUIRED for Protocol v1" + + # Validate + validated = AgentRunContext.model_validate(context_dict) + assert validated.delivery is not None diff --git a/tests/unit_tests/agent/test_event_first_protocol.py b/tests/unit_tests/agent/test_event_first_protocol.py new file mode 100644 index 000000000..04c71adfa --- /dev/null +++ b/tests/unit_tests/agent/test_event_first_protocol.py @@ -0,0 +1,375 @@ +"""Tests for event-first Protocol v1 entities and Query entry adapter. + +Tests cover: +1. Query -> AgentEventEnvelope conversion +2. Current config -> AgentConfig projection and single-binding resolution +3. AgentRunContext not inlining full history by default +4. LangBot Host not defining context-window controls +5. Event-first run() entry point +""" +from __future__ import annotations + +import pytest +from unittest.mock import Mock + +# Import SDK entities +from langbot_plugin.api.entities.builtin.agent_runner.event import ( + AgentEventContext, +) +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger +from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext +from langbot_plugin.api.entities.builtin.agent_runner.result import ( + AgentRunResult, +) + +# Import LangBot host models +from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter +from langbot.pkg.agent.runner.binding_resolver import ( + AgentBindingResolver, + AgentBindingResolutionError, +) + + +class TestQueryToEventEnvelope: + """Test Query -> AgentEventEnvelope conversion.""" + + def test_query_to_event_basic_fields(self, mock_query): + """Test basic field conversion from Query to Event envelope.""" + event = QueryEntryAdapter.query_to_event(mock_query) + + assert event.event_type == "message.received" + assert event.source == "host_adapter" + assert event.bot_id == mock_query.bot_uuid + assert event.actor is not None + assert event.actor.actor_type == "user" + + def test_query_to_event_input(self, mock_query): + """Test input conversion from Query.""" + event = QueryEntryAdapter.query_to_event(mock_query) + + assert event.input is not None + assert event.input.text == "Hello world" + assert "message_chain" not in event.input.model_dump() + + def test_query_to_event_conversation(self, mock_query): + """Test conversation context extraction.""" + event = QueryEntryAdapter.query_to_event(mock_query) + + assert event.conversation_id == "conv-uuid-123" + + def test_query_to_event_prefers_variable_conversation_id_when_conversation_uuid_missing(self, mock_query): + """Pipeline variables can provide the conversation identity for state scope.""" + mock_query.session.using_conversation.uuid = None + mock_query.variables["conversation_id"] = "conv-from-vars" + + event = QueryEntryAdapter.query_to_event(mock_query) + + assert event.conversation_id == "conv-from-vars" + + def test_query_to_event_falls_back_to_launcher_session_for_state_scope(self, mock_query): + """Debug Chat and legacy pipeline runs may not have a conversation UUID.""" + mock_query.session.using_conversation.uuid = None + + event = QueryEntryAdapter.query_to_event(mock_query) + + assert event.conversation_id == "person_launcher-123" + + def test_query_to_event_delivery_context(self, mock_query): + """Test delivery context extraction.""" + event = QueryEntryAdapter.query_to_event(mock_query) + + assert event.delivery is not None + assert event.delivery.surface == "platform" + assert isinstance(event.delivery.supports_streaming, bool) + + def test_query_to_event_preserves_source_event_data(self, mock_query): + """Test source event metadata survives the adapter boundary.""" + source_event = Mock() + source_event.type = "platform.message.created" + source_event.time = 1700000000 + source_event.sender = None + source_event.model_dump = Mock(return_value={ + "type": "platform.message.created", + "message_id": "source-message-1", + "source_platform_object": {"large": "payload"}, + }) + mock_query.message_event = source_event + + event = QueryEntryAdapter.query_to_event(mock_query) + + assert event.source_event_type == "platform.message.created" + assert event.event_time == 1700000000 + assert event.data == { + "type": "platform.message.created", + "message_id": "source-message-1", + } + + def test_query_to_event_keeps_large_payloads_out_of_event_data(self, mock_query): + """Large or nested platform payloads should not be duplicated into event.data.""" + source_event = Mock() + source_event.type = "platform.message.created" + source_event.time = 1700000000 + source_event.sender = None + source_event.model_dump = Mock(return_value={ + "type": "platform.message.created", + "message_id": "source-message-1", + "message_chain": [{"type": "Image", "base64": "data:image/png;base64," + ("a" * 1024)}], + "raw_text": "x" * 1024, + "source_platform_object": {"large": "payload"}, + }) + mock_query.message_event = source_event + + event = QueryEntryAdapter.query_to_event(mock_query) + + assert event.data == { + "type": "platform.message.created", + "message_id": "source-message-1", + } + + def test_query_to_event_handles_missing_message_chain(self, mock_query): + """Test delivery context building when Query has no message_chain.""" + delattr(mock_query, "message_chain") + + event = QueryEntryAdapter.query_to_event(mock_query) + + assert event.delivery.reply_target == {"message_id": None} + + def test_query_to_event_scopes_pipeline_local_event_ids(self, mock_query): + """Pipeline-local message IDs must not become global audit IDs.""" + first = QueryEntryAdapter.query_to_event(mock_query) + + mock_query.launcher_id = "launcher-456" + second = QueryEntryAdapter.query_to_event(mock_query) + + assert first.event_id.startswith("host:") + assert first.event_id != "789" + assert second.event_id != first.event_id + + +class TestQueryConfigToAgentConfig: + """Test current config projection and single-Agent binding resolution.""" + + def test_config_to_agent_config_runner_id(self, mock_query): + """Test AgentConfig runner_id extraction.""" + agent_config = QueryEntryAdapter.config_to_agent_config( + mock_query, "plugin:author/plugin/runner" + ) + + assert agent_config.runner_id == "plugin:author/plugin/runner" + + def test_config_to_agent_config_uses_legacy_runner_config_migration(self, mock_query): + """Temporary query adapter must share the normal runner config resolver.""" + mock_query.pipeline_config = { + "ai": { + "runner": {"runner": "local-agent"}, + "local-agent": { + "model": "model-primary", + "knowledge-base": "kb-001", + }, + } + } + + agent_config = QueryEntryAdapter.config_to_agent_config( + mock_query, + "plugin:langbot/local-agent/default", + ) + + assert agent_config.runner_config["model"] == { + "primary": "model-primary", + "fallbacks": [], + } + assert agent_config.runner_config["knowledge-bases"] == ["kb-001"] + + def test_resolver_projects_agent_scope(self, mock_query): + """Test binding scope projection through the resolver.""" + event = QueryEntryAdapter.query_to_event(mock_query) + agent_config = QueryEntryAdapter.config_to_agent_config( + mock_query, "plugin:test/plugin/runner" + ) + binding = AgentBindingResolver().resolve_one(event, [agent_config]) + + assert binding.scope.scope_type == "agent" + assert binding.scope.scope_id == mock_query.pipeline_uuid + assert binding.agent_id == mock_query.pipeline_uuid + + def test_resolver_rejects_multiple_matching_agents(self, mock_query): + """Event dispatch is single-Agent in v1.""" + event = QueryEntryAdapter.query_to_event(mock_query) + first = QueryEntryAdapter.config_to_agent_config( + mock_query, "plugin:test/plugin/runner" + ) + second = first.model_copy(update={"agent_id": "agent_2"}) + + with pytest.raises(AgentBindingResolutionError): + AgentBindingResolver().resolve_one(event, [first, second]) + +class TestAgentRunContextProtocolV1: + """Test AgentRunContext Protocol v1 behavior.""" + + def test_sdk_context_event_required(self): + """Test that event is required in Protocol v1 context.""" + trigger = AgentTrigger(type="message.received") + event = AgentEventContext( + event_id="evt_1", + event_type="message.received", + source="platform", + ) + input = AgentInput(text="Hello") + from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources + from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext + from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + + ctx = AgentRunContext( + run_id="run_1", + trigger=trigger, + event=event, + input=input, + delivery=DeliveryContext(surface="platform"), + resources=AgentResources(), + runtime=AgentRuntimeContext(), + ) + + assert ctx.event is not None + assert ctx.event.event_type == "message.received" + + def test_sdk_context_has_no_history_message_fields(self): + """AgentRunContext should not expose inline history message fields.""" + trigger = AgentTrigger(type="message.received") + event = AgentEventContext( + event_id="evt_1", + event_type="message.received", + source="platform", + ) + input = AgentInput(text="Hello") + from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources + from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext + from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + + ctx = AgentRunContext( + run_id="run_1", + trigger=trigger, + event=event, + input=input, + delivery=DeliveryContext(surface="platform"), + resources=AgentResources(), + runtime=AgentRuntimeContext(), + ) + + assert "messages" not in AgentRunContext.model_fields + assert "bootstrap" not in AgentRunContext.model_fields + assert not hasattr(ctx, "bootstrap") + + +class TestHostManagedHistoryNotInProtocol: + """Test that Host-managed history payloads are not in Protocol v1.""" + + def test_messages_not_in_sdk_context_top_level(self): + """AgentRunContext should not expose top-level history messages.""" + ctx_fields = AgentRunContext.model_fields.keys() + + assert "messages" not in ctx_fields + + +class TestSDKResultProtocolV1: + """Test SDK AgentRunResult for Protocol v1.""" + + def test_result_requires_run_id(self): + """Test result requires run_id for Protocol v1.""" + from langbot_plugin.api.entities.builtin.provider.message import Message + + result = AgentRunResult.message_completed( + run_id="run_1", + message=Message(role="assistant", content="Hello"), + ) + + assert result.run_id == "run_1" + +# Fixtures +@pytest.fixture +def mock_query(): + """Create a mock query for testing.""" + query = Mock() + query.query_id = 123 + query.bot_uuid = "bot-uuid-123" + query.pipeline_uuid = "pipeline-uuid-456" + query.launcher_type = Mock(value="person") + query.launcher_id = "launcher-123" + query.sender_id = "sender-123" + query.pipeline_config = { + "ai": { + "runner": "plugin:test/plugin/runner", + } + } + query.variables = {} + + # Create a proper content element mock + content_elem = Mock(spec=['type', 'text', 'model_dump']) + content_elem.type = 'text' + content_elem.text = 'Hello world' + content_elem.model_dump = Mock(return_value={'type': 'text', 'text': 'Hello world'}) + + query.user_message = Mock() + query.user_message.content = [content_elem] + + # Create message_chain mock + message_chain = Mock() + message_chain.message_id = 789 + message_chain.model_dump = Mock(return_value={'message_id': 789, 'components': []}) + query.message_chain = message_chain + + query.message_event = None + + # Mock session with proper conversation + query.session = Mock() + query.session.launcher_type = Mock(value="person") + query.session.launcher_id = "launcher-123" + query.session.using_conversation = Mock() + query.session.using_conversation.uuid = "conv-uuid-123" + + # Mock use_funcs (empty list by default) + query.use_funcs = [] + query.use_llm_model_uuid = None + + return query + + +@pytest.fixture +def mock_query_no_session(): + """Create a mock Query without session.""" + query = Mock() + query.query_id = 456 + query.bot_uuid = "bot-uuid-456" + query.pipeline_uuid = "pipeline-uuid-789" + query.launcher_type = Mock(value="person") + query.launcher_id = "launcher-456" + query.sender_id = "sender-456" + query.pipeline_config = { + "ai": { + "runner": "plugin:test/plugin/runner", + } + } + query.variables = {} + + # Create a proper content element mock + content_elem = Mock(spec=['type', 'text', 'model_dump']) + content_elem.type = 'text' + content_elem.text = 'Test message' + content_elem.model_dump = Mock(return_value={'type': 'text', 'text': 'Test message'}) + + query.user_message = Mock() + query.user_message.content = [content_elem] + + message_chain = Mock() + message_chain.message_id = -1 + message_chain.model_dump = Mock(return_value={'message_id': -1, 'components': []}) + query.message_chain = message_chain + + query.message_event = None + query.session = None + + # Mock use_funcs + query.use_funcs = [] + query.use_llm_model_uuid = None + + return query diff --git a/tests/unit_tests/agent/test_event_log_transcript.py b/tests/unit_tests/agent/test_event_log_transcript.py new file mode 100644 index 000000000..450b4111d --- /dev/null +++ b/tests/unit_tests/agent/test_event_log_transcript.py @@ -0,0 +1,795 @@ +"""Tests for EventLog, Transcript, and history/event APIs.""" +from __future__ import annotations + +import datetime + +import pytest + +from langbot.pkg.agent.runner.host_models import ( + AgentEventEnvelope, + AgentBinding, + BindingScope, + ResourcePolicy, + StatePolicy, + DeliveryPolicy, +) +from langbot.pkg.agent.runner.event_log_store import EventLogStore +from langbot.pkg.agent.runner.transcript_store import TranscriptStore +from langbot.pkg.agent.runner.session_registry import get_session_registry +from langbot_plugin.api.entities.builtin.agent_runner.event import ( + ActorContext, +) +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + + +def make_event_envelope( + event_id: str = "evt_1", + event_type: str = "message.received", + conversation_id: str | None = "conv_1", + actor_id: str | None = "user_1", + input_text: str = "Hello", +) -> AgentEventEnvelope: + """Create a test event envelope.""" + return AgentEventEnvelope( + event_id=event_id, + event_type=event_type, + event_time=1700000000, + source="platform", + bot_id="bot_1", + workspace_id=None, + conversation_id=conversation_id, + thread_id=None, + actor=ActorContext( + actor_type="user", + actor_id=actor_id, + actor_name="Test User", + ) if actor_id else None, + subject=None, + input=AgentInput(text=input_text), + delivery=DeliveryContext(surface="test"), + ) + + +def make_binding(runner_id: str = "plugin:test/plugin/runner") -> AgentBinding: + """Create a test binding.""" + return AgentBinding( + binding_id="binding_1", + scope=BindingScope(scope_type="agent", scope_id="pipeline_1"), + event_types=["message.received"], + runner_id=runner_id, + runner_config={}, + resource_policy=ResourcePolicy(), + state_policy=StatePolicy(), + delivery_policy=DeliveryPolicy(), + enabled=True, + ) + + +class TestEventLogStore: + """Test EventLogStore operations.""" + + @pytest.mark.asyncio + async def test_append_event(self, mock_db_engine): + """Test appending an event to EventLog.""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = EventLogStore(mock_db_engine) + + mock_session = AsyncMock() + mock_session.add = MagicMock() + mock_session.commit = AsyncMock() + + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + event_id = await store.append_event( + event_id="evt_1", + event_type="message.received", + source="platform", + bot_id="bot_1", + conversation_id="conv_1", + actor_type="user", + actor_id="user_1", + input_summary="Hello world", + run_id="run_1", + runner_id="plugin:test/plugin/runner", + ) + + assert event_id == "evt_1" + stored_event = mock_session.add.call_args.args[0] + assert stored_event.metadata_json is None + + @pytest.mark.asyncio + async def test_append_event_stores_metadata_json(self, mock_db_engine): + """EventLog metadata records steering dispatch/audit facts.""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = EventLogStore(mock_db_engine) + + mock_session = AsyncMock() + mock_session.add = MagicMock() + mock_session.commit = AsyncMock() + + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + event_id = await store.append_event( + event_id="evt_steering", + event_type="message.received", + source="platform", + run_id="run_1", + runner_id="plugin:test/plugin/runner", + metadata={ + "steering": { + "status": "queued", + "claimed_by_run_id": "run_1", + } + }, + ) + + assert event_id == "evt_steering" + stored_event = mock_session.add.call_args.args[0] + assert '"status": "queued"' in stored_event.metadata_json + assert '"claimed_by_run_id": "run_1"' in stored_event.metadata_json + + @pytest.mark.asyncio + async def test_append_event_truncates_input_summary(self, mock_db_engine): + """Test that long input summaries are truncated.""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = EventLogStore(mock_db_engine) + + mock_session = AsyncMock() + mock_session.add = MagicMock() + mock_session.commit = AsyncMock() + + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + long_text = "x" * 2000 + event_id = await store.append_event( + event_id="evt_2", + event_type="message.received", + source="platform", + input_summary=long_text, + ) + + assert event_id == "evt_2" + + @pytest.mark.asyncio + async def test_page_events_with_conversation_filter(self, mock_db_engine): + """Test paging events with conversation_id filter.""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = EventLogStore(mock_db_engine) + + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [] + + mock_session = AsyncMock() + mock_session.execute = AsyncMock(return_value=mock_result) + + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + items, next_seq, has_more = await store.page_events( + conversation_id="conv_1", + limit=10, + ) + + assert isinstance(items, list) + + +class TestTranscriptStore: + """Test TranscriptStore operations.""" + + @pytest.mark.asyncio + async def test_append_transcript(self, mock_db_engine): + """Test appending a transcript item.""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = TranscriptStore(mock_db_engine) + + mock_session = AsyncMock() + mock_session.add = MagicMock() + mock_session.commit = AsyncMock() + + # Mock _get_next_seq + with patch.object(store, '_get_next_seq', return_value=1): + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + transcript_id = await store.append_transcript( + transcript_id=None, # Auto-generate + event_id="evt_1", + conversation_id="conv_1", + role="user", + content="Hello", + ) + + assert transcript_id is not None + + @pytest.mark.asyncio + async def test_append_transcript_with_attachments(self, mock_db_engine): + """Test appending transcript with attachment refs.""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = TranscriptStore(mock_db_engine) + + mock_session = AsyncMock() + mock_session.add = MagicMock() + mock_session.commit = AsyncMock() + + with patch.object(store, '_get_next_seq', return_value=1): + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + transcript_id = await store.append_transcript( + transcript_id=None, # Auto-generate + event_id="evt_2", + conversation_id="conv_1", + role="assistant", + content="Here's an image", + attachment_refs=[ + {"id": "att_1", "type": "image", "url": "http://example.com/img.png"} + ], + ) + + assert transcript_id is not None + + @pytest.mark.asyncio + async def test_page_transcript_backward(self, mock_db_engine): + """Test paging transcript backward (older items).""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = TranscriptStore(mock_db_engine) + + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [] + + mock_session = AsyncMock() + mock_session.execute = AsyncMock(return_value=mock_result) + + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + items, next_seq, prev_seq, has_more = await store.page_transcript( + conversation_id="conv_1", + limit=10, + direction="backward", + ) + + assert isinstance(items, list) + + @pytest.mark.asyncio + async def test_page_transcript_has_hard_limit(self, mock_db_engine): + """Test that transcript paging has a hard limit.""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = TranscriptStore(mock_db_engine) + + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [] + + mock_session = AsyncMock() + mock_session.execute = AsyncMock(return_value=mock_result) + + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + # Request more than the hard limit + items, next_seq, prev_seq, has_more = await store.page_transcript( + conversation_id="conv_1", + limit=200, # Request 200, but hard limit is 100 + ) + + # The store should cap at 100 + assert len(items) <= store.HARD_LIMIT + + @pytest.mark.asyncio + async def test_search_transcript(self, mock_db_engine): + """Test searching transcript.""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = TranscriptStore(mock_db_engine) + + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [] + + mock_session = AsyncMock() + mock_session.execute = AsyncMock(return_value=mock_result) + + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + items = await store.search_transcript( + conversation_id="conv_1", + query_text="database", + top_k=10, + ) + + assert isinstance(items, list) + + +class TestHistoryPageAuthorization: + """Test history.page authorization.""" + + @pytest.mark.asyncio + async def test_history_page_requires_run_id(self, mock_handler, mock_db_engine): + """Test history.page requires run_id.""" + from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + + # Mock call_action to simulate the handler + result = await mock_handler.call_action( + PluginToRuntimeAction.HISTORY_PAGE, + {"run_id": None}, + ) + + # Should return error + assert result.get("ok") is False or "error" in str(result).lower() + + @pytest.mark.asyncio + async def test_history_page_validates_conversation_scope(self, mock_db_engine): + """Test history.page only allows access to run's conversation.""" + # This test verifies the authorization logic + # The actual implementation validates conversation_id matches session + session_registry = get_session_registry() + + await session_registry.register( + run_id="run_1", + runner_id="plugin:test/plugin/runner", + query_id=None, + plugin_identity="test/plugin", + resources={"models": [], "tools": [], "knowledge_bases": [], "storage": {"plugin_storage": True}}, + conversation_id="conv_1", + ) + + session = await session_registry.get("run_1") + assert session is not None + assert session["authorization"]["conversation_id"] == "conv_1" + + # Cleanup + await session_registry.unregister("run_1") + + +class TestEventGetAuthorization: + """Test event.get authorization.""" + + @pytest.mark.asyncio + async def test_event_get_requires_run_id(self, mock_handler): + """Test event.get requires run_id.""" + from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + + result = await mock_handler.call_action( + PluginToRuntimeAction.EVENT_GET, + {"run_id": None, "event_id": "evt_1"}, + ) + + # Should return error + assert result.get("ok") is False or "error" in str(result).lower() + + +class TestContextAccessPopulation: + """Test ContextAccess population in build_context_from_event.""" + + @pytest.mark.asyncio + async def test_context_access_has_history_apis_when_permitted(self, mock_db_engine): + """Test ContextAccess shows available APIs based on permissions.""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = TranscriptStore(mock_db_engine) + + mock_result = MagicMock() + mock_result.scalars.return_value.first.return_value = None + + mock_session = AsyncMock() + mock_session.execute = AsyncMock(return_value=mock_result) + + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + cursor = await store.get_latest_cursor("conv_1") + # Should return None or a cursor string + assert cursor is None or isinstance(cursor, str) + + @pytest.mark.asyncio + async def test_context_access_shows_has_history_before(self, mock_db_engine): + """Test ContextAccess indicates if history exists.""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = TranscriptStore(mock_db_engine) + + mock_result = MagicMock() + mock_result.scalar.return_value = 0 + + mock_session = AsyncMock() + mock_session.execute = AsyncMock(return_value=mock_result) + + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + has_history = await store.has_history_before("conv_1", 10) + assert isinstance(has_history, bool) + + +class TestEventLogStoreRealSQLite: + """Test EventLogStore with real SQLite database.""" + + @pytest.fixture + async def db_engine(self): + """Create an in-memory SQLite database for testing.""" + from sqlalchemy.ext.asyncio import create_async_engine + from langbot.pkg.entity.persistence.base import Base + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + + # Create tables + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + yield engine + + await engine.dispose() + + @pytest.mark.asyncio + async def test_append_get_event_round_trip(self, db_engine): + """Test append_event -> get_event round trip with real DB.""" + store = EventLogStore(db_engine) + + # Append event + event_id = await store.append_event( + event_id="evt_real_001", + event_type="message.received", + source="platform", + bot_id="bot_001", + conversation_id="conv_001", + actor_type="user", + actor_id="user_001", + actor_name="Test User", + input_summary="Hello world", + run_id="run_001", + runner_id="plugin:test/plugin/runner", + ) + + assert event_id == "evt_real_001" + + # Get event + event = await store.get_event(event_id) + assert event is not None + assert event["event_id"] == "evt_real_001" + assert event["event_type"] == "message.received" + assert event["source"] == "platform" + assert event["conversation_id"] == "conv_001" + assert event["actor_type"] == "user" + assert event["actor_id"] == "user_001" + + @pytest.mark.asyncio + async def test_page_events(self, db_engine): + """Test page_events with real DB.""" + store = EventLogStore(db_engine) + + # Append multiple events + for i in range(5): + await store.append_event( + event_id=f"evt_real_{i:03d}", + event_type="message.received", + source="platform", + conversation_id="conv_001", + input_summary=f"Message {i}", + ) + + # Page events + items, next_seq, has_more = await store.page_events( + conversation_id="conv_001", + limit=3, + ) + + assert len(items) == 3 + assert has_more is True + + @pytest.mark.asyncio + async def test_get_latest_cursor(self, db_engine): + """Test get_latest_cursor with real DB.""" + store = EventLogStore(db_engine) + + # Append events + for i in range(3): + await store.append_event( + event_id=f"evt_cursor_{i:03d}", + event_type="message.received", + source="platform", + conversation_id="conv_cursor", + ) + + # Get latest cursor + cursor = await store.get_latest_cursor("conv_cursor") + assert cursor is not None + assert int(cursor) > 0 + + @pytest.mark.asyncio + async def test_cleanup_events_older_than(self, db_engine): + """EventLog cleanup removes only rows older than the cutoff.""" + import sqlalchemy + from langbot.pkg.entity.persistence.event_log import EventLog + + store = EventLogStore(db_engine) + cutoff = datetime.datetime.utcnow() + await store.append_event( + event_id="evt_cleanup_old", + event_type="message.received", + source="platform", + conversation_id="conv_cleanup", + ) + await store.append_event( + event_id="evt_cleanup_new", + event_type="message.received", + source="platform", + conversation_id="conv_cleanup", + ) + async with store._session_factory() as session: + await session.execute( + sqlalchemy.update(EventLog) + .where(EventLog.event_id == "evt_cleanup_old") + .values(created_at=cutoff - datetime.timedelta(days=2)) + ) + await session.execute( + sqlalchemy.update(EventLog) + .where(EventLog.event_id == "evt_cleanup_new") + .values(created_at=cutoff + datetime.timedelta(days=2)) + ) + await session.commit() + + removed = await store.cleanup_events_older_than(cutoff) + + assert removed == 1 + assert await store.get_event("evt_cleanup_old") is None + assert await store.get_event("evt_cleanup_new") is not None + + +class TestTranscriptStoreRealSQLite: + """Test TranscriptStore with real SQLite database.""" + + @pytest.fixture + async def db_engine(self): + """Create an in-memory SQLite database for testing.""" + from sqlalchemy.ext.asyncio import create_async_engine + from langbot.pkg.entity.persistence.base import Base + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + + # Create tables + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + yield engine + + await engine.dispose() + + @pytest.mark.asyncio + async def test_append_page_transcript_round_trip(self, db_engine): + """Test append_transcript -> page_transcript round trip with real DB.""" + store = TranscriptStore(db_engine) + + # Append transcript items + for i in range(3): + await store.append_transcript( + transcript_id=f"trans_real_{i:03d}", + event_id=f"evt_{i:03d}", + conversation_id="conv_001", + role="user" if i % 2 == 0 else "assistant", + content=f"Message {i}", + ) + + # Page transcript + items, next_seq, prev_seq, has_more = await store.page_transcript( + conversation_id="conv_001", + limit=10, + ) + + assert len(items) == 3 + assert items[0]["conversation_id"] == "conv_001" + + @pytest.mark.asyncio + async def test_get_legacy_provider_messages_projects_transcript_history(self, db_engine): + """Transcript is the canonical source; legacy Pipeline readers get a Message view.""" + store = TranscriptStore(db_engine) + + await store.append_transcript( + transcript_id="trans_view_001", + event_id="evt_view_001", + conversation_id="conv_view", + role="user", + content="User text", + content_json={ + "role": "user", + "content": [{"type": "text", "text": "User structured text"}], + }, + ) + await store.append_transcript( + transcript_id="trans_view_002", + event_id="evt_view_002", + conversation_id="conv_view", + role="tool", + item_type="tool_result", + content="ignored tool result", + ) + await store.append_transcript( + transcript_id="trans_view_003", + event_id="evt_view_003", + conversation_id="conv_view", + role="assistant", + content="Assistant text", + ) + + messages = await store.get_legacy_provider_messages("conv_view") + + assert [message.role for message in messages] == ["user", "assistant"] + assert messages[0].content[0].text == "User structured text" + assert messages[1].content == "Assistant text" + + @pytest.mark.asyncio + async def test_get_legacy_provider_messages_filters_scope(self, db_engine): + """Legacy Pipeline history projection must stay inside the current run scope.""" + store = TranscriptStore(db_engine) + + await store.append_transcript( + transcript_id="trans_scope_001", + event_id="evt_scope_001", + conversation_id="conv_scope", + bot_id="bot_001", + workspace_id="workspace_001", + thread_id="thread_001", + role="user", + content="Current scope text", + ) + await store.append_transcript( + transcript_id="trans_scope_002", + event_id="evt_scope_002", + conversation_id="conv_scope", + bot_id="bot_002", + workspace_id="workspace_001", + thread_id="thread_001", + role="assistant", + content="Other bot text", + ) + await store.append_transcript( + transcript_id="trans_scope_003", + event_id="evt_scope_003", + conversation_id="conv_scope", + bot_id="bot_001", + workspace_id="workspace_001", + thread_id="thread_002", + role="assistant", + content="Other thread text", + ) + + messages = await store.get_legacy_provider_messages( + "conv_scope", + bot_id="bot_001", + workspace_id="workspace_001", + thread_id="thread_001", + strict_thread=True, + ) + + assert [message.content for message in messages] == ["Current scope text"] + + @pytest.mark.asyncio + async def test_search_transcript_real_db(self, db_engine): + """Test search_transcript with real DB.""" + store = TranscriptStore(db_engine) + + # Append transcript items + await store.append_transcript( + transcript_id="trans_search_001", + event_id="evt_search_001", + conversation_id="conv_search", + role="user", + content="I want to learn about databases", + ) + await store.append_transcript( + transcript_id="trans_search_002", + event_id="evt_search_002", + conversation_id="conv_search", + role="assistant", + content="Here is information about databases", + ) + + # Search for "database" + items = await store.search_transcript( + conversation_id="conv_search", + query_text="database", + ) + + # Should find at least one match + assert len(items) >= 1 + + @pytest.mark.asyncio + async def test_get_latest_cursor_real_db(self, db_engine): + """Test get_latest_cursor with real DB.""" + store = TranscriptStore(db_engine) + + # Append transcript items + for i in range(3): + await store.append_transcript( + transcript_id=f"trans_cursor_{i:03d}", + event_id=f"evt_cursor_{i:03d}", + conversation_id="conv_cursor", + role="user", + content=f"Message {i}", + ) + + # Get latest cursor + cursor = await store.get_latest_cursor("conv_cursor") + assert cursor is not None + assert int(cursor) > 0 + + @pytest.mark.asyncio + async def test_cleanup_transcripts_older_than(self, db_engine): + """Transcript cleanup removes only rows older than the cutoff.""" + import sqlalchemy + from langbot.pkg.entity.persistence.transcript import Transcript + + store = TranscriptStore(db_engine) + cutoff = datetime.datetime.utcnow() + await store.append_transcript( + transcript_id="trans_cleanup_old", + event_id="evt_cleanup_old", + conversation_id="conv_cleanup", + role="user", + content="old", + ) + await store.append_transcript( + transcript_id="trans_cleanup_new", + event_id="evt_cleanup_new", + conversation_id="conv_cleanup", + role="assistant", + content="new", + ) + async with store._session_factory() as session: + await session.execute( + sqlalchemy.update(Transcript) + .where(Transcript.transcript_id == "trans_cleanup_old") + .values(created_at=cutoff - datetime.timedelta(days=2)) + ) + await session.execute( + sqlalchemy.update(Transcript) + .where(Transcript.transcript_id == "trans_cleanup_new") + .values(created_at=cutoff + datetime.timedelta(days=2)) + ) + await session.commit() + + removed = await store.cleanup_transcripts_older_than(cutoff) + items, _, _, _ = await store.page_transcript("conv_cleanup", limit=10) + + assert removed == 1 + assert [item["content"] for item in items] == ["new"] + + +# Fixtures +@pytest.fixture +def mock_db_engine(): + """Create a mock database engine for AsyncSession-based stores.""" + from unittest.mock import MagicMock + from sqlalchemy.ext.asyncio import AsyncEngine + + engine = MagicMock(spec=AsyncEngine) + return engine + + +@pytest.fixture +def mock_handler(): + """Create a mock handler for testing actions.""" + from langbot_plugin.runtime.io.handler import Handler + + class MockHandler(Handler): + def __init__(self): + self._responses = {} + + async def call_action(self, action, data, timeout=30): + # Simulate error response for missing run_id + if not data.get("run_id"): + return {"ok": False, "message": "run_id is required"} + return {"ok": True, "data": {}} + + return MockHandler() diff --git a/tests/unit_tests/agent/test_handler_auth.py b/tests/unit_tests/agent/test_handler_auth.py new file mode 100644 index 000000000..18516951e --- /dev/null +++ b/tests/unit_tests/agent/test_handler_auth.py @@ -0,0 +1,2015 @@ +"""Tests for RuntimeConnectionHandler proxy action authorization. + +Tests focus on: +- INVOKE_LLM authorization +- INVOKE_LLM_STREAM authorization +- CALL_TOOL authorization +- RETRIEVE_KNOWLEDGE_BASE authorization + +Authorization paths: +1. AgentRunner calls: has run_id, validates against session_registry +2. Regular plugin calls: no run_id, unscoped plugin action path +""" + +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 + +# Import shared test fixtures from conftest.py +from .conftest import make_resources, make_session + + +class MockModel: + """Mock LLM model for testing.""" + + def __init__(self, uuid: str): + self.uuid = uuid + self.provider = MagicMock() + self.provider.invoke_llm = AsyncMock(return_value=MagicMock(model_dump=lambda: {'content': 'response'})) + + +class MockEmbeddingModel: + """Mock embedding model for testing.""" + + def __init__(self, uuid: str): + self.uuid = uuid + self.provider = MagicMock() + + +class MockKnowledgeBase: + """Mock knowledge base for testing.""" + + def __init__(self, uuid: str, name: str = 'KB'): + self.knowledge_base_entity = MagicMock() + self.knowledge_base_entity.description = f'{name} description' + self._uuid = uuid + self._name = name + self.retrieve = AsyncMock(return_value=[]) + + def get_uuid(self): + return self._uuid + + def get_name(self): + return self._name + + +class MockQuery: + """Mock query for testing.""" + + def __init__(self, query_id: int = 1): + self.query_id = query_id + self.session = MagicMock() + self.session.launcher_type = MagicMock() + self.session.launcher_type.value = 'telegram' + self.session.launcher_id = 'group_123' + self.sender_id = 'user_001' + self.bot_uuid = 'bot_001' + self.pipeline_config = { + 'ai': { + 'runner': { + 'id': 'plugin:test/runner/default', + }, + 'runner_config': { + 'plugin:test/runner/default': { + 'knowledge-bases': ['kb_001', 'kb_002'], + }, + }, + }, + } + + +class MockApplication: + """Mock Application for testing.""" + + def __init__(self): + self.logger = MagicMock() + self.logger.debug = MagicMock() + self.logger.warning = MagicMock() + self.logger.info = MagicMock() + self.logger.error = MagicMock() + + self.query_pool = MagicMock() + self.query_pool.cached_queries = {} + + self.model_mgr = MagicMock() + self.model_mgr.get_model_by_uuid = AsyncMock(return_value=None) + self.model_mgr.get_embedding_model_by_uuid = AsyncMock(return_value=None) + + self.tool_mgr = MagicMock() + self.tool_mgr.execute_func_call = AsyncMock(return_value={'result': 'success'}) + + self.rag_mgr = MagicMock() + self.rag_mgr.get_knowledge_base_by_uuid = AsyncMock(return_value=None) + self.rag_mgr.knowledge_bases = {} + + self.persistence_mgr = MagicMock() + self.persistence_mgr.execute_async = AsyncMock(return_value=MagicMock(first=lambda: None)) + + +class FakeAgentRunnerRegistry: + async def get(self, runner_id, bound_plugins=None): + return AgentRunnerDescriptor( + id=runner_id, + source='plugin', + label={'en_US': 'Test Runner'}, + plugin_author='test', + plugin_name='runner', + runner_name='default', + config_schema=[ + {'name': 'knowledge-bases', 'type': 'knowledge-base-multi-selector', 'default': []}, + ], + capabilities={'knowledge_retrieval': True}, + permissions={'knowledge_bases': ['list', 'retrieve']}, + ) + + +class MockConnection: + """Mock connection for testing.""" + + pass + + +class TestPipelineKnowledgeBaseScope: + """Tests for schema-driven pipeline KB scope resolution.""" + + @pytest.mark.asyncio + async def test_uses_preprocessed_query_scope(self): + app = MockApplication() + query = MockQuery() + query.variables = {'_knowledge_base_uuids': ['kb_var', '__none__', 'kb_var']} + + kb_uuids = await _get_pipeline_knowledge_base_uuids(app, query) + + assert kb_uuids == ['kb_var'] + + @pytest.mark.asyncio + async def test_uses_runner_schema_when_query_scope_not_preprocessed(self): + app = MockApplication() + app.agent_runner_registry = FakeAgentRunnerRegistry() + query = MockQuery() + query.variables = {} + + kb_uuids = await _get_pipeline_knowledge_base_uuids(app, query) + + assert kb_uuids == ['kb_001', 'kb_002'] + + +class MockDisconnectCallback: + """Mock disconnect callback for testing.""" + + async def __call__(self): + return True + + +class TestInvokeLLMAuthorization: + """Tests for INVOKE_LLM authorization.""" + + @pytest.mark.asyncio + async def test_invoke_llm_authorized_with_run_id(self): + """INVOKE_LLM: authorized when model in session.resources.""" + # Setup registry with session + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_authorized', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + # Verify authorization logic directly + session = await registry.get('run_authorized') + assert session is not None + assert registry.is_resource_allowed(session, 'model', 'model_001') is True + + # Cleanup + await registry.unregister('run_authorized') + + @pytest.mark.asyncio + async def test_invoke_llm_unauthorized_with_run_id(self): + """INVOKE_LLM: unauthorized when model not in session.resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_unauthorized', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + # Test authorization logic directly + session = await registry.get('run_unauthorized') + assert session is not None + # model_002 is not in resources + assert registry.is_resource_allowed(session, 'model', 'model_002') is False + + await registry.unregister('run_unauthorized') + + @pytest.mark.asyncio + async def test_invoke_llm_session_not_found(self): + """INVOKE_LLM: session not found should return error.""" + registry = AgentRunSessionRegistry() + + # No session registered for this run_id + session = await registry.get('run_nonexistent') + assert session is None + + @pytest.mark.asyncio + async def test_invoke_llm_no_run_id_unrestricted(self): + """INVOKE_LLM: no run_id should be unrestricted (backward compat).""" + # When no run_id is provided, the authorization check is skipped + # This is the unscoped path for regular plugin calls + + # Simulate: if not run_id, skip authorization + run_id = None + # Authorization check should NOT be triggered + assert run_id is None # No authorization check + + +class TestInvokeLLMStreamAuthorization: + """Tests for INVOKE_LLM_STREAM authorization.""" + + @pytest.mark.asyncio + async def test_invoke_llm_stream_authorized_with_run_id(self): + """INVOKE_LLM_STREAM: authorized when model in session.resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_stream_authorized', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_stream_authorized') + assert session is not None + assert registry.is_resource_allowed(session, 'model', 'model_001') is True + + await registry.unregister('run_stream_authorized') + + @pytest.mark.asyncio + async def test_invoke_llm_stream_unauthorized_with_run_id(self): + """INVOKE_LLM_STREAM: unauthorized when model not in session.resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_stream_unauthorized', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_stream_unauthorized') + assert session is not None + assert registry.is_resource_allowed(session, 'model', 'model_002') is False + + await registry.unregister('run_stream_unauthorized') + + @pytest.mark.asyncio + async def test_invoke_llm_stream_no_run_id_unrestricted(self): + """INVOKE_LLM_STREAM: no run_id should be unrestricted.""" + run_id = None + # No authorization check + 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'}}, + }, + }, + ) + + detail = _build_tool_detail(manifest_tool, requested_tool_name='author/plugin/search') + + 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 + + +class TestCallToolAuthorization: + """Tests for CALL_TOOL authorization.""" + + @pytest.mark.asyncio + async def test_call_tool_authorized_with_run_id(self): + """CALL_TOOL: authorized when tool in session.resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(tools=[{'tool_name': 'web_search'}]) + + await registry.register( + run_id='run_tool_authorized', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_tool_authorized') + assert session is not None + assert registry.is_resource_allowed(session, 'tool', 'web_search') is True + + await registry.unregister('run_tool_authorized') + + @pytest.mark.asyncio + async def test_call_tool_unauthorized_with_run_id(self): + """CALL_TOOL: unauthorized when tool not in session.resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(tools=[{'tool_name': 'web_search'}]) + + await registry.register( + run_id='run_tool_unauthorized', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_tool_unauthorized') + assert session is not None + assert registry.is_resource_allowed(session, 'tool', 'image_gen') is False + + await registry.unregister('run_tool_unauthorized') + + @pytest.mark.asyncio + async def test_call_tool_no_run_id_unrestricted(self): + """CALL_TOOL: no run_id should be unrestricted.""" + run_id = None + # No authorization check + assert run_id is None + + +class TestRetrieveKnowledgeBaseAuthorization: + """Tests for RETRIEVE_KNOWLEDGE_BASE authorization.""" + + @pytest.mark.asyncio + async def test_retrieve_knowledge_base_authorized_with_run_id(self): + """RETRIEVE_KNOWLEDGE_BASE: authorized when kb in session.resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}]) + + await registry.register( + run_id='run_kb_authorized', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_kb_authorized') + assert session is not None + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is True + + await registry.unregister('run_kb_authorized') + + @pytest.mark.asyncio + async def test_retrieve_knowledge_base_unauthorized_with_run_id(self): + """RETRIEVE_KNOWLEDGE_BASE: unauthorized when kb not in session.resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}]) + + await registry.register( + run_id='run_kb_unauthorized', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_kb_unauthorized') + assert session is not None + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_999') is False + + await registry.unregister('run_kb_unauthorized') + + @pytest.mark.asyncio + async def test_retrieve_knowledge_base_no_run_id_pipeline_check(self): + """RETRIEVE_KNOWLEDGE_BASE: no run_id checks pipeline config.""" + # When no run_id, the handler checks against pipeline's configured KBs + # This is the unscoped path for regular plugin calls + + from langbot.pkg.agent.runner.config_migration import ConfigMigration + + # Simulate pipeline config + pipeline_config = { + 'ai': { + 'runner': { + 'id': 'plugin:test/runner/default', + }, + 'runner_config': { + 'plugin:test/runner/default': { + 'knowledge-bases': ['kb_001', 'kb_002'], + }, + }, + }, + } + + runner_id = ConfigMigration.resolve_runner_id(pipeline_config) + assert runner_id == 'plugin:test/runner/default' + + runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id) + allowed_kbs = runner_config.get('knowledge-bases', []) + assert 'kb_001' in allowed_kbs + assert 'kb_999' not in allowed_kbs + + +class TestAuthorizationPathDifferentiation: + """Tests that verify AgentRunner vs regular plugin call differentiation.""" + + @pytest.mark.asyncio + async def test_agent_runner_path_with_run_id(self): + """AgentRunner calls provide run_id and use session_registry.""" + registry = AgentRunSessionRegistry() + + # AgentRunner call has run_id + run_id = 'run_agent_123' + + # Register session with resources + await registry.register( + run_id=run_id, + runner_id='plugin:test/agent/default', + query_id=1, + plugin_identity='test/agent', + resources=make_resources( + models=[{'model_id': 'model_xyz'}], + tools=[{'tool_name': 'agent_tool'}], + knowledge_bases=[{'kb_id': 'kb_agent'}], + ), + ) + + session = await registry.get(run_id) + assert session is not None + + # Authorization checks + assert registry.is_resource_allowed(session, 'model', 'model_xyz') is True + assert registry.is_resource_allowed(session, 'model', 'other_model') is False + assert registry.is_resource_allowed(session, 'tool', 'agent_tool') is True + assert registry.is_resource_allowed(session, 'tool', 'other_tool') is False + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_agent') is True + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_other') is False + + await registry.unregister(run_id) + + @pytest.mark.asyncio + async def test_regular_plugin_path_no_run_id(self): + """Regular plugin calls have no run_id and skip session check.""" + # Regular plugin call has no run_id + run_id = None + + # Authorization check should be skipped when run_id is None. + # This is handled in handler.py with: if run_id: ... + assert run_id is None + + # For regular plugins: + # - INVOKE_LLM: unrestricted access to any model + # - CALL_TOOL: unrestricted access to any tool + # - RETRIEVE_KNOWLEDGE_BASE: checks pipeline config instead + + +class TestHandlerAuthorizationErrorMessages: + """Tests for error message content in authorization failures.""" + + def test_model_not_authorized_error_message(self): + """Error message should mention model not authorized.""" + expected_msg = 'Model model_999 is not authorized for this agent run' + assert 'not authorized' in expected_msg + assert 'model_999' in expected_msg + + def test_tool_not_authorized_error_message(self): + """Error message should mention tool not authorized.""" + expected_msg = 'Tool image_gen is not authorized for this agent run' + assert 'not authorized' in expected_msg + assert 'image_gen' in expected_msg + + def test_kb_not_authorized_error_message(self): + """Error message should mention kb not authorized.""" + expected_msg = 'Knowledge base kb_999 is not authorized for this agent run' + assert 'not authorized' in expected_msg + assert 'kb_999' in expected_msg + + def test_session_not_found_error_message(self): + """Error message should mention session not found.""" + expected_msg = 'Run session run_xyz not found or expired' + assert 'not found' in expected_msg + assert 'run_xyz' in expected_msg + + +class TestRETRIEVEKNOWLEDGEBASEBugFix: + """Tests for the RETRIEVE_KNOWLEDGE_BASE bug fix in handler.py. + + Bug: Previously, the handler directly accessed pipeline_config['ai']['local-agent'] + without first resolving the runner_id, causing issues when non-local-agent runners + were used. + + Fix: Now uses ConfigMigration.resolve_runner_id first, then resolve_runner_config. + """ + + def test_retrieve_kb_fix_local_agent_runner(self): + """Fix should work for local-agent runner.""" + from langbot.pkg.agent.runner.config_migration import ConfigMigration + + pipeline_config = { + 'ai': { + 'runner': { + 'id': 'plugin:langbot/local-agent/default', + }, + 'runner_config': { + 'plugin:langbot/local-agent/default': { + 'knowledge-bases': ['kb_001'], + }, + }, + }, + } + + runner_id = ConfigMigration.resolve_runner_id(pipeline_config) + runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id) + allowed_kbs = runner_config.get('knowledge-bases', []) + + assert 'kb_001' in allowed_kbs + + def test_retrieve_kb_fix_other_runner(self): + """Fix should work for non-local-agent runners.""" + from langbot.pkg.agent.runner.config_migration import ConfigMigration + + pipeline_config = { + 'ai': { + 'runner': { + 'id': 'plugin:custom/my-agent/default', + }, + 'runner_config': { + 'plugin:custom/my-agent/default': { + 'knowledge-bases': ['kb_custom'], + }, + }, + }, + } + + runner_id = ConfigMigration.resolve_runner_id(pipeline_config) + runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id) + allowed_kbs = runner_config.get('knowledge-bases', []) + + assert 'kb_custom' in allowed_kbs + + def test_retrieve_kb_reads_old_runner_format(self): + """Old runner format is resolved for migration compatibility.""" + from langbot.pkg.agent.runner.config_migration import ConfigMigration + + pipeline_config = { + 'ai': { + 'runner': { + 'runner': 'local-agent', + }, + 'local-agent': { + 'knowledge-bases': ['kb_legacy'], + }, + }, + } + + runner_id = ConfigMigration.resolve_runner_id(pipeline_config) + runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id) + assert runner_id == 'plugin:langbot/local-agent/default' + assert runner_config.get('knowledge-bases') == ['kb_legacy'] + + +class TestHandlerActionAuthorization: + """Tests for real handler action-level authorization. + + These tests simulate RuntimeConnectionHandler action handlers + to verify actual authorization behavior at the action level. + """ + + @pytest.mark.asyncio + async def test_invoke_llm_handler_authorized_path(self): + """INVOKE_LLM handler: authorized when model in resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_invoke_llm_auth', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + # Simulate handler authorization logic + run_id = 'run_invoke_llm_auth' + llm_model_uuid = 'model_001' + + session_registry = registry + session = await session_registry.get(run_id) + assert session is not None + + # Authorization check (same as handler.py line 352) + is_allowed = session_registry.is_resource_allowed(session, 'model', llm_model_uuid) + assert is_allowed is True + + await registry.unregister(run_id) + + @pytest.mark.asyncio + async def test_invoke_llm_handler_unauthorized_path(self): + """INVOKE_LLM handler: unauthorized when model not in resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_invoke_llm_unauth', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + run_id = 'run_invoke_llm_unauth' + llm_model_uuid = 'model_999' # Not in resources + + session_registry = registry + session = await session_registry.get(run_id) + assert session is not None + + # Authorization check (same as handler.py line 352) + is_allowed = session_registry.is_resource_allowed(session, 'model', llm_model_uuid) + assert is_allowed is False + + # Should return error response (handler.py line 357) + expected_error = f'Model {llm_model_uuid} is not authorized for this agent run' + assert 'not authorized' in expected_error + + await registry.unregister(run_id) + + @pytest.mark.asyncio + async def test_invoke_llm_handler_session_not_found(self): + """INVOKE_LLM handler: session not found returns error.""" + registry = AgentRunSessionRegistry() + + # No session registered + run_id = 'run_nonexistent' + session = await registry.get(run_id) + assert session is None + + # Handler should return error (handler.py line 348) + expected_error = f'Run session {run_id} not found or expired' + assert 'not found' in expected_error + + @pytest.mark.asyncio + async def test_invoke_llm_handler_no_run_id_unrestricted(self): + """INVOKE_LLM handler: no run_id skips authorization (backward compat).""" + # Simulate handler logic: if not run_id, skip authorization + run_id = None + + # In handler.py, authorization check is inside: if run_id: ... + # So when run_id is None, authorization is skipped. + assert run_id is None + + @pytest.mark.asyncio + async def test_call_tool_handler_authorized_path(self): + """CALL_TOOL handler: authorized when tool in resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(tools=[{'tool_name': 'web_search'}]) + + await registry.register( + run_id='run_call_tool_auth', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + run_id = 'run_call_tool_auth' + tool_name = 'web_search' + + session_registry = registry + session = await session_registry.get(run_id) + assert session is not None + + # Authorization check (handler.py line 475) + is_allowed = session_registry.is_resource_allowed(session, 'tool', tool_name) + assert is_allowed is True + + await registry.unregister(run_id) + + @pytest.mark.asyncio + async def test_call_tool_handler_unauthorized_path(self): + """CALL_TOOL handler: unauthorized when tool not in resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(tools=[{'tool_name': 'web_search'}]) + + await registry.register( + run_id='run_call_tool_unauth', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + run_id = 'run_call_tool_unauth' + tool_name = 'image_gen' # Not in resources + + session_registry = registry + session = await session_registry.get(run_id) + assert session is not None + + # Authorization check + is_allowed = session_registry.is_resource_allowed(session, 'tool', tool_name) + assert is_allowed is False + + # Should return error (handler.py line 480) + expected_error = f'Tool {tool_name} is not authorized for this agent run' + assert 'not authorized' in expected_error + + await registry.unregister(run_id) + + @pytest.mark.asyncio + async def test_call_tool_handler_no_run_id_unrestricted(self): + """CALL_TOOL handler: no run_id skips authorization.""" + run_id = None + + # Authorization check is inside: if run_id: ... + assert run_id is None + + @pytest.mark.asyncio + async def test_retrieve_knowledge_base_handler_authorized_path(self): + """RETRIEVE_KNOWLEDGE_BASE handler: authorized when kb in resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}]) + + await registry.register( + run_id='run_kb_auth', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + run_id = 'run_kb_auth' + kb_id = 'kb_001' + + session_registry = registry + session = await session_registry.get(run_id) + assert session is not None + + # Authorization check (handler.py line 889) + is_allowed = session_registry.is_resource_allowed(session, 'knowledge_base', kb_id) + assert is_allowed is True + + await registry.unregister(run_id) + + @pytest.mark.asyncio + async def test_retrieve_knowledge_base_handler_unauthorized_path(self): + """RETRIEVE_KNOWLEDGE_BASE handler: unauthorized when kb not in resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}]) + + await registry.register( + run_id='run_kb_unauth', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + run_id = 'run_kb_unauth' + kb_id = 'kb_999' # Not in resources + + session_registry = registry + session = await session_registry.get(run_id) + assert session is not None + + # Authorization check + is_allowed = session_registry.is_resource_allowed(session, 'knowledge_base', kb_id) + assert is_allowed is False + + # Should return error (handler.py line 894) + expected_error = f'Knowledge base {kb_id} is not authorized for this agent run' + assert 'not authorized' in expected_error + + await registry.unregister(run_id) + + +class TestSDKAgentRunAPIProxyFieldConsistency: + """Tests for SDK AgentRunAPIProxy field name consistency with Host handler. + + These tests verify that SDK sends field names that match what Host handler reads. + """ + + def test_call_tool_field_names_match(self): + """CALL_TOOL: SDK 'parameters' matches Host 'parameters'.""" + # SDK agent_run_api.py line 146: "parameters": parameters + # Host handler.py line 457: parameters = data['parameters'] + sdk_field = 'parameters' + host_field = 'parameters' + assert sdk_field == host_field + + def test_call_tool_run_id_field_present(self): + """CALL_TOOL: SDK includes 'run_id' field.""" + # SDK agent_run_api.py line 144: "run_id": self.run_id + # Host handler.py line 458: run_id = data.get('run_id') + sdk_fields = ['run_id', 'tool_name', 'parameters'] + host_expected_fields = ['tool_name', 'parameters', 'run_id'] + + for field in host_expected_fields: + assert field in sdk_fields + + def test_invoke_llm_field_names_match(self): + """INVOKE_LLM: SDK fields match Host handler.""" + # SDK agent_run_api.py lines 77-82 + sdk_fields = ['run_id', 'llm_model_uuid', 'messages', 'funcs', 'extra_args', 'timeout'] + # Host handler.py lines 333-337 + host_fields = ['llm_model_uuid', 'messages', 'funcs', 'extra_args', 'run_id'] + + for field in host_fields: + assert field in sdk_fields + + def test_invoke_llm_stream_field_names_match(self): + """INVOKE_LLM_STREAM: SDK fields match Host handler.""" + # SDK agent_run_api.py lines 111-116 + sdk_fields = ['run_id', 'llm_model_uuid', 'messages', 'funcs', 'extra_args'] + # Host handler.py lines 397-401 + host_fields = ['llm_model_uuid', 'messages', 'funcs', 'extra_args', 'run_id'] + + for field in host_fields: + assert field in sdk_fields + + def test_retrieve_knowledge_base_field_names_match(self): + """RETRIEVE_KNOWLEDGE_BASE: SDK fields match Host handler.""" + # SDK agent_run_api.py lines 178-183 + sdk_fields = ['run_id', 'kb_id', 'query_text', 'top_k', 'filters'] + + # Note: query_id is from query context, not SDK proxy + for field in ['run_id', 'kb_id', 'query_text', 'top_k', 'filters']: + assert field in sdk_fields + + def test_retrieve_knowledge_base_action_enum_correct(self): + """RETRIEVE_KNOWLEDGE_BASE: SDK uses correct action enum.""" + from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + + # SDK agent_run_api.py line 178: PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE + # Host handler.py line 851: @self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE) + action = PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE + assert action.value == 'retrieve_knowledge_base' + + # Verify it's different from unrestricted RETRIEVE_KNOWLEDGE + unrestricted_action = PluginToRuntimeAction.RETRIEVE_KNOWLEDGE + assert unrestricted_action.value == 'retrieve_knowledge' + assert action != unrestricted_action + + +class TestNoRunIdBackwardCompatPath: + """Tests for unscoped plugin action path when no run_id is provided. + + Regular plugins (non-AgentRunner) don't have run_id and should + have unrestricted access to certain APIs. + """ + + @pytest.mark.asyncio + async def test_invoke_llm_no_run_id_unrestricted_access(self): + """INVOKE_LLM: no run_id means unrestricted model access.""" + # Handler.py line 340: if run_id: ... + # When run_id is None, the authorization block is skipped + run_id = None + llm_model_uuid = 'any_model' + + # Simulate handler logic: no run_id skips authorization. + assert run_id is None + + # Model can be any UUID (unrestricted) + assert llm_model_uuid == 'any_model' + + @pytest.mark.asyncio + async def test_call_tool_no_run_id_unrestricted_access(self): + """CALL_TOOL: no run_id means unrestricted tool access.""" + run_id = None + tool_name = 'any_tool' + + # Handler.py line 463: if run_id: ... + assert run_id is None + + assert tool_name == 'any_tool' + + @pytest.mark.asyncio + async def test_retrieve_knowledge_base_no_run_id_pipeline_check(self): + """RETRIEVE_KNOWLEDGE_BASE: no run_id uses pipeline config check.""" + from langbot.pkg.agent.runner.config_migration import ConfigMigration + + # When no run_id, handler.py lines 897-914 check pipeline config + pipeline_config = { + 'ai': { + 'runner': { + 'id': 'plugin:test/runner/default', + }, + 'runner_config': { + 'plugin:test/runner/default': { + 'knowledge-bases': ['kb_001', 'kb_002'], + }, + }, + }, + } + + runner_id = ConfigMigration.resolve_runner_id(pipeline_config) + runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id) + allowed_kb_uuids = runner_config.get('knowledge-bases', []) + + # kb_001 should be allowed + assert 'kb_001' in allowed_kb_uuids + # kb_999 should NOT be allowed + assert 'kb_999' not in allowed_kb_uuids + + +class TestSessionExpiryAndCleanup: + """Tests for session expiry and cleanup scenarios.""" + + @pytest.mark.asyncio + async def test_session_expiry_detection(self): + """Session expiry: old session should be considered expired.""" + import time + + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + # Register session + await registry.register( + run_id='run_expiry_test', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_expiry_test') + assert session is not None + + # Check session status + started_at = session['status']['started_at'] + last_activity = session['status']['last_activity_at'] + assert last_activity >= started_at + + # Session should be valid initially + current_time = int(time.time()) + assert current_time - started_at < 10 # Less than 10 seconds old + + await registry.unregister('run_expiry_test') + + @pytest.mark.asyncio + async def test_cleanup_stale_sessions(self): + """Cleanup: stale sessions should be removed.""" + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + # Register session + await registry.register( + run_id='run_cleanup_test', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + # Session exists + session = await registry.get('run_cleanup_test') + assert session is not None + + # Cleanup with max_age=0 (immediate cleanup) + # Note: This won't actually cleanup because session is just created + # We need to manually test cleanup logic + cleaned = await registry.cleanup_stale_sessions(max_age_seconds=0) + assert isinstance(cleaned, int) + + # Session should still exist (it was just created) + # With max_age=0, sessions with last_activity > 0 seconds ago would be cleaned + # But since it's just created, last_activity_at is current time + session_after = await registry.get('run_cleanup_test') + assert session_after is not None + + await registry.unregister('run_cleanup_test') + + @pytest.mark.asyncio + async def test_unregister_removes_session(self): + """Unregister: session should be removed from registry.""" + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_unregister_test', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + # Session exists + session = await registry.get('run_unregister_test') + assert session is not None + + # Unregister + await registry.unregister('run_unregister_test') + + # Session should not exist + session_after = await registry.get('run_unregister_test') + assert session_after is None + + +class TestResourceTypeValidation: + """Tests for different resource type validation in is_resource_allowed.""" + + @pytest.mark.asyncio + async def test_model_resource_validation(self): + """Model resource: correct model_id validation.""" + registry = AgentRunSessionRegistry() + resources = make_resources( + models=[ + {'model_id': 'model_001'}, + {'model_id': 'model_002'}, + ] + ) + + await registry.register( + run_id='run_model_validation', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_model_validation') + + # Authorized models + assert registry.is_resource_allowed(session, 'model', 'model_001') is True + assert registry.is_resource_allowed(session, 'model', 'model_002') is True + + # Unauthorized models + assert registry.is_resource_allowed(session, 'model', 'model_999') is False + + await registry.unregister('run_model_validation') + + @pytest.mark.asyncio + async def test_tool_resource_validation(self): + """Tool resource: correct tool_name validation.""" + registry = AgentRunSessionRegistry() + resources = make_resources( + tools=[ + {'tool_name': 'web_search'}, + {'tool_name': 'image_gen'}, + ] + ) + + await registry.register( + run_id='run_tool_validation', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_tool_validation') + + # Authorized tools + assert registry.is_resource_allowed(session, 'tool', 'web_search') is True + assert registry.is_resource_allowed(session, 'tool', 'image_gen') is True + + # Unauthorized tools + assert registry.is_resource_allowed(session, 'tool', 'file_upload') is False + + await registry.unregister('run_tool_validation') + + @pytest.mark.asyncio + async def test_knowledge_base_resource_validation(self): + """Knowledge base resource: correct kb_id validation.""" + registry = AgentRunSessionRegistry() + resources = make_resources( + knowledge_bases=[ + {'kb_id': 'kb_001'}, + {'kb_id': 'kb_002'}, + ] + ) + + await registry.register( + run_id='run_kb_validation', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_kb_validation') + + # Authorized KBs + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is True + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_002') is True + + # Unauthorized KBs + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_999') is False + + await registry.unregister('run_kb_validation') + + @pytest.mark.asyncio + async def test_storage_resource_validation(self): + """Storage resource: boolean permission validation.""" + registry = AgentRunSessionRegistry() + resources = make_resources() + resources['storage'] = {'plugin_storage': True, 'workspace_storage': False} + + await registry.register( + run_id='run_storage_validation', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_storage_validation') + + # Plugin storage allowed + assert registry.is_resource_allowed(session, 'storage', 'plugin') is True + + # Workspace storage not allowed + assert registry.is_resource_allowed(session, 'storage', 'workspace') is False + + await registry.unregister('run_storage_validation') + + def test_unknown_resource_type_returns_false(self): + """Unknown resource type: should return False.""" + registry = AgentRunSessionRegistry() + resources = make_resources() + + session = make_session(resources=resources) + + # Unknown resource type should return False + assert registry.is_resource_allowed(session, 'unknown_type', 'any_id') is False + + +class TestBypassPrevention: + """Tests to ensure AgentRunAPIProxy cannot bypass authorization.""" + + @pytest.mark.asyncio + async def test_cannot_bypass_via_unrestricted_retrieve_knowledge(self): + """Cannot bypass KB authorization via unrestricted RETRIEVE_KNOWLEDGE action.""" + # AgentRunAPIProxy uses RETRIEVE_KNOWLEDGE_BASE (with run_id) + # RETRIEVE_KNOWLEDGE is unrestricted and separate + # AgentRunner should NOT use RETRIEVE_KNOWLEDGE to bypass authorization + + registry = AgentRunSessionRegistry() + resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}]) + + await registry.register( + run_id='run_bypass_test', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_bypass_test') + + # kb_002 is not authorized + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_002') is False + + # If AgentRunner tried to use RETRIEVE_KNOWLEDGE (unrestricted), + # it would bypass authorization - but AgentRunAPIProxy correctly uses + # RETRIE_KNOWLEDGE_BASE which requires authorization + + from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + + # Verify SDK uses correct action + assert PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE.value == 'retrieve_knowledge_base' + + await registry.unregister('run_bypass_test') + + @pytest.mark.asyncio + async def test_cannot_bypass_via_missing_run_id_in_session(self): + """Cannot bypass by using run_id that doesn't exist in registry.""" + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_valid', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + # Try to use a run_id that doesn't exist + fake_run_id = 'run_fake' + session = await registry.get(fake_run_id) + assert session is None + + # Handler should return error for non-existent run_id + # (handler.py line 348, 466, 881) + expected_error = f'Run session {fake_run_id} not found or expired' + assert 'not found' in expected_error + + await registry.unregister('run_valid') + + +class TestValidateRunAuthorizationHelper: + """Tests for _validate_run_authorization helper function. + + This helper is used by INVOKE_LLM, INVOKE_LLM_STREAM, CALL_TOOL, + and RETRIEVE_KNOWLEDGE_BASE handlers to validate run_id authorization. + + Note: This helper uses get_session_registry() which returns the global singleton. + Tests must use the same global registry. + """ + + @pytest.mark.asyncio + async def test_validate_returns_session_when_authorized(self): + """_validate_run_authorization returns session when resource is authorized.""" + # Use global session registry (same as _validate_run_authorization) + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_validate_test_helper', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + # Import the helper + from langbot.pkg.plugin.handler import _validate_run_authorization + + # Create mock application + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + session, error = await _validate_run_authorization( + 'run_validate_test_helper', + 'model', + 'model_001', + mock_ap, + caller_plugin_identity='test/runner', + ) + + # Should return session, no error + assert session is not None + assert error is None + assert session['run_id'] == 'run_validate_test_helper' + + await registry.unregister('run_validate_test_helper') + + @pytest.mark.asyncio + async def test_validate_returns_error_when_session_not_found(self): + """_validate_run_authorization returns error when session not found.""" + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + mock_ap.logger.warning = MagicMock() + + session, error = await _validate_run_authorization('run_nonexistent_helper', 'model', 'model_001', mock_ap) + + # Should return no session, error response + assert session is None + assert error is not None + assert 'not found' in error.message.lower() + assert mock_ap.logger.warning.called + + @pytest.mark.asyncio + async def test_validate_returns_error_when_resource_not_allowed(self): + """_validate_run_authorization returns error when resource not allowed.""" + # Use global session registry + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_unauthorized_helper', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + mock_ap.logger.warning = MagicMock() + + session, error = await _validate_run_authorization( + 'run_unauthorized_helper', + 'model', + 'model_999', # Not in resources + mock_ap, + caller_plugin_identity='test/runner', + ) + + # Should return no session, error response + assert session is None + assert error is not None + assert 'not authorized' in error.message.lower() + assert mock_ap.logger.warning.called + + await registry.unregister('run_unauthorized_helper') + + @pytest.mark.asyncio + async def test_validate_for_tool_resource_type(self): + """_validate_run_authorization works for tool resource type.""" + # Use global session registry + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(tools=[{'tool_name': 'web_search'}]) + + await registry.register( + run_id='run_tool_test_helper', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + session, error = await _validate_run_authorization( + 'run_tool_test_helper', + 'tool', + 'web_search', + mock_ap, + caller_plugin_identity='test/runner', + ) + + assert session is not None + assert error is None + + await registry.unregister('run_tool_test_helper') + + @pytest.mark.asyncio + async def test_validate_for_knowledge_base_resource_type(self): + """_validate_run_authorization works for knowledge_base resource type.""" + # Use global session registry + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}]) + + await registry.register( + run_id='run_kb_test_helper', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + session, error = await _validate_run_authorization( + 'run_kb_test_helper', + 'knowledge_base', + 'kb_001', + mock_ap, + caller_plugin_identity='test/runner', + ) + + assert session is not None + assert error is None + + await registry.unregister('run_kb_test_helper') + + +class TestStorageResourcePermissionHelper: + """Tests for session_registry.is_resource_allowed for storage resource type. + + The 'storage' resource type has different permission model: + - resource_id can be 'plugin' or 'workspace' + - Permission is boolean flag, not list membership + """ + + @pytest.mark.asyncio + async def test_plugin_storage_allowed_when_true(self): + """is_resource_allowed returns True when plugin_storage=True.""" + registry = AgentRunSessionRegistry() + resources = make_resources() + resources['storage'] = {'plugin_storage': True, 'workspace_storage': False} + + await registry.register( + run_id='run_plugin_storage', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_plugin_storage') + + assert registry.is_resource_allowed(session, 'storage', 'plugin') is True + assert registry.is_resource_allowed(session, 'storage', 'workspace') is False + + await registry.unregister('run_plugin_storage') + + @pytest.mark.asyncio + async def test_workspace_storage_allowed_when_true(self): + """is_resource_allowed returns True when workspace_storage=True.""" + registry = AgentRunSessionRegistry() + resources = make_resources() + resources['storage'] = {'plugin_storage': False, 'workspace_storage': True} + + await registry.register( + run_id='run_workspace_storage', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_workspace_storage') + + assert registry.is_resource_allowed(session, 'storage', 'plugin') is False + assert registry.is_resource_allowed(session, 'storage', 'workspace') is True + + await registry.unregister('run_workspace_storage') + + @pytest.mark.asyncio + async def test_both_storage_types_disabled(self): + """is_resource_allowed returns False when both storage types disabled.""" + registry = AgentRunSessionRegistry() + resources = make_resources() + resources['storage'] = {'plugin_storage': False, 'workspace_storage': False} + + await registry.register( + run_id='run_no_storage', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_no_storage') + + assert registry.is_resource_allowed(session, 'storage', 'plugin') is False + assert registry.is_resource_allowed(session, 'storage', 'workspace') is False + + await registry.unregister('run_no_storage') + + @pytest.mark.asyncio + async def test_unknown_storage_resource_id_returns_false(self): + """is_resource_allowed returns False for unknown storage resource_id.""" + registry = AgentRunSessionRegistry() + resources = make_resources() + resources['storage'] = {'plugin_storage': True, 'workspace_storage': True} + + await registry.register( + run_id='run_unknown_storage', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_unknown_storage') + + # Unknown storage resource_id + assert registry.is_resource_allowed(session, 'storage', 'unknown_type') is False + + await registry.unregister('run_unknown_storage') + + def test_storage_permission_with_missing_storage_field(self): + """is_resource_allowed handles missing storage field gracefully.""" + registry = AgentRunSessionRegistry() + + session = make_session(resources={}) + + # Should return False for both storage types + assert registry.is_resource_allowed(session, 'storage', 'plugin') is False + assert registry.is_resource_allowed(session, 'storage', 'workspace') is False + + + +class TestRealActionHandlerSimulation: + """Tests that simulate real RuntimeConnectionHandler action registration and execution. + + These tests attempt to verify the actual handler behavior without full integration. + Uses global session registry to match _validate_run_authorization behavior. + """ + + @pytest.mark.asyncio + async def test_action_handler_invoke_llm_flow(self): + """Simulate INVOKE_LLM action handler authorization flow.""" + # Use global session registry + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_invoke_llm_flow_sim', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + # Simulate handler logic + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + # Step 1: Validate authorization + session, error = await _validate_run_authorization( + 'run_invoke_llm_flow_sim', + 'model', + 'model_001', + mock_ap, + caller_plugin_identity='test/runner', + ) + + # Should pass authorization + assert session is not None + assert error is None + + # Step 2: Handler would invoke LLM (not tested here, would need mock model) + + await registry.unregister('run_invoke_llm_flow_sim') + + @pytest.mark.asyncio + async def test_action_handler_rejects_unauthorized_model(self): + """Simulate INVOKE_LLM handler rejecting unauthorized model.""" + # Use global session registry + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_reject_model_sim', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + mock_ap.logger.warning = MagicMock() + + # Try to access unauthorized model + session, error = await _validate_run_authorization( + 'run_reject_model_sim', + 'model', + 'model_999', + mock_ap, + caller_plugin_identity='test/runner', + ) + + # Should reject + assert session is None + assert error is not None + assert 'not authorized' in error.message.lower() + assert mock_ap.logger.warning.called + + await registry.unregister('run_reject_model_sim') + + @pytest.mark.asyncio + async def test_action_handler_session_not_found_flow(self): + """Simulate handler behavior when session not found.""" + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + mock_ap.logger.warning = MagicMock() + + # Try to validate with non-existent run_id + session, error = await _validate_run_authorization( + 'run_nonexistent_session_flow', 'model', 'model_001', mock_ap + ) + + # Should return error + assert session is None + assert error is not None + assert 'not found' in error.message.lower() + assert mock_ap.logger.warning.called + + +class TestStoragePermissionValidation: + """Tests for Host-side storage permission validation via _validate_run_authorization. + + Phase 6: Storage actions (SET/GET/DELETE_BINARY_STORAGE) now validate + storage permissions via _validate_run_authorization when run_id is present. + """ + + @pytest.mark.asyncio + async def test_plugin_storage_allowed_when_permitted(self): + """_validate_run_authorization allows 'plugin' storage when permitted.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(storage={'plugin_storage': True, 'workspace_storage': False}) + + await registry.register( + run_id='run_plugin_storage_auth', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + session, error = await _validate_run_authorization( + 'run_plugin_storage_auth', + 'storage', + 'plugin', + mock_ap, + caller_plugin_identity='test/runner', + ) + + assert session is not None + assert error is None + + await registry.unregister('run_plugin_storage_auth') + + @pytest.mark.asyncio + async def test_plugin_storage_denied_when_not_permitted(self): + """_validate_run_authorization denies 'plugin' storage when not permitted.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': False}) + + await registry.register( + run_id='run_plugin_storage_denied', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + mock_ap.logger.warning = MagicMock() + + session, error = await _validate_run_authorization( + 'run_plugin_storage_denied', + 'storage', + 'plugin', + mock_ap, + caller_plugin_identity='test/runner', + ) + + assert session is None + assert error is not None + assert 'not authorized' in error.message.lower() + + await registry.unregister('run_plugin_storage_denied') + + @pytest.mark.asyncio + async def test_workspace_storage_allowed_when_permitted(self): + """_validate_run_authorization allows 'workspace' storage when permitted.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': True}) + + await registry.register( + run_id='run_workspace_storage_auth', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + session, error = await _validate_run_authorization( + 'run_workspace_storage_auth', + 'storage', + 'workspace', + mock_ap, + caller_plugin_identity='test/runner', + ) + + assert session is not None + assert error is None + + await registry.unregister('run_workspace_storage_auth') + + @pytest.mark.asyncio + async def test_workspace_storage_denied_when_not_permitted(self): + """_validate_run_authorization denies 'workspace' storage when not permitted.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': False}) + + await registry.register( + run_id='run_workspace_storage_denied', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + mock_ap.logger.warning = MagicMock() + + session, error = await _validate_run_authorization( + 'run_workspace_storage_denied', + 'storage', + 'workspace', + mock_ap, + caller_plugin_identity='test/runner', + ) + + assert session is None + assert error is not None + assert 'not authorized' in error.message.lower() + + await registry.unregister('run_workspace_storage_denied') + + +class TestOperationPermissionValidation: + """Tests operation-level Host-side run authorization.""" + + @pytest.mark.asyncio + async def test_model_operation_denied_when_resource_only_allows_invoke(self): + from langbot.pkg.agent.runner.session_registry import get_session_registry + from langbot.pkg.plugin.handler import _validate_run_authorization + + registry = get_session_registry() + await registry.register( + run_id='run_model_operation_denied', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=make_resources(models=[{'model_id': 'model_001', 'operations': ['invoke']}]), + ) + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + session, error = await _validate_run_authorization( + 'run_model_operation_denied', + 'model', + 'model_001', + mock_ap, + caller_plugin_identity='test/runner', + operation='stream', + ) + + assert session is None + assert error is not None + assert 'operation stream' in error.message + + await registry.unregister('run_model_operation_denied') + + +class TestCallerPluginIdentityValidation: + """Tests for caller_plugin_identity cross-plugin validation. + + Phase 6: _validate_run_authorization now validates that the caller plugin + identity matches the session's plugin_identity, preventing cross-plugin + unauthorized access if one plugin tries to use another's run_id. + """ + + @pytest.mark.asyncio + async def test_same_plugin_identity_allowed(self): + """_validate_run_authorization allows when caller matches session.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_identity_match', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', # Session owner + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + session, error = await _validate_run_authorization( + 'run_identity_match', + 'model', + 'model_001', + mock_ap, + caller_plugin_identity='test/runner', # Caller is same plugin + ) + + assert session is not None + assert error is None + + await registry.unregister('run_identity_match') + + @pytest.mark.asyncio + async def test_different_plugin_identity_denied(self): + """_validate_run_authorization denies when caller differs from session.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_identity_mismatch', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', # Session owner + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + mock_ap.logger.warning = MagicMock() + + session, error = await _validate_run_authorization( + 'run_identity_mismatch', + 'model', + 'model_001', + mock_ap, + caller_plugin_identity='other/plugin', # Different plugin trying to use run_id + ) + + assert session is None + assert error is not None + assert 'mismatch' in error.message.lower() + + await registry.unregister('run_identity_mismatch') + + @pytest.mark.asyncio + async def test_run_id_requires_caller_identity(self): + """Run-scoped authorization requires caller_plugin_identity.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_no_caller_identity', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + session, error = await _validate_run_authorization( + 'run_no_caller_identity', + 'model', + 'model_001', + mock_ap, + caller_plugin_identity=None, + ) + + assert session is None + assert error is not None + assert 'caller_plugin_identity is required' in error.message + + await registry.unregister('run_no_caller_identity') + + @pytest.mark.asyncio + async def test_session_missing_plugin_identity_denied(self): + """Malformed legacy sessions without plugin_identity fail closed.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + session = make_session( + run_id='run_missing_session_identity', + runner_id='plugin:test/runner/default', + plugin_identity='', + resources=resources, + ) + async with registry._lock: + registry._sessions['run_missing_session_identity'] = session + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + session, error = await _validate_run_authorization( + 'run_missing_session_identity', + 'model', + 'model_001', + mock_ap, + caller_plugin_identity='test/runner', + ) + + assert session is None + assert error is not None + assert 'no plugin_identity' in error.message + + await registry.unregister('run_missing_session_identity') + + @pytest.mark.asyncio + async def test_pull_api_session_missing_plugin_identity_denied(self): + """Pull API validation also fails closed for missing session identity.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + session = make_session( + run_id='run_missing_pull_identity', + runner_id='plugin:test/runner/default', + plugin_identity='', + available_apis={'history_page': True}, + ) + async with registry._lock: + registry._sessions['run_missing_pull_identity'] = session + + from langbot.pkg.plugin.handler import _validate_agent_run_session + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + session, error = await _validate_agent_run_session( + 'run_missing_pull_identity', + 'test/runner', + mock_ap, + 'HISTORY_PAGE', + 'history_page', + ) + + assert session is None + assert error is not None + assert 'no plugin_identity' in error.message + + await registry.unregister('run_missing_pull_identity') + + +class TestBackwardCompatStorageNoRunId: + """Tests for unscoped storage actions without run_id. + + Regular plugins (non-AgentRunner) don't have run_id and should + have unrestricted access to storage APIs. + """ + + def test_storage_no_run_id_skips_validation(self): + """Storage actions without run_id skip Host-side validation.""" + # Handler.py: if run_id: ...validation... + # When run_id is None, validation is skipped + run_id = None + + # Simulate handler logic: no run_id skips validation. + assert run_id is None + + # Storage access unrestricted for regular plugins + assert run_id is None + + def test_file_no_run_id_skips_validation(self): + """GET_CONFIG_FILE without run_id skips Host-side validation.""" + run_id = None + + assert run_id is None + + # File access unrestricted for regular plugins + assert run_id is None diff --git a/tests/unit_tests/agent/test_history_event_api_auth.py b/tests/unit_tests/agent/test_history_event_api_auth.py new file mode 100644 index 000000000..ab5392d37 --- /dev/null +++ b/tests/unit_tests/agent/test_history_event_api_auth.py @@ -0,0 +1,323 @@ +"""Tests for AgentRunner history/event pull API authorization.""" +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.agent.runner.event_log_store import EventLogStore +from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry +from langbot.pkg.entity.persistence import event_log as event_log_model +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.plugin.handler import RuntimeConnectionHandler +from langbot_plugin.api.entities.builtin.agent_runner.page_results import ( + AgentEventRecord, + EventPage, +) +from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + +from .conftest import make_resources + + +class FakeConnection: + pass + + +class FakeApplication: + def __init__(self, db_engine): + self.logger = MagicMock() + self.persistence_mgr = MagicMock() + self.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + +@pytest.fixture +def session_registry(monkeypatch): + registry = AgentRunSessionRegistry() + monkeypatch.setattr( + 'langbot.pkg.agent.runner.session_registry._global_registry', + registry, + ) + return registry + + +@pytest.fixture +async def db_engine(): + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + assert event_log_model.EventLog.__tablename__ == 'event_log' + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + + +def _handler(db_engine, session_registry): + async def fake_disconnect(): + return True + + fake_app = FakeApplication(db_engine) + return RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + + +async def _register_session( + session_registry, + *, + run_id='run_1', + conversation_id='conv_1', + bot_id=None, + workspace_id=None, + thread_id=None, + available_apis=None, +): + await session_registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=None, + plugin_identity='test/runner', + resources=make_resources(), + conversation_id=conversation_id, + bot_id=bot_id, + workspace_id=workspace_id, + thread_id=thread_id, + available_apis=available_apis or {}, + ) + + +@pytest.mark.asyncio +async def test_history_page_requires_runtime_capability(session_registry, db_engine): + await _register_session(session_registry, available_apis={'history_page': False}) + handler = _handler(db_engine, session_registry) + history_page = handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value] + + result = await history_page({ + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code != 0 + assert 'not authorized' in result.message.lower() + + +@pytest.mark.asyncio +async def test_history_page_rejects_cross_conversation(session_registry, db_engine): + await _register_session(session_registry, available_apis={'history_page': True}) + handler = _handler(db_engine, session_registry) + history_page = handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value] + + result = await history_page({ + 'run_id': 'run_1', + 'conversation_id': 'conv_other', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code != 0 + assert 'not accessible' in result.message.lower() + + +@pytest.mark.asyncio +async def test_history_search_rejects_filter_conversation_override(session_registry, db_engine): + await _register_session(session_registry, available_apis={'history_search': True}) + handler = _handler(db_engine, session_registry) + history_search = handler.actions[PluginToRuntimeAction.HISTORY_SEARCH.value] + + result = await history_search({ + 'run_id': 'run_1', + 'query': 'hello', + 'filters': {'conversation_id': 'conv_other'}, + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code != 0 + assert 'not accessible' in result.message.lower() + + +@pytest.mark.asyncio +async def test_event_page_requires_runtime_capability(session_registry, db_engine): + await _register_session(session_registry, available_apis={'event_page': False}) + handler = _handler(db_engine, session_registry) + event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value] + + result = await event_page({ + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code != 0 + assert 'not authorized' in result.message.lower() + + +@pytest.mark.asyncio +async def test_event_page_rejects_cross_conversation(session_registry, db_engine): + await _register_session(session_registry, available_apis={'event_page': True}) + handler = _handler(db_engine, session_registry) + event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value] + + result = await event_page({ + 'run_id': 'run_1', + 'conversation_id': 'conv_other', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code != 0 + assert 'not accessible' in result.message.lower() + + +@pytest.mark.asyncio +async def test_event_get_returns_sdk_record_projection(session_registry, db_engine): + await _register_session(session_registry, available_apis={'event_get': True}) + store = EventLogStore(db_engine) + event_id = await store.append_event( + event_id='evt_projection_1', + event_type='message.received', + source='platform', + conversation_id='conv_1', + actor_type='user', + actor_id='user_1', + input_summary='hello', + input_json={'internal': 'not part of AgentEventRecord'}, + run_id='run_1', + runner_id='plugin:test/runner/default', + ) + handler = _handler(db_engine, session_registry) + event_get = handler.actions[PluginToRuntimeAction.EVENT_GET.value] + + result = await event_get({ + 'run_id': 'run_1', + 'event_id': event_id, + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code == 0 + AgentEventRecord.model_validate(result.data) + assert 'id' not in result.data + assert 'input_json' not in result.data + assert 'run_id' not in result.data + assert 'runner_id' not in result.data + assert result.data['seq'] is not None + assert result.data['cursor'] == str(result.data['seq']) + + +@pytest.mark.asyncio +async def test_event_page_returns_sdk_page_projection(session_registry, db_engine): + await _register_session(session_registry, available_apis={'event_page': True}) + store = EventLogStore(db_engine) + await store.append_event( + event_id='evt_projection_page_1', + event_type='message.received', + source='platform', + conversation_id='conv_1', + input_json={'internal': 'not part of AgentEventRecord'}, + run_id='run_other', + runner_id='plugin:test/runner/default', + ) + handler = _handler(db_engine, session_registry) + event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value] + + result = await event_page({ + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code == 0 + page = EventPage.model_validate(result.data) + assert len(page.items) == 1 + item = result.data['items'][0] + assert 'id' not in item + assert 'input_json' not in item + assert 'run_id' not in item + assert 'runner_id' not in item + + +@pytest.mark.asyncio +async def test_history_page_filters_run_scope_thread_and_bot(session_registry, db_engine): + from langbot.pkg.agent.runner.transcript_store import TranscriptStore + + await _register_session( + session_registry, + bot_id='bot_1', + thread_id='thread_1', + available_apis={'history_page': True}, + ) + store = TranscriptStore(db_engine) + await store.append_transcript( + transcript_id='tr_visible', + event_id='evt_visible', + conversation_id='conv_1', + role='user', + bot_id='bot_1', + thread_id='thread_1', + content='visible', + ) + await store.append_transcript( + transcript_id='tr_other_bot', + event_id='evt_other_bot', + conversation_id='conv_1', + role='user', + bot_id='bot_2', + thread_id='thread_1', + content='hidden bot', + ) + await store.append_transcript( + transcript_id='tr_other_thread', + event_id='evt_other_thread', + conversation_id='conv_1', + role='user', + bot_id='bot_1', + thread_id='thread_2', + content='hidden thread', + ) + handler = _handler(db_engine, session_registry) + history_page = handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value] + + result = await history_page({ + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code == 0 + assert [item['content'] for item in result.data['items']] == ['visible'] + + +@pytest.mark.asyncio +async def test_event_page_filters_run_scope_thread_and_bot(session_registry, db_engine): + await _register_session( + session_registry, + bot_id='bot_1', + thread_id='thread_1', + available_apis={'event_page': True}, + ) + store = EventLogStore(db_engine) + await store.append_event( + event_id='evt_visible', + event_type='message.received', + source='platform', + bot_id='bot_1', + conversation_id='conv_1', + thread_id='thread_1', + ) + await store.append_event( + event_id='evt_other_bot', + event_type='message.received', + source='platform', + bot_id='bot_2', + conversation_id='conv_1', + thread_id='thread_1', + ) + await store.append_event( + event_id='evt_other_thread', + event_type='message.received', + source='platform', + bot_id='bot_1', + conversation_id='conv_1', + thread_id='thread_2', + ) + handler = _handler(db_engine, session_registry) + event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value] + + result = await event_page({ + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code == 0 + assert [item['event_id'] for item in result.data['items']] == ['evt_visible'] diff --git a/tests/unit_tests/agent/test_id.py b/tests/unit_tests/agent/test_id.py new file mode 100644 index 000000000..55941c1d5 --- /dev/null +++ b/tests/unit_tests/agent/test_id.py @@ -0,0 +1,137 @@ +"""Tests for agent runner ID parsing and formatting.""" +from __future__ import annotations + +import pytest + +from langbot.pkg.agent.runner.id import ( + parse_runner_id, + format_runner_id, + RunnerIdParts, + is_plugin_runner_id, +) + + +class TestRunnerIdParsing: + """Tests for parse_runner_id.""" + + def test_parse_plugin_runner_id(self): + """Parse valid plugin runner ID.""" + runner_id = 'plugin:langbot/local-agent/default' + parts = parse_runner_id(runner_id) + + assert parts.source == 'plugin' + assert parts.plugin_author == 'langbot' + assert parts.plugin_name == 'local-agent' + assert parts.runner_name == 'default' + + def test_parse_plugin_runner_id_complex_names(self): + """Parse plugin runner ID with complex names.""" + runner_id = 'plugin:alice/helpdesk-agent/ticket-handler' + parts = parse_runner_id(runner_id) + + assert parts.source == 'plugin' + assert parts.plugin_author == 'alice' + assert parts.plugin_name == 'helpdesk-agent' + assert parts.runner_name == 'ticket-handler' + + def test_parse_invalid_plugin_runner_id_missing_parts(self): + """Parse invalid plugin runner ID with missing parts.""" + runner_id = 'plugin:langbot/local-agent' + + with pytest.raises(ValueError) as exc_info: + parse_runner_id(runner_id) + + assert 'Invalid plugin runner ID format' in str(exc_info.value) + + def test_parse_invalid_plugin_runner_id_empty_parts(self): + """Parse invalid plugin runner ID with empty parts.""" + runner_id = 'plugin://default' + + with pytest.raises(ValueError) as exc_info: + parse_runner_id(runner_id) + + assert 'non-empty' in str(exc_info.value) + + def test_parse_invalid_runner_id_not_plugin(self): + """Parse invalid runner ID without plugin prefix.""" + runner_id = 'local-agent' + + with pytest.raises(ValueError) as exc_info: + parse_runner_id(runner_id) + + assert 'Invalid runner ID format' in str(exc_info.value) + + def test_parse_invalid_runner_id_empty_string(self): + """Parse empty runner ID.""" + runner_id = '' + + with pytest.raises(ValueError): + parse_runner_id(runner_id) + + +class TestRunnerIdFormatting: + """Tests for format_runner_id.""" + + def test_format_plugin_runner_id(self): + """Format plugin runner ID.""" + runner_id = format_runner_id( + source='plugin', + plugin_author='langbot', + plugin_name='local-agent', + runner_name='default', + ) + + assert runner_id == 'plugin:langbot/local-agent/default' + + def test_format_invalid_source(self): + """Format runner ID with invalid source.""" + with pytest.raises(ValueError) as exc_info: + format_runner_id( + source='builtin', + plugin_author='langbot', + plugin_name='local-agent', + runner_name='default', + ) + + assert 'Invalid runner source' in str(exc_info.value) + + +class TestRunnerIdParts: + """Tests for RunnerIdParts dataclass.""" + + def test_get_plugin_id(self): + """Get plugin ID from parts.""" + parts = RunnerIdParts( + source='plugin', + plugin_author='langbot', + plugin_name='local-agent', + runner_name='default', + ) + + assert parts.to_plugin_id() == 'langbot/local-agent' + + def test_frozen_dataclass(self): + """RunnerIdParts should be immutable.""" + parts = RunnerIdParts( + source='plugin', + plugin_author='langbot', + plugin_name='local-agent', + runner_name='default', + ) + + with pytest.raises(Exception): # FrozenInstanceError + parts.plugin_author = 'other' + + +class TestIsPluginRunnerId: + """Tests for is_plugin_runner_id.""" + + def test_is_plugin_runner_id_true(self): + """Check plugin runner ID returns True.""" + assert is_plugin_runner_id('plugin:langbot/local-agent/default') is True + + def test_is_plugin_runner_id_false(self): + """Check non-plugin runner ID returns False.""" + assert is_plugin_runner_id('local-agent') is False + assert is_plugin_runner_id('builtin:local-agent') is False + assert is_plugin_runner_id('') is False \ No newline at end of file diff --git a/tests/unit_tests/agent/test_orchestrator_integration.py b/tests/unit_tests/agent/test_orchestrator_integration.py new file mode 100644 index 000000000..703701348 --- /dev/null +++ b/tests/unit_tests/agent/test_orchestrator_integration.py @@ -0,0 +1,1151 @@ +"""Integration-style tests for AgentRunOrchestrator with a fake plugin runner.""" + +from __future__ import annotations + +import asyncio +import datetime +import types +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine + +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor +from langbot.pkg.agent.runner.errors import RunnerExecutionError +from langbot.pkg.agent.runner.orchestrator import AgentRunOrchestrator +from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter +from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver +from langbot.pkg.agent.runner.session_registry import get_session_registry +from langbot.pkg.agent.runner.run_ledger_store import RunLedgerStore +from langbot.pkg.agent.runner.persistent_state_store import reset_persistent_state_store +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.provider import message as provider_message +from langbot_plugin.api.entities.builtin.provider import session as provider_session +from langbot_plugin.api.entities.builtin.resource import tool as resource_tool + + +RUNNER_ID = 'plugin:langbot/local-agent/default' + + +class FakeLogger: + def __init__(self): + self.warnings: list[str] = [] + + def debug(self, msg): + pass + + def info(self, msg): + pass + + def warning(self, msg, *args, **kwargs): + self.warnings.append(str(msg)) + + def error(self, msg): + pass + + +class FakeVersionManager: + def get_current_version(self): + return 'test-version' + + +class FakeModel: + def __init__(self, model_type: str = 'chat'): + self.model_entity = types.SimpleNamespace(model_type=model_type) + self.provider_entity = types.SimpleNamespace(name='fake-provider') + + +class FakeKnowledgeBase: + def __init__(self, kb_id: str): + self.kb_id = kb_id + self.knowledge_base_entity = types.SimpleNamespace(kb_type='fake') + + def get_name(self): + return f'KB {self.kb_id}' + + +class FakePluginConnector: + is_enable_plugin = True + + def __init__(self, results=None, error: Exception | None = None, delay: float = 0): + self.results = results or [] + self.error = error + self.delay = delay + self.calls: list[dict] = [] + self.contexts: list[dict] = [] + self.sessions_during_run: list[dict | None] = [] + + async def run_agent(self, plugin_author, plugin_name, runner_name, context): + self.calls.append( + { + 'plugin_author': plugin_author, + 'plugin_name': plugin_name, + 'runner_name': runner_name, + } + ) + self.contexts.append(context) + self.sessions_during_run.append(await get_session_registry().get(context['run_id'])) + + if self.error: + raise self.error + + for result in self.results: + if self.delay: + await asyncio.sleep(self.delay) + yield result + + +class FakeRegistry: + def __init__(self, descriptor: AgentRunnerDescriptor): + self.descriptor = descriptor + self.calls: list[dict] = [] + + async def get(self, runner_id, bound_plugins=None): + self.calls.append({'runner_id': runner_id, 'bound_plugins': bound_plugins}) + assert runner_id == self.descriptor.id + return self.descriptor + + +class FakePersistenceManager: + def __init__(self, db_engine: AsyncEngine): + self._db_engine = db_engine + + def get_db_engine(self): + return self._db_engine + + +class FakeApplication: + def __init__(self, plugin_connector: FakePluginConnector, db_engine: AsyncEngine): + self.logger = FakeLogger() + self.ver_mgr = FakeVersionManager() + self.plugin_connector = plugin_connector + self.persistence_mgr = FakePersistenceManager(db_engine) + + self.model_mgr = types.SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=FakeModel())) + self.rag_mgr = types.SimpleNamespace( + get_knowledge_base_by_uuid=AsyncMock(return_value=FakeKnowledgeBase('kb_001')) + ) + self.skill_mgr = types.SimpleNamespace( + skills={ + 'demo': { + 'name': 'demo', + 'display_name': 'Demo Skill', + 'description': 'Helps with demo tasks.', + }, + 'hidden': { + 'name': 'hidden', + 'display_name': 'Hidden Skill', + 'description': 'Not bound to this pipeline.', + }, + } + ) + + +class FakeConversation: + uuid = 'conv_existing' + create_time = datetime.datetime(2026, 5, 15, 12, 0, 0) + + +def make_descriptor() -> AgentRunnerDescriptor: + return AgentRunnerDescriptor( + id=RUNNER_ID, + source='plugin', + label={'en_US': 'Local Agent'}, + plugin_author='langbot', + plugin_name='local-agent', + runner_name='default', + capabilities={ + 'streaming': True, + 'tool_calling': True, + 'knowledge_retrieval': True, + 'skill_authoring': True, + }, + permissions={ + 'models': ['invoke', 'stream'], + 'tools': ['detail', 'call'], + 'knowledge_bases': ['list', 'retrieve'], + 'history': ['page', 'search'], + 'events': ['get', 'page'], + 'storage': ['plugin'], + }, + config_schema=[ + {'name': 'model', 'type': 'model-fallback-selector'}, + {'name': 'knowledge-bases', 'type': 'knowledge-base-multi-selector', 'default': []}, + ], + ) + + +def make_query(): + async def fake_func(**kwargs): + return kwargs + + message_chain = platform_message.MessageChain( + [ + platform_message.Source( + id='msg_001', + time=datetime.datetime(2026, 5, 15, 12, 0, 0), + ), + platform_message.Plain(text='hello'), + platform_message.File(name='spec.txt', url='https://example.com/spec.txt'), + ] + ) + sender = platform_entities.Friend(id='user_001', nickname='Alice', remark=None) + message_event = platform_events.FriendMessage(sender=sender, message_chain=message_chain, time=1_784_098_800.0) + session = types.SimpleNamespace( + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user_001', + sender_id='user_001', + using_conversation=FakeConversation(), + ) + + return types.SimpleNamespace( + query_id=1001, + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user_001', + sender_id='user_001', + message_event=message_event, + message_chain=message_chain, + bot_uuid='bot_001', + pipeline_uuid='pipeline_001', + pipeline_config={ + 'ai': { + 'runner': {'id': RUNNER_ID}, + 'runner_config': { + RUNNER_ID: { + 'model': {'primary': 'model_primary', 'fallbacks': ['model_fallback']}, + 'knowledge-bases': ['kb_001'], + 'timeout': 30, + }, + }, + }, + }, + session=session, + messages=[], + user_message=provider_message.Message( + role='user', + content=[ + provider_message.ContentElement.from_text('hello'), + provider_message.ContentElement.from_file_url('https://example.com/spec.txt', 'spec.txt'), + ], + ), + variables={ + '_pipeline_bound_plugins': ['langbot/local-agent'], + '_fallback_model_uuids': ['model_fallback'], + '_pipeline_bound_skills': ['demo'], + 'public_param': 'visible', + }, + use_llm_model_uuid='model_primary', + use_funcs=[ + resource_tool.LLMTool( + name='langbot/test-tool/search', + human_desc='Search', + description='Search test data', + parameters={'type': 'object', 'properties': {'q': {'type': 'string'}}}, + func=fake_func, + ) + ], + ) + + +def test_context_builder_includes_consumable_base64_attachments(): + query = make_query() + query.user_message = provider_message.Message( + role='user', + content=[ + provider_message.ContentElement.from_text('see attached'), + provider_message.ContentElement.from_image_base64('data:image/png;base64,aGVsbG8='), + provider_message.ContentElement.from_file_base64('data:text/plain;base64,aGVsbG8=', 'hello.txt'), + ], + ) + query.message_chain = platform_message.MessageChain( + [platform_message.Image(base64='data:image/jpeg;base64,aGVsbG8=')] + ) + + input_data = QueryEntryAdapter._build_input(query) + + assert input_data.contents[0].text == 'see attached' + assert input_data.contents[1].image_base64 == 'data:image/png;base64,aGVsbG8=' + assert input_data.contents[2].file_base64 == 'data:text/plain;base64,aGVsbG8=' + + attachment_types = [attachment.type for attachment in input_data.attachments] + assert attachment_types == ['image', 'file', 'image'] + assert input_data.attachments[1].name == 'hello.txt' + + +def test_context_builder_deduplicates_message_chain_attachments(): + query = make_query() + query.user_message = None + query.message_chain = platform_message.MessageChain( + [platform_message.Image(base64='data:image/jpeg;base64,aGVsbG8=')] + ) + + input_data = QueryEntryAdapter._build_input(query) + + assert [content.type for content in input_data.contents] == ['image_base64'] + assert len(input_data.attachments) == 1 + assert input_data.attachments[0].type == 'image' + assert input_data.attachments[0].content == 'data:image/jpeg;base64,aGVsbG8=' + + +def test_context_builder_preserves_same_source_duplicate_attachments(): + query = make_query() + query.user_message = provider_message.Message( + role='user', + content=[ + provider_message.ContentElement.from_image_base64('data:image/png;base64,aGVsbG8='), + provider_message.ContentElement.from_image_base64('data:image/png;base64,aGVsbG8='), + ], + ) + query.message_chain = platform_message.MessageChain([]) + + input_data = QueryEntryAdapter._build_input(query) + + assert [attachment.type for attachment in input_data.attachments] == ['image', 'image'] + + +@pytest.fixture(autouse=True) +async def clean_agent_state(): + """Reset all singleton stores and create a test database engine.""" + from langbot.pkg.entity.persistence.base import Base + + reset_persistent_state_store() + registry = get_session_registry() + for session in await registry.list_active_runs(): + await registry.unregister(session['run_id']) + + # Create in-memory SQLite engine for tests + test_engine = create_async_engine('sqlite+aiosqlite:///:memory:') + + # Create tables + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + yield test_engine + + # Cleanup + for session in await registry.list_active_runs(): + await registry.unregister(session['run_id']) + reset_persistent_state_store() + await test_engine.dispose() + + +@pytest.mark.asyncio +async def test_orchestrator_runs_fake_plugin_with_authorized_context(clean_agent_state): + """Test that orchestrator properly builds and passes authorized context to runner.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'fake response'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + + messages = [message async for message in orchestrator.run_from_query(query)] + + assert len(messages) == 1 + assert messages[0].content == 'fake response' + assert plugin_connector.calls == [ + { + 'plugin_author': 'langbot', + 'plugin_name': 'local-agent', + 'runner_name': 'default', + } + ] + + context = plugin_connector.contexts[0] + assert context['config']['timeout'] == 30 + assert context['runtime']['deadline_at'] is not None + # Protocol v1: params is in adapter.extra + assert context['adapter']['extra']['params'] == {'public_param': 'visible'} + assert context['event']['event_type'] == 'message.received' + # Note: source_event_type is in event.source_event_type, not event.data + # (event.data contains the raw event payload, not metadata) + assert context['actor']['actor_id'] == 'user_001' + assert context['actor']['actor_name'] == 'Alice' + assert context['subject']['subject_id'] == 'msg_001' + assert context['input']['attachments'] + assert context['context']['available_apis']['run_get'] is True + assert context['context']['available_apis']['run_list'] is True + assert context['context']['available_apis']['run_events_page'] is True + assert context['context']['available_apis']['run_cancel'] is True + assert context['context']['available_apis']['run_append_result'] is False + assert context['context']['available_apis']['run_finalize'] is False + assert context['context']['available_apis']['run_claim'] is False + assert context['context']['available_apis']['run_renew_claim'] is False + assert context['context']['available_apis']['run_release_claim'] is False + assert context['context']['available_apis']['runtime_register'] is False + assert context['context']['available_apis']['runtime_heartbeat'] is False + assert context['context']['available_apis']['runtime_list'] is False + + resources = context['resources'] + assert {m['model_id'] for m in resources['models']} == {'model_primary', 'model_fallback'} + assert resources['tools'][0]['tool_name'] == 'langbot/test-tool/search' + assert resources['knowledge_bases'][0]['kb_id'] == 'kb_001' + assert resources['skills'] == [ + { + 'skill_name': 'demo', + 'display_name': 'Demo Skill', + 'description': 'Helps with demo tasks.', + } + ] + assert resources['storage']['plugin_storage'] is True + + session_during_run = plugin_connector.sessions_during_run[0] + assert session_during_run is not None + assert session_during_run['plugin_identity'] == 'langbot/local-agent' + assert session_during_run['authorization']['authorized_ids']['tool'] == {'langbot/test-tool/search'} + assert session_during_run['authorization']['authorized_ids']['skill'] == {'demo'} + assert await get_session_registry().get(context['run_id']) is None + + +@pytest.mark.asyncio +async def test_orchestrator_persists_run_ledger(clean_agent_state): + """AgentRunOrchestrator records Host-owned run and result events.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'fake response'}}, + }, + { + 'type': 'run.completed', + 'data': {'finish_reason': 'stop'}, + 'usage': {'prompt_tokens': 2, 'completion_tokens': 3, 'total_tokens': 5}, + }, + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + + messages = [message async for message in orchestrator.run_from_query(make_query())] + + assert len(messages) == 1 + run_id = plugin_connector.contexts[0]['run_id'] + store = RunLedgerStore(db_engine) + + run = await store.get_run(run_id) + assert run is not None + assert run['status'] == 'completed' + assert run['event_id'] == plugin_connector.contexts[0]['event']['event_id'] + assert run['runner_id'] == RUNNER_ID + assert run['usage'] == { + 'prompt_tokens': 2, + 'completion_tokens': 3, + 'total_tokens': 5, + } + + events, next_cursor, prev_cursor, has_more = await store.page_run_events( + run_id=run_id, + limit=10, + ) + assert [event['sequence'] for event in events] == [1, 2] + assert [event['type'] for event in events] == ['message.completed', 'run.completed'] + assert next_cursor is None + assert prev_cursor == 1 + assert has_more is False + + +@pytest.mark.asyncio +async def test_orchestrator_stops_after_cancel_request(clean_agent_state): + """A persisted cancel request stops further synchronous runner consumption.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'first'}}, + }, + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'second'}}, + }, + ] + ) + orchestrator = AgentRunOrchestrator(FakeApplication(plugin_connector, db_engine), FakeRegistry(descriptor)) + original_append_run_result = orchestrator.journal.append_run_result + cancel_requested = False + + async def append_and_cancel_once(*args, **kwargs): + nonlocal cancel_requested + event = await original_append_run_result(*args, **kwargs) + if not cancel_requested: + cancel_requested = True + await RunLedgerStore(db_engine).request_cancel( + run_id=kwargs['run_id'], + status_reason='user stopped', + ) + return event + + orchestrator.journal.append_run_result = append_and_cancel_once + + messages = [message async for message in orchestrator.run_from_query(make_query())] + + assert [message.content for message in messages] == ['first'] + run_id = plugin_connector.contexts[0]['run_id'] + run = await RunLedgerStore(db_engine).get_run(run_id) + assert run is not None + assert run['status'] == 'cancelled' + assert run['status_reason'] == 'user stopped' + + +@pytest.mark.asyncio +async def test_orchestrator_does_not_package_query_messages_into_context(clean_agent_state): + """Host should not build an agent working-context window from query.messages.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'fake response'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + query.pipeline_config['ai']['runner_config'][RUNNER_ID]['custom-option'] = 2 + query.messages = [ + provider_message.Message(role='user', content='message 1'), + provider_message.Message(role='assistant', content='response 1'), + provider_message.Message(role='user', content='message 2'), + provider_message.Message(role='assistant', content='response 2'), + provider_message.Message(role='user', content='message 3'), + provider_message.Message(role='assistant', content='response 3'), + ] + + messages = [message async for message in orchestrator.run_from_query(query)] + + assert len(messages) == 1 + context = plugin_connector.contexts[0] + assert context['config']['custom-option'] == 2 + assert 'bootstrap' not in context + assert set(context['adapter']) == {'extra'} + assert 'context_packaging' not in context['runtime']['metadata'] + assert [message.content for message in query.messages] == [ + 'message 1', + 'response 1', + 'message 2', + 'response 2', + 'message 3', + 'response 3', + ] + + +@pytest.mark.asyncio +async def test_orchestrator_streams_fake_plugin_deltas(clean_agent_state): + """Test that orchestrator properly streams message chunks.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + {'type': 'message.delta', 'data': {'chunk': {'role': 'assistant', 'content': 'hel'}}}, + {'type': 'message.delta', 'data': {'chunk': {'role': 'assistant', 'content': 'hello'}}}, + {'type': 'run.completed', 'data': {'finish_reason': 'stop'}}, + ] + ) + orchestrator = AgentRunOrchestrator(FakeApplication(plugin_connector, db_engine), FakeRegistry(descriptor)) + + chunks = [message async for message in orchestrator.run_from_query(make_query())] + + assert [chunk.content for chunk in chunks] == ['hel', 'hello'] + + +@pytest.mark.asyncio +async def test_orchestrator_persists_run_completed_message_transcript(clean_agent_state): + """run.completed(message=...) should be treated as the final assistant transcript.""" + from langbot.pkg.agent.runner.transcript_store import TranscriptStore + + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'run.completed', + 'data': { + 'finish_reason': 'stop', + 'message': {'role': 'assistant', 'content': 'final response'}, + }, + }, + ] + ) + orchestrator = AgentRunOrchestrator(FakeApplication(plugin_connector, db_engine), FakeRegistry(descriptor)) + query = make_query() + + messages = [message async for message in orchestrator.run_from_query(query)] + + assert [message.content for message in messages] == ['final response'] + transcript_store = TranscriptStore(db_engine) + transcripts, _, _, _ = await transcript_store.page_transcript(query.session.using_conversation.uuid, limit=10) + assistant_items = [item for item in transcripts if item['role'] == 'assistant'] + assert len(assistant_items) == 1 + assert assistant_items[0]['content'] == 'final response' + + +@pytest.mark.asyncio +async def test_orchestrator_drops_duplicate_result_sequence(clean_agent_state): + """Duplicate runner result sequences are idempotently ignored.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.delta', + 'sequence': 1, + 'data': {'chunk': {'role': 'assistant', 'content': 'first'}}, + }, + { + 'type': 'message.delta', + 'sequence': 1, + 'data': {'chunk': {'role': 'assistant', 'content': 'duplicate'}}, + }, + { + 'type': 'message.delta', + 'sequence': 3, + 'data': {'chunk': {'role': 'assistant', 'content': 'after-gap'}}, + }, + {'type': 'run.completed', 'sequence': 4, 'data': {'finish_reason': 'stop'}}, + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + + chunks = [message async for message in orchestrator.run_from_query(make_query())] + + assert [chunk.content for chunk in chunks] == ['first', 'after-gap'] + assert any('duplicate result sequence 1' in warning for warning in ap.logger.warnings) + assert any('result sequence gap or out-of-order' in warning for warning in ap.logger.warnings) + + +@pytest.mark.asyncio +async def test_orchestrator_applies_state_updates_and_suppresses_protocol_event(clean_agent_state): + """Test that state.updated events are applied and not yielded to pipeline.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'state.updated', + 'data': { + 'scope': 'conversation', + 'key': 'external.conversation_id', + 'value': 'external_conv_123', + }, + }, + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'state saved'}}, + }, + ] + ) + orchestrator = AgentRunOrchestrator(FakeApplication(plugin_connector, db_engine), FakeRegistry(descriptor)) + query = make_query() + + messages = [message async for message in orchestrator.run_from_query(query)] + + assert [message.content for message in messages] == ['state saved'] + # State is persisted to the database via PersistentStateStore. + + +@pytest.mark.asyncio +async def test_orchestrator_unregisters_session_after_runner_failure(clean_agent_state): + """Test that session is unregistered even when runner fails.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'run.failed', + 'data': {'error': 'boom', 'code': 'fake.error', 'retryable': False}, + } + ] + ) + orchestrator = AgentRunOrchestrator(FakeApplication(plugin_connector, db_engine), FakeRegistry(descriptor)) + + with pytest.raises(RunnerExecutionError): + [message async for message in orchestrator.run_from_query(make_query())] + + context = plugin_connector.contexts[0] + assert plugin_connector.sessions_during_run[0] is not None + assert await get_session_registry().get(context['run_id']) is None + + +@pytest.mark.asyncio +async def test_orchestrator_unregisters_session_after_event_log_failure(clean_agent_state): + """Journal failures before runner invocation must not leave steerable sessions.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'unused'}}, + } + ] + ) + orchestrator = AgentRunOrchestrator(FakeApplication(plugin_connector, db_engine), FakeRegistry(descriptor)) + orchestrator.journal.write_event_log = AsyncMock(side_effect=RuntimeError('journal unavailable')) + + with pytest.raises(RuntimeError, match='journal unavailable'): + [message async for message in orchestrator.run_from_query(make_query())] + + assert plugin_connector.contexts == [] + assert await get_session_registry().list_active_runs() == [] + + +@pytest.mark.asyncio +async def test_orchestrator_enforces_total_runner_deadline(clean_agent_state): + """Test that orchestrator enforces total runner timeout.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'too late'}}, + } + ], + delay=0.05, + ) + orchestrator = AgentRunOrchestrator(FakeApplication(plugin_connector, db_engine), FakeRegistry(descriptor)) + query = make_query() + query.pipeline_config['ai']['runner_config'][RUNNER_ID]['timeout'] = 0.01 + + with pytest.raises(RunnerExecutionError) as exc_info: + [message async for message in orchestrator.run_from_query(query)] + + assert exc_info.value.retryable is True + assert 'runner.timeout' in str(exc_info.value) + assert await get_session_registry().list_active_runs() == [] + + +class TestQueryEntrySessionQueryId: + """Tests for internal query_id entering session registry.""" + + @pytest.mark.asyncio + async def test_query_id_registered_in_session_for_query_entry_flow(self, clean_agent_state): + """query_id from Query entry flow is registered internally in session.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'response'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + query.user_message = provider_message.Message( + role='user', + content=[ + provider_message.ContentElement.from_text('hello'), + provider_message.ContentElement.from_image_base64('data:image/png;base64,aGVsbG8='), + provider_message.ContentElement.from_file_base64('data:text/plain;base64,aGVsbG8=', 'hello.txt'), + ], + ) + + messages = [message async for message in orchestrator.run_from_query(query)] + + assert len(messages) == 1 + # Verify session during run had query_id + session_during_run = plugin_connector.sessions_during_run[0] + assert session_during_run is not None + assert session_during_run['query_id'] == query.query_id + + @pytest.mark.asyncio + async def test_no_query_id_for_pure_event_first_flow(self, clean_agent_state): + """Pure event-first flow has query_id=None in session.""" + from langbot.pkg.agent.runner.host_models import ( + AgentEventEnvelope, + AgentBinding, + BindingScope, + StatePolicy, + DeliveryPolicy, + ResourcePolicy, + ) + from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput + from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'response'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + + # Create event and binding directly (not from Query) + event = AgentEventEnvelope( + event_id='evt_001', + event_type='message.received', + event_time=1234567890, + source='test', + bot_id='bot_001', + workspace_id=None, + conversation_id='conv_001', + thread_id=None, + actor=None, + subject=None, + input=AgentInput(text='hello', contents=[], attachments=[]), + delivery=DeliveryContext(surface='test', supports_streaming=True), + ) + binding = AgentBinding( + binding_id='binding_001', + scope=BindingScope(scope_type='agent', scope_id='pipeline_001'), + event_types=['message.received'], + runner_id=RUNNER_ID, + runner_config={}, + resource_policy=ResourcePolicy(), + state_policy=StatePolicy(enable_state=False, state_scopes=[]), + delivery_policy=DeliveryPolicy(enable_streaming=True, enable_reply=True), + enabled=True, + ) + + messages = [message async for message in orchestrator.run(event, binding)] + + assert len(messages) == 1 + # Verify session during run has query_id=None + session_during_run = plugin_connector.sessions_during_run[0] + assert session_during_run is not None + assert session_during_run['query_id'] is None + + +class TestQueryEntryAdapterParams: + """Tests for params handling in Query entry adapter.""" + + @pytest.mark.asyncio + async def test_prompt_not_pushed_into_adapter_extra(self, clean_agent_state): + """Pipeline prompt is not pushed into adapter.extra; runners pull it through prompt_get.""" + from langbot_plugin.api.entities.builtin.provider import prompt as provider_prompt + + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'response'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + + # Add prompt to query + query.prompt = provider_prompt.Prompt( + name='test_prompt', + messages=[ + provider_message.Message(role='system', content='You are a helpful assistant.'), + ], + ) + + _messages = [message async for message in orchestrator.run_from_query(query)] + + context = plugin_connector.contexts[0] + assert 'prompt' not in context + assert 'prompt' not in context['adapter']['extra'] + assert context['context']['available_apis']['prompt_get'] is True + + @pytest.mark.asyncio + async def test_params_filtering_keeps_public_param(self, clean_agent_state): + """Public params are kept.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'response'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + query.variables = { + 'public_param': 'visible', + 'another_param': 123, + } + + _messages = [message async for message in orchestrator.run_from_query(query)] + + context = plugin_connector.contexts[0] + assert context['adapter']['extra']['params'] == { + 'public_param': 'visible', + 'another_param': 123, + } + + @pytest.mark.asyncio + async def test_params_filtering_removes_internal_vars(self, clean_agent_state): + """Internal variables (starting with _) are filtered.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'response'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + query.variables = { + 'public_param': 'visible', + '_internal_var': 'should_be_filtered', + '_pipeline_bound_plugins': ['plugin1'], + } + + _messages = [message async for message in orchestrator.run_from_query(query)] + + context = plugin_connector.contexts[0] + params = context['adapter']['extra']['params'] + assert 'public_param' in params + assert '_internal_var' not in params + assert '_pipeline_bound_plugins' not in params + + @pytest.mark.asyncio + async def test_params_filtering_removes_sensitive_patterns(self, clean_agent_state): + """Sensitive naming patterns are filtered.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'response'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + query.variables = { + 'public_param': 'visible', + 'api_token': 'secret123', + 'secret_key': 'secret456', + 'password': 'secret789', + 'credential': 'secret000', + } + + _messages = [message async for message in orchestrator.run_from_query(query)] + + context = plugin_connector.contexts[0] + params = context['adapter']['extra']['params'] + assert 'public_param' in params + assert 'api_token' not in params + assert 'secret_key' not in params + assert 'password' not in params + assert 'credential' not in params + + @pytest.mark.asyncio + async def test_params_filtering_removes_non_json_serializable(self, clean_agent_state): + """Non-JSON-serializable values are filtered.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'response'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + query.variables = { + 'public_param': 'visible', + 'a_set': {1, 2, 3}, # set is not JSON-serializable + 'a_lambda': lambda x: x, # function is not JSON-serializable + } + + _messages = [message async for message in orchestrator.run_from_query(query)] + + context = plugin_connector.contexts[0] + params = context['adapter']['extra']['params'] + assert 'public_param' in params + assert 'a_set' not in params + assert 'a_lambda' not in params + + +class TestQueryEntryAdapterHostCapabilities: + """Tests for event-first host capabilities via Query entry adapter path.""" + + @pytest.mark.asyncio + async def test_state_updated_writes_to_persistent_store(self, clean_agent_state): + """state.updated via Pipeline path writes to PersistentStateStore.""" + from langbot.pkg.agent.runner.persistent_state_store import get_persistent_state_store + + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'state.updated', + 'data': { + 'scope': 'conversation', + 'key': 'external.test_key', + 'value': 'test_value', + }, + }, + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'state saved'}}, + }, + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + query.user_message = provider_message.Message( + role='user', + content=[ + provider_message.ContentElement.from_text('hello'), + provider_message.ContentElement.from_image_base64('data:image/png;base64,aGVsbG8='), + ], + ) + + messages = [message async for message in orchestrator.run_from_query(query)] + + assert len(messages) == 1 + assert messages[0].content == 'state saved' + + # Verify state was written to PersistentStateStore + persistent_store = get_persistent_state_store(db_engine) + # Build snapshot to check if state was written + # Note: We need to rebuild the event and binding to query the store + from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter + + event = QueryEntryAdapter.query_to_event(query) + agent_config = QueryEntryAdapter.config_to_agent_config(query, RUNNER_ID) + binding = AgentBindingResolver().resolve_one(event, [agent_config]) + + snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor) + assert snapshot['conversation']['external.test_key'] == 'test_value' + + @pytest.mark.asyncio + async def test_run_from_query_restores_activated_skills_from_state(self, clean_agent_state): + """Persisted activated skill names are restored into the next Query run.""" + from langbot.pkg.agent.runner.persistent_state_store import get_persistent_state_store + from langbot.pkg.provider.tools.loaders.skill import ( + ACTIVATED_SKILL_NAMES_STATE_KEY, + ACTIVATED_SKILLS_KEY, + ) + + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'restored'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + + persistent_store = get_persistent_state_store(db_engine) + event = QueryEntryAdapter.query_to_event(query) + agent_config = QueryEntryAdapter.config_to_agent_config(query, RUNNER_ID) + binding = AgentBindingResolver().resolve_one(event, [agent_config]) + success, error = await persistent_store.apply_update_from_event( + event, + binding, + descriptor, + 'conversation', + ACTIVATED_SKILL_NAMES_STATE_KEY, + ['demo'], + None, + ) + assert success is True + assert error is None + + messages = [message async for message in orchestrator.run_from_query(query)] + + assert len(messages) == 1 + assert query.variables[ACTIVATED_SKILLS_KEY]['demo']['name'] == 'demo' + + @pytest.mark.asyncio + async def test_event_log_and_transcript_written(self, clean_agent_state): + """EventLog and Transcript are written via Pipeline path.""" + from langbot.pkg.agent.runner.event_log_store import EventLogStore + from langbot.pkg.agent.runner.transcript_store import TranscriptStore + + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'assistant response'}}, + }, + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + query.user_message = provider_message.Message( + role='user', + content=[ + provider_message.ContentElement.from_text('hello'), + provider_message.ContentElement.from_image_base64('data:image/png;base64,aGVsbG8='), + ], + ) + + messages = [message async for message in orchestrator.run_from_query(query)] + + assert len(messages) == 1 + + # Check EventLog has incoming event + event_log_store = EventLogStore(db_engine) + event_logs, _, _ = await event_log_store.page_events( + conversation_id=query.session.using_conversation.uuid, + limit=10, + ) + assert len(event_logs) >= 1 + # First event should be the incoming message.received + assert event_logs[0]['event_type'] == 'message.received' + assert event_logs[0]['input_json']['contents'][1]['image_base64'] is None + assert event_logs[0]['input_json']['contents'][1]['content_redacted'] is True + assert 'aGVsbG8=' not in str(event_logs[0]['input_json']) + + # Check Transcript has user and assistant messages + transcript_store = TranscriptStore(db_engine) + transcripts, _, _, _ = await transcript_store.page_transcript( + conversation_id=query.session.using_conversation.uuid, + limit=10, + include_attachments=True, + ) + assert len(transcripts) >= 2 + # Find user and assistant messages + roles = [t['role'] for t in transcripts] + assert 'user' in roles + assert 'assistant' in roles + user_item = next(t for t in transcripts if t['role'] == 'user') + assert user_item['content_json']['content'][1]['image_base64'] is None + assert user_item['attachment_refs'][0]['content'] is None + assert 'aGVsbG8=' not in str(user_item) diff --git a/tests/unit_tests/agent/test_registry.py b/tests/unit_tests/agent/test_registry.py new file mode 100644 index 000000000..92fb0a2ff --- /dev/null +++ b/tests/unit_tests/agent/test_registry.py @@ -0,0 +1,272 @@ +"""Tests for agent runner registry.""" + +from __future__ import annotations + +import pytest + +from langbot.pkg.agent.runner.registry import AgentRunnerRegistry +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor +from langbot.pkg.agent.runner.errors import RunnerNotFoundError, RunnerNotAuthorizedError + + +class FakeApplication: + """Fake Application for testing.""" + + def __init__(self): + class FakeLogger: + def info(self, msg): + pass + + def debug(self, msg): + pass + + def warning(self, msg): + pass + + def error(self, msg): + pass + + self.logger = FakeLogger() + + class FakePluginConnector: + is_enable_plugin = True + + async def list_agent_runners(self, bound_plugins=None): + # Return sample runner data + return [ + { + 'plugin_author': 'langbot', + 'plugin_name': 'local-agent', + 'runner_name': 'default', + 'manifest': { + 'id': 'plugin:langbot/local-agent/default', + 'name': 'default', + 'label': {'en_US': 'Local Agent'}, + 'capabilities': {'streaming': True}, + 'permissions': {}, + 'config_schema': [], + }, + }, + { + 'plugin_author': 'alice', + 'plugin_name': 'my-agent', + 'runner_name': 'custom', + 'manifest': { + 'id': 'plugin:alice/my-agent/custom', + 'name': 'custom', + 'label': {'en_US': 'Custom Agent'}, + 'capabilities': {}, + 'permissions': {}, + 'config_schema': [{'name': 'param1', 'type': 'string'}], + }, + }, + # Invalid runner - wrong kind + { + 'plugin_author': 'bad', + 'plugin_name': 'wrong-kind', + 'runner_name': 'default', + 'manifest': { + 'kind': 'Tool', # Wrong kind + 'metadata': {}, + 'spec': {}, + }, + }, + # Invalid runner - missing name + { + 'plugin_author': 'bad', + 'plugin_name': 'missing-name', + 'runner_name': 'default', + 'manifest': { + 'kind': 'AgentRunner', + 'metadata': {}, # No name + 'spec': {}, + }, + }, + ] + + self.plugin_connector = FakePluginConnector() + + +class TestRegistryDiscovery: + """Tests for runner discovery.""" + + @pytest.mark.asyncio + async def test_discover_valid_runners(self): + """Discover valid runners from plugin runtime.""" + ap = FakeApplication() + registry = AgentRunnerRegistry(ap) + + runners = await registry.list_runners(use_cache=False) + + # Should find 2 valid runners (langbot/local-agent and alice/my-agent) + assert len(runners) == 2 + + ids = [r.id for r in runners] + assert 'plugin:langbot/local-agent/default' in ids + assert 'plugin:alice/my-agent/custom' in ids + + @pytest.mark.asyncio + async def test_discover_caches_results(self): + """Discovery should cache results.""" + ap = FakeApplication() + registry = AgentRunnerRegistry(ap) + + # First discovery + runners1 = await registry.list_runners(use_cache=True) + + # Second call should use cache + runners2 = await registry.list_runners(use_cache=True) + + assert registry._cache is not None + assert len(runners1) == len(runners2) + + @pytest.mark.asyncio + async def test_discover_handles_plugin_disabled(self): + """Discovery returns empty when plugin system disabled.""" + ap = FakeApplication() + ap.plugin_connector.is_enable_plugin = False + registry = AgentRunnerRegistry(ap) + + runners = await registry.list_runners(use_cache=False) + + assert runners == [] + + @pytest.mark.asyncio + async def test_cache_not_polluted_by_bound_plugins(self): + """Cache should contain ALL runners, not filtered by bound_plugins. + + Regression test: get(bound_plugins=["a/b"]) should not pollute cache, + so subsequent list_runners(bound_plugins=None) should return all runners. + """ + ap = FakeApplication() + registry = AgentRunnerRegistry(ap) + + # First: get with bound_plugins filter (should not pollute cache) + descriptor = await registry.get( + 'plugin:langbot/local-agent/default', + bound_plugins=['langbot/local-agent'], + ) + assert descriptor.id == 'plugin:langbot/local-agent/default' + + # Cache should contain ALL runners (both langbot and alice) + assert registry._cache is not None + assert len(registry._cache) == 2 # Both runners in cache + assert 'plugin:langbot/local-agent/default' in registry._cache + assert 'plugin:alice/my-agent/custom' in registry._cache + + # Second: list_runners without filter should return ALL runners + all_runners = await registry.list_runners(bound_plugins=None, use_cache=True) + assert len(all_runners) == 2 # Both runners returned + + # Third: list_runners with different filter should work correctly + alice_runners = await registry.list_runners(bound_plugins=['alice/my-agent'], use_cache=True) + assert len(alice_runners) == 1 + assert alice_runners[0].id == 'plugin:alice/my-agent/custom' + + +class TestRegistryGet: + """Tests for getting specific runner.""" + + @pytest.mark.asyncio + async def test_get_existing_runner(self): + """Get existing runner by ID.""" + ap = FakeApplication() + registry = AgentRunnerRegistry(ap) + + descriptor = await registry.get('plugin:langbot/local-agent/default') + + assert descriptor.id == 'plugin:langbot/local-agent/default' + assert descriptor.plugin_author == 'langbot' + assert descriptor.plugin_name == 'local-agent' + assert descriptor.runner_name == 'default' + + @pytest.mark.asyncio + async def test_get_nonexistent_runner(self): + """Get nonexistent runner raises RunnerNotFoundError.""" + ap = FakeApplication() + registry = AgentRunnerRegistry(ap) + + with pytest.raises(RunnerNotFoundError) as exc_info: + await registry.get('plugin:notexist/unknown/default') + + assert exc_info.value.runner_id == 'plugin:notexist/unknown/default' + + @pytest.mark.asyncio + async def test_get_runner_with_bound_plugins_filter(self): + """Get runner with bound plugins authorization.""" + ap = FakeApplication() + registry = AgentRunnerRegistry(ap) + + # Authorized - langbot plugin in bound list + descriptor = await registry.get( + 'plugin:langbot/local-agent/default', + bound_plugins=['langbot/local-agent'], + ) + assert descriptor is not None + + # Not authorized - plugin not in bound list + with pytest.raises(RunnerNotAuthorizedError): + await registry.get( + 'plugin:alice/my-agent/custom', + bound_plugins=['langbot/local-agent'], + ) + + +class TestRegistryMetadataForPipeline: + """Tests for get_runner_metadata_for_pipeline.""" + + @pytest.mark.asyncio + async def test_get_metadata_options_and_stages(self): + """Get metadata options and stages for pipeline UI.""" + ap = FakeApplication() + registry = AgentRunnerRegistry(ap) + + options, stages = await registry.get_runner_metadata_for_pipeline() + + # Should have options for each runner + assert len(options) == 2 + option_ids = [o['name'] for o in options] + assert 'plugin:langbot/local-agent/default' in option_ids + assert 'plugin:alice/my-agent/custom' in option_ids + + # Config comes from the typed manifest. + assert len(stages) == 1 + assert stages[0]['name'] == 'plugin:alice/my-agent/custom' + assert stages[0]['config'][0]['name'] == 'param1' + assert stages[0]['config'][0]['type'] == 'string' + assert stages[0]['config'][0]['id'] == 'plugin:alice/my-agent/custom.param1' + + +class TestDescriptorValidation: + """Tests for descriptor validation.""" + + def test_validate_runner_descriptor(self): + """Validate correctly built descriptor.""" + descriptor = AgentRunnerDescriptor( + id='plugin:test/my-runner/default', + source='plugin', + label={'en_US': 'Test Runner'}, + plugin_author='test', + plugin_name='my-runner', + runner_name='default', + ) + + assert descriptor.id == 'plugin:test/my-runner/default' + assert descriptor.get_plugin_id() == 'test/my-runner' + assert 'protocol_version' not in AgentRunnerDescriptor.model_fields + + def test_descriptor_capabilities(self): + """Descriptor capability helper methods.""" + descriptor = AgentRunnerDescriptor( + id='plugin:test/my-runner/default', + source='plugin', + label={'en_US': 'Test Runner'}, + plugin_author='test', + plugin_name='my-runner', + runner_name='default', + capabilities={'streaming': True, 'tool_calling': False}, + ) + + assert descriptor.supports_streaming() is True + assert descriptor.supports_tool_calling() is False + assert descriptor.supports_knowledge_retrieval() is False diff --git a/tests/unit_tests/agent/test_resource_builder.py b/tests/unit_tests/agent/test_resource_builder.py new file mode 100644 index 000000000..e3fb9420b --- /dev/null +++ b/tests/unit_tests/agent/test_resource_builder.py @@ -0,0 +1,400 @@ +"""Tests for AgentResourceBuilder.""" +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor +from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver +from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter +from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder + + +RUNNER_ID = 'plugin:test/runner/default' +FULL_PERMISSIONS = { + 'models': ['invoke', 'stream', 'rerank'], + 'tools': ['detail', 'call'], + 'knowledge_bases': ['list', 'retrieve'], + 'history': ['page', 'search'], + 'events': ['get', 'page'], + 'storage': ['plugin', 'workspace'], +} + + +def make_descriptor( + *, + config_schema: list[dict] | None = None, + capabilities: dict | None = None, + permissions: dict | None = None, +) -> AgentRunnerDescriptor: + return AgentRunnerDescriptor( + id=RUNNER_ID, + source='plugin', + label={'en_US': 'Test Runner'}, + plugin_author='test', + plugin_name='runner', + runner_name='default', + capabilities=capabilities or {}, + permissions=permissions if permissions is not None else FULL_PERMISSIONS, + config_schema=config_schema or [], + ) + + +def make_model(model_type='llm', provider='test-provider'): + return SimpleNamespace( + model_entity=SimpleNamespace(model_type=model_type), + provider_entity=SimpleNamespace(name=provider), + ) + + +def make_query( + runner_config: dict, + *, + variables: dict | None = None, + use_llm_model_uuid=None, + use_funcs: list | None = None, +): + return SimpleNamespace( + query_id=1, + bot_uuid='bot_001', + launcher_type='person', + launcher_id='launcher_001', + sender_id='sender_001', + message_event=None, + message_chain=None, + user_message=None, + session=None, + pipeline_config={ + 'ai': { + 'runner': {'id': RUNNER_ID}, + 'runner_config': {RUNNER_ID: runner_config}, + }, + }, + variables=variables or {}, + use_llm_model_uuid=use_llm_model_uuid, + use_funcs=use_funcs or [], + pipeline_uuid='pipeline_001', + ) + + +async def build_resources(app, query, descriptor): + event = QueryEntryAdapter.query_to_event(query) + agent_config = QueryEntryAdapter.config_to_agent_config(query, descriptor.id) + binding = AgentBindingResolver().resolve_one(event, [agent_config]) + return await AgentResourceBuilder(app).build_resources_from_binding( + event=event, + binding=binding, + descriptor=descriptor, + ) + + +@pytest.fixture +def app(): + mock_app = Mock() + mock_app.logger = Mock() + mock_app.model_mgr = Mock() + mock_app.rag_mgr = Mock() + mock_app.rag_mgr.get_knowledge_base_by_uuid = AsyncMock(return_value=None) + return mock_app + + +@pytest.mark.asyncio +async def test_build_models_authorizes_config_declared_llm_and_rerank_models(app): + """DynamicForm model selectors should become run-scoped authorized models.""" + llm_models = { + 'primary': make_model(), + 'fallback': make_model(), + 'aux': make_model(provider='aux-provider'), + } + rerank_models = { + 'rerank': make_model(model_type='rerank', provider='rerank-provider'), + } + + async def get_model_by_uuid(model_uuid): + return llm_models.get(model_uuid) + + async def get_rerank_model_by_uuid(model_uuid): + return rerank_models.get(model_uuid) + + app.model_mgr.get_model_by_uuid = AsyncMock(side_effect=get_model_by_uuid) + app.model_mgr.get_rerank_model_by_uuid = AsyncMock(side_effect=get_rerank_model_by_uuid) + descriptor = make_descriptor( + config_schema=[ + {'name': 'model', 'type': 'model-fallback-selector'}, + {'name': 'aux-model', 'type': 'llm-model-selector'}, + {'name': 'rerank-model', 'type': 'rerank-model-selector'}, + ], + ) + query = make_query({ + 'model': {'primary': 'primary', 'fallbacks': ['fallback', 'primary']}, + 'aux-model': 'aux', + 'rerank-model': 'rerank', + }) + + resources = await build_resources(app, query, descriptor) + + assert resources['models'] == [ + {'model_id': 'primary', 'model_type': 'llm', 'provider': 'test-provider', 'operations': ['invoke', 'stream']}, + {'model_id': 'fallback', 'model_type': 'llm', 'provider': 'test-provider', 'operations': ['invoke', 'stream']}, + {'model_id': 'aux', 'model_type': 'llm', 'provider': 'aux-provider', 'operations': ['invoke', 'stream']}, + {'model_id': 'rerank', 'model_type': 'rerank', 'provider': 'rerank-provider', 'operations': ['rerank']}, + ] + + +@pytest.mark.asyncio +async def test_build_models_from_config_without_manifest_acl(app): + """Config-selected models are not projected without manifest model permissions.""" + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=make_model()) + app.model_mgr.get_rerank_model_by_uuid = AsyncMock(return_value=make_model(model_type='rerank')) + descriptor = make_descriptor( + config_schema=[ + {'name': 'model', 'type': 'model-fallback-selector'}, + {'name': 'rerank-model', 'type': 'rerank-model-selector'}, + ], + permissions={}, + ) + query = make_query({ + 'model': {'primary': 'primary', 'fallbacks': ['fallback']}, + 'rerank-model': 'rerank', + }) + + resources = await build_resources(app, query, descriptor) + + assert resources['models'] == [] + + +@pytest.mark.asyncio +async def test_build_models_authorizes_rerank_and_llm_refs_from_config(app): + """Config-selected model references are projected regardless of method granularity.""" + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=make_model()) + app.model_mgr.get_rerank_model_by_uuid = AsyncMock( + return_value=make_model(model_type='rerank', provider='rerank-provider') + ) + descriptor = make_descriptor( + config_schema=[ + {'name': 'model', 'type': 'llm-model-selector'}, + {'name': 'rerank-model', 'type': 'rerank-model-selector'}, + ], + ) + query = make_query({ + 'model': 'llm', + 'rerank-model': 'rerank', + }) + + resources = await build_resources(app, query, descriptor) + + assert resources['models'] == [ + {'model_id': 'llm', 'model_type': 'llm', 'provider': 'test-provider', 'operations': ['invoke', 'stream']}, + {'model_id': 'rerank', 'model_type': 'rerank', 'provider': 'rerank-provider', 'operations': ['rerank']}, + ] + + +@pytest.mark.asyncio +async def test_build_resources_accepts_dynamic_form_type_aliases(app): + """Frontend DynamicForm aliases should resolve to runtime resource grants.""" + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=make_model()) + + async def get_kb(kb_uuid): + return SimpleNamespace( + uuid=kb_uuid, + get_name=lambda: f'name-{kb_uuid}', + knowledge_base_entity=SimpleNamespace(kb_type='default'), + ) + + app.rag_mgr.get_knowledge_base_by_uuid = AsyncMock(side_effect=get_kb) + descriptor = make_descriptor( + capabilities={'knowledge_retrieval': True}, + config_schema=[ + {'name': 'model', 'type': 'select-llm-model'}, + {'name': 'knowledge-bases', 'type': 'select-knowledge-bases'}, + ], + ) + query = make_query({ + 'model': 'llm_alias', + 'knowledge-bases': ['kb_alias'], + }) + + resources = await build_resources(app, query, descriptor) + + assert resources['models'] == [ + {'model_id': 'llm_alias', 'model_type': 'llm', 'provider': 'test-provider', 'operations': ['invoke', 'stream']}, + ] + assert resources['knowledge_bases'] == [ + {'kb_id': 'kb_alias', 'kb_name': 'name-kb_alias', 'kb_type': 'default', 'operations': ['list', 'retrieve']}, + ] + + +@pytest.mark.asyncio +async def test_build_models_manifest_permission_narrows_binding(app): + """Manifest model permissions narrower than binding should remove LLM grants.""" + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=make_model()) + app.model_mgr.get_rerank_model_by_uuid = AsyncMock( + return_value=make_model(model_type='rerank', provider='rerank-provider') + ) + descriptor = make_descriptor( + config_schema=[ + {'name': 'model', 'type': 'llm-model-selector'}, + {'name': 'rerank-model', 'type': 'rerank-model-selector'}, + ], + permissions={ + **FULL_PERMISSIONS, + 'models': ['rerank'], + }, + ) + query = make_query({ + 'model': 'llm', + 'rerank-model': 'rerank', + }) + + resources = await build_resources(app, query, descriptor) + + assert resources['models'] == [ + {'model_id': 'rerank', 'model_type': 'rerank', 'provider': 'rerank-provider', 'operations': ['rerank']}, + ] + + +@pytest.mark.asyncio +async def test_build_models_deduplicates_query_and_config_models(app): + """A model selected by both preproc and runner config should appear once.""" + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=make_model()) + app.model_mgr.get_rerank_model_by_uuid = AsyncMock(return_value=None) + descriptor = make_descriptor( + config_schema=[ + {'name': 'model', 'type': 'model-fallback-selector'}, + ], + ) + query = make_query( + {'model': {'primary': 'primary', 'fallbacks': ['fallback']}}, + variables={'_fallback_model_uuids': ['fallback']}, + use_llm_model_uuid='primary', + ) + + resources = await build_resources(app, query, descriptor) + + assert [model['model_id'] for model in resources['models']] == ['primary', 'fallback'] + + +@pytest.mark.asyncio +async def test_build_tools_authorizes_query_declared_tools(app): + """Tools discovered by Pipeline preprocessing become run-scoped authorized resources.""" + descriptor = make_descriptor( + capabilities={'tool_calling': True}, + ) + query = make_query( + {}, + use_funcs=[ + {'name': 'qa_plugin_echo', 'description': 'Echo test tool'}, + SimpleNamespace(name='qa_mcp_echo'), + ], + ) + + resources = await build_resources(app, query, descriptor) + + assert resources['tools'] == [ + { + 'tool_name': 'qa_plugin_echo', + 'tool_type': None, + 'description': None, + 'operations': ['detail', 'call'], + }, + { + 'tool_name': 'qa_mcp_echo', + 'tool_type': None, + 'description': None, + 'operations': ['detail', 'call'], + }, + ] + + +@pytest.mark.asyncio +async def test_build_tools_manifest_permission_denies_binding_tools(app): + """Binding tool grants should be removed when manifest does not request tools.""" + descriptor = make_descriptor( + capabilities={'tool_calling': True}, + permissions={ + **FULL_PERMISSIONS, + 'tools': [], + }, + ) + query = make_query( + {}, + use_funcs=[ + {'name': 'qa_plugin_echo', 'description': 'Echo test tool'}, + ], + ) + + resources = await build_resources(app, query, descriptor) + + assert resources['tools'] == [] + + +@pytest.mark.asyncio +async def test_build_knowledge_bases_unions_config_and_policy_grants(app): + descriptor = make_descriptor( + capabilities={'knowledge_retrieval': True}, + config_schema=[ + {'name': 'knowledge-bases', 'type': 'knowledge-base-multi-selector'}, + ], + ) + query = make_query( + {'knowledge-bases': ['kb_config']}, + variables={'_knowledge_base_uuids': ['kb_policy']}, + ) + + async def get_kb(kb_uuid): + return SimpleNamespace( + uuid=kb_uuid, + get_name=lambda: f'name-{kb_uuid}', + knowledge_base_entity=SimpleNamespace(kb_type='default'), + ) + + app.rag_mgr.get_knowledge_base_by_uuid = AsyncMock(side_effect=get_kb) + + resources = await build_resources(app, query, descriptor) + + assert resources['knowledge_bases'] == [ + {'kb_id': 'kb_config', 'kb_name': 'name-kb_config', 'kb_type': 'default', 'operations': ['list', 'retrieve']}, + {'kb_id': 'kb_policy', 'kb_name': 'name-kb_policy', 'kb_type': 'default', 'operations': ['list', 'retrieve']}, + ] + + +@pytest.mark.asyncio +async def test_build_knowledge_bases_manifest_permission_denies_binding_kbs(app): + descriptor = make_descriptor( + capabilities={'knowledge_retrieval': True}, + permissions={ + **FULL_PERMISSIONS, + 'knowledge_bases': [], + }, + config_schema=[ + {'name': 'knowledge-bases', 'type': 'knowledge-base-multi-selector'}, + ], + ) + query = make_query( + {'knowledge-bases': ['kb_config']}, + variables={'_knowledge_base_uuids': ['kb_policy']}, + ) + + resources = await build_resources(app, query, descriptor) + + assert resources['knowledge_bases'] == [] + + +@pytest.mark.asyncio +async def test_build_storage_intersects_manifest_and_binding_policy(app): + descriptor = make_descriptor( + permissions={ + **FULL_PERMISSIONS, + 'storage': ['plugin'], + }, + ) + query = make_query({}) + + resources = await build_resources(app, query, descriptor) + + assert resources['storage'] == { + 'plugin_storage': True, + 'workspace_storage': False, + } diff --git a/tests/unit_tests/agent/test_result_normalizer.py b/tests/unit_tests/agent/test_result_normalizer.py new file mode 100644 index 000000000..882369dab --- /dev/null +++ b/tests/unit_tests/agent/test_result_normalizer.py @@ -0,0 +1,365 @@ +"""Tests for agent runner result normalizer.""" +from __future__ import annotations + +import pytest + +from langbot.pkg.agent.runner.result_normalizer import AgentResultNormalizer +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor +from langbot.pkg.agent.runner.errors import RunnerExecutionError, RunnerProtocolError + +from langbot_plugin.api.entities.builtin.provider import message as provider_message + + +class FakeApplication: + """Fake Application for testing.""" + def __init__(self): + class FakeLogger: + def __init__(self): + self.warnings = [] + + def info(self, msg): + pass + def debug(self, msg): + pass + def warning(self, msg): + self.warnings.append(msg) + def error(self, msg): + pass + + self.logger = FakeLogger() + + +def make_descriptor(): + """Create a test descriptor.""" + return AgentRunnerDescriptor( + id='plugin:langbot/local-agent/default', + source='plugin', + label={'en_US': 'Local Agent', 'zh_Hans': '内置 Agent'}, + plugin_author='langbot', + plugin_name='local-agent', + runner_name='default', + capabilities={'streaming': True}, + ) + + +class TestNormalizeMessageDelta: + """Tests for normalizing message.delta results.""" + + @pytest.mark.asyncio + async def test_normalize_message_delta_text(self): + """Normalize message.delta with text chunk.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'message.delta', + 'data': { + 'chunk': { + 'role': 'assistant', + 'content': 'Hello', + }, + }, + } + + result = await normalizer.normalize(result_dict, descriptor) + + assert result is not None + assert isinstance(result, provider_message.MessageChunk) + assert result.role == 'assistant' + assert result.content == 'Hello' + + @pytest.mark.asyncio + async def test_normalize_message_delta_missing_chunk(self): + """Invalid message.delta payload is dropped.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'message.delta', + 'data': {}, + } + + result = await normalizer.normalize(result_dict, descriptor) + + assert result is None + + +class TestNormalizeMessageCompleted: + """Tests for normalizing message.completed results.""" + + @pytest.mark.asyncio + async def test_normalize_message_completed(self): + """Normalize message.completed with full message.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'message.completed', + 'data': { + 'message': { + 'role': 'assistant', + 'content': 'Complete response', + }, + }, + } + + result = await normalizer.normalize(result_dict, descriptor) + + assert result is not None + assert isinstance(result, provider_message.Message) + assert result.role == 'assistant' + assert result.content == 'Complete response' + + @pytest.mark.asyncio + async def test_normalize_message_completed_missing_message(self): + """Invalid message.completed payload is dropped.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'message.completed', + 'data': {}, + } + + result = await normalizer.normalize(result_dict, descriptor) + + assert result is None + + +class TestNormalizeRunCompleted: + """Tests for normalizing run.completed results.""" + + @pytest.mark.asyncio + async def test_normalize_run_completed_with_message(self): + """Normalize run.completed with final message.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'run.completed', + 'data': { + 'message': { + 'role': 'assistant', + 'content': 'Final response', + }, + 'finish_reason': 'stop', + }, + } + + result = await normalizer.normalize(result_dict, descriptor) + + assert result is not None + assert isinstance(result, provider_message.Message) + + @pytest.mark.asyncio + async def test_normalize_run_completed_without_message(self): + """Normalize run.completed without message.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'run.completed', + 'data': { + 'finish_reason': 'stop', + }, + } + + result = await normalizer.normalize(result_dict, descriptor) + + assert result is None + + +class TestNormalizeRunFailed: + """Tests for normalizing run.failed results.""" + + @pytest.mark.asyncio + async def test_normalize_run_failed(self): + """Normalize run.failed raises RunnerExecutionError.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'run.failed', + 'data': { + 'error': 'Upstream timeout', + 'code': 'upstream.timeout', + 'retryable': True, + }, + } + + with pytest.raises(RunnerExecutionError) as exc_info: + await normalizer.normalize(result_dict, descriptor) + + assert exc_info.value.runner_id == 'plugin:langbot/local-agent/default' + assert exc_info.value.retryable is True + assert 'timeout' in str(exc_info.value) + + +class TestNormalizeNonMessageResults: + """Tests for normalizing non-message results.""" + + @pytest.mark.asyncio + async def test_normalize_tool_call_started(self): + """Normalize tool.call.started returns None.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'tool.call.started', + 'data': { + 'tool_call_id': 'call_1', + 'tool_name': 'weather', + }, + } + + result = await normalizer.normalize(result_dict, descriptor) + assert result is None + + @pytest.mark.asyncio + async def test_normalize_tool_call_completed(self): + """Normalize tool.call.completed returns None.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'tool.call.completed', + 'data': { + 'tool_call_id': 'call_1', + 'tool_name': 'weather', + 'result': {'temp': 20}, + }, + } + + result = await normalizer.normalize(result_dict, descriptor) + assert result is None + + @pytest.mark.asyncio + async def test_normalize_state_updated(self): + """Normalize state.updated returns None.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'state.updated', + 'data': { + 'scope': 'conversation', + 'key': 'external_conversation_id', + 'value': 'abc123', + }, + } + + result = await normalizer.normalize(result_dict, descriptor) + assert result is None + + @pytest.mark.asyncio + async def test_normalize_action_requested(self): + """Normalize action.requested returns None (EBA reserved).""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'action.requested', + 'data': { + 'action': 'platform.message.edit', + 'payload': {}, + }, + } + + result = await normalizer.normalize(result_dict, descriptor) + assert result is None + + @pytest.mark.asyncio + async def test_invalid_state_updated_payload_is_dropped(self): + """Invalid state.updated payload returns None with a warning.""" + app = FakeApplication() + normalizer = AgentResultNormalizer(app) + descriptor = make_descriptor() + + result = await normalizer.normalize( + { + 'type': 'state.updated', + 'data': { + 'scope': 'invalid', + 'key': 'k', + 'value': 'v', + }, + }, + descriptor, + ) + + assert result is None + assert app.logger.warnings + +class TestNormalizeInvalidResults: + """Tests for handling invalid results.""" + + @pytest.mark.asyncio + async def test_normalize_missing_type(self): + """Normalize result without type.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'data': {}, + } + + with pytest.raises(RunnerProtocolError) as exc_info: + await normalizer.normalize(result_dict, descriptor) + + assert 'Missing result type' in str(exc_info.value) + + @pytest.mark.asyncio + async def test_normalize_unknown_type(self): + """Normalize unknown type returns None.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'unknown_type', + 'data': {}, + } + + result = await normalizer.normalize(result_dict, descriptor) + assert result is None + + @pytest.mark.asyncio + async def test_normalize_legacy_type_returns_none(self): + """Legacy types (chunk, text, finish) are now treated as unknown.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + # chunk is now unknown + result_dict = { + 'type': 'chunk', + 'data': { + 'message_chunk': { + 'role': 'assistant', + 'content': 'Legacy chunk', + }, + }, + } + result = await normalizer.normalize(result_dict, descriptor) + assert result is None + + # text is now unknown + result_dict = { + 'type': 'text', + 'data': { + 'content': 'Legacy text', + }, + } + result = await normalizer.normalize(result_dict, descriptor) + assert result is None + + # finish is now unknown + result_dict = { + 'type': 'finish', + 'data': { + 'message': { + 'role': 'assistant', + 'content': 'Legacy finish', + }, + }, + } + result = await normalizer.normalize(result_dict, descriptor) + assert result is None diff --git a/tests/unit_tests/agent/test_run_ledger_api_auth.py b/tests/unit_tests/agent/test_run_ledger_api_auth.py new file mode 100644 index 000000000..d71ce4215 --- /dev/null +++ b/tests/unit_tests/agent/test_run_ledger_api_auth.py @@ -0,0 +1,1499 @@ +"""Tests for AgentRunner run ledger pull API authorization.""" + +from __future__ import annotations + +import datetime +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +import sqlalchemy +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.orm import sessionmaker + +from langbot.pkg.agent.runner.run_ledger_store import RunLedgerStore +from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry +from langbot.pkg.entity.persistence import agent_run as agent_run_model +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.plugin.handler import RuntimeConnectionHandler +from langbot_plugin.api.entities.builtin.agent_runner.run_ledger import ( + AgentRun, + AgentRunEvent, + RunEventPage, + RunPage, +) +from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + +from .conftest import make_resources + + +class FakeConnection: + pass + + +class FakeApplication: + def __init__(self, db_engine, admin_plugins=None, runner_registry=None): + self.logger = MagicMock() + self.persistence_mgr = MagicMock() + self.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + self.agent_runner_registry = runner_registry + self.instance_config = SimpleNamespace( + data={ + 'agent_runner': { + 'admin_plugins': admin_plugins or [], + } + } + ) + + +@pytest.fixture +def session_registry(monkeypatch): + registry = AgentRunSessionRegistry() + monkeypatch.setattr( + 'langbot.pkg.agent.runner.session_registry._global_registry', + registry, + ) + return registry + + +@pytest.fixture +async def db_engine(): + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + assert agent_run_model.AgentRun.__tablename__ == 'agent_run' + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + + +class FakeRunnerRegistry: + def __init__(self, runners): + self.runners = runners + self.calls = [] + + async def list_runners(self, *, bound_plugins=None, use_cache=True): + self.calls.append({'bound_plugins': bound_plugins, 'use_cache': use_cache}) + return self.runners + + +def _handler(db_engine, admin_plugins=None, runner_registry=None): + async def fake_disconnect(): + return True + + fake_app = FakeApplication(db_engine, admin_plugins=admin_plugins, runner_registry=runner_registry) + return RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + + +async def _register_session( + session_registry, + *, + run_id='run_1', + conversation_id='conv_1', + available_apis=None, +): + await session_registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=None, + plugin_identity='test/runner', + resources=make_resources(), + conversation_id=conversation_id, + bot_id='bot_1', + workspace_id='workspace_1', + thread_id=None, + available_apis=available_apis or {}, + ) + + +async def _create_run( + db_engine, + *, + run_id='run_1', + conversation_id='conv_1', + bot_id='bot_1', + workspace_id='workspace_1', + thread_id=None, + plugin_identity='test/runner', + runner_id='plugin:test/runner/default', + available_apis=None, + status='running', + queue_name=None, + priority=0, + requested_runtime_id=None, +): + store = RunLedgerStore(db_engine) + await store.create_run( + run_id=run_id, + event_id='evt_1', + binding_id='binding_1', + runner_id=runner_id, + conversation_id=conversation_id, + bot_id=bot_id, + workspace_id=workspace_id, + thread_id=thread_id, + status=status, + queue_name=queue_name, + priority=priority, + requested_runtime_id=requested_runtime_id, + authorization={ + 'runner_id': runner_id, + 'binding_id': 'binding_1', + 'plugin_identity': plugin_identity, + 'resources': make_resources(), + 'available_apis': available_apis or {}, + 'conversation_id': conversation_id, + 'bot_id': bot_id, + 'workspace_id': workspace_id, + 'thread_id': thread_id, + 'state_policy': {'enable_state': True, 'state_scopes': ['conversation', 'actor']}, + 'state_context': {}, + }, + ) + await store.append_event( + run_id=run_id, + sequence=1, + event_type='message.completed', + data={'message': {'role': 'assistant', 'content': 'ok'}}, + ) + + +@pytest.mark.asyncio +async def test_run_get_returns_current_run(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_get': True}) + await _create_run(db_engine) + handler = _handler(db_engine) + run_get = handler.actions[PluginToRuntimeAction.RUN_GET.value] + + result = await run_get( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code == 0 + run = AgentRun.model_validate(result.data) + assert run.run_id == 'run_1' + assert run.status == 'running' + + +@pytest.mark.asyncio +async def test_run_list_rejects_cross_conversation(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_list': True}) + handler = _handler(db_engine) + run_list = handler.actions[PluginToRuntimeAction.RUN_LIST.value] + + result = await run_list( + { + 'run_id': 'run_1', + 'conversation_id': 'conv_other', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code != 0 + assert 'not accessible' in result.message.lower() + + +@pytest.mark.asyncio +async def test_run_list_returns_scoped_runs(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_list': True}) + await _create_run(db_engine) + await _create_run(db_engine, run_id='run_other', conversation_id='conv_other') + handler = _handler(db_engine) + run_list = handler.actions[PluginToRuntimeAction.RUN_LIST.value] + + result = await run_list( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code == 0 + page = RunPage.model_validate(result.data) + assert [run.run_id for run in page.items] == ['run_1'] + + +@pytest.mark.asyncio +async def test_run_list_filters_same_scope_different_runner_owner(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_list': True}) + await _create_run(db_engine) + await _create_run( + db_engine, + run_id='run_other_owner', + conversation_id='conv_1', + plugin_identity='other/runner', + runner_id='plugin:other/runner/default', + available_apis={'run_list': True}, + ) + handler = _handler(db_engine) + run_list = handler.actions[PluginToRuntimeAction.RUN_LIST.value] + + result = await run_list( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code == 0 + page = RunPage.model_validate(result.data) + assert [run.run_id for run in page.items] == ['run_1'] + + +@pytest.mark.asyncio +async def test_non_admin_target_run_actions_reject_same_scope_different_runner_owner( + session_registry, + db_engine, +): + await _register_session( + session_registry, + available_apis={ + 'run_get': True, + 'run_events_page': True, + 'run_cancel': True, + 'run_append_result': True, + 'run_finalize': True, + }, + ) + await _create_run( + db_engine, + available_apis={ + 'run_get': True, + 'run_events_page': True, + 'run_cancel': True, + 'run_append_result': True, + 'run_finalize': True, + }, + ) + await _create_run( + db_engine, + run_id='run_other_owner', + conversation_id='conv_1', + plugin_identity='other/runner', + runner_id='plugin:other/runner/default', + available_apis={ + 'run_get': True, + 'run_events_page': True, + 'run_cancel': True, + 'run_append_result': True, + 'run_finalize': True, + }, + ) + handler = _handler(db_engine) + + calls = [ + ( + PluginToRuntimeAction.RUN_GET.value, + { + 'run_id': 'run_1', + 'target_run_id': 'run_other_owner', + 'caller_plugin_identity': 'test/runner', + }, + ), + ( + PluginToRuntimeAction.RUN_EVENTS_PAGE.value, + { + 'run_id': 'run_1', + 'target_run_id': 'run_other_owner', + 'caller_plugin_identity': 'test/runner', + }, + ), + ( + PluginToRuntimeAction.RUN_CANCEL.value, + { + 'run_id': 'run_1', + 'target_run_id': 'run_other_owner', + 'caller_plugin_identity': 'test/runner', + 'reason': 'not allowed', + }, + ), + ( + PluginToRuntimeAction.RUN_APPEND_RESULT.value, + { + 'run_id': 'run_1', + 'target_run_id': 'run_other_owner', + 'caller_plugin_identity': 'test/runner', + 'sequence': 2, + 'result': { + 'type': 'tool.call.started', + 'data': {'tool_call_id': 'call_1', 'tool_name': 'weather'}, + }, + }, + ), + ( + PluginToRuntimeAction.RUN_FINALIZE.value, + { + 'run_id': 'run_1', + 'target_run_id': 'run_other_owner', + 'caller_plugin_identity': 'test/runner', + 'status': 'completed', + }, + ), + ] + + for action, payload in calls: + result = await handler.actions[action](payload) + assert result.code != 0, action + assert 'not accessible' in result.message.lower() + + +@pytest.mark.asyncio +async def test_run_events_page_returns_events(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_events_page': True}) + await _create_run(db_engine) + handler = _handler(db_engine) + run_events_page = handler.actions[PluginToRuntimeAction.RUN_EVENTS_PAGE.value] + + result = await run_events_page( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code == 0 + page = RunEventPage.model_validate(result.data) + assert [item.sequence for item in page.items] == [1] + assert page.items[0].type == 'message.completed' + + +@pytest.mark.asyncio +async def test_run_get_uses_persistent_authorization_after_session_expired(db_engine): + await _create_run(db_engine, available_apis={'run_get': True}) + handler = _handler(db_engine) + run_get = handler.actions[PluginToRuntimeAction.RUN_GET.value] + + result = await run_get( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code == 0 + run = AgentRun.model_validate(result.data) + assert run.run_id == 'run_1' + + +@pytest.mark.asyncio +async def test_persistent_run_get_rejects_cross_scope(db_engine): + await _create_run(db_engine, available_apis={'run_get': True}) + await _create_run( + db_engine, + run_id='run_other', + conversation_id='conv_other', + available_apis={'run_get': True}, + ) + handler = _handler(db_engine) + run_get = handler.actions[PluginToRuntimeAction.RUN_GET.value] + + result = await run_get( + { + 'run_id': 'run_1', + 'target_run_id': 'run_other', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code != 0 + assert 'not accessible' in result.message.lower() + + +@pytest.mark.asyncio +async def test_persistent_run_get_requires_capability(db_engine): + await _create_run(db_engine, available_apis={'run_get': False}) + handler = _handler(db_engine) + run_get = handler.actions[PluginToRuntimeAction.RUN_GET.value] + + result = await run_get( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code != 0 + assert 'not authorized' in result.message.lower() + + +@pytest.mark.asyncio +async def test_agent_run_admin_can_list_all_runs_with_own_run_session(session_registry, db_engine): + await _register_session(session_registry, available_apis={}) + await _create_run(db_engine) + await _create_run( + db_engine, + run_id='run_other', + conversation_id='conv_other', + bot_id='bot_other', + workspace_id='workspace_other', + plugin_identity='other/runner', + runner_id='plugin:other/runner/default', + available_apis={'run_list': True}, + ) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'test/runner', + 'permissions': ['agent_run:admin'], + } + ], + ) + run_list = handler.actions[PluginToRuntimeAction.RUN_LIST.value] + + result = await run_list( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'statuses': ['running'], + } + ) + + assert result.code == 0 + page = RunPage.model_validate(result.data) + assert [run.run_id for run in page.items] == ['run_other', 'run_1'] + + +@pytest.mark.asyncio +async def test_agent_run_admin_permission_string_allows_without_run_id(db_engine): + await _create_run(db_engine) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'test/runner', + 'permissions': 'agent_run:admin', + } + ], + ) + run_list = handler.actions[PluginToRuntimeAction.RUN_LIST.value] + + result = await run_list( + { + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code == 0 + page = RunPage.model_validate(result.data) + assert [run.run_id for run in page.items] == ['run_1'] + + +@pytest.mark.asyncio +async def test_agent_run_admin_can_list_runner_registry_without_run_id(db_engine): + runner_registry = FakeRunnerRegistry( + [ + { + 'id': 'plugin:test/runner/default', + 'source': 'plugin', + 'plugin_author': 'test', + 'plugin_name': 'runner', + 'runner_name': 'default', + 'label': {'en_US': 'Default'}, + } + ] + ) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'langbot/control', + 'permissions': ['agent_run:admin'], + } + ], + runner_registry=runner_registry, + ) + runner_list = handler.actions['runner_list'] + + result = await runner_list( + { + 'caller_plugin_identity': 'langbot/control', + 'include_plugins': ['test/runner'], + } + ) + + assert result.code == 0 + assert result.data['items'][0]['id'] == 'plugin:test/runner/default' + assert runner_registry.calls == [ + { + 'bound_plugins': ['test/runner'], + 'use_cache': True, + } + ] + + +@pytest.mark.asyncio +async def test_unconfigured_plugin_cannot_list_runner_registry(db_engine): + handler = _handler(db_engine, runner_registry=FakeRunnerRegistry([])) + runner_list = handler.actions['runner_list'] + + result = await runner_list({'caller_plugin_identity': 'test/runner'}) + + assert result.code != 0 + assert 'not authorized' in result.message.lower() + + +@pytest.mark.asyncio +async def test_agent_run_admin_can_get_and_page_cross_scope_with_own_run_session(session_registry, db_engine): + await _register_session(session_registry, available_apis={}) + await _create_run( + db_engine, + run_id='run_other', + conversation_id='conv_other', + bot_id='bot_other', + workspace_id='workspace_other', + plugin_identity='other/runner', + runner_id='plugin:other/runner/default', + available_apis={'run_get': True, 'run_events_page': True}, + ) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'test/runner', + 'permissions': ['agent_run:admin'], + } + ], + ) + run_get = handler.actions[PluginToRuntimeAction.RUN_GET.value] + run_events_page = handler.actions[PluginToRuntimeAction.RUN_EVENTS_PAGE.value] + + run_result = await run_get( + { + 'run_id': 'run_1', + 'target_run_id': 'run_other', + 'caller_plugin_identity': 'test/runner', + } + ) + events_result = await run_events_page( + { + 'run_id': 'run_1', + 'target_run_id': 'run_other', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert run_result.code == 0 + assert AgentRun.model_validate(run_result.data).run_id == 'run_other' + assert events_result.code == 0 + page = RunEventPage.model_validate(events_result.data) + assert [event.type for event in page.items] == ['message.completed'] + + +@pytest.mark.asyncio +async def test_agent_run_admin_can_get_and_page_cross_scope_without_run_id(db_engine): + await _create_run( + db_engine, + run_id='run_other', + conversation_id='conv_other', + bot_id='bot_other', + workspace_id='workspace_other', + plugin_identity='other/runner', + runner_id='plugin:other/runner/default', + ) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'langbot/control', + 'permissions': ['agent_run:admin'], + } + ], + ) + run_get = handler.actions[PluginToRuntimeAction.RUN_GET.value] + run_events_page = handler.actions[PluginToRuntimeAction.RUN_EVENTS_PAGE.value] + + run_result = await run_get( + { + 'target_run_id': 'run_other', + 'caller_plugin_identity': 'langbot/control', + } + ) + events_result = await run_events_page( + { + 'target_run_id': 'run_other', + 'caller_plugin_identity': 'langbot/control', + } + ) + + assert run_result.code == 0 + assert AgentRun.model_validate(run_result.data).run_id == 'run_other' + assert events_result.code == 0 + page = RunEventPage.model_validate(events_result.data) + assert [event.type for event in page.items] == ['message.completed'] + + +@pytest.mark.asyncio +async def test_unconfigured_plugin_cannot_use_admin_run_actions_without_run_id(db_engine): + await _create_run(db_engine) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'langbot/control', + 'permissions': ['agent_run:admin'], + } + ], + ) + run_list = handler.actions[PluginToRuntimeAction.RUN_LIST.value] + + result = await run_list( + { + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code != 0 + assert 'run_id is required' in result.message.lower() + + +@pytest.mark.asyncio +async def test_agent_run_admin_can_cancel_cross_scope_with_own_run_session(session_registry, db_engine): + await _register_session(session_registry, available_apis={}) + await _create_run( + db_engine, + run_id='run_other', + conversation_id='conv_other', + bot_id='bot_other', + workspace_id='workspace_other', + plugin_identity='other/runner', + runner_id='plugin:other/runner/default', + ) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'test/runner', + 'permissions': ['agent_run:admin'], + } + ], + ) + run_cancel = handler.actions[PluginToRuntimeAction.RUN_CANCEL.value] + + result = await run_cancel( + { + 'run_id': 'run_1', + 'target_run_id': 'run_other', + 'caller_plugin_identity': 'test/runner', + 'reason': 'admin requested', + } + ) + + assert result.code == 0 + run = AgentRun.model_validate(result.data) + assert run.run_id == 'run_other' + assert run.cancel_requested_at is not None + assert run.status_reason == 'admin requested' + events, _next_cursor, _prev_cursor, _has_more = await RunLedgerStore(db_engine).page_run_events( + run_id='run_other', + ) + assert [event['type'] for event in events] == ['message.completed', 'admin.run_cancel'] + assert events[1]['source'] == 'host' + assert events[1]['data']['caller_plugin_identity'] == 'test/runner' + assert events[1]['metadata'] == {'permission': 'agent_run:admin'} + + +@pytest.mark.asyncio +async def test_configured_admin_identity_cannot_be_spoofed_with_other_run_session(session_registry, db_engine): + await _register_session(session_registry, available_apis={}) + await _create_run(db_engine) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'langbot/control', + 'permissions': ['agent_run:admin'], + } + ], + ) + run_get = handler.actions[PluginToRuntimeAction.RUN_GET.value] + + result = await run_get( + { + 'run_id': 'run_1', + 'target_run_id': 'run_1', + 'caller_plugin_identity': 'langbot/control', + } + ) + + assert result.code != 0 + assert 'mismatch' in result.message.lower() + + +@pytest.mark.asyncio +async def test_agent_run_admin_permission_does_not_grant_runtime_admin(session_registry, db_engine): + await _register_session(session_registry, available_apis={}) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'test/runner', + 'permissions': ['agent_run:admin'], + } + ], + ) + runtime_list = handler.actions[PluginToRuntimeAction.RUNTIME_LIST.value] + + result = await runtime_list( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code != 0 + assert 'not authorized' in result.message.lower() + + +@pytest.mark.asyncio +async def test_runtime_admin_can_register_list_and_claim_with_own_run_session(session_registry, db_engine): + await _register_session(session_registry, available_apis={}) + await RunLedgerStore(db_engine).create_run( + run_id='queued_run', + event_id='evt_queued', + binding_id='binding_1', + runner_id='plugin:other/runner/default', + conversation_id='conv_1', + bot_id='bot_1', + workspace_id='workspace_1', + status='queued', + queue_name='default', + priority=5, + ) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'test/runner', + 'permissions': ['runtime:admin'], + } + ], + ) + runtime_register = handler.actions[PluginToRuntimeAction.RUNTIME_REGISTER.value] + runtime_list = handler.actions[PluginToRuntimeAction.RUNTIME_LIST.value] + run_claim = handler.actions[PluginToRuntimeAction.RUN_CLAIM.value] + + registered = await runtime_register( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'display_name': 'Runtime 1', + 'labels': {'region': 'test'}, + } + ) + page = await runtime_list( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'statuses': ['online'], + 'labels': {'region': 'test'}, + } + ) + claimed = await run_claim( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'queue_name': 'default', + 'runner_ids': ['plugin:other/runner/default'], + } + ) + + assert registered.code == 0 + assert registered.data['runtime_id'] == 'runtime_1' + assert page.code == 0 + assert [item['runtime_id'] for item in page.data['items']] == ['runtime_1'] + assert claimed.code == 0 + assert claimed.data['run_id'] == 'queued_run' + assert claimed.data['claimed_by_runtime_id'] == 'runtime_1' + + +@pytest.mark.asyncio +async def test_runtime_admin_can_register_list_and_claim_without_run_id(db_engine): + await RunLedgerStore(db_engine).create_run( + run_id='queued_run', + event_id='evt_queued', + binding_id='binding_1', + runner_id='plugin:other/runner/default', + conversation_id='conv_1', + bot_id='bot_1', + workspace_id='workspace_1', + status='queued', + queue_name='default', + priority=5, + ) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'langbot/control', + 'permissions': ['runtime:admin'], + } + ], + ) + runtime_register = handler.actions[PluginToRuntimeAction.RUNTIME_REGISTER.value] + runtime_list = handler.actions[PluginToRuntimeAction.RUNTIME_LIST.value] + run_claim = handler.actions[PluginToRuntimeAction.RUN_CLAIM.value] + + registered = await runtime_register( + { + 'caller_plugin_identity': 'langbot/control', + 'runtime_id': 'runtime_1', + 'display_name': 'Runtime 1', + 'labels': {'region': 'test'}, + } + ) + page = await runtime_list( + { + 'caller_plugin_identity': 'langbot/control', + 'statuses': ['online'], + 'labels': {'region': 'test'}, + } + ) + claimed = await run_claim( + { + 'caller_plugin_identity': 'langbot/control', + 'runtime_id': 'runtime_1', + 'queue_name': 'default', + 'runner_ids': ['plugin:other/runner/default'], + } + ) + + assert registered.code == 0 + assert registered.data['runtime_id'] == 'runtime_1' + assert page.code == 0 + assert [item['runtime_id'] for item in page.data['items']] == ['runtime_1'] + assert claimed.code == 0 + assert claimed.data['run_id'] == 'queued_run' + assert claimed.data['claimed_by_runtime_id'] == 'runtime_1' + + +@pytest.mark.asyncio +async def test_runtime_admin_can_reconcile_without_run_id(db_engine): + store = RunLedgerStore(db_engine) + await store.register_runtime( + runtime_id='runtime_stale', + display_name='Runtime Stale', + heartbeat_deadline_seconds=60, + ) + await store.create_run( + run_id='claimed_run', + event_id='evt_claimed', + binding_id='binding_1', + runner_id='plugin:other/runner/default', + status='queued', + queue_name='default', + ) + claim = await store.claim_next_run(runtime_id='runtime_stale', queue_name='default', lease_seconds=60) + assert claim is not None + + session_factory = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) + expired_at = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(seconds=1) + async with session_factory() as session: + await session.execute( + sqlalchemy.update(agent_run_model.AgentRun) + .where(agent_run_model.AgentRun.run_id == 'claimed_run') + .values(claim_lease_expires_at=expired_at) + ) + await session.execute( + sqlalchemy.update(agent_run_model.AgentRuntime) + .where(agent_run_model.AgentRuntime.runtime_id == 'runtime_stale') + .values( + last_heartbeat_at=expired_at, + heartbeat_deadline_at=expired_at, + ) + ) + await session.commit() + + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'langbot/control', + 'permissions': ['runtime:admin'], + } + ], + ) + runtime_reconcile = handler.actions['runtime_reconcile'] + + result = await runtime_reconcile({'caller_plugin_identity': 'langbot/control'}) + + assert result.code == 0 + assert result.data['stale_count'] == 1 + assert result.data['released_claim_count'] == 1 + assert result.data['stale_runtimes'][0]['runtime_id'] == 'runtime_stale' + assert result.data['released_claims'][0]['run_id'] == 'claimed_run' + assert (await store.get_runtime('runtime_stale'))['status'] == 'stale' + released_run = await store.get_run('claimed_run') + assert released_run is not None + assert released_run['status'] == 'queued' + assert released_run['claimed_by_runtime_id'] is None + assert 'claim_token' not in released_run + + +@pytest.mark.asyncio +async def test_unconfigured_plugin_cannot_reconcile_runtime(db_engine): + handler = _handler(db_engine) + runtime_reconcile = handler.actions['runtime_reconcile'] + + result = await runtime_reconcile({'caller_plugin_identity': 'test/runner'}) + + assert result.code != 0 + assert 'not authorized' in result.message.lower() + + +@pytest.mark.asyncio +async def test_disabled_admin_plugin_entry_does_not_grant_access(session_registry, db_engine): + await _register_session(session_registry, available_apis={}) + await _create_run(db_engine) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'test/runner', + 'permissions': ['agent_run:admin', 'runtime:admin'], + 'enabled': False, + } + ], + ) + run_get = handler.actions[PluginToRuntimeAction.RUN_GET.value] + + result = await run_get( + { + 'run_id': 'run_1', + 'target_run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code != 0 + assert 'not authorized' in result.message.lower() + + +@pytest.mark.asyncio +async def test_run_cancel_basic_path(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_cancel': True}) + await _create_run(db_engine) + handler = _handler(db_engine) + run_cancel = handler.actions[PluginToRuntimeAction.RUN_CANCEL.value] + + result = await run_cancel( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'reason': 'user requested', + } + ) + + assert result.code == 0 + run = AgentRun.model_validate(result.data) + assert run.run_id == 'run_1' + assert run.cancel_requested_at is not None + assert run.status_reason == 'user requested' + + +@pytest.mark.asyncio +async def test_run_append_result_basic_path(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_append_result': True}) + await _create_run(db_engine) + handler = _handler(db_engine) + run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value] + + result = await run_append_result( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'sequence': 2, + 'result': { + 'type': 'tool.call.started', + 'data': {'tool_call_id': 'call_1', 'tool_name': 'weather'}, + 'usage': {'output_tokens': 1}, + }, + } + ) + + assert result.code == 0 + event = AgentRunEvent.model_validate(result.data) + assert event.run_id == 'run_1' + assert event.sequence == 2 + assert event.type == 'tool.call.started' + assert event.data == {'tool_call_id': 'call_1', 'tool_name': 'weather'} + assert event.usage == {'output_tokens': 1} + + +@pytest.mark.asyncio +async def test_run_append_result_rejects_side_effecting_result_type(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_append_result': True}) + await _create_run(db_engine) + handler = _handler(db_engine) + run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value] + + result = await run_append_result( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'sequence': 2, + 'result': { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'hello'}}, + }, + } + ) + + assert result.code != 0 + assert 'canonical runner result path' in result.message + + +@pytest.mark.asyncio +async def test_run_append_result_rejects_unknown_result_type(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_append_result': True}) + await _create_run(db_engine) + handler = _handler(db_engine) + run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value] + + result = await run_append_result( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'sequence': 2, + 'result': { + 'type': 'unknown.event', + 'data': {}, + }, + } + ) + + assert result.code != 0 + assert 'unknown result type' in result.message + + +@pytest.mark.asyncio +async def test_run_append_result_validates_known_payloads(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_append_result': True}) + await _create_run(db_engine) + handler = _handler(db_engine) + run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value] + + result = await run_append_result( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'sequence': 2, + 'result': { + 'type': 'action.requested', + 'data': {'payload': {}}, + }, + } + ) + + assert result.code != 0 + assert 'invalid action.requested payload' in result.message + + +@pytest.mark.asyncio +async def test_run_append_and_finalize_claimed_target_require_active_claim(session_registry, db_engine): + await _register_session( + session_registry, + available_apis={'run_append_result': True, 'run_finalize': True}, + ) + await _create_run( + db_engine, + available_apis={'run_append_result': True, 'run_finalize': True}, + ) + await _create_run( + db_engine, + run_id='run_claimed', + conversation_id='conv_1', + available_apis={'run_append_result': True, 'run_finalize': True}, + status='queued', + queue_name='default', + ) + store = RunLedgerStore(db_engine) + claim = await store.claim_next_run(runtime_id='runtime_1', queue_name='default') + assert claim is not None + token = claim['claim_token'] + handler = _handler(db_engine) + run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value] + run_finalize = handler.actions[PluginToRuntimeAction.RUN_FINALIZE.value] + + missing_claim = await run_append_result( + { + 'run_id': 'run_1', + 'target_run_id': 'run_claimed', + 'caller_plugin_identity': 'test/runner', + 'sequence': 2, + 'result': { + 'type': 'tool.call.started', + 'data': {'tool_call_id': 'call_1', 'tool_name': 'weather'}, + }, + } + ) + wrong_claim = await run_finalize( + { + 'run_id': 'run_1', + 'target_run_id': 'run_claimed', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'claim_token': 'wrong-token', + 'status': 'completed', + } + ) + appended = await run_append_result( + { + 'run_id': 'run_1', + 'target_run_id': 'run_claimed', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'claim_token': token, + 'sequence': 2, + 'result': { + 'type': 'tool.call.started', + 'data': {'tool_call_id': 'call_1', 'tool_name': 'weather'}, + }, + } + ) + finalized = await run_finalize( + { + 'run_id': 'run_1', + 'target_run_id': 'run_claimed', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'claim_token': token, + 'status': 'completed', + } + ) + + assert missing_claim.code != 0 + assert 'active claim ownership' in missing_claim.message + assert wrong_claim.code != 0 + assert 'claim ownership is not active' in wrong_claim.message + assert appended.code == 0 + assert finalized.code == 0 + assert finalized.data['status'] == 'completed' + + +@pytest.mark.asyncio +async def test_run_finalize_basic_path(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_finalize': True}) + await _create_run(db_engine) + handler = _handler(db_engine) + run_finalize = handler.actions[PluginToRuntimeAction.RUN_FINALIZE.value] + + result = await run_finalize( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'status': 'completed', + 'status_reason': 'done', + 'usage': {'total_tokens': 3}, + } + ) + + assert result.code == 0 + run = AgentRun.model_validate(result.data) + assert run.status == 'completed' + assert run.status_reason == 'done' + assert run.finished_at is not None + assert run.usage == {'total_tokens': 3} + + +@pytest.mark.asyncio +async def test_runtime_register_heartbeat_and_list_actions(session_registry, db_engine): + await _register_session( + session_registry, + available_apis={ + 'runtime_register': True, + 'runtime_heartbeat': True, + 'runtime_list': True, + }, + ) + handler = _handler(db_engine) + runtime_register = handler.actions[PluginToRuntimeAction.RUNTIME_REGISTER.value] + runtime_heartbeat = handler.actions[PluginToRuntimeAction.RUNTIME_HEARTBEAT.value] + runtime_list = handler.actions[PluginToRuntimeAction.RUNTIME_LIST.value] + + registered = await runtime_register( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'display_name': 'Runtime 1', + 'capabilities': {'runner': True}, + 'labels': {'region': 'test'}, + 'metadata': {'slots': 2}, + } + ) + + assert registered.code == 0 + assert registered.data['runtime_id'] == 'runtime_1' + assert registered.data['capabilities'] == {'runner': True} + + heartbeat = await runtime_heartbeat( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'capabilities': {'runner': True, 'stream': True}, + 'labels': {'region': 'test'}, + 'metadata': {'active_runs': 1}, + } + ) + + assert heartbeat.code == 0 + assert heartbeat.data['capabilities'] == {'runner': True, 'stream': True} + assert heartbeat.data['metadata'] == {'slots': 2, 'active_runs': 1} + + page = await runtime_list( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'statuses': ['online'], + 'labels': {'region': 'test'}, + } + ) + + assert page.code == 0 + assert [item['runtime_id'] for item in page.data['items']] == ['runtime_1'] + + +@pytest.mark.asyncio +async def test_run_claim_renew_and_release_actions(session_registry, db_engine): + await _register_session( + session_registry, + available_apis={ + 'run_claim': True, + 'run_renew_claim': True, + 'run_release_claim': True, + }, + ) + await RunLedgerStore(db_engine).create_run( + run_id='queued_run', + event_id='evt_queued', + binding_id='binding_1', + runner_id='plugin:test/runner/default', + conversation_id='conv_1', + bot_id='bot_1', + workspace_id='workspace_1', + status='queued', + queue_name='default', + priority=5, + ) + handler = _handler(db_engine) + run_claim = handler.actions[PluginToRuntimeAction.RUN_CLAIM.value] + run_renew_claim = handler.actions[PluginToRuntimeAction.RUN_RENEW_CLAIM.value] + run_release_claim = handler.actions[PluginToRuntimeAction.RUN_RELEASE_CLAIM.value] + + claimed = await run_claim( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'queue_name': 'default', + 'lease_seconds': 30, + } + ) + + assert claimed.code == 0 + assert claimed.data['run_id'] == 'queued_run' + assert claimed.data['status'] == 'claimed' + assert claimed.data['claimed_by_runtime_id'] == 'runtime_1' + claim_token = claimed.data['claim_token'] + + renewed = await run_renew_claim( + { + 'run_id': 'run_1', + 'target_run_id': 'queued_run', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'claim_token': claim_token, + 'lease_seconds': 60, + } + ) + + assert renewed.code == 0 + assert 'claim_token' not in renewed.data + + released = await run_release_claim( + { + 'run_id': 'run_1', + 'target_run_id': 'queued_run', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'claim_token': claim_token, + 'reason': 'done with lease', + } + ) + + assert released.code == 0 + assert released.data['status'] == 'queued' + assert released.data['claimed_by_runtime_id'] is None + assert 'claim_token' not in released.data + + +@pytest.mark.asyncio +async def test_non_admin_run_claim_is_scoped_to_session_runner_and_conversation(session_registry, db_engine): + await _register_session( + session_registry, + available_apis={'run_claim': True}, + ) + store = RunLedgerStore(db_engine) + await store.create_run( + run_id='other_runner_queued', + event_id='evt_other_runner', + binding_id='binding_1', + runner_id='plugin:other/runner/default', + conversation_id='conv_1', + bot_id='bot_1', + workspace_id='workspace_1', + status='queued', + queue_name='default', + priority=30, + ) + await store.create_run( + run_id='other_conversation_queued', + event_id='evt_other_conversation', + binding_id='binding_1', + runner_id='plugin:test/runner/default', + conversation_id='conv_other', + bot_id='bot_1', + workspace_id='workspace_1', + status='queued', + queue_name='default', + priority=20, + ) + await store.create_run( + run_id='own_queued', + event_id='evt_own', + binding_id='binding_1', + runner_id='plugin:test/runner/default', + conversation_id='conv_1', + bot_id='bot_1', + workspace_id='workspace_1', + status='queued', + queue_name='default', + priority=10, + ) + handler = _handler(db_engine) + run_claim = handler.actions[PluginToRuntimeAction.RUN_CLAIM.value] + + spoofed = await run_claim( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'queue_name': 'default', + 'runner_ids': ['plugin:other/runner/default'], + } + ) + claimed = await run_claim( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'queue_name': 'default', + } + ) + + assert spoofed.code != 0 + assert 'not accessible' in spoofed.message.lower() + assert claimed.code == 0 + assert claimed.data['run_id'] == 'own_queued' + + +@pytest.mark.asyncio +async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_registry, db_engine): + await _register_session( + session_registry, + available_apis={'run_renew_claim': True, 'run_release_claim': True}, + ) + await _create_run(db_engine) + await _create_run( + db_engine, + run_id='other_claimed', + conversation_id='conv_1', + plugin_identity='other/runner', + runner_id='plugin:other/runner/default', + available_apis={'run_renew_claim': True, 'run_release_claim': True}, + status='queued', + queue_name='default', + ) + store = RunLedgerStore(db_engine) + claim = await store.claim_next_run(runtime_id='runtime_1', queue_name='default') + assert claim is not None + handler = _handler(db_engine) + run_renew_claim = handler.actions[PluginToRuntimeAction.RUN_RENEW_CLAIM.value] + run_release_claim = handler.actions[PluginToRuntimeAction.RUN_RELEASE_CLAIM.value] + + renewed = await run_renew_claim( + { + 'run_id': 'run_1', + 'target_run_id': 'other_claimed', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'claim_token': claim['claim_token'], + } + ) + released = await run_release_claim( + { + 'run_id': 'run_1', + 'target_run_id': 'other_claimed', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'claim_token': claim['claim_token'], + } + ) + + assert renewed.code != 0 + assert 'not accessible' in renewed.message.lower() + assert released.code != 0 + assert 'not accessible' in released.message.lower() + + +@pytest.mark.asyncio +async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_engine): + await _register_session( + session_registry, + available_apis={'run_claim': True, 'run_release_claim': True}, + ) + await RunLedgerStore(db_engine).create_run( + run_id='queued_run', + event_id='evt_queued', + binding_id='binding_1', + runner_id='plugin:test/runner/default', + conversation_id='conv_1', + bot_id='bot_1', + workspace_id='workspace_1', + status='queued', + queue_name='default', + ) + handler = _handler(db_engine) + run_claim = handler.actions[PluginToRuntimeAction.RUN_CLAIM.value] + run_release_claim = handler.actions[PluginToRuntimeAction.RUN_RELEASE_CLAIM.value] + + claimed = await run_claim( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'queue_name': 'default', + } + ) + assert claimed.code == 0 + + released = await run_release_claim( + { + 'run_id': 'run_1', + 'target_run_id': 'queued_run', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'claim_token': claimed.data['claim_token'], + 'status': 'completed', + } + ) + + assert released.code != 0 + assert 'use run_finalize' in released.message diff --git a/tests/unit_tests/agent/test_run_ledger_store.py b/tests/unit_tests/agent/test_run_ledger_store.py new file mode 100644 index 000000000..c3667fcde --- /dev/null +++ b/tests/unit_tests/agent/test_run_ledger_store.py @@ -0,0 +1,430 @@ +"""Tests for RunLedgerStore host primitives.""" + +from __future__ import annotations + +import datetime + +import pytest +import sqlalchemy +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import sessionmaker + +from langbot.pkg.agent.runner.run_ledger_store import RunLedgerStore +from langbot.pkg.entity.persistence.agent_run import AgentRun +from langbot.pkg.entity.persistence.base import Base + + +UTC = datetime.timezone.utc + + +@pytest.fixture +async def db_engine(tmp_path): + db_path = tmp_path / 'run_ledger_store.db' + engine = create_async_engine(f'sqlite+aiosqlite:///{db_path}', echo=False) + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + yield engine + + await engine.dispose() + + +@pytest.fixture +def store(db_engine): + return RunLedgerStore(db_engine) + + +@pytest.mark.asyncio +async def test_create_queued_run_claim_renew_release(store): + run = await store.create_run( + run_id='run-queued', + event_id='evt-1', + binding_id='binding-1', + runner_id='runner-a', + status='queued', + queue_name='default', + priority=10, + requested_runtime_id='runtime-a', + ) + + assert run['status'] == 'queued' + assert run['started_at'] is None + assert run['queue_name'] == 'default' + assert run['priority'] == 10 + assert run['requested_runtime_id'] == 'runtime-a' + + assert await store.claim_next_run(runtime_id='runtime-b', queue_name='default') is None + + claimed = await store.claim_next_run(runtime_id='runtime-a', queue_name='default', lease_seconds=30) + + assert claimed is not None + assert claimed['run_id'] == 'run-queued' + assert claimed['status'] == 'claimed' + assert claimed['claimed_by_runtime_id'] == 'runtime-a' + assert claimed['claim_token'] + assert claimed['dispatch_attempts'] == 1 + assert claimed['claim_lease_expires_at'] is not None + assert claimed['last_claimed_at'] is not None + + token = claimed['claim_token'] + assert await store.renew_claim(run_id='run-queued', claim_token='wrong-token') is None + + renewed = await store.renew_claim(run_id='run-queued', claim_token=token, lease_seconds=90) + + assert renewed is not None + assert 'claim_token' not in renewed + assert renewed['claim_lease_expires_at'] >= claimed['claim_lease_expires_at'] + + released = await store.release_claim( + run_id='run-queued', + claim_token=token, + status='queued', + status_reason='runtime released capacity', + ) + + assert released is not None + assert released['status'] == 'queued' + assert released['status_reason'] == 'runtime released capacity' + assert released['claimed_by_runtime_id'] is None + assert 'claim_token' not in released + assert released['claim_lease_expires_at'] is None + assert released['dispatch_attempts'] == 1 + + +@pytest.mark.asyncio +async def test_claim_next_run_applies_scope_filters(store): + await store.create_run( + run_id='run-other-runner', + event_id='evt-other-runner', + binding_id='binding-1', + runner_id='runner-b', + conversation_id='conv-a', + bot_id='bot-a', + workspace_id='workspace-a', + status='queued', + queue_name='default', + priority=30, + ) + await store.create_run( + run_id='run-other-conversation', + event_id='evt-other-conversation', + binding_id='binding-1', + runner_id='runner-a', + conversation_id='conv-b', + bot_id='bot-a', + workspace_id='workspace-a', + status='queued', + queue_name='default', + priority=20, + ) + await store.create_run( + run_id='run-allowed', + event_id='evt-allowed', + binding_id='binding-1', + runner_id='runner-a', + conversation_id='conv-a', + bot_id='bot-a', + workspace_id='workspace-a', + status='queued', + queue_name='default', + priority=10, + ) + + claimed = await store.claim_next_run( + runtime_id='runtime-a', + queue_name='default', + runner_ids=['runner-a'], + conversation_id='conv-a', + bot_id='bot-a', + workspace_id='workspace-a', + thread_id=None, + strict_thread=True, + ) + + assert claimed is not None + assert claimed['run_id'] == 'run-allowed' + + +@pytest.mark.asyncio +async def test_expired_claim_can_be_reclaimed(store, db_engine): + await store.create_run( + run_id='run-expired', + event_id='evt-2', + binding_id='binding-1', + runner_id='runner-a', + status='queued', + queue_name='default', + ) + first_claim = await store.claim_next_run(runtime_id='runtime-a', queue_name='default', lease_seconds=60) + assert first_claim is not None + + session_factory = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) + async with session_factory() as session: + await session.execute( + sqlalchemy.update(AgentRun) + .where(AgentRun.run_id == 'run-expired') + .values(claim_lease_expires_at=datetime.datetime.now(UTC) - datetime.timedelta(seconds=1)) + ) + await session.commit() + + reclaimed = await store.claim_next_run(runtime_id='runtime-b', queue_name='default', lease_seconds=60) + + assert reclaimed is not None + assert reclaimed['run_id'] == 'run-expired' + assert reclaimed['claimed_by_runtime_id'] == 'runtime-b' + assert reclaimed['claim_token'] != first_claim['claim_token'] + assert reclaimed['dispatch_attempts'] == 2 + + +@pytest.mark.asyncio +async def test_release_expired_claims_requeues_runs(store, db_engine): + await store.create_run( + run_id='run-expired-release', + event_id='evt-3', + binding_id='binding-1', + runner_id='runner-a', + status='queued', + queue_name='default', + ) + await store.create_run( + run_id='run-active-claim', + event_id='evt-4', + binding_id='binding-1', + runner_id='runner-a', + status='queued', + queue_name='default', + ) + expired_claim = await store.claim_next_run(runtime_id='runtime-a', queue_name='default', lease_seconds=60) + active_claim = await store.claim_next_run(runtime_id='runtime-b', queue_name='default', lease_seconds=60) + assert expired_claim is not None + assert active_claim is not None + + session_factory = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) + async with session_factory() as session: + await session.execute( + sqlalchemy.update(AgentRun) + .where(AgentRun.run_id == 'run-expired-release') + .values(claim_lease_expires_at=datetime.datetime.now(UTC) - datetime.timedelta(seconds=1)) + ) + await session.commit() + + released = await store.release_expired_claims() + + assert [run['run_id'] for run in released] == ['run-expired-release'] + assert released[0]['status'] == 'queued' + assert released[0]['status_reason'] == 'claim lease expired' + assert released[0]['claimed_by_runtime_id'] is None + assert 'claim_token' not in released[0] + assert released[0]['claim_lease_expires_at'] is None + + active = await store.get_run('run-active-claim') + assert active is not None + assert active['status'] == 'claimed' + assert active['claimed_by_runtime_id'] == active_claim['claimed_by_runtime_id'] + assert 'claim_token' not in active + + +@pytest.mark.asyncio +async def test_expired_claim_cannot_renew_or_release(store, db_engine): + await store.create_run( + run_id='run-stale-claim', + event_id='evt-stale', + binding_id='binding-1', + runner_id='runner-a', + status='queued', + queue_name='default', + ) + claimed = await store.claim_next_run(runtime_id='runtime-a', queue_name='default', lease_seconds=60) + assert claimed is not None + token = claimed['claim_token'] + + session_factory = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) + async with session_factory() as session: + await session.execute( + sqlalchemy.update(AgentRun) + .where(AgentRun.run_id == 'run-stale-claim') + .values(claim_lease_expires_at=datetime.datetime.now(UTC) - datetime.timedelta(seconds=1)) + ) + await session.commit() + + assert await store.renew_claim(run_id='run-stale-claim', claim_token=token, runtime_id='runtime-a') is None + assert await store.release_claim(run_id='run-stale-claim', claim_token=token, runtime_id='runtime-a') is None + + +@pytest.mark.asyncio +async def test_run_status_validation_and_terminal_transition_rules(store): + with pytest.raises(ValueError, match='Unknown run status'): + await store.create_run( + run_id='run-invalid-create', + event_id='evt-invalid', + binding_id='binding-1', + runner_id='runner-a', + status='bogus', + ) + + await store.create_run( + run_id='run-invalid-release', + event_id='evt-release', + binding_id='binding-1', + runner_id='runner-a', + status='queued', + queue_name='default', + ) + claim = await store.claim_next_run(runtime_id='runtime-a', queue_name='default') + assert claim is not None + with pytest.raises(ValueError, match='Unknown run status'): + await store.release_claim( + run_id='run-invalid-release', + claim_token=claim['claim_token'], + runtime_id='runtime-a', + status='bogus', + ) + + await store.create_run( + run_id='run-terminal', + event_id='evt-terminal', + binding_id='binding-1', + runner_id='runner-a', + ) + with pytest.raises(ValueError, match='Unknown run status'): + await store.finalize_run(run_id='run-terminal', status='bogus') + + completed = await store.finalize_run( + run_id='run-terminal', + status='completed', + metadata={'attempt': 1}, + ) + assert completed is not None + assert completed['status'] == 'completed' + + merged = await store.finalize_run( + run_id='run-terminal', + status='completed', + metadata={'retry_observed': True}, + ) + assert merged is not None + assert merged['metadata'] == {'attempt': 1, 'retry_observed': True} + + with pytest.raises(ValueError, match='Cannot transition terminal run'): + await store.finalize_run(run_id='run-terminal', status='failed') + + +@pytest.mark.asyncio +async def test_append_audit_event_uses_next_sequence(store): + await store.create_run( + run_id='run-audit', + event_id='evt-5', + binding_id='binding-1', + runner_id='runner-a', + ) + await store.append_event( + run_id='run-audit', + sequence=1, + event_type='message.completed', + data={'ok': True}, + ) + + event = await store.append_audit_event( + run_id='run-audit', + event_type='admin.run_cancel', + data={'action': 'run_cancel'}, + metadata={'permission': 'agent_run:admin'}, + ) + + assert event is not None + assert event['sequence'] == 2 + assert event['type'] == 'admin.run_cancel' + assert event['source'] == 'host' + assert event['data'] == {'action': 'run_cancel'} + assert event['metadata'] == {'permission': 'agent_run:admin'} + assert await store.append_audit_event(run_id='missing', event_type='admin.missing') is None + + +@pytest.mark.asyncio +async def test_runtime_register_heartbeat_list_and_mark_stale(store): + registered = await store.register_runtime( + runtime_id='runtime-a', + display_name='Runtime A', + endpoint='http://runtime-a', + version='1.0.0', + capabilities={'stream': True}, + labels={'region': 'test'}, + metadata={'slot_count': 2}, + heartbeat_deadline_seconds=30, + ) + + assert registered['runtime_id'] == 'runtime-a' + assert registered['status'] == 'online' + assert registered['display_name'] == 'Runtime A' + assert registered['capabilities'] == {'stream': True} + assert registered['labels'] == {'region': 'test'} + assert registered['metadata'] == {'slot_count': 2} + assert registered['last_heartbeat_at'] is not None + assert registered['heartbeat_deadline_at'] is not None + + heartbeat = await store.heartbeat_runtime( + runtime_id='runtime-a', + metadata={'active_runs': 1}, + heartbeat_deadline_seconds=30, + ) + + assert heartbeat is not None + assert heartbeat['metadata'] == {'slot_count': 2, 'active_runs': 1} + + runtimes, total_count = await store.list_runtimes(statuses=['online']) + assert [runtime['runtime_id'] for runtime in runtimes] == ['runtime-a'] + assert total_count == 1 + + stale = await store.mark_stale_runtimes( + now=datetime.datetime.now(UTC) + datetime.timedelta(seconds=31), + ) + + assert [runtime['runtime_id'] for runtime in stale] == ['runtime-a'] + assert stale[0]['status'] == 'stale' + assert (await store.get_runtime('runtime-a'))['status'] == 'stale' + + +@pytest.mark.asyncio +async def test_runtime_stats_splits_active_and_claimed_runs(store): + await store.register_runtime(runtime_id='runtime-a') + await store.create_run( + run_id='run-running', + event_id='evt-running', + binding_id='binding-1', + runner_id='runner-a', + status='running', + ) + await store.create_run( + run_id='run-claimed', + event_id='evt-claimed', + binding_id='binding-1', + runner_id='runner-a', + status='queued', + queue_name='default', + ) + assert await store.claim_next_run(runtime_id='runtime-a', queue_name='default') is not None + + stats = await store.get_runtime_stats() + + assert stats['active_runs'] == 2 + assert stats['claimed_runs'] == 1 + + +@pytest.mark.asyncio +async def test_runner_stats_reports_zero_success_rate_for_failed_only_runner(store): + now = int(datetime.datetime.now(UTC).timestamp()) + await store.create_run( + run_id='run-failed', + event_id='evt-failed', + binding_id='binding-1', + runner_id='runner-a', + status='failed', + ) + + stats = await store.get_runner_stats(start_time=now - 10, end_time=now + 10) + + assert stats[0]['runner_id'] == 'runner-a' + assert stats[0]['failed_runs'] == 1 + assert stats[0]['success_rate'] == 0.0 diff --git a/tests/unit_tests/agent/test_session_registry.py b/tests/unit_tests/agent/test_session_registry.py new file mode 100644 index 000000000..3c2f05ef4 --- /dev/null +++ b/tests/unit_tests/agent/test_session_registry.py @@ -0,0 +1,633 @@ +"""Tests for AgentRunSessionRegistry.""" +from __future__ import annotations + +import pytest +import asyncio +import time + +from langbot.pkg.agent.runner.session_registry import ( + AgentRunSessionRegistry, + AgentRunSession, + MAX_STEERING_QUEUE_ITEMS, + get_session_registry, +) + +# Import shared test fixtures from conftest.py +from .conftest import make_resources, make_session + + +class TestSessionRegistryBasic: + """Tests for basic registry operations.""" + + @pytest.mark.asyncio + async def test_register_and_get(self): + """Register and retrieve a session.""" + registry = AgentRunSessionRegistry() + run_id = 'run_abc' + resources = make_resources( + models=[{'model_id': 'model_001', 'model_type': 'chat', 'provider': 'openai'}], + tools=[{'tool_name': 'web_search', 'tool_type': 'builtin'}], + ) + await registry.register( + run_id=run_id, + runner_id='plugin:test/my-runner/default', + query_id=1, + plugin_identity='test/my-runner', + resources=resources, + ) + + result = await registry.get(run_id) + assert result is not None + assert result['run_id'] == run_id + assert result['runner_id'] == 'plugin:test/my-runner/default' + assert result['query_id'] == 1 + assert result['plugin_identity'] == 'test/my-runner' + auth_resources = result['authorization']['resources'] + assert len(auth_resources['models']) == 1 + assert auth_resources['models'][0]['model_id'] == 'model_001' + assert 'resources' not in result + assert 'permissions' not in result + assert '_authorized_ids' not in result + + @pytest.mark.asyncio + async def test_register_requires_plugin_identity(self): + """Agent run sessions must always have an owning plugin identity.""" + registry = AgentRunSessionRegistry() + + with pytest.raises(ValueError, match='plugin_identity is required'): + await registry.register( + run_id='run_missing_identity', + runner_id='plugin:test/my-runner/default', + query_id=1, + plugin_identity='', + resources=make_resources(), + ) + + @pytest.mark.asyncio + async def test_register_freezes_authorization_snapshot(self): + """Register should freeze authorization data for the run.""" + registry = AgentRunSessionRegistry() + resources = make_resources( + models=[{'model_id': 'model_001'}], + storage={'plugin_storage': True, 'workspace_storage': False}, + ) + + await registry.register( + run_id='run_snapshot', + runner_id='plugin:test/my-runner/default', + query_id=1, + plugin_identity='test/my-runner', + resources=resources, + available_apis={'history_page': True}, + conversation_id='conv_001', + ) + + resources['models'].append({'model_id': 'model_late'}) + resources['storage']['workspace_storage'] = True + + session = await registry.get('run_snapshot') + assert session is not None + authorization = session['authorization'] + assert authorization['conversation_id'] == 'conv_001' + assert authorization['available_apis'] == {'history_page': True} + assert registry.is_resource_allowed(session, 'model', 'model_001') is True + assert registry.is_resource_allowed(session, 'model', 'model_late') is False + assert registry.is_resource_allowed(session, 'storage', 'workspace') is False + + @pytest.mark.asyncio + async def test_get_nonexistent_session(self): + """Get should return None for nonexistent run_id.""" + registry = AgentRunSessionRegistry() + result = await registry.get('nonexistent_run') + assert result is None + + @pytest.mark.asyncio + async def test_unregister(self): + """Unregister should remove session.""" + registry = AgentRunSessionRegistry() + run_id = 'run_xyz' + + await registry.register( + run_id=run_id, + runner_id='plugin:test/my-runner/default', + query_id=1, + plugin_identity='test/my-runner', + resources=make_resources(), + ) + + # Verify registered + result = await registry.get(run_id) + assert result is not None + + # Unregister + await registry.unregister(run_id) + + # Verify unregistered + result = await registry.get(run_id) + assert result is None + + @pytest.mark.asyncio + async def test_unregister_nonexistent(self): + """Unregister nonexistent session should not raise error.""" + registry = AgentRunSessionRegistry() + # Should not raise + await registry.unregister('nonexistent_run') + + @pytest.mark.asyncio + async def test_update_activity(self): + """Update activity should update last_activity_at.""" + registry = AgentRunSessionRegistry() + run_id = 'run_activity' + + # Create session with manually set old timestamp + now = int(time.time()) + old_session: AgentRunSession = make_session( + run_id=run_id, + runner_id='plugin:test/my-runner/default', + query_id=1, + plugin_identity='test/my-runner', + ) + old_session['status'] = { + 'started_at': now - 100, + 'last_activity_at': now - 100, + } + + async with registry._lock: + registry._sessions[run_id] = old_session + + # Get initial session + session1 = await registry.get(run_id) + initial_time = session1['status']['last_activity_at'] + + # Update activity + await registry.update_activity(run_id) + + # Verify updated - should be significantly different (100 seconds) + session2 = await registry.get(run_id) + assert session2['status']['last_activity_at'] > initial_time + assert session2['status']['last_activity_at'] - initial_time >= 100 + + @pytest.mark.asyncio + async def test_update_activity_nonexistent(self): + """Update activity on nonexistent session should not raise.""" + registry = AgentRunSessionRegistry() + # Should not raise + await registry.update_activity('nonexistent_run') + + @pytest.mark.asyncio + async def test_list_active_runs(self): + """List active runs should return all sessions.""" + registry = AgentRunSessionRegistry() + + await registry.register('run_1', 'plugin:a/b/default', 1, 'a/b', make_resources()) + await registry.register('run_2', 'plugin:c/d/default', 2, 'c/d', make_resources()) + + active_runs = await registry.list_active_runs() + assert len(active_runs) == 2 + run_ids = [r['run_id'] for r in active_runs] + assert 'run_1' in run_ids + assert 'run_2' in run_ids + + @pytest.mark.asyncio + async def test_cleanup_stale_sessions(self): + """Cleanup should remove old sessions.""" + registry = AgentRunSessionRegistry() + + # Create sessions with manually set old timestamp + now = int(time.time()) + old_session: AgentRunSession = make_session( + run_id='old_run', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + ) + old_session['status'] = { + 'started_at': now - 7200, + 'last_activity_at': now - 7200, + } + new_session: AgentRunSession = make_session( + run_id='new_run', + runner_id='plugin:test/runner/default', + query_id=2, + plugin_identity='test/runner', + ) + new_session['status'] = { + 'started_at': now, + 'last_activity_at': now, + } + + async with registry._lock: + registry._sessions['old_run'] = old_session + registry._sessions['new_run'] = new_session + + # Cleanup sessions older than 1 hour + cleaned = await registry.cleanup_stale_sessions(max_age_seconds=3600) + assert cleaned == 1 + + # Verify old session removed, new remains + assert await registry.get('old_run') is None + assert await registry.get('new_run') is not None + + @pytest.mark.asyncio + async def test_pull_steering_all_preserves_queue_order(self): + """Default all-mode steering returns every queued item in FIFO order.""" + registry = AgentRunSessionRegistry() + await registry.register( + run_id='run_steering', + runner_id='plugin:test/my-runner/default', + query_id=1, + plugin_identity='test/my-runner', + resources=make_resources(), + conversation_id='conv_1', + available_apis={'steering_pull': True}, + ) + + await registry.enqueue_steering('run_steering', {'event': {'event_id': 'event_1'}, 'input': {'text': 'first'}}) + await registry.enqueue_steering('run_steering', {'event': {'event_id': 'event_2'}, 'input': {'text': 'second'}}) + await registry.enqueue_steering('run_steering', {'event': {'event_id': 'event_3'}, 'input': {'text': 'third'}}) + + items = await registry.pull_steering('run_steering', mode='all') + assert [item['event']['event_id'] for item in items] == ['event_1', 'event_2', 'event_3'] + assert await registry.pull_steering('run_steering', mode='all') == [] + + @pytest.mark.asyncio + async def test_pull_steering_one_at_a_time_leaves_remaining_items(self): + """one-at-a-time is an explicit runner-side throttling mode.""" + registry = AgentRunSessionRegistry() + await registry.register( + run_id='run_steering_one', + runner_id='plugin:test/my-runner/default', + query_id=1, + plugin_identity='test/my-runner', + resources=make_resources(), + conversation_id='conv_1', + available_apis={'steering_pull': True}, + ) + + await registry.enqueue_steering('run_steering_one', {'event': {'event_id': 'event_1'}}) + await registry.enqueue_steering('run_steering_one', {'event': {'event_id': 'event_2'}}) + + first = await registry.pull_steering('run_steering_one', mode='one-at-a-time') + second = await registry.pull_steering('run_steering_one', mode='one-at-a-time') + + assert [item['event']['event_id'] for item in first] == ['event_1'] + assert [item['event']['event_id'] for item in second] == ['event_2'] + + @pytest.mark.asyncio + async def test_enqueue_steering_rejects_when_queue_is_full(self): + """A full steering queue does not claim more queries.""" + registry = AgentRunSessionRegistry() + await registry.register( + run_id='run_steering_full', + runner_id='plugin:test/my-runner/default', + query_id=1, + plugin_identity='test/my-runner', + resources=make_resources(), + conversation_id='conv_1', + available_apis={'steering_pull': True}, + ) + + for index in range(MAX_STEERING_QUEUE_ITEMS): + assert await registry.enqueue_steering( + 'run_steering_full', + {'event': {'event_id': f'event_{index}'}}, + ) + + assert not await registry.enqueue_steering( + 'run_steering_full', + {'event': {'event_id': 'overflow'}}, + ) + + items = await registry.pull_steering('run_steering_full', mode='all') + assert len(items) == MAX_STEERING_QUEUE_ITEMS + assert all(item['event']['event_id'] != 'overflow' for item in items) + + @pytest.mark.asyncio + async def test_find_steering_target_requires_same_scope(self): + """Steering claims must not cross bot/workspace/thread boundaries.""" + registry = AgentRunSessionRegistry() + await registry.register( + run_id='run_steering_scoped', + runner_id='plugin:test/my-runner/default', + query_id=1, + plugin_identity='test/my-runner', + resources=make_resources(), + conversation_id='conv_1', + bot_id='bot_1', + workspace_id='workspace_1', + thread_id='thread_1', + available_apis={'steering_pull': True}, + ) + + assert await registry.find_steering_target( + conversation_id='conv_1', + runner_id='plugin:test/my-runner/default', + bot_id='bot_1', + workspace_id='workspace_1', + thread_id='thread_1', + ) == 'run_steering_scoped' + assert await registry.find_steering_target( + conversation_id='conv_1', + runner_id='plugin:test/my-runner/default', + bot_id='bot_2', + workspace_id='workspace_1', + thread_id='thread_1', + ) is None + assert await registry.find_steering_target( + conversation_id='conv_1', + runner_id='plugin:test/my-runner/default', + bot_id='bot_1', + workspace_id='workspace_1', + thread_id='thread_2', + ) is None + + @pytest.mark.asyncio + async def test_unregister_returns_pending_steering_queue(self): + """Unregister returns the removed session so callers can audit pending steering.""" + registry = AgentRunSessionRegistry() + await registry.register( + run_id='run_steering_unregister', + runner_id='plugin:test/my-runner/default', + query_id=1, + plugin_identity='test/my-runner', + resources=make_resources(), + conversation_id='conv_1', + available_apis={'steering_pull': True}, + ) + await registry.enqueue_steering( + 'run_steering_unregister', + {'event': {'event_id': 'event_pending'}}, + ) + + session = await registry.unregister('run_steering_unregister') + + assert session is not None + assert session['steering_queue'][0]['event']['event_id'] == 'event_pending' + assert await registry.get('run_steering_unregister') is None + + +class TestIsResourceAllowed: + """Tests for is_resource_allowed validation.""" + + def test_model_allowed(self): + """Model in resources should be allowed.""" + registry = AgentRunSessionRegistry() + resources = make_resources( + models=[ + {'model_id': 'model_001', 'model_type': 'chat', 'provider': 'openai'}, + {'model_id': 'model_002', 'model_type': 'embedding', 'provider': 'anthropic'}, + ] + ) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'model', 'model_001') is True + assert registry.is_resource_allowed(session, 'model', 'model_002') is True + + def test_model_operation_denied(self): + """Model resources should enforce operation-level grants.""" + registry = AgentRunSessionRegistry() + resources = make_resources( + models=[ + {'model_id': 'model_001', 'operations': ['invoke']}, + ] + ) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'model', 'model_001', 'invoke') is True + assert registry.is_resource_allowed(session, 'model', 'model_001', 'stream') is False + + def test_model_not_allowed(self): + """Model not in resources should be denied.""" + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'model', 'model_999') is False + + def test_model_empty_resources(self): + """Empty models list should deny all.""" + registry = AgentRunSessionRegistry() + resources = make_resources(models=[]) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'model', 'model_001') is False + + def test_tool_allowed(self): + """Tool in resources should be allowed.""" + registry = AgentRunSessionRegistry() + resources = make_resources( + tools=[ + {'tool_name': 'web_search', 'tool_type': 'builtin'}, + {'tool_name': 'code_exec', 'tool_type': 'plugin'}, + ] + ) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'tool', 'web_search') is True + assert registry.is_resource_allowed(session, 'tool', 'code_exec') is True + + def test_tool_operation_denied(self): + """Tool resources should enforce detail/call grants.""" + registry = AgentRunSessionRegistry() + resources = make_resources( + tools=[ + {'tool_name': 'web_search', 'operations': ['detail']}, + ] + ) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'tool', 'web_search', 'detail') is True + assert registry.is_resource_allowed(session, 'tool', 'web_search', 'call') is False + + def test_tool_not_allowed(self): + """Tool not in resources should be denied.""" + registry = AgentRunSessionRegistry() + resources = make_resources(tools=[{'tool_name': 'web_search'}]) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'tool', 'image_gen') is False + + def test_tool_empty_resources(self): + """Empty tools list should deny all.""" + registry = AgentRunSessionRegistry() + resources = make_resources(tools=[]) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'tool', 'web_search') is False + + def test_knowledge_base_allowed(self): + """Knowledge base in resources should be allowed.""" + registry = AgentRunSessionRegistry() + resources = make_resources( + knowledge_bases=[ + {'kb_id': 'kb_001', 'kb_name': 'docs', 'kb_type': 'vector'}, + {'kb_id': 'kb_002', 'kb_name': 'faq', 'kb_type': 'keyword'}, + ] + ) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is True + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_002') is True + + def test_knowledge_base_not_allowed(self): + """Knowledge base not in resources should be denied.""" + registry = AgentRunSessionRegistry() + resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}]) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_999') is False + + def test_knowledge_base_empty_resources(self): + """Empty knowledge bases list should deny all.""" + registry = AgentRunSessionRegistry() + resources = make_resources(knowledge_bases=[]) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is False + + def test_skill_allowed(self): + """Skill in resources should be allowed.""" + registry = AgentRunSessionRegistry() + resources = make_resources( + skills=[ + {'skill_name': 'demo', 'display_name': 'Demo'}, + {'skill_name': 'writer', 'display_name': 'Writer'}, + ] + ) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'skill', 'demo') is True + assert registry.is_resource_allowed(session, 'skill', 'writer') is True + assert registry.is_resource_allowed(session, 'skill', 'hidden') is False + + def test_storage_plugin_allowed(self): + """Plugin storage permission should be checked.""" + registry = AgentRunSessionRegistry() + resources = make_resources(storage={'plugin_storage': True, 'workspace_storage': False}) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'storage', 'plugin') is True + assert registry.is_resource_allowed(session, 'storage', 'workspace') is False + + def test_storage_workspace_allowed(self): + """Workspace storage permission should be checked.""" + registry = AgentRunSessionRegistry() + resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': True}) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'storage', 'plugin') is False + assert registry.is_resource_allowed(session, 'storage', 'workspace') is True + + def test_storage_both_denied(self): + """Both storage permissions denied.""" + registry = AgentRunSessionRegistry() + resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': False}) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'storage', 'plugin') is False + assert registry.is_resource_allowed(session, 'storage', 'workspace') is False + + def test_unknown_resource_type(self): + """Unknown resource type should return False.""" + registry = AgentRunSessionRegistry() + session = make_session(resources=make_resources()) + + assert registry.is_resource_allowed(session, 'unknown_type', 'something') is False + + def test_missing_resources_field(self): + """Missing resources field should not raise.""" + registry = AgentRunSessionRegistry() + session = make_session(resources={'models': []}) # Missing other fields + + # Should not raise, should return False + assert registry.is_resource_allowed(session, 'tool', 'web_search') is False + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is False + + +class TestGlobalRegistry: + """Tests for global registry singleton.""" + + def test_get_session_registry_returns_instance(self): + """get_session_registry should return AgentRunSessionRegistry.""" + # Use a separate test that doesn't modify global state + # The singleton pattern works in production, but modifying globals + # in tests can cause UnboundLocalError due to Python scoping + # Instead, just verify the function signature + from langbot.pkg.agent.runner.session_registry import get_session_registry + assert callable(get_session_registry) + + # Create a fresh instance directly to verify the class works + fresh_registry = AgentRunSessionRegistry() + assert isinstance(fresh_registry, AgentRunSessionRegistry) + + def test_global_registry_singleton_behavior(self): + """The global registry should maintain singleton behavior.""" + # Test singleton behavior without modifying global state + # In production, calling get_session_registry() multiple times + # returns the same instance. We verify this by checking the + # module-level variable directly. + from langbot.pkg.agent.runner.session_registry import _global_registry + + # Check that the global variable exists and is either None or an instance + global_reg = _global_registry + if global_reg is None: + # First call creates the instance + registry1 = get_session_registry() + assert isinstance(registry1, AgentRunSessionRegistry) + # Subsequent calls return the same instance + registry2 = get_session_registry() + assert registry1 is registry2 + else: + # Instance already exists, verify singleton + registry1 = get_session_registry() + registry2 = get_session_registry() + assert registry1 is registry2 + assert registry1 is global_reg + + +class TestThreadSafety: + """Tests for asyncio.Lock thread safety.""" + + @pytest.mark.asyncio + async def test_concurrent_register(self): + """Concurrent register should be safe.""" + registry = AgentRunSessionRegistry() + + # Register multiple sessions concurrently + tasks = [] + for i in range(10): + tasks.append( + registry.register( + f'run_{i}', + 'plugin:test/runner/default', + i, + 'test/runner', + make_resources(), + ) + ) + + await asyncio.gather(*tasks) + + # All sessions should be registered + active_runs = await registry.list_active_runs() + assert len(active_runs) == 10 + + @pytest.mark.asyncio + async def test_concurrent_register_and_unregister(self): + """Concurrent register and unregister should be safe.""" + registry = AgentRunSessionRegistry() + + # Register + await registry.register('run_1', 'plugin:test/runner/default', 1, 'test/runner', make_resources()) + + # Concurrent unregister and get + tasks = [ + registry.unregister('run_1'), + registry.get('run_1'), + ] + + await asyncio.gather(*tasks) + + # After both complete, session should be unregistered + result = await registry.get('run_1') + assert result is None diff --git a/tests/unit_tests/agent/test_state_api_auth.py b/tests/unit_tests/agent/test_state_api_auth.py new file mode 100644 index 000000000..5e1300f2c --- /dev/null +++ b/tests/unit_tests/agent/test_state_api_auth.py @@ -0,0 +1,544 @@ +"""Tests for State API handler authorization in RuntimeConnectionHandler. + +Tests focus on: +- STATE_GET authorization +- STATE_SET authorization +- STATE_DELETE authorization +- STATE_LIST authorization + +These tests instantiate real RuntimeConnectionHandler action handlers and verify: +- Authorization errors for missing/mismatched caller_plugin_identity +- Authorization errors for disabled state or scope +- Full flow: set -> get -> list -> delete with real SQLite + +Authorization rules: +- caller_plugin_identity is REQUIRED when session has plugin_identity +- caller_plugin_identity must match session's plugin_identity +- enable_state must be True +- scope must be in state_scopes +""" +from __future__ import annotations + +import pytest +from unittest.mock import MagicMock, patch +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry +from langbot.pkg.agent.runner.persistent_state_store import PersistentStateStore, reset_persistent_state_store +from langbot.pkg.plugin.handler import RuntimeConnectionHandler +from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + +# Import shared test fixtures +from .conftest import make_resources + + +class FakeConnection: + """Fake connection for testing.""" + pass + + +class FakeApplication: + """Fake Application for testing.""" + def __init__(self, db_engine=None): + self.logger = MagicMock() + self.logger.debug = MagicMock() + self.logger.warning = MagicMock() + self.logger.error = MagicMock() + self.persistence_mgr = MagicMock() + self.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + +@pytest.fixture +def session_registry(): + """Create a fresh session registry for each test.""" + return AgentRunSessionRegistry() + + +@pytest.fixture +async def db_engine(): + """Create an in-memory SQLite database for testing.""" + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + yield engine + await engine.dispose() + + +@pytest.fixture +async def persistent_store(db_engine): + """Create a persistent state store with real SQLite.""" + reset_persistent_state_store() + store = PersistentStateStore(db_engine) + + # Create the table + from langbot.pkg.entity.persistence.agent_runner_state import AgentRunnerState + + async with db_engine.begin() as conn: + await conn.run_sync(AgentRunnerState.__table__.create, checkfirst=True) + + yield store + reset_persistent_state_store() + + +class TestStateAPIHandlerAuthorization: + """Tests for State API handler authorization with real action calls.""" + + @pytest.mark.asyncio + async def test_state_get_missing_run_id_returns_error(self, session_registry, db_engine, persistent_store): + """STATE_GET: missing run_id returns error.""" + fake_app = FakeApplication(db_engine) + fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + async def fake_disconnect(): + return True + + with patch('langbot.pkg.plugin.handler.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) + state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value] + + # Call without run_id + result = await state_get_handler({'scope': 'conversation', 'key': 'test_key'}) + + assert result.code != 0 + assert 'run_id is required' in result.message + + @pytest.mark.asyncio + async def test_state_get_run_not_found_returns_error(self, session_registry, db_engine, persistent_store): + """STATE_GET: run_id not in session registry returns error.""" + fake_app = FakeApplication(db_engine) + fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + async def fake_disconnect(): + return True + + with patch('langbot.pkg.plugin.handler.get_session_registry', return_value=session_registry): + handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value] + + # Call with non-existent run_id + result = await state_get_handler({ + 'run_id': 'nonexistent_run', + 'scope': 'conversation', + 'key': 'test_key', + }) + + assert result.code != 0 + assert 'not found' in result.message.lower() + + @pytest.mark.asyncio + async def test_state_get_missing_caller_plugin_identity_returns_error(self, session_registry, db_engine, persistent_store): + """STATE_GET: missing caller_plugin_identity when session has plugin_identity returns error.""" + fake_app = FakeApplication(db_engine) + fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + # Register session with plugin_identity + await session_registry.register( + run_id='run_test_missing_identity', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=make_resources(), + available_apis={'state': True}, + state_policy={'enable_state': True, 'state_scopes': ['conversation']}, + state_context={'scope_keys': {'conversation': 'conv_key'}, 'binding_identity': 'binding_1'}, + ) + + async def fake_disconnect(): + return True + + with patch('langbot.pkg.plugin.handler.get_session_registry', return_value=session_registry): + handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value] + + # Call without caller_plugin_identity + result = await state_get_handler({ + 'run_id': 'run_test_missing_identity', + 'scope': 'conversation', + 'key': 'test_key', + }) + + assert result.code != 0 + assert 'caller_plugin_identity is required' in result.message + + await session_registry.unregister('run_test_missing_identity') + + @pytest.mark.asyncio + async def test_state_get_caller_identity_mismatch_returns_error(self, session_registry, db_engine, persistent_store): + """STATE_GET: caller_plugin_identity mismatch returns error.""" + fake_app = FakeApplication(db_engine) + fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + await session_registry.register( + run_id='run_test_mismatch', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=make_resources(), + available_apis={'state': True}, + state_policy={'enable_state': True, 'state_scopes': ['conversation']}, + state_context={'scope_keys': {'conversation': 'conv_key'}, 'binding_identity': 'binding_1'}, + ) + + async def fake_disconnect(): + return True + + with patch('langbot.pkg.plugin.handler.get_session_registry', return_value=session_registry): + handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value] + + # Call with wrong caller_plugin_identity + result = await state_get_handler({ + 'run_id': 'run_test_mismatch', + 'scope': 'conversation', + 'key': 'test_key', + 'caller_plugin_identity': 'other/plugin', + }) + + assert result.code != 0 + assert 'mismatch' in result.message.lower() + + await session_registry.unregister('run_test_mismatch') + + @pytest.mark.asyncio + async def test_state_get_enable_state_false_returns_error(self, session_registry, db_engine, persistent_store): + """STATE_GET: enable_state=False returns error.""" + fake_app = FakeApplication(db_engine) + fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + await session_registry.register( + run_id='run_test_disabled', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=make_resources(), + available_apis={'state': True}, + state_policy={'enable_state': False, 'state_scopes': []}, + state_context={'scope_keys': {}, 'binding_identity': 'binding_1'}, + ) + + async def fake_disconnect(): + return True + + with patch('langbot.pkg.plugin.handler.get_session_registry', return_value=session_registry): + handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value] + + result = await state_get_handler({ + 'run_id': 'run_test_disabled', + 'scope': 'conversation', + 'key': 'test_key', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code != 0 + assert 'disabled' in result.message.lower() + + await session_registry.unregister('run_test_disabled') + + @pytest.mark.asyncio + async def test_state_get_scope_not_enabled_returns_error(self, session_registry, db_engine, persistent_store): + """STATE_GET: scope not in state_scopes returns error.""" + fake_app = FakeApplication(db_engine) + fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + await session_registry.register( + run_id='run_test_scope_disabled', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=make_resources(), + available_apis={'state': True}, + state_policy={'enable_state': True, 'state_scopes': ['conversation']}, + state_context={'scope_keys': {'conversation': 'conv_key', 'actor': 'actor_key'}, 'binding_identity': 'binding_1'}, + ) + + async def fake_disconnect(): + return True + + with patch('langbot.pkg.plugin.handler.get_session_registry', return_value=session_registry): + handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value] + + # Request 'actor' scope which is not in state_scopes + result = await state_get_handler({ + 'run_id': 'run_test_scope_disabled', + 'scope': 'actor', + 'key': 'test_key', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code != 0 + assert 'not enabled' in result.message.lower() or 'scope' in result.message.lower() + + await session_registry.unregister('run_test_scope_disabled') + + @pytest.mark.asyncio + async def test_state_get_missing_scope_key_returns_error(self, session_registry, db_engine, persistent_store): + """STATE_GET: missing scope_key in state_context returns error.""" + fake_app = FakeApplication(db_engine) + fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + await session_registry.register( + run_id='run_test_no_scope_key', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=make_resources(), + available_apis={'state': True}, + state_policy={'enable_state': True, 'state_scopes': ['conversation']}, + state_context={'scope_keys': {}, 'binding_identity': 'binding_1'}, # No scope_keys + ) + + async def fake_disconnect(): + return True + + with patch('langbot.pkg.plugin.handler.get_session_registry', return_value=session_registry): + handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value] + + result = await state_get_handler({ + 'run_id': 'run_test_no_scope_key', + 'scope': 'conversation', + 'key': 'test_key', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code != 0 + assert 'not available' in result.message.lower() + + await session_registry.unregister('run_test_no_scope_key') + + +class TestStateAPIFullFlowWithRealDB: + """Tests for complete State API flow with real SQLite database.""" + + @pytest.mark.asyncio + async def test_state_set_get_list_delete_flow(self, session_registry, db_engine, persistent_store): + """Test complete state flow: set -> get -> list -> delete with real SQLite.""" + fake_app = FakeApplication(db_engine) + fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + # Register session + await session_registry.register( + run_id='run_full_flow', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=make_resources(), + available_apis={'state': True}, + state_policy={'enable_state': True, 'state_scopes': ['conversation', 'runner']}, + state_context={ + 'scope_keys': { + 'conversation': 'conv:test_runner:binding_1:conv_123', + 'runner': 'runner:test_runner:binding_1', + }, + 'binding_identity': 'binding_1', + 'conversation_id': 'conv_123', + }, + ) + + async def fake_disconnect(): + return True + + with patch('langbot.pkg.plugin.handler.get_session_registry', return_value=session_registry): + handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + + # Verify session has correct state_context + session = await session_registry.get('run_full_flow') + assert session is not None + state_ctx = session['authorization']['state_context'] + assert state_ctx is not None, f"state_context is None. Session keys: {list(session.keys())}" + assert 'scope_keys' in state_ctx, f"scope_keys not in state_context: {state_ctx}" + assert 'conversation' in state_ctx['scope_keys'], f"conversation not in scope_keys: {state_ctx['scope_keys']}" + + # Get handlers (actions dict is keyed by action value string) + state_set_handler = handler.actions[PluginToRuntimeAction.STATE_SET.value] + state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value] + state_list_handler = handler.actions[PluginToRuntimeAction.STATE_LIST.value] + state_delete_handler = handler.actions[PluginToRuntimeAction.STATE_DELETE.value] + + # 1. STATE_SET + set_result = await state_set_handler({ + 'run_id': 'run_full_flow', + 'scope': 'conversation', + 'key': 'external.test_key', + 'value': {'data': 'test_value'}, + 'caller_plugin_identity': 'test/runner', + }) + + assert set_result.code == 0 + assert set_result.data.get('success') is True + + # 2. STATE_GET + get_result = await state_get_handler({ + 'run_id': 'run_full_flow', + 'scope': 'conversation', + 'key': 'external.test_key', + 'caller_plugin_identity': 'test/runner', + }) + + assert get_result.code == 0 + assert get_result.data.get('value') == {'data': 'test_value'} + + # 3. STATE_LIST + list_result = await state_list_handler({ + 'run_id': 'run_full_flow', + 'scope': 'conversation', + 'prefix': 'external.', + 'caller_plugin_identity': 'test/runner', + }) + + assert list_result.code == 0 + keys = list_result.data.get('keys', []) + assert 'external.test_key' in keys + + # 4. STATE_DELETE + delete_result = await state_delete_handler({ + 'run_id': 'run_full_flow', + 'scope': 'conversation', + 'key': 'external.test_key', + 'caller_plugin_identity': 'test/runner', + }) + + assert delete_result.code == 0 + + # 5. Verify deleted + get_after_delete = await state_get_handler({ + 'run_id': 'run_full_flow', + 'scope': 'conversation', + 'key': 'external.test_key', + 'caller_plugin_identity': 'test/runner', + }) + + assert get_after_delete.code == 0 + assert get_after_delete.data.get('value') is None + + await session_registry.unregister('run_full_flow') + + +class TestStateHandlerReadsFromAuthorizationSnapshot: + """Tests verifying handlers read state_policy/state_context from authorization snapshot.""" + + @pytest.mark.asyncio + async def test_state_handler_reads_state_policy_from_authorization(self, session_registry, db_engine, persistent_store): + """Handler reads state_policy from session['authorization'], not resources.""" + fake_app = FakeApplication(db_engine) + fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + # Register with explicit state_policy in the authorization snapshot + await session_registry.register( + run_id='run_policy_top_level', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=make_resources(), + available_apis={'state': True}, + state_policy={'enable_state': False, 'state_scopes': []}, + state_context={'scope_keys': {}, 'binding_identity': 'binding_1'}, + ) + + # Verify resources does NOT contain state_policy + session = await session_registry.get('run_policy_top_level') + assert session is not None + resources = session['authorization']['resources'] + assert 'state_policy' not in resources, "resources should NOT contain state_policy" + + async def fake_disconnect(): + return True + + with patch('langbot.pkg.plugin.handler.get_session_registry', return_value=session_registry): + handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value] + + # Should fail because enable_state=False in authorization.state_policy + result = await state_get_handler({ + 'run_id': 'run_policy_top_level', + 'scope': 'conversation', + 'key': 'test_key', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code != 0 + assert 'disabled' in result.message.lower() + + await session_registry.unregister('run_policy_top_level') + + @pytest.mark.asyncio + async def test_state_handler_reads_state_context_from_authorization(self, session_registry, db_engine, persistent_store): + """Handler reads state_context from session['authorization'], not resources.""" + fake_app = FakeApplication(db_engine) + fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + # Register with explicit state_context in the authorization snapshot + await session_registry.register( + run_id='run_context_top_level', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=make_resources(), + available_apis={'state': True}, + state_policy={'enable_state': True, 'state_scopes': ['conversation']}, + state_context={'scope_keys': {'conversation': 'conv_key_xyz'}, 'binding_identity': 'binding_xyz'}, + ) + + # Verify resources does NOT contain state_context + session = await session_registry.get('run_context_top_level') + assert session is not None + resources = session['authorization']['resources'] + assert 'state_context' not in resources, "resources should NOT contain state_context" + + async def fake_disconnect(): + return True + + with patch('langbot.pkg.plugin.handler.get_session_registry', return_value=session_registry): + handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + state_set_handler = handler.actions[PluginToRuntimeAction.STATE_SET.value] + + # Should use scope_key from authorization.state_context.scope_keys.conversation + result = await state_set_handler({ + 'run_id': 'run_context_top_level', + 'scope': 'conversation', + 'key': 'test_key', + 'value': 'test_value', + 'caller_plugin_identity': 'test/runner', + }) + + # Should succeed - scope_key was found in state_context + assert result.code == 0 + + await session_registry.unregister('run_context_top_level') + + +class TestResourcesDoesNotContainStateMetadata: + """Tests verifying resources is clean - no state metadata mixed in.""" + + @pytest.mark.asyncio + async def test_resources_clean_after_register(self, session_registry): + """After register(), only authorization contains resources and state metadata.""" + resources = make_resources() + + await session_registry.register( + run_id='run_resources_clean', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + state_policy={'enable_state': True, 'state_scopes': ['conversation']}, + state_context={'scope_keys': {'conversation': 'conv_key'}, 'binding_identity': 'binding_1'}, + ) + + session = await session_registry.get('run_resources_clean') + assert session is not None + + # Verify resources is nested under authorization and is clean. + assert 'resources' not in session + session_resources = session['authorization']['resources'] + assert 'state_policy' not in session_resources, \ + "authorization['resources'] should NOT contain state_policy" + assert 'state_context' not in session_resources, \ + "authorization['resources'] should NOT contain state_context" + + assert 'state_policy' in session['authorization'] + assert 'state_context' in session['authorization'] + + await session_registry.unregister('run_resources_clean') diff --git a/tests/unit_tests/agent/test_state_store.py b/tests/unit_tests/agent/test_state_store.py new file mode 100644 index 000000000..668f40212 --- /dev/null +++ b/tests/unit_tests/agent/test_state_store.py @@ -0,0 +1,383 @@ +"""Tests for persistent AgentRunner state store.""" +from __future__ import annotations + +import asyncio +import os +import tempfile + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor +from langbot.pkg.agent.runner.host_models import BindingScope, StatePolicy +from langbot.pkg.agent.runner.persistent_state_store import PersistentStateStore +from langbot.pkg.agent.runner.state_scope import ( + STATE_KEY_ALIASES, + VALID_STATE_SCOPES, + build_state_context, + build_state_scope_key, + get_binding_identity, + normalize_state_key, +) + + +def make_descriptor(runner_id: str = 'plugin:test/my-runner/default') -> AgentRunnerDescriptor: + """Create a test descriptor.""" + return AgentRunnerDescriptor( + id=runner_id, + source='plugin', + label={'en_US': 'Test Runner'}, + plugin_author='test', + plugin_name='my-runner', + runner_name='default', + capabilities={'streaming': True}, + ) + + +class FakeActorContext: + """Fake actor context for event testing.""" + def __init__(self, actor_type: str = 'user', actor_id: str = 'user_123', actor_name: str = 'Test User'): + self.actor_type = actor_type + self.actor_id = actor_id + self.actor_name = actor_name + + +class FakeSubjectContext: + """Fake subject context for event testing.""" + def __init__(self, subject_type: str = 'message', subject_id: str = 'msg_001', data: dict | None = None): + self.subject_type = subject_type + self.subject_id = subject_id + self.data = data or {} + + +class FakeEventEnvelope: + """Fake event envelope for testing event-first state.""" + def __init__( + self, + event_id: str = 'evt_001', + event_type: str = 'message.received', + conversation_id: str | None = 'conv_001', + actor: FakeActorContext | None = None, + subject: FakeSubjectContext | None = None, + bot_id: str = 'bot_001', + workspace_id: str = 'ws_001', + thread_id: str | None = None, + ): + self.event_id = event_id + self.event_type = event_type + self.event_time = 1700000000 + self.source = 'platform' + self.bot_id = bot_id + self.workspace_id = workspace_id + self.conversation_id = conversation_id + self.thread_id = thread_id + self.actor = actor or FakeActorContext() + self.subject = subject + self.raw_ref = None + + +class FakeBinding: + """Fake binding for testing state.""" + def __init__( + self, + binding_id: str = 'binding_001', + state_policy: StatePolicy | None = None, + scope_type: str = 'agent', + scope_id: str = 'agent_001', + ): + self.binding_id = binding_id + self.scope = BindingScope(scope_type=scope_type, scope_id=scope_id) + self.state_policy = state_policy or StatePolicy() + + +class TestStateScopeHelpers: + """Tests for shared state scope helpers.""" + + def test_valid_state_scopes(self): + assert VALID_STATE_SCOPES == ('conversation', 'actor', 'subject', 'runner') + + def test_state_key_aliases(self): + assert STATE_KEY_ALIASES == {'conversation_id': 'external.conversation_id'} + assert normalize_state_key('conversation_id') == 'external.conversation_id' + assert normalize_state_key('external.session_id') == 'external.session_id' + + def test_binding_identity_uses_binding_id_first(self): + binding = FakeBinding(binding_id='binding_a') + assert get_binding_identity(binding) == 'binding_a' + + def test_binding_identity_falls_back_to_scope(self): + binding = FakeBinding(binding_id='', scope_type='workspace', scope_id='ws_001') + assert get_binding_identity(binding) == 'workspace:ws_001' + + def test_scope_key_building(self): + descriptor = make_descriptor() + binding = FakeBinding(binding_id='binding_a') + event = FakeEventEnvelope( + conversation_id='conv_001', + actor=FakeActorContext(actor_id='user_001'), + subject=FakeSubjectContext(subject_id='msg_001'), + thread_id='thread_001', + ) + + keys = { + scope: build_state_scope_key(scope, event, binding, descriptor) + for scope in VALID_STATE_SCOPES + } + + assert keys['conversation'].startswith('conversation:v2:') + assert keys['actor'].startswith('actor:v2:') + assert keys['subject'].startswith('subject:v2:') + assert keys['runner'].startswith('runner:v2:') + assert len(set(keys.values())) == len(keys) + + def test_scope_key_missing_identity_returns_none(self): + descriptor = make_descriptor() + binding = FakeBinding() + event = FakeEventEnvelope(conversation_id=None, actor=None, subject=None) + + assert build_state_scope_key('conversation', event, binding, descriptor) is None + assert build_state_scope_key('subject', event, binding, descriptor) is None + assert build_state_scope_key('runner', event, binding, descriptor) is not None + + def test_build_state_context(self): + descriptor = make_descriptor() + binding = FakeBinding(binding_id='binding_a') + event = FakeEventEnvelope( + conversation_id='conv_001', + actor=FakeActorContext(actor_id='user_001'), + subject=FakeSubjectContext(subject_id='msg_001'), + ) + + context = build_state_context(event, binding, descriptor) + + assert context['binding_identity'] == 'binding_a' + assert context['conversation_id'] == 'conv_001' + assert context['actor_id'] == 'user_001' + assert set(context['scope_keys']) == {'conversation', 'actor', 'subject', 'runner'} + + +class TestPersistentStateStore: + """Tests for persistent database-backed state store.""" + + @pytest.fixture + async def db_engine(self): + """Create a temporary async SQLite database for testing.""" + with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: + db_path = f.name + + engine = create_async_engine(f'sqlite+aiosqlite:///{db_path}', echo=False) + + from langbot.pkg.entity.persistence.base import Base + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + yield engine + + await engine.dispose() + os.unlink(db_path) + + @pytest.fixture + async def persistent_store(self, db_engine): + """Create a persistent state store for testing.""" + store = PersistentStateStore(db_engine) + yield store + await store.clear_all() + + @pytest.mark.asyncio + async def test_build_snapshot_empty(self, persistent_store): + descriptor = make_descriptor() + event = FakeEventEnvelope(conversation_id='conv_001') + binding = FakeBinding() + + snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor) + + assert snapshot['conversation'] == {'external.conversation_id': 'conv_001'} + assert snapshot['actor'] == {} + assert snapshot['subject'] == {} + assert snapshot['runner'] == {} + + @pytest.mark.asyncio + async def test_state_set_and_get(self, persistent_store): + descriptor = make_descriptor() + event = FakeEventEnvelope(conversation_id='conv_001') + binding = FakeBinding() + + success, error = await persistent_store.apply_update_from_event( + event, binding, descriptor, 'conversation', 'test_key', {'nested': 'value'}, None + ) + assert success is True + assert error is None + + snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor) + assert snapshot['conversation']['test_key'] == {'nested': 'value'} + + @pytest.mark.asyncio + async def test_concurrent_first_state_set_uses_upsert(self, persistent_store): + scope_key = 'conversation:runner:binding:conv_concurrent' + + async def set_value(value: int): + return await persistent_store.state_set( + scope_key=scope_key, + state_key='external.concurrent', + value={'value': value}, + runner_id='plugin:test/my-runner/default', + binding_identity='binding_001', + scope='conversation', + ) + + results = await asyncio.gather(*(set_value(value) for value in range(8))) + + assert all(success is True and error is None for success, error in results) + stored = await persistent_store.state_get(scope_key, 'external.concurrent') + assert stored in [{'value': value} for value in range(8)] + + @pytest.mark.asyncio + async def test_state_api_methods_normalize_public_key_aliases(self, persistent_store): + scope_key = 'conversation:runner:binding:conv_001' + + success, error = await persistent_store.state_set( + scope_key=scope_key, + state_key='conversation_id', + value='conv_001', + runner_id='plugin:test/my-runner/default', + binding_identity='binding_001', + scope='conversation', + ) + + assert success is True + assert error is None + assert await persistent_store.state_get(scope_key, 'external.conversation_id') == 'conv_001' + assert await persistent_store.state_get(scope_key, 'conversation_id') == 'conv_001' + + keys, _ = await persistent_store.state_list(scope_key, prefix='conversation_id') + assert keys == ['external.conversation_id'] + + assert await persistent_store.state_delete(scope_key, 'conversation_id') is True + assert await persistent_store.state_get(scope_key, 'external.conversation_id') is None + + @pytest.mark.asyncio + async def test_binding_isolation(self, persistent_store): + descriptor = make_descriptor() + event = FakeEventEnvelope(conversation_id='conv_001') + binding_a = FakeBinding(binding_id='binding_a') + binding_b = FakeBinding(binding_id='binding_b') + + await persistent_store.apply_update_from_event( + event, binding_a, descriptor, 'conversation', 'key', 'value_a', None + ) + + snapshot_b = await persistent_store.build_snapshot_from_event(event, binding_b, descriptor) + assert snapshot_b['conversation'] == {'external.conversation_id': 'conv_001'} + + snapshot_a = await persistent_store.build_snapshot_from_event(event, binding_a, descriptor) + assert snapshot_a['conversation']['key'] == 'value_a' + + @pytest.mark.asyncio + async def test_policy_disable_state(self, persistent_store): + descriptor = make_descriptor() + event = FakeEventEnvelope(conversation_id='conv_001') + binding = FakeBinding(state_policy=StatePolicy(enable_state=False)) + + snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor) + assert snapshot == {'conversation': {}, 'actor': {}, 'subject': {}, 'runner': {}} + + success, error = await persistent_store.apply_update_from_event( + event, binding, descriptor, 'conversation', 'key', 'value', None + ) + assert success is False + assert 'disabled' in error.lower() + + @pytest.mark.asyncio + async def test_policy_scope_restriction(self, persistent_store): + descriptor = make_descriptor() + event = FakeEventEnvelope( + conversation_id='conv_001', + actor=FakeActorContext(actor_id='user_001'), + ) + binding = FakeBinding(state_policy=StatePolicy(state_scopes=['conversation'])) + + success_conv, _ = await persistent_store.apply_update_from_event( + event, binding, descriptor, 'conversation', 'key', 'value_conv', None + ) + assert success_conv is True + + success_actor, error_actor = await persistent_store.apply_update_from_event( + event, binding, descriptor, 'actor', 'key', 'value_actor', None + ) + assert success_actor is False + assert 'not enabled' in error_actor.lower() + + @pytest.mark.asyncio + async def test_value_json_size_limit(self, persistent_store): + descriptor = make_descriptor() + event = FakeEventEnvelope(conversation_id='conv_001') + binding = FakeBinding() + + large_value = 'x' * (300 * 1024) + + success, error = await persistent_store.apply_update_from_event( + event, binding, descriptor, 'conversation', 'key', large_value, None + ) + assert success is False + assert 'exceeds limit' in error.lower() + + @pytest.mark.asyncio + async def test_value_not_json_serializable(self, persistent_store): + descriptor = make_descriptor() + event = FakeEventEnvelope(conversation_id='conv_001') + binding = FakeBinding() + + success, error = await persistent_store.apply_update_from_event( + event, binding, descriptor, 'conversation', 'key', {'key': {1, 2, 3}}, None + ) + assert success is False + assert 'json' in error.lower() + + @pytest.mark.asyncio + async def test_state_list(self, persistent_store): + descriptor = make_descriptor() + event = FakeEventEnvelope(conversation_id='conv_001') + binding = FakeBinding() + + await persistent_store.apply_update_from_event( + event, binding, descriptor, 'conversation', 'external.id', '123', None + ) + await persistent_store.apply_update_from_event( + event, binding, descriptor, 'conversation', 'external.name', 'test', None + ) + await persistent_store.apply_update_from_event( + event, binding, descriptor, 'conversation', 'memory.key', 'value', None + ) + + scope_key = build_state_scope_key('conversation', event, binding, descriptor) + + keys, has_more = await persistent_store.state_list(scope_key) + assert len(keys) == 3 + assert has_more is False + + keys_ext, _ = await persistent_store.state_list(scope_key, prefix='external.') + assert len(keys_ext) == 2 + assert 'external.id' in keys_ext + assert 'external.name' in keys_ext + + @pytest.mark.asyncio + async def test_state_delete(self, persistent_store): + descriptor = make_descriptor() + event = FakeEventEnvelope(conversation_id='conv_001') + binding = FakeBinding() + + await persistent_store.apply_update_from_event( + event, binding, descriptor, 'conversation', 'key', 'value', None + ) + snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor) + assert snapshot['conversation']['key'] == 'value' + + scope_key = build_state_scope_key('conversation', event, binding, descriptor) + deleted = await persistent_store.state_delete(scope_key, 'key') + assert deleted is True + + snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor) + assert 'key' not in snapshot['conversation'] + + deleted_again = await persistent_store.state_delete(scope_key, 'key') + assert deleted_again is False diff --git a/tests/unit_tests/api/service/test_model_service.py b/tests/unit_tests/api/service/test_model_service.py index 42129ed3b..e22248a52 100644 --- a/tests/unit_tests/api/service/test_model_service.py +++ b/tests/unit_tests/api/service/test_model_service.py @@ -13,10 +13,13 @@ Source: src/langbot/pkg/api/http/service/model.py from __future__ import annotations -import pytest -from unittest.mock import AsyncMock, Mock from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock +import pytest + +from langbot.pkg.agent.runner.default_config import AgentRunnerDefaultConfigService +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor from langbot.pkg.api.http.service.model import ( LLMModelsService, EmbeddingModelsService, @@ -29,6 +32,7 @@ from langbot.pkg.entity.persistence.model import LLMModel, EmbeddingModel, Reran pytestmark = pytest.mark.asyncio +RUNNER_ID = 'plugin:test/runner/default' def _create_mock_llm_model( @@ -101,6 +105,22 @@ def _create_mock_result(items: list = None, first_item=None): return result +class FakeAgentRunnerRegistry: + async def get(self, runner_id, bound_plugins=None): + return AgentRunnerDescriptor( + id=runner_id, + source='plugin', + label={'en_US': 'Test Runner'}, + plugin_author='test', + plugin_name='runner', + runner_name='default', + config_schema=[ + {'name': 'model', 'type': 'model-fallback-selector', 'default': {'primary': '', 'fallbacks': []}}, + ], + permissions={'models': ['invoke']}, + ) + + class TestParseProviderApiKeys: """Tests for _parse_provider_api_keys helper function.""" @@ -451,6 +471,52 @@ class TestLLMModelsServiceCreateLLMModel: assert runtime_entity.extra_args == {'temperature': 0.2} assert 'context_length' not in runtime_entity.extra_args + async def test_create_llm_model_auto_sets_schema_defined_default_pipeline_model(self): + """Auto-default model selection should use runner schema, not legacy field names.""" + ap = SimpleNamespace() + ap.logger = Mock() + ap.persistence_mgr = SimpleNamespace() + ap.model_mgr = SimpleNamespace() + ap.model_mgr.provider_dict = {'provider-uuid': Mock()} + ap.model_mgr.llm_models = [] + ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock()) + ap.pipeline_service = SimpleNamespace(update_pipeline=AsyncMock()) + ap.agent_runner_registry = FakeAgentRunnerRegistry() + ap.agent_runner_default_config_service = AgentRunnerDefaultConfigService(ap) + + pipeline = SimpleNamespace( + uuid='pipeline-uuid', + config={ + 'ai': { + 'runner': {'id': RUNNER_ID}, + 'runner_config': { + RUNNER_ID: { + 'model': {'primary': '', 'fallbacks': []}, + }, + }, + }, + }, + ) + ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=pipeline)) + + service = LLMModelsService(ap) + + model_uuid = await service.create_llm_model({ + 'uuid': 'new-model-uuid', + 'name': 'New LLM', + 'provider_uuid': 'provider-uuid', + 'abilities': [], + 'extra_args': {}, + }, preserve_uuid=True) + + assert model_uuid == 'new-model-uuid' + ap.pipeline_service.update_pipeline.assert_awaited_once() + updated_config = ap.pipeline_service.update_pipeline.await_args.args[1]['config'] + assert updated_config['ai']['runner_config'][RUNNER_ID]['model'] == { + 'primary': 'new-model-uuid', + 'fallbacks': [], + } + async def test_create_llm_model_provider_not_found_raises_error(self): """Raises Exception when provider not found in runtime.""" # Setup diff --git a/tests/unit_tests/api/test_pipeline_service_defaults.py b/tests/unit_tests/api/test_pipeline_service_defaults.py new file mode 100644 index 000000000..415a4a3d5 --- /dev/null +++ b/tests/unit_tests/api/test_pipeline_service_defaults.py @@ -0,0 +1,77 @@ +"""Tests for dynamic default pipeline config rendering.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor +from langbot.pkg.api.http.service.pipeline import PipelineService + + +class FakeLogger: + def warning(self, msg): + pass + + +class FakeRegistry: + def __init__(self, runners): + self.runners = runners + + async def list_runners(self, bound_plugins=None): + return self.runners + + +def make_runner(runner_id: str, config_schema: list[dict]): + parts = runner_id.removeprefix('plugin:').split('/') + return AgentRunnerDescriptor( + id=runner_id, + source='plugin', + label={'en_US': runner_id}, + plugin_author=parts[0], + plugin_name=parts[1], + runner_name=parts[2], + config_schema=config_schema, + ) + + +@pytest.mark.asyncio +async def test_default_pipeline_config_uses_first_installed_runner_schema(): + local_agent = make_runner( + 'plugin:langbot/local-agent/default', + [ + {'name': 'model', 'type': 'model-fallback-selector', 'default': {'primary': '', 'fallbacks': []}}, + {'name': 'prompt', 'type': 'prompt-editor', 'default': [{'role': 'system', 'content': 'Hello'}]}, + ], + ) + custom_agent = make_runner( + 'plugin:alice/custom-agent/default', + [{'name': 'api-key', 'type': 'string', 'default': ''}], + ) + ap = SimpleNamespace( + logger=FakeLogger(), + agent_runner_registry=FakeRegistry([custom_agent, local_agent]), + ) + + config = await PipelineService(ap).get_default_pipeline_config() + + assert config['ai']['runner']['id'] == 'plugin:alice/custom-agent/default' + assert config['ai']['runner_config'] == { + 'plugin:alice/custom-agent/default': { + 'api-key': '', + }, + } + + +@pytest.mark.asyncio +async def test_default_pipeline_config_stays_neutral_without_installed_runners(): + ap = SimpleNamespace( + logger=FakeLogger(), + agent_runner_registry=FakeRegistry([]), + ) + + config = await PipelineService(ap).get_default_pipeline_config() + + assert config['ai']['runner']['id'] == '' + assert config['ai']['runner_config'] == {} diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py index acf53264f..99c74cc85 100644 --- a/tests/unit_tests/box/test_box_service.py +++ b/tests/unit_tests/box/test_box_service.py @@ -181,6 +181,23 @@ def make_app( ) +def test_resolve_box_session_id_reads_current_runner_config(): + query = make_query(101) + query.pipeline_config = { + 'ai': { + 'runner': {'id': 'plugin:langbot/local-agent/default'}, + 'runner_config': { + 'plugin:langbot/local-agent/default': { + 'box-session-id-template': 'bot-{launcher_id}-{sender_id}', + }, + }, + }, + } + service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) + + assert service.resolve_box_session_id(query) == 'bot-test_user-test_user' + + @pytest.mark.asyncio async def test_box_service_without_explicit_client_initializes_internal_connector(monkeypatch: pytest.MonkeyPatch): connector = Mock() diff --git a/tests/unit_tests/pipeline/conftest.py b/tests/unit_tests/pipeline/conftest.py index ce8ee7eb0..80e70dcd3 100644 --- a/tests/unit_tests/pipeline/conftest.py +++ b/tests/unit_tests/pipeline/conftest.py @@ -27,6 +27,9 @@ import langbot_plugin.api.entities.builtin.provider.session as provider_session from langbot.pkg.pipeline import entities as pipeline_entities +DEFAULT_RUNNER_ID = 'plugin:langbot/local-agent/default' + + class MockApplication: """Mock Application object providing all basic dependencies needed by stages""" @@ -202,8 +205,13 @@ def sample_query(sample_message_chain, sample_message_event, mock_adapter): bot_uuid='test-bot-uuid', pipeline_config={ 'ai': { - 'runner': {'runner': 'local-agent'}, - 'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'}, + 'runner': {'id': DEFAULT_RUNNER_ID}, + 'runner_config': { + DEFAULT_RUNNER_ID: { + 'model': {'primary': 'test-model-uuid', 'fallbacks': []}, + 'prompt': [{'role': 'system', 'content': 'test-prompt'}], + }, + }, }, 'output': {'misc': {'at-sender': False, 'quote-origin': False}}, 'trigger': {'misc': {'combine-quote-message': False}}, @@ -227,8 +235,13 @@ def sample_pipeline_config(): """Provides sample pipeline configuration""" return { 'ai': { - 'runner': {'runner': 'local-agent'}, - 'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'}, + 'runner': {'id': DEFAULT_RUNNER_ID}, + 'runner_config': { + DEFAULT_RUNNER_ID: { + 'model': {'primary': 'test-model-uuid', 'fallbacks': []}, + 'prompt': [{'role': 'system', 'content': 'test-prompt'}], + }, + }, }, 'output': {'misc': {'at-sender': False, 'quote-origin': False}}, 'trigger': {'misc': {'combine-quote-message': False}}, diff --git a/tests/unit_tests/pipeline/test_controller.py b/tests/unit_tests/pipeline/test_controller.py new file mode 100644 index 000000000..8c5fc6d85 --- /dev/null +++ b/tests/unit_tests/pipeline/test_controller.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from langbot.pkg.agent.runner.errors import RunnerNotFoundError +from langbot.pkg.pipeline.controller import Controller + + +def make_app(): + app = SimpleNamespace() + app.instance_config = SimpleNamespace(data={'concurrency': {'pipeline': 10}}) + app.logger = MagicMock() + app.pipeline_mgr = SimpleNamespace() + app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock() + app.sess_mgr = SimpleNamespace() + app.sess_mgr.get_session = AsyncMock(return_value=SimpleNamespace()) + app.agent_run_orchestrator = SimpleNamespace() + app.agent_run_orchestrator.try_claim_steering_from_query = AsyncMock() + return app + + +def make_pipeline(): + return SimpleNamespace( + pipeline_entity=SimpleNamespace(config={'ai': {'runner': {'id': 'plugin:test/runner/default'}}}), + bound_plugins=['test/runner'], + bound_mcp_servers=[], + ) + + +@pytest.mark.asyncio +async def test_try_claim_steering_returns_false_when_runner_lookup_fails(): + app = make_app() + app.pipeline_mgr.get_pipeline_by_uuid.return_value = make_pipeline() + app.agent_run_orchestrator.try_claim_steering_from_query.side_effect = RunnerNotFoundError( + 'plugin:missing/runner/default' + ) + controller = Controller(app) + query = SimpleNamespace(query_id=1, pipeline_uuid='pipeline-001', variables={}) + + claimed = await controller._try_claim_steering_before_session_slot(query) + + assert claimed is False + app.logger.warning.assert_called_once() + + +@pytest.mark.asyncio +async def test_try_claim_steering_sets_pipeline_context_before_claiming(): + app = make_app() + pipeline = make_pipeline() + app.pipeline_mgr.get_pipeline_by_uuid.return_value = pipeline + app.agent_run_orchestrator.try_claim_steering_from_query.return_value = True + controller = Controller(app) + query = SimpleNamespace(query_id=2, pipeline_uuid='pipeline-002', variables={}) + + claimed = await controller._try_claim_steering_before_session_slot(query) + + assert claimed is True + assert query.pipeline_config is pipeline.pipeline_entity.config + assert query.variables['_pipeline_bound_plugins'] == ['test/runner'] + app.agent_run_orchestrator.try_claim_steering_from_query.assert_awaited_once_with(query) diff --git a/tests/unit_tests/pipeline/test_msgtrun.py b/tests/unit_tests/pipeline/test_msgtrun.py deleted file mode 100644 index 4470c6945..000000000 --- a/tests/unit_tests/pipeline/test_msgtrun.py +++ /dev/null @@ -1,321 +0,0 @@ -""" -Unit tests for ConversationMessageTruncator (msgtrun) pipeline stage. - -Tests cover: -- Normal truncation behavior based on max-round -- Boundary length handling -- Empty message handling -- Multi-message chain truncation -""" - -from __future__ import annotations - -import pytest -from importlib import import_module - -from tests.factories import ( - FakeApp, - text_query, -) - -import langbot_plugin.api.entities.builtin.provider.message as provider_message - - -def get_msgtrun_module(): - """Lazy import to avoid circular import issues.""" - # Import pipelinemgr first to trigger stage registration - import_module('langbot.pkg.pipeline.pipelinemgr') - return import_module('langbot.pkg.pipeline.msgtrun.msgtrun') - - -def get_truncator_module(): - """Lazy import for truncator base.""" - return import_module('langbot.pkg.pipeline.msgtrun.truncator') - - -def get_entities_module(): - """Lazy import for pipeline entities.""" - return import_module('langbot.pkg.pipeline.entities') - - -def get_round_truncator_module(): - """Lazy import for round truncator.""" - return import_module('langbot.pkg.pipeline.msgtrun.truncators.round') - - -def make_truncate_config(max_round: int = 5): - """Create a pipeline config with max-round setting.""" - return { - 'ai': { - 'local-agent': { - 'max-round': max_round, - } - } - } - - -class TestConversationMessageTruncatorInit: - """Tests for ConversationMessageTruncator initialization.""" - - @pytest.mark.asyncio - async def test_initialize_round_truncator(self): - """Initialize should select 'round' truncator by default.""" - msgtrun = get_msgtrun_module() - truncator = get_truncator_module() - - app = FakeApp() - stage = msgtrun.ConversationMessageTruncator(app) - - pipeline_config = make_truncate_config() - - await stage.initialize(pipeline_config) - - assert stage.trun is not None - assert isinstance(stage.trun, truncator.Truncator) - - @pytest.mark.asyncio - async def test_initialize_unknown_truncator_raises(self): - """Initialize with unknown truncator method should raise ValueError.""" - msgtrun = get_msgtrun_module() - truncator = get_truncator_module() - - # Save original preregistered_truncators - original_truncators = truncator.preregistered_truncators.copy() - - try: - # Clear registered truncators to simulate unknown method - truncator.preregistered_truncators = [] - - app = FakeApp() - stage = msgtrun.ConversationMessageTruncator(app) - - pipeline_config = make_truncate_config() - - with pytest.raises(ValueError, match='Unknown truncator'): - await stage.initialize(pipeline_config) - finally: - # Restore original truncators - truncator.preregistered_truncators = original_truncators - - -class TestRoundTruncatorProcess: - """Tests for RoundTruncator truncation behavior.""" - - @pytest.mark.asyncio - async def test_truncate_within_limit(self): - """Messages within max-round limit should not be truncated.""" - msgtrun = get_msgtrun_module() - entities = get_entities_module() - - app = FakeApp() - stage = msgtrun.ConversationMessageTruncator(app) - - pipeline_config = make_truncate_config(max_round=5) - - await stage.initialize(pipeline_config) - - # Create query with 3 messages (within limit) - query = text_query('current message') - query.pipeline_config = pipeline_config - query.messages = [ - provider_message.Message(role='user', content='message 1'), - provider_message.Message(role='assistant', content='response 1'), - provider_message.Message(role='user', content='message 2'), - provider_message.Message(role='assistant', content='response 2'), - provider_message.Message(role='user', content='current message'), - ] - - result = await stage.process(query, 'ConversationMessageTruncator') - - assert result.result_type == entities.ResultType.CONTINUE - # All messages should be preserved - assert len(result.new_query.messages) == 5 - - @pytest.mark.asyncio - async def test_truncate_exceeds_limit(self): - """Messages exceeding max-round should be truncated precisely. - - Algorithm: traverse backwards, collect while current_round < max_round, count user messages as rounds. - For max_round=2 with 7 messages (u1, a1, u2, a2, u3, a3, u_current): - - Iterate: u_current(r=0<2, collect, r=1), a3(r=1<2, collect), u3(r=1<2, collect, r=2) - - a2: r=2 not < 2 → break - - Collected reverse: [u_current, a3, u3] - - Reversed: [u3, a3, u_current] = 3 messages - """ - msgtrun = get_msgtrun_module() - entities = get_entities_module() - - app = FakeApp() - stage = msgtrun.ConversationMessageTruncator(app) - - pipeline_config = make_truncate_config(max_round=2) # Only keep 2 rounds - - await stage.initialize(pipeline_config) - - # Create query with many messages exceeding limit - # 7 messages = 3 full rounds + 1 current user - query = text_query('current message') - query.pipeline_config = pipeline_config - query.messages = [ - provider_message.Message(role='user', content='message 1'), - provider_message.Message(role='assistant', content='response 1'), - provider_message.Message(role='user', content='message 2'), - provider_message.Message(role='assistant', content='response 2'), - provider_message.Message(role='user', content='message 3'), - provider_message.Message(role='assistant', content='response 3'), - provider_message.Message(role='user', content='current message'), - ] - - result = await stage.process(query, 'ConversationMessageTruncator') - - assert result.result_type == entities.ResultType.CONTINUE - # Should keep exactly 3 messages: message3, response3, current message - messages = result.new_query.messages - assert len(messages) == 3 - - # Verify exact message content - assert messages[0].role == 'user' - assert messages[0].content == 'message 3' - assert messages[1].role == 'assistant' - assert messages[1].content == 'response 3' - assert messages[2].role == 'user' - assert messages[2].content == 'current message' - - @pytest.mark.asyncio - async def test_truncate_empty_messages(self): - """Empty messages list should return empty list.""" - msgtrun = get_msgtrun_module() - entities = get_entities_module() - - app = FakeApp() - stage = msgtrun.ConversationMessageTruncator(app) - - pipeline_config = make_truncate_config() - - await stage.initialize(pipeline_config) - - query = text_query('hello') - query.pipeline_config = pipeline_config - query.messages = [] - - result = await stage.process(query, 'ConversationMessageTruncator') - - assert result.result_type == entities.ResultType.CONTINUE - assert len(result.new_query.messages) == 0 - - @pytest.mark.asyncio - async def test_truncate_single_message(self): - """Single message should be preserved.""" - msgtrun = get_msgtrun_module() - entities = get_entities_module() - - app = FakeApp() - stage = msgtrun.ConversationMessageTruncator(app) - - pipeline_config = make_truncate_config() - - await stage.initialize(pipeline_config) - - query = text_query('hello') - query.pipeline_config = pipeline_config - query.messages = [ - provider_message.Message(role='user', content='hello'), - ] - - result = await stage.process(query, 'ConversationMessageTruncator') - - assert result.result_type == entities.ResultType.CONTINUE - assert len(result.new_query.messages) == 1 - - @pytest.mark.asyncio - async def test_truncate_preserves_order(self): - """Truncation should preserve message order.""" - msgtrun = get_msgtrun_module() - entities = get_entities_module() - - app = FakeApp() - stage = msgtrun.ConversationMessageTruncator(app) - - pipeline_config = make_truncate_config(max_round=2) - - await stage.initialize(pipeline_config) - - query = text_query('current') - query.pipeline_config = pipeline_config - query.messages = [ - provider_message.Message(role='user', content='user1'), - provider_message.Message(role='assistant', content='asst1'), - provider_message.Message(role='user', content='user2'), - provider_message.Message(role='assistant', content='asst2'), - provider_message.Message(role='user', content='user3'), - ] - - result = await stage.process(query, 'ConversationMessageTruncator') - - assert result.result_type == entities.ResultType.CONTINUE - - messages = result.new_query.messages - assert [(msg.role, msg.content) for msg in messages] == [ - ('user', 'user2'), - ('assistant', 'asst2'), - ('user', 'user3'), - ] - - @pytest.mark.asyncio - async def test_truncate_max_round_one(self): - """max-round=1 should only keep last user message.""" - msgtrun = get_msgtrun_module() - entities = get_entities_module() - - app = FakeApp() - stage = msgtrun.ConversationMessageTruncator(app) - - pipeline_config = make_truncate_config(max_round=1) - - await stage.initialize(pipeline_config) - - query = text_query('current') - query.pipeline_config = pipeline_config - query.messages = [ - provider_message.Message(role='user', content='old1'), - provider_message.Message(role='assistant', content='old1_resp'), - provider_message.Message(role='user', content='current'), - ] - - result = await stage.process(query, 'ConversationMessageTruncator') - - assert result.result_type == entities.ResultType.CONTINUE - messages = result.new_query.messages - assert [(msg.role, msg.content) for msg in messages] == [('user', 'current')] - - -class TestRoundTruncatorDirect: - """Direct tests for RoundTruncator class.""" - - @pytest.mark.asyncio - async def test_round_truncator_direct_process(self): - """Test RoundTruncator truncate method directly.""" - truncator_mod = get_truncator_module() - - app = FakeApp() - - # Get the RoundTruncator class from preregistered - for trun_cls in truncator_mod.preregistered_truncators: - if trun_cls.name == 'round': - trun = trun_cls(app) - break - - query = text_query('hello') - query.pipeline_config = make_truncate_config(max_round=3) - query.messages = [ - provider_message.Message(role='user', content='m1'), - provider_message.Message(role='assistant', content='r1'), - provider_message.Message(role='user', content='m2'), - provider_message.Message(role='assistant', content='r2'), - provider_message.Message(role='user', content='hello'), - ] - - result = await trun.truncate(query) - - assert result is not None - assert hasattr(result, 'messages') diff --git a/tests/unit_tests/pipeline/test_n8nsvapi.py b/tests/unit_tests/pipeline/test_n8nsvapi.py deleted file mode 100644 index 787472375..000000000 --- a/tests/unit_tests/pipeline/test_n8nsvapi.py +++ /dev/null @@ -1,353 +0,0 @@ -""" -Unit tests for N8nServiceAPIRunner._process_response - -Tests cover four scenarios: -- Stream adapter + n8n stream format (type:item/end) -- Stream adapter + n8n plain JSON -- Non-stream adapter + n8n stream format -- Non-stream adapter + n8n plain JSON -""" - -from __future__ import annotations - -import json -import sys -from unittest.mock import AsyncMock, MagicMock, Mock, patch - -import pytest -import langbot_plugin.api.entities.builtin.provider.message as provider_message - -# Break the circular import chain while importing n8nsvapi: -# n8nsvapi → runner → app → pipelinemgr → all runners → runner (partially init) -# The stubs are restored in a ``finally`` block so this module does NOT pollute -# sys.modules for other test modules (e.g. ones importing the real -# LocalAgentRunner, which would otherwise inherit ``object`` and break). -# Mirrors master's intent but uses try/finally so a raised import doesn't -# leave the global namespace in a stubbed state, and includes -# ``langbot.pkg.utils.httpclient`` which master didn't stub. -_runner_stub = MagicMock() -_runner_stub.runner_class = lambda name: (lambda cls: cls) # no-op decorator -_runner_stub.RequestRunner = object -_import_stubs = { - 'langbot.pkg.provider.runner': _runner_stub, - 'langbot.pkg.core.app': MagicMock(), - 'langbot.pkg.utils.httpclient': MagicMock(), -} -_saved_modules = {name: sys.modules.get(name) for name in _import_stubs} -for _name, _stub in _import_stubs.items(): - sys.modules[_name] = _stub -try: - from langbot.pkg.provider.runners.n8nsvapi import N8nServiceAPIRunner -finally: - for _name, _original in _saved_modules.items(): - if _original is None: - sys.modules.pop(_name, None) - else: - sys.modules[_name] = _original - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def make_runner(output_key: str = 'response') -> N8nServiceAPIRunner: - ap = Mock() - ap.logger = Mock() - pipeline_config = { - 'ai': { - 'n8n-service-api': { - 'webhook-url': 'http://test-n8n/webhook', - 'output-key': output_key, - 'auth-type': 'none', - } - } - } - return N8nServiceAPIRunner(ap, pipeline_config) - - -def make_mock_response(chunks: list[bytes | str], status: int = 200): - """Build a minimal aiohttp.ClientResponse mock with iter_chunked support.""" - response = Mock() - response.status = status - - async def iter_chunked(size): - for chunk in chunks: - yield chunk - - response.content = Mock() - response.content.iter_chunked = iter_chunked - return response - - -async def collect_chunks(runner: N8nServiceAPIRunner, chunks: list[bytes | str]): - """Run _process_response and collect all yielded MessageChunks.""" - response = make_mock_response(chunks) - result = [] - async for chunk in runner._process_response(response): - result.append(chunk) - return result - - -# --------------------------------------------------------------------------- -# _process_response: stream format (type:item/end) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_stream_format_single_item(): - """Single item + end in one chunk yields final chunk with full content.""" - runner = make_runner() - data = b'{"type":"item","content":"hello"}{"type":"end"}' - - chunks = await collect_chunks(runner, [data]) - - assert len(chunks) == 1 - assert chunks[0].is_final is True - assert chunks[0].content == 'hello' - assert chunks[0].msg_sequence == 1 - - -@pytest.mark.asyncio -async def test_stream_format_multi_item_accumulates(): - """Multiple items accumulate into full_content.""" - runner = make_runner() - chunks_data = [ - b'{"type":"item","content":"foo"}', - b'{"type":"item","content":"bar"}', - b'{"type":"end"}', - ] - - chunks = await collect_chunks(runner, chunks_data) - - assert len(chunks) == 1 - assert chunks[0].is_final is True - assert chunks[0].content == 'foobar' - assert chunks[0].msg_sequence == 1 - - -@pytest.mark.asyncio -async def test_stream_format_batches_every_8_items(): - """Every 8th item triggers an intermediate yield before the final.""" - runner = make_runner() - items = [f'{{"type":"item","content":"{i}"}}' for i in range(8)] - items.append('{"type":"end"}') - data = ''.join(items).encode() - - chunks = await collect_chunks(runner, [data]) - - assert len(chunks) == 2 - assert chunks[0].is_final is False - assert chunks[0].content == '01234567' - assert chunks[0].msg_sequence == 1 - assert chunks[1].is_final is True - assert chunks[1].content == '01234567' - assert chunks[1].msg_sequence == 2 - - -@pytest.mark.asyncio -async def test_stream_format_split_across_network_chunks(): - """JSON split across multiple network chunks is reassembled correctly.""" - runner = make_runner() - part1 = b'{"type":"item","con' - part2 = b'tent":"world"}{"type":"end"}' - - chunks = await collect_chunks(runner, [part1, part2]) - - assert len(chunks) == 1 - assert chunks[0].is_final is True - assert chunks[0].content == 'world' - - -@pytest.mark.asyncio -async def test_stream_format_no_spurious_empty_yield(): - """chunk_idx==0 guard prevents spurious empty yield before any item is received.""" - runner = make_runner() - # Send some non-stream JSON first, then stream - data = b'{"type":"item","content":"x"}{"type":"end"}' - - chunks = await collect_chunks(runner, [data]) - - assert len(chunks) == 1 - assert chunks[0].content == 'x' - - -# --------------------------------------------------------------------------- -# _process_response: plain JSON fallback -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_plain_json_with_output_key(): - """Plain JSON with matching output_key extracts value via output_key.""" - runner = make_runner(output_key='response') - data = json.dumps({'response': 'hello world'}).encode() - - chunks = await collect_chunks(runner, [data]) - - assert len(chunks) == 1 - assert chunks[0].is_final is True - assert chunks[0].content == 'hello world' - - -@pytest.mark.asyncio -async def test_plain_json_output_key_not_found(): - """Plain JSON without output_key falls back to entire JSON string.""" - runner = make_runner(output_key='response') - payload = {'other_key': 'hello'} - data = json.dumps(payload).encode() - - chunks = await collect_chunks(runner, [data]) - - assert len(chunks) == 1 - assert chunks[0].is_final is True - assert json.loads(chunks[0].content) == payload - - -@pytest.mark.asyncio -async def test_plain_json_output_key_empty_string(): - """output_key present but value is empty string — returns empty string, not whole JSON.""" - runner = make_runner(output_key='response') - data = json.dumps({'response': ''}).encode() - - chunks = await collect_chunks(runner, [data]) - - assert len(chunks) == 1 - assert chunks[0].is_final is True - assert chunks[0].content == '' - - -@pytest.mark.asyncio -async def test_plain_json_non_dict_response(): - """Plain JSON array falls back to raw text.""" - runner = make_runner() - data = b'["a", "b"]' - - chunks = await collect_chunks(runner, [data]) - - assert len(chunks) == 1 - assert chunks[0].is_final is True - assert chunks[0].content == '["a", "b"]' - - -@pytest.mark.asyncio -async def test_invalid_json_returns_raw_text(): - """Non-JSON response returns raw text as-is.""" - runner = make_runner() - data = b'plain text response' - - chunks = await collect_chunks(runner, [data]) - - assert len(chunks) == 1 - assert chunks[0].is_final is True - assert chunks[0].content == 'plain text response' - - -# --------------------------------------------------------------------------- -# _call_webhook: output type depends on is_stream -# --------------------------------------------------------------------------- - - -def make_query(is_stream: bool): - """Build a minimal Query mock.""" - query = Mock() - query.adapter = AsyncMock() - query.adapter.is_stream_output_supported = AsyncMock(return_value=is_stream) - - session = Mock() - session.using_conversation = Mock() - session.using_conversation.uuid = 'test-uuid' - session.launcher_type = Mock() - session.launcher_type.value = 'person' - session.launcher_id = '12345' - query.session = session - - query.user_message = Mock() - query.user_message.content = 'hi' - query.variables = {} - return query - - -def make_http_session_mock(response_bytes: bytes, status: int = 200): - """Mock httpclient.get_session() returning a session whose post() yields response_bytes.""" - mock_response = make_mock_response([response_bytes], status=status) - mock_response.status = status - - mock_cm = AsyncMock() - mock_cm.__aenter__ = AsyncMock(return_value=mock_response) - mock_cm.__aexit__ = AsyncMock(return_value=False) - - mock_session = Mock() - mock_session.post = Mock(return_value=mock_cm) - return mock_session - - -@pytest.mark.asyncio -async def test_call_webhook_nonstream_adapter_plain_json(): - """Non-stream adapter + plain JSON → single Message with output_key value.""" - runner = make_runner(output_key='response') - query = make_query(is_stream=False) - http_session = make_http_session_mock(json.dumps({'response': 'result text'}).encode()) - - with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session): - results = [] - async for msg in runner._call_webhook(query): - results.append(msg) - - assert len(results) == 1 - assert isinstance(results[0], provider_message.Message) - assert results[0].content == 'result text' - - -@pytest.mark.asyncio -async def test_call_webhook_stream_adapter_stream_format(): - """Stream adapter + stream format → MessageChunks, last is_final.""" - runner = make_runner() - query = make_query(is_stream=True) - data = b'{"type":"item","content":"hi"}{"type":"end"}' - http_session = make_http_session_mock(data) - - with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session): - results = [] - async for msg in runner._call_webhook(query): - results.append(msg) - - assert all(isinstance(r, provider_message.MessageChunk) for r in results) - assert results[-1].is_final is True - assert results[-1].content == 'hi' - - -@pytest.mark.asyncio -async def test_call_webhook_stream_adapter_plain_json(): - """Stream adapter + plain JSON → single MessageChunk with is_final=True.""" - runner = make_runner(output_key='response') - query = make_query(is_stream=True) - data = json.dumps({'response': 'fallback'}).encode() - http_session = make_http_session_mock(data) - - with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session): - results = [] - async for msg in runner._call_webhook(query): - results.append(msg) - - assert all(isinstance(r, provider_message.MessageChunk) for r in results) - assert results[-1].is_final is True - assert results[-1].content == 'fallback' - - -@pytest.mark.asyncio -async def test_call_webhook_nonstream_adapter_stream_format(): - """Non-stream adapter + stream format → single Message with accumulated content.""" - runner = make_runner() - query = make_query(is_stream=False) - data = b'{"type":"item","content":"foo"}{"type":"item","content":"bar"}{"type":"end"}' - http_session = make_http_session_mock(data) - - with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session): - results = [] - async for msg in runner._call_webhook(query): - results.append(msg) - - assert len(results) == 1 - assert isinstance(results[0], provider_message.Message) - assert results[0].content == 'foobar' diff --git a/tests/unit_tests/plugin/test_handler_actions.py b/tests/unit_tests/plugin/test_handler_actions.py index a135e8b91..a371c239d 100644 --- a/tests/unit_tests/plugin/test_handler_actions.py +++ b/tests/unit_tests/plugin/test_handler_actions.py @@ -7,6 +7,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, Mock import pytest +from langbot_plugin.api.entities.builtin.provider import message as provider_message from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction, RuntimeToLangBotAction @@ -27,6 +28,22 @@ def compiled_params(statement): return statement.compile().params +def make_agent_resources( + models: list[dict] | None = None, + tools: list[dict] | None = None, + knowledge_bases: list[dict] | None = None, +): + """Create a minimal AgentRun resources payload for run-scoped action tests.""" + return { + 'models': models or [], + 'tools': tools or [], + 'knowledge_bases': knowledge_bases or [], + 'files': [], + 'storage': {'plugin_storage': False, 'workspace_storage': False}, + 'platform_capabilities': {}, + } + + class TestRagRerankAction: """Tests for RAG rerank action handler.""" @@ -421,3 +438,433 @@ class TestHandlerQueryLookup: assert response.code == 0 assert response.data == {'bot_uuid': 'test-bot-uuid'} + + +class TestAgentRunProxyActions: + """Tests for AgentRunner proxy actions that need host Query semantics.""" + + @pytest.fixture + def app(self): + mock_app = Mock() + mock_app.logger = Mock() + mock_app.query_pool = Mock() + mock_app.query_pool.cached_queries = {} + mock_app.model_mgr = Mock() + mock_app.model_mgr.get_model_by_uuid = AsyncMock() + mock_app.model_mgr.get_rerank_model_by_uuid = AsyncMock() + mock_app.tool_mgr = Mock() + mock_app.tool_mgr.execute_func_call = AsyncMock(return_value={'ok': True}) + return mock_app + + @staticmethod + def query(remove_think=True): + return SimpleNamespace( + pipeline_config={'output': {'misc': {'remove-think': remove_think}}}, + variables={}, + prompt=SimpleNamespace( + messages=[provider_message.Message(role='system', content='effective prompt')] + ), + ) + + @pytest.mark.asyncio + async def test_get_prompt_returns_query_effective_prompt(self, app): + """GET_PROMPT returns the preprocessed Query prompt for the active run.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + run_id = 'run_proxy_get_prompt' + query = self.query() + app.query_pool.cached_queries[900] = query + + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=900, + plugin_identity='test/runner', + resources=make_agent_resources(), + available_apis={'prompt_get': True}, + ) + + runtime_handler = make_handler(app) + + try: + response = await runtime_handler.actions[PluginToRuntimeAction.GET_PROMPT.value]({ + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + }) + finally: + await registry.unregister(run_id) + + assert response.code == 0 + assert response.data['prompt'][0]['role'] == 'system' + assert response.data['prompt'][0]['content'] == 'effective prompt' + + @pytest.mark.asyncio + async def test_invoke_llm_restores_query_and_model_options(self, app): + """INVOKE_LLM passes Query, model extra_args and remove-think to provider.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + run_id = 'run_proxy_invoke_llm_options' + query = self.query(remove_think=True) + app.query_pool.cached_queries[901] = query + + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=901, + plugin_identity='test/runner', + resources=make_agent_resources(models=[{'model_id': 'llm_001'}]), + ) + + provider = SimpleNamespace( + invoke_llm=AsyncMock(return_value=provider_message.Message(role='assistant', content='ok')), + ) + model = SimpleNamespace( + model_entity=SimpleNamespace( + abilities=['func_call'], + extra_args={'temperature': 0.2, 'top_p': 0.8}, + ), + provider=provider, + ) + app.model_mgr.get_model_by_uuid.return_value = model + runtime_handler = make_handler(app) + + try: + response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM.value]({ + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'llm_model_uuid': 'llm_001', + 'messages': [{'role': 'user', 'content': 'hello'}], + 'funcs': [{ + 'name': 'search', + 'human_desc': 'Search', + 'description': 'Search', + 'parameters': {'type': 'object'}, + }], + 'extra_args': {'temperature': 0.7, 'presence_penalty': 0.1}, + }) + finally: + await registry.unregister(run_id) + + assert response.code == 0 + provider.invoke_llm.assert_awaited_once() + kwargs = provider.invoke_llm.await_args.kwargs + assert kwargs['query'] is query + assert kwargs['extra_args'] == { + 'temperature': 0.7, + 'top_p': 0.8, + 'presence_penalty': 0.1, + } + assert kwargs['remove_think'] is True + assert [tool.name for tool in kwargs['funcs']] == ['search'] + + @pytest.mark.asyncio + async def test_invoke_llm_returns_provider_usage(self, app): + """INVOKE_LLM includes optional provider usage in the action response.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + from langbot.pkg.provider.modelmgr import requester as model_requester + + usage = { + 'prompt_tokens': 11, + 'completion_tokens': 7, + 'total_tokens': 18, + 'prompt_tokens_details': {'cached_tokens': 3}, + } + + class UsageProvider: + async def invoke_llm(self, **kwargs): + kwargs['query'].variables[model_requester.LLM_USAGE_QUERY_VARIABLE] = usage + return provider_message.Message(role='assistant', content='ok') + + run_id = 'run_proxy_invoke_llm_usage' + query = self.query() + app.query_pool.cached_queries[905] = query + + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=905, + plugin_identity='test/runner', + resources=make_agent_resources(models=[{'model_id': 'llm_usage_001'}]), + ) + + model = SimpleNamespace( + model_entity=SimpleNamespace(abilities=[], extra_args={}), + provider=UsageProvider(), + ) + app.model_mgr.get_model_by_uuid.return_value = model + runtime_handler = make_handler(app) + + try: + response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM.value]({ + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'llm_model_uuid': 'llm_usage_001', + 'messages': [{'role': 'user', 'content': 'hello'}], + }) + finally: + await registry.unregister(run_id) + + assert response.code == 0 + assert response.data['message']['content'] == 'ok' + assert response.data['usage'] == usage + assert model_requester.LLM_USAGE_QUERY_VARIABLE not in query.variables + + @pytest.mark.asyncio + async def test_invoke_llm_stream_restores_query_and_options(self, app): + """INVOKE_LLM_STREAM applies the same host context as non-streaming calls.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + class StreamProvider: + def __init__(self): + self.kwargs = None + + async def invoke_llm_stream(self, **kwargs): + self.kwargs = kwargs + yield provider_message.MessageChunk(role='assistant', content='hi') + + run_id = 'run_proxy_invoke_llm_stream_options' + query = self.query(remove_think=False) + app.query_pool.cached_queries[902] = query + + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=902, + plugin_identity='test/runner', + resources=make_agent_resources(models=[{'model_id': 'llm_stream_001'}]), + ) + + provider = StreamProvider() + model = SimpleNamespace( + model_entity=SimpleNamespace(abilities=[], extra_args={'max_tokens': 128}), + provider=provider, + ) + app.model_mgr.get_model_by_uuid.return_value = model + runtime_handler = make_handler(app) + + responses = [] + try: + stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]({ + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'llm_model_uuid': 'llm_stream_001', + 'messages': [{'role': 'user', 'content': 'hello'}], + 'funcs': [{ + 'name': 'search', + 'human_desc': 'Search', + 'description': 'Search', + 'parameters': {'type': 'object'}, + }], + 'extra_args': {'max_tokens': 256}, + 'remove_think': True, + }) + async for response in stream: + responses.append(response) + finally: + await registry.unregister(run_id) + + assert [response.code for response in responses] == [0] + assert provider.kwargs['query'] is query + assert provider.kwargs['extra_args'] == {'max_tokens': 256} + assert provider.kwargs['remove_think'] is True + assert provider.kwargs['funcs'] == [] + + @pytest.mark.asyncio + async def test_invoke_llm_stream_skips_none_chunks(self, app): + """INVOKE_LLM_STREAM tolerates provider heartbeat/no-op chunks.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + class StreamProvider: + async def invoke_llm_stream(self, **kwargs): + yield provider_message.MessageChunk(role='assistant', content='ok') + yield None + yield provider_message.MessageChunk(role='assistant', content=' done', is_final=True) + + run_id = 'run_proxy_invoke_llm_stream_none_chunks' + query = self.query() + app.query_pool.cached_queries[904] = query + + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=904, + plugin_identity='test/runner', + resources=make_agent_resources(models=[{'model_id': 'llm_stream_002'}]), + ) + + model = SimpleNamespace( + model_entity=SimpleNamespace(abilities=[], extra_args={}), + provider=StreamProvider(), + ) + app.model_mgr.get_model_by_uuid.return_value = model + runtime_handler = make_handler(app) + + responses = [] + try: + stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]({ + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'llm_model_uuid': 'llm_stream_002', + 'messages': [{'role': 'user', 'content': 'hello'}], + }) + async for response in stream: + responses.append(response) + finally: + await registry.unregister(run_id) + + assert [response.code for response in responses] == [0, 0] + assert [response.data['chunk']['content'] for response in responses] == ['ok', ' done'] + + @pytest.mark.asyncio + async def test_invoke_llm_stream_returns_provider_usage_event(self, app): + """INVOKE_LLM_STREAM emits a final usage-only action response when available.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + from langbot.pkg.provider.modelmgr import requester as model_requester + + usage = { + 'prompt_tokens': 9, + 'completion_tokens': 4, + 'total_tokens': 13, + 'prompt_tokens_details': {'cached_tokens': 2}, + } + + class StreamProvider: + async def invoke_llm_stream(self, **kwargs): + yield provider_message.MessageChunk(role='assistant', content='ok') + kwargs['query'].variables[model_requester.LLM_USAGE_QUERY_VARIABLE] = usage + + run_id = 'run_proxy_invoke_llm_stream_usage' + query = self.query() + app.query_pool.cached_queries[906] = query + + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=906, + plugin_identity='test/runner', + resources=make_agent_resources(models=[{'model_id': 'llm_stream_usage_001'}]), + ) + + model = SimpleNamespace( + model_entity=SimpleNamespace(abilities=[], extra_args={}), + provider=StreamProvider(), + ) + app.model_mgr.get_model_by_uuid.return_value = model + runtime_handler = make_handler(app) + + responses = [] + try: + stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]({ + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'llm_model_uuid': 'llm_stream_usage_001', + 'messages': [{'role': 'user', 'content': 'hello'}], + }) + async for response in stream: + responses.append(response) + finally: + await registry.unregister(run_id) + + assert [response.code for response in responses] == [0, 0] + assert responses[0].data['chunk']['content'] == 'ok' + assert responses[1].data == {'usage': usage} + assert model_requester.LLM_USAGE_QUERY_VARIABLE not in query.variables + + @pytest.mark.asyncio + async def test_call_tool_passes_current_query(self, app): + """CALL_TOOL passes the current Query back into tool execution.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + run_id = 'run_proxy_call_tool_query' + query = self.query() + app.query_pool.cached_queries[903] = query + + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=903, + plugin_identity='test/runner', + resources=make_agent_resources(tools=[{'tool_name': 'test/search'}]), + ) + + runtime_handler = make_handler(app) + + try: + response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value]({ + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'tool_name': 'test/search', + 'parameters': {'q': 'langbot'}, + }) + finally: + await registry.unregister(run_id) + + assert response.code == 0 + assert getattr(query, '_agent_run_session')['run_id'] == run_id + app.tool_mgr.execute_func_call.assert_awaited_once_with( + name='test/search', + parameters={'q': 'langbot'}, + query=query, + ) + + @pytest.mark.asyncio + async def test_invoke_rerank_uses_authorized_model_and_extra_args(self, app): + """INVOKE_RERANK validates run-scoped model access and merges model extra_args.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + run_id = 'run_proxy_rerank_options' + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=904, + plugin_identity='test/runner', + resources=make_agent_resources(models=[{'model_id': 'rerank_001'}]), + ) + + provider = SimpleNamespace( + invoke_rerank=AsyncMock(return_value=[ + {'index': 0, 'relevance_score': 0.2}, + {'index': 1, 'relevance_score': 0.9}, + ]), + ) + rerank_model = SimpleNamespace( + model_entity=SimpleNamespace(extra_args={'top_n': 5, 'return_documents': False}), + provider=provider, + ) + app.model_mgr.get_rerank_model_by_uuid.return_value = rerank_model + runtime_handler = make_handler(app) + + try: + response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_RERANK.value]({ + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'rerank_model_uuid': 'rerank_001', + 'query': 'hello', + 'documents': ['a', 'b'], + 'top_k': 1, + 'extra_args': {'top_n': 2}, + }) + finally: + await registry.unregister(run_id) + + assert response.code == 0 + assert response.data['results'] == [{'index': 1, 'relevance_score': 0.9}] + provider.invoke_rerank.assert_awaited_once() + kwargs = provider.invoke_rerank.await_args.kwargs + assert kwargs['extra_args'] == {'top_n': 2, 'return_documents': False} diff --git a/tests/unit_tests/provider/runners/__init__.py b/tests/unit_tests/provider/runners/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/unit_tests/provider/runners/test_difysvapi_runner.py b/tests/unit_tests/provider/runners/test_difysvapi_runner.py deleted file mode 100644 index 366ef6d87..000000000 --- a/tests/unit_tests/provider/runners/test_difysvapi_runner.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Tests for DifyServiceAPIRunner pure utility methods. - -Tests the helper methods that don't require real Dify API calls. -""" - -from __future__ import annotations - -import pytest - - -class TestDifyExtractTextOutput: - """Tests for _extract_dify_text_output method.""" - - def _create_runner(self): - """Create runner instance.""" - from unittest.mock import MagicMock - - from langbot.pkg.provider.runners.difysvapi import DifyServiceAPIRunner - - mock_app = MagicMock() - pipeline_config = { - 'ai': { - 'dify-service-api': { - 'app-type': 'chat', - 'api-key': 'test-key', - 'base-url': 'https://api.dify.ai', - } - }, - 'output': {'misc': {}}, - } - - runner = DifyServiceAPIRunner(mock_app, pipeline_config) - runner.dify_client = MagicMock() - - return runner - - def test_extract_none_value(self): - """None returns empty string.""" - runner = self._create_runner() - - result = runner._extract_dify_text_output(None) - - assert result == '' - - def test_extract_string_value(self): - """Plain string is returned.""" - runner = self._create_runner() - - result = runner._extract_dify_text_output('plain text') - - assert result == 'plain text' - - def test_extract_dict_with_content(self): - """Dict with 'content' key extracts content.""" - runner = self._create_runner() - - result = runner._extract_dify_text_output({'content': 'extracted content'}) - - assert result == 'extracted content' - - def test_extract_dict_without_content(self): - """Dict without 'content' key is JSON dumped.""" - runner = self._create_runner() - - result = runner._extract_dify_text_output({'key': 'value'}) - - assert 'key' in result - assert 'value' in result - - def test_extract_json_string_with_content(self): - """JSON string with 'content' key extracts content.""" - runner = self._create_runner() - - result = runner._extract_dify_text_output('{"content": "json content"}') - - assert result == 'json content' - - def test_extract_json_string_without_content(self): - """JSON string without 'content' key returns original.""" - runner = self._create_runner() - - result = runner._extract_dify_text_output('{"other": "value"}') - - assert '{"other": "value"}' in result - - def test_extract_whitespace_string(self): - """Whitespace string returns empty.""" - runner = self._create_runner() - - result = runner._extract_dify_text_output(' ') - - assert result == '' - - -class TestDifyRunnerConfigValidation: - """Tests for runner config validation.""" - - def test_invalid_app_type_raises(self): - """Invalid app-type raises DifyAPIError.""" - from unittest.mock import MagicMock - - from langbot.pkg.provider.runners.difysvapi import DifyServiceAPIRunner - from langbot.libs.dify_service_api.v1.errors import DifyAPIError - - mock_app = MagicMock() - pipeline_config = { - 'ai': { - 'dify-service-api': { - 'app-type': 'invalid-type', - 'api-key': 'test', - 'base-url': 'https://api.dify.ai', - } - }, - 'output': {'misc': {}}, - } - - with pytest.raises(DifyAPIError, match='不支持'): - DifyServiceAPIRunner(mock_app, pipeline_config) - - def test_valid_app_types(self): - """Valid app-types don't raise.""" - from unittest.mock import MagicMock - - from langbot.pkg.provider.runners.difysvapi import DifyServiceAPIRunner - - mock_app = MagicMock() - - for app_type in ['chat', 'agent', 'workflow']: - pipeline_config = { - 'ai': { - 'dify-service-api': { - 'app-type': app_type, - 'api-key': 'test', - 'base-url': 'https://api.dify.ai', - } - }, - 'output': {'misc': {}}, - } - - runner = DifyServiceAPIRunner(mock_app, pipeline_config) - # Should not raise - assert runner is not None - - -class TestDifyRunnerInit: - """Tests for runner initialization.""" - - def test_runner_stores_config(self): - """Runner stores pipeline_config.""" - from unittest.mock import MagicMock - - from langbot.pkg.provider.runners.difysvapi import DifyServiceAPIRunner - - mock_app = MagicMock() - pipeline_config = { - 'ai': { - 'dify-service-api': { - 'app-type': 'chat', - 'api-key': 'test-key', - 'base-url': 'https://api.dify.ai', - } - }, - 'output': {'misc': {}}, - } - - runner = DifyServiceAPIRunner(mock_app, pipeline_config) - - assert runner.pipeline_config == pipeline_config - assert runner.ap == mock_app diff --git a/tests/unit_tests/provider/test_localagent_sandbox_exec.py b/tests/unit_tests/provider/test_localagent_sandbox_exec.py deleted file mode 100644 index 08b4c540b..000000000 --- a/tests/unit_tests/provider/test_localagent_sandbox_exec.py +++ /dev/null @@ -1,281 +0,0 @@ -from __future__ import annotations - -import json -from types import SimpleNamespace -from unittest.mock import AsyncMock, Mock - -import pytest - -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.provider.message as provider_message -import langbot_plugin.api.entities.builtin.provider.session as provider_session - -from langbot.pkg.provider.runners.localagent import LocalAgentRunner, _StreamAccumulator - - -class RecordingProvider: - def __init__(self): - self.requests: list[dict] = [] - - async def invoke_llm(self, query, model, messages, funcs, extra_args=None, remove_think=None): - self.requests.append( - { - 'messages': list(messages), - 'funcs': list(funcs), - 'remove_think': remove_think, - } - ) - - if len(self.requests) == 1: - return provider_message.Message( - role='assistant', - content='Let me calculate that exactly.', - tool_calls=[ - provider_message.ToolCall( - id='call-1', - type='function', - function=provider_message.FunctionCall( - name='exec', - arguments=json.dumps( - {'command': ("python - <<'PY'\nnums = [1, 2, 3, 4]\nprint(sum(nums) / len(nums))\nPY")} - ), - ), - ) - ], - ) - - tool_result = json.loads(messages[-1].content) - return provider_message.Message( - role='assistant', - content=f'The average is {tool_result["stdout"]}.', - ) - - -class RecordingStreamProvider: - def __init__(self): - self.stream_requests: list[dict] = [] - - def invoke_llm_stream(self, query, model, messages, funcs, extra_args=None, remove_think=None): - self.stream_requests.append( - { - 'messages': list(messages), - 'funcs': list(funcs), - 'remove_think': remove_think, - } - ) - - async def _stream(): - if len(self.stream_requests) == 1: - yield provider_message.MessageChunk( - role='assistant', - tool_calls=[ - provider_message.ToolCall( - id='call-1', - type='function', - function=provider_message.FunctionCall( - name='exec', - arguments=json.dumps({'command': "python -c 'print(1)'"}), - ), - ) - ], - is_final=True, - ) - return - - yield provider_message.MessageChunk( - role='assistant', - content='Tool execution failed.', - is_final=True, - ) - - return _stream() - - -def make_query() -> pipeline_query.Query: - adapter = AsyncMock() - adapter.is_stream_output_supported = AsyncMock(return_value=False) - - return pipeline_query.Query.model_construct( - query_id='avg-query', - launcher_type=provider_session.LauncherTypes.PERSON, - launcher_id=12345, - sender_id=12345, - message_chain=[], - message_event=None, - adapter=adapter, - pipeline_uuid='pipeline-uuid', - bot_uuid='bot-uuid', - pipeline_config={ - 'ai': { - 'runner': {'runner': 'local-agent'}, - 'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'}, - }, - 'output': {'misc': {'remove-think': False}}, - }, - prompt=SimpleNamespace(messages=[]), - messages=[], - user_message=provider_message.Message( - role='user', - content='Please calculate the average of 1, 2, 3, and 4.', - ), - use_funcs=[SimpleNamespace(name='exec')], - use_llm_model_uuid='test-model-uuid', - variables={}, - ) - - -def test_stream_accumulator_merges_fragmented_tool_call_arguments(): - accumulator = _StreamAccumulator(msg_sequence=1) - - assert ( - accumulator.add( - provider_message.MessageChunk( - role='assistant', - tool_calls=[ - provider_message.ToolCall( - id='call-1', - type='function', - function=provider_message.FunctionCall(name='exec', arguments='{"command":'), - ) - ], - ) - ) - is None - ) - - emitted = accumulator.add( - provider_message.MessageChunk( - role='assistant', - tool_calls=[ - provider_message.ToolCall( - id='call-1', - type='function', - function=provider_message.FunctionCall(name='exec', arguments='"pwd"}'), - ) - ], - is_final=True, - ) - ) - - assert emitted is not None - final_msg = accumulator.final_message() - assert final_msg.tool_calls[0].function.name == 'exec' - assert final_msg.tool_calls[0].function.arguments == '{"command":"pwd"}' - - -@pytest.mark.asyncio -async def test_localagent_uses_exec_for_exact_calculation(): - provider = RecordingProvider() - model = SimpleNamespace( - provider=provider, - model_entity=SimpleNamespace( - uuid='test-model-uuid', - name='test-model', - abilities=['func_call'], - extra_args={}, - ), - ) - - tool_manager = SimpleNamespace( - execute_func_call=AsyncMock( - return_value={ - 'session_id': 'avg-query', - 'backend': 'podman', - 'status': 'completed', - 'ok': True, - 'exit_code': 0, - 'stdout': '2.5', - 'stderr': '', - 'duration_ms': 18, - } - ) - ) - - app = SimpleNamespace( - logger=Mock(), - model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)), - tool_mgr=tool_manager, - rag_mgr=SimpleNamespace(), - box_service=SimpleNamespace( - get_system_guidance=Mock( - return_value=( - 'When the exec tool is available, use it for exact calculations, statistics, ' - 'structured data parsing, and code execution instead of estimating mentally. ' - 'Unless the user explicitly asks for the script, code, or implementation details, ' - 'do not include the generated script in the final answer. ' - 'A default workspace is mounted at /workspace for file tasks.' - ) - ), - ), - skill_mgr=SimpleNamespace( - get_skills_for_pipeline=AsyncMock(return_value=[]), - detect_skill_activation=AsyncMock(return_value=None), - build_activation_prompt=Mock(return_value=None), - ), - ) - - runner = LocalAgentRunner(app, pipeline_config={}) - query = make_query() - - results = [message async for message in runner.run(query)] - - assert [message.role for message in results] == ['assistant', 'tool', 'assistant'] - assert results[-1].content == 'The average is 2.5.' - - tool_manager.execute_func_call.assert_awaited_once() - tool_name, tool_parameters = tool_manager.execute_func_call.await_args.args[:2] - assert tool_name == 'exec' - assert 'print(sum(nums) / len(nums))' in tool_parameters['command'] - - first_request = provider.requests[0] - assert any( - message.role == 'system' - and 'exec' in str(message.content) - and 'exact calculations' in str(message.content) - and 'Unless the user explicitly asks for the script' in str(message.content) - and '/workspace' in str(message.content) - for message in first_request['messages'] - ) - assert [tool.name for tool in first_request['funcs']] == ['exec'] - - -@pytest.mark.asyncio -async def test_localagent_streaming_tool_error_yields_message_chunks(): - provider = RecordingStreamProvider() - model = SimpleNamespace( - provider=provider, - model_entity=SimpleNamespace( - uuid='test-model-uuid', - name='test-model', - abilities=['func_call'], - extra_args={}, - ), - ) - - adapter = AsyncMock() - adapter.is_stream_output_supported = AsyncMock(return_value=True) - - query = make_query() - query.adapter = adapter - - app = SimpleNamespace( - logger=Mock(), - model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)), - tool_mgr=SimpleNamespace(execute_func_call=AsyncMock(side_effect=RuntimeError('boom'))), - rag_mgr=SimpleNamespace(), - box_service=SimpleNamespace( - get_system_guidance=Mock(return_value='sandbox guidance'), - ), - skill_mgr=SimpleNamespace( - get_skills_for_pipeline=AsyncMock(return_value=[]), - detect_skill_activation=AsyncMock(return_value=None), - build_activation_prompt=Mock(return_value=None), - ), - ) - - runner = LocalAgentRunner(app, pipeline_config={}) - - results = [message async for message in runner.run(query)] - - assert all(isinstance(message, provider_message.MessageChunk) for message in results) - assert any(message.role == 'tool' and message.content == 'err: boom' for message in results) diff --git a/tests/unit_tests/provider/test_model_service.py b/tests/unit_tests/provider/test_model_service.py index b4e1b3ca8..c5cfd118a 100644 --- a/tests/unit_tests/provider/test_model_service.py +++ b/tests/unit_tests/provider/test_model_service.py @@ -12,13 +12,39 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.provider.session as provider_session from langbot.pkg.api.http.service.model import _runtime_model_data +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor from langbot.pkg.api.http.service.provider import ModelProviderService from langbot.pkg.entity.persistence import model as persistence_model from langbot.pkg.pipeline.preproc.preproc import PreProcessor from langbot.pkg.provider.modelmgr import requester from langbot.pkg.provider.modelmgr.modelmgr import ModelManager from langbot.pkg.provider.modelmgr.token import TokenManager -from langbot.pkg.provider.runners.localagent import LocalAgentRunner + + +DEFAULT_RUNNER_ID = 'plugin:langbot/local-agent/default' + + +class FakeAgentRunnerRegistry: + async def get(self, runner_id, bound_plugins=None): + return AgentRunnerDescriptor( + id=runner_id, + source='plugin', + label={'en_US': 'Local Agent'}, + plugin_author='langbot', + plugin_name='local-agent', + runner_name='default', + config_schema=[ + {'name': 'model', 'type': 'model-fallback-selector'}, + {'name': 'prompt', 'type': 'prompt-editor', 'default': []}, + {'name': 'knowledge-bases', 'type': 'knowledge-base-multi-selector', 'default': []}, + ], + capabilities={'tool_calling': True, 'knowledge_retrieval': True, 'multimodal_input': True}, + permissions={ + 'models': ['invoke', 'stream'], + 'tools': ['detail', 'call'], + 'knowledge_bases': ['list', 'retrieve'], + }, + ) def test_runtime_llm_model_data_preserves_uuid_after_update_payload_uuid_removed(): @@ -120,6 +146,7 @@ async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline() ap = SimpleNamespace() ap.logger = Mock() + ap.agent_runner_registry = FakeAgentRunnerRegistry() ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock()) ap.tool_mgr = SimpleNamespace(get_all_tools=AsyncMock(return_value=[])) ap.skill_mgr = None # PreProcessor only uses skill_mgr for the local-agent skill-binding branch @@ -183,11 +210,13 @@ async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline() ) pipeline_config = { 'ai': { - 'runner': {'runner': 'local-agent'}, - 'local-agent': { - 'model': {'primary': model_uuid, 'fallbacks': []}, - 'prompt': [], - 'knowledge-bases': [], + 'runner': {'id': DEFAULT_RUNNER_ID}, + 'runner_config': { + DEFAULT_RUNNER_ID: { + 'model': {'primary': model_uuid, 'fallbacks': []}, + 'prompt': [], + 'knowledge-bases': [], + }, }, }, 'trigger': {'misc': {'combine-quote-message': False}}, @@ -220,8 +249,3 @@ async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline() processed_query = result.new_query assert processed_query.use_llm_model_uuid == model_uuid - - runner = SimpleNamespace(ap=ap, pipeline_config=pipeline_config) - candidates = await LocalAgentRunner._get_model_candidates(runner, processed_query) - - assert [model.model_entity.uuid for model in candidates] == [model_uuid] diff --git a/tests/unit_tests/provider/test_requester_base.py b/tests/unit_tests/provider/test_requester_base.py index 71c0da653..7345ac460 100644 --- a/tests/unit_tests/provider/test_requester_base.py +++ b/tests/unit_tests/provider/test_requester_base.py @@ -302,6 +302,59 @@ async def test_runtime_provider_invoke_llm_delegates(runtime_provider, runtime_l assert result.role == 'assistant' +@pytest.mark.asyncio +async def test_runtime_provider_invoke_llm_stashes_usage(runtime_provider, runtime_llm_model): + """RuntimeProvider preserves requester usage for upstream action handlers.""" + provider = runtime_provider + + import langbot_plugin.api.entities.builtin.provider.message as provider_message + import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + query = pipeline_query.Query.model_construct( + query_id='test-query-usage', + launcher_type='person', + launcher_id=12345, + sender_id=12345, + message_chain=None, + message_event=None, + adapter=None, + pipeline_uuid='pipeline-uuid', + bot_uuid='bot-uuid', + pipeline_config={'ai': {}, 'output': {}, 'trigger': {}}, + session=None, + prompt=None, + messages=[], + user_message=None, + use_funcs=[], + use_llm_model_uuid=None, + variables={}, + resp_messages=[], + resp_message_chain=None, + current_stage_name=None, + ) + usage = { + 'prompt_tokens': 11, + 'completion_tokens': 7, + 'total_tokens': 18, + 'prompt_tokens_details': {'cached_tokens': 3}, + } + provider.requester.invoke_llm = AsyncMock( + return_value=( + provider_message.Message(role='assistant', content='ok'), + usage, + ) + ) + + result = await provider.invoke_llm( + query, + runtime_llm_model, + [provider_message.Message(role='user', content='Hello')], + ) + + assert result.content == 'ok' + assert query.variables[requester.LLM_USAGE_QUERY_VARIABLE] == usage + + @pytest.mark.asyncio async def test_runtime_provider_invoke_llm_stream_yields_chunks(runtime_provider, runtime_llm_model): """Test RuntimeProvider.invoke_llm_stream yields chunks from requester.""" @@ -345,6 +398,61 @@ async def test_runtime_provider_invoke_llm_stream_yields_chunks(runtime_provider assert chunks[0].role == 'assistant' +@pytest.mark.asyncio +async def test_runtime_provider_invoke_llm_stream_stashes_usage(runtime_provider, runtime_llm_model): + """RuntimeProvider transfers captured stream usage to the public query usage key.""" + provider = runtime_provider + + import langbot_plugin.api.entities.builtin.provider.message as provider_message + import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + query = pipeline_query.Query.model_construct( + query_id='test-stream-usage', + launcher_type='person', + launcher_id=12345, + sender_id=12345, + message_chain=None, + message_event=None, + adapter=None, + pipeline_uuid='pipeline-uuid', + bot_uuid='bot-uuid', + pipeline_config={'ai': {}, 'output': {}, 'trigger': {}}, + session=None, + prompt=None, + messages=[], + user_message=None, + use_funcs=[], + use_llm_model_uuid=None, + variables={}, + resp_messages=[], + resp_message_chain=None, + current_stage_name=None, + ) + usage = { + 'prompt_tokens': 13, + 'completion_tokens': 2, + 'total_tokens': 15, + } + + async def fake_stream(**kwargs): + kwargs['query'].variables[requester.LLM_USAGE_QUERY_VARIABLE] = usage + yield provider_message.MessageChunk(role='assistant', content='ok') + + provider.requester.invoke_llm_stream = fake_stream + + chunks = [ + chunk + async for chunk in provider.invoke_llm_stream( + query, + runtime_llm_model, + [provider_message.Message(role='user', content='Hello')], + ) + ] + + assert len(chunks) == 1 + assert query.variables[requester.LLM_USAGE_QUERY_VARIABLE] == usage + + @pytest.mark.asyncio async def test_runtime_provider_invoke_embedding_returns_vectors(runtime_provider, runtime_embedding_model): """Test RuntimeProvider.invoke_embedding returns embedding vectors.""" diff --git a/tests/unit_tests/test_preproc.py b/tests/unit_tests/test_preproc.py index 3164f35b8..f58d3f2c5 100644 --- a/tests/unit_tests/test_preproc.py +++ b/tests/unit_tests/test_preproc.py @@ -8,6 +8,10 @@ from unittest.mock import AsyncMock, Mock import pytest +from langbot_plugin.api.entities.builtin.agent_runner.manifest import ( + AgentRunnerCapabilities, + AgentRunnerPermissions, +) from langbot_plugin.api.entities.builtin.pipeline.query import Query from langbot_plugin.api.entities.builtin.platform.entities import Friend from langbot_plugin.api.entities.builtin.platform.events import FriendMessage @@ -17,6 +21,32 @@ from langbot_plugin.api.entities.builtin.provider.prompt import Prompt from langbot_plugin.api.entities.builtin.provider.session import Conversation, LauncherTypes, Session +class _FakeRunnerDescriptor: + config_schema = [ + {'name': 'model', 'type': 'model-fallback-selector'}, + {'name': 'prompt', 'type': 'prompt-editor', 'default': []}, + {'name': 'knowledge-bases', 'type': 'knowledge-base-multi-selector', 'default': []}, + ] + permissions = { + 'models': ['invoke', 'stream'], + 'tools': ['detail', 'call'], + 'knowledge_bases': ['list', 'retrieve'], + } + permissions = AgentRunnerPermissions.model_validate(permissions) + capabilities = AgentRunnerCapabilities( + tool_calling=True, + knowledge_retrieval=True, + multimodal_input=True, + skill_authoring=True, + ) + + def supports_tool_calling(self): + return self.capabilities.tool_calling + + def supports_knowledge_retrieval(self): + return self.capabilities.knowledge_retrieval + + def _make_query() -> Query: message_chain = MessageChain([Plain(text='create a skill')]) return Query( @@ -34,11 +64,13 @@ def _make_query() -> Query: pipeline_uuid='pipe-1', pipeline_config={ 'ai': { - 'runner': {'runner': 'local-agent'}, - 'local-agent': { - 'model': {'primary': 'model-1', 'fallbacks': []}, - 'prompt': 'default', - 'knowledge-bases': [], + 'runner': {'id': 'plugin:langbot/local-agent/default'}, + 'runner_config': { + 'plugin:langbot/local-agent/default': { + 'model': {'primary': 'model-1', 'fallbacks': []}, + 'prompt': [], + 'knowledge-bases': [], + }, }, }, 'trigger': {'misc': {}}, @@ -57,6 +89,15 @@ def _make_conversation() -> Conversation: ) +async def _passthrough_preproc_event(event, bound_plugins): + return SimpleNamespace( + event=SimpleNamespace( + default_prompt=event.default_prompt, + prompt=event.prompt, + ) + ) + + def _make_app(*, skill_service) -> SimpleNamespace: session = Session(launcher_type=LauncherTypes.PERSON, launcher_id='launcher-1', sender_id='sender-1') conversation = _make_conversation() @@ -83,8 +124,8 @@ def _make_app(*, skill_service) -> SimpleNamespace: pipeline_service=SimpleNamespace( get_pipeline=AsyncMock(return_value={'extensions_preferences': {'enable_all_skills': True}}) ), + agent_runner_registry=SimpleNamespace(get=AsyncMock(return_value=_FakeRunnerDescriptor())), skill_mgr=SimpleNamespace( - build_skill_aware_prompt_addition=Mock(return_value=''), skills={}, ), skill_service=skill_service, @@ -121,6 +162,28 @@ async def test_preproc_enables_skill_authoring_tools_when_skill_service_availabl app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None, include_skill_authoring=True) +@pytest.mark.asyncio +async def test_preproc_puts_host_skill_tools_into_query_scope(): + """AgentRunner resource authorization consumes the tools discovered by preproc.""" + preproc_module, entities_module = _import_preproc_modules() + + app = _make_app(skill_service=SimpleNamespace()) + app.tool_mgr.get_all_tools = AsyncMock( + return_value=[ + SimpleNamespace(name='activate'), + SimpleNamespace(name='register_skill'), + ] + ) + query = _make_query() + stage = preproc_module.PreProcessor(app) + + 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) + 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(): preproc_module, entities_module = _import_preproc_modules() @@ -135,30 +198,24 @@ async def test_preproc_disables_skill_authoring_tools_when_skill_service_missing @pytest.mark.asyncio -async def test_preproc_injects_skill_index_into_system_prompt(): - """The Tool Call activation pattern still needs the LLM to know which - skills exist. PreProcessor must append the SkillManager's index - addendum to the first system message.""" +async def test_preproc_records_all_visible_skills_without_prompt_injection(): preproc_module, entities_module = _import_preproc_modules() app = _make_app(skill_service=SimpleNamespace()) - addendum = '\n\nAvailable Skills:\n- demo (demo): Demo skill.\n\nCall activate ...' - app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value=addendum) query = _make_query() result = await stage_process_capture(preproc_module, app, query) assert result.result_type == entities_module.ResultType.CONTINUE - app.skill_mgr.build_skill_aware_prompt_addition.assert_called_once_with(bound_skills=None) + app.pipeline_service.get_pipeline.assert_awaited_once_with('pipe-1') + assert query.variables.get('_pipeline_bound_skills') is None head = query.prompt.messages[0] assert head.role == 'system' - assert head.content.endswith(addendum) + assert head.content == 'system prompt' @pytest.mark.asyncio async def test_preproc_respects_pipeline_bound_skills_subset(): - """When ``enable_all_skills`` is false the bound list is passed through - so the addendum only mentions skills allowed for this pipeline.""" preproc_module, entities_module = _import_preproc_modules() app = _make_app(skill_service=SimpleNamespace()) @@ -170,31 +227,78 @@ async def test_preproc_respects_pipeline_bound_skills_subset(): } } ) - app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value='') query = _make_query() result = await stage_process_capture(preproc_module, app, query) assert result.result_type == entities_module.ResultType.CONTINUE - app.skill_mgr.build_skill_aware_prompt_addition.assert_called_once_with(bound_skills=['only-this']) assert query.variables.get('_pipeline_bound_skills') == ['only-this'] + assert query.prompt.messages[0].content == 'system prompt' @pytest.mark.asyncio -async def test_preproc_skips_injection_when_addendum_is_empty(): - """No visible skills → system prompt is left untouched (no - ``Available Skills`` block appended).""" +async def test_preproc_does_not_load_skill_preferences_without_skill_authoring_service(): preproc_module, entities_module = _import_preproc_modules() - app = _make_app(skill_service=SimpleNamespace()) - app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value='') + app = _make_app(skill_service=None) query = _make_query() result = await stage_process_capture(preproc_module, app, query) assert result.result_type == entities_module.ResultType.CONTINUE - if query.prompt and query.prompt.messages: - assert 'Available Skills' not in (query.prompt.messages[0].content or '') + app.pipeline_service.get_pipeline.assert_not_awaited() + assert '_pipeline_bound_skills' not in query.variables + assert query.prompt.messages[0].content == 'system prompt' + + +@pytest.mark.asyncio +async def test_preproc_uses_transcript_history_view_when_available(): + preproc_module, entities_module = _import_preproc_modules() + + app = _make_app(skill_service=SimpleNamespace()) + conversation = app.sess_mgr.get_conversation.return_value + conversation.messages = [Message(role='user', content='legacy history')] + app.plugin_connector.emit_event = AsyncMock(side_effect=_passthrough_preproc_event) + + transcript_messages = [ + Message(role='user', content='from transcript user'), + Message(role='assistant', content='from transcript assistant'), + ] + + stage = preproc_module.PreProcessor(app) + stage._load_agent_runner_history_messages = AsyncMock(return_value=transcript_messages) + + query = _make_query() + result = await stage.process(query, 'PreProcessor') + + assert result.result_type == entities_module.ResultType.CONTINUE + assert query.messages == transcript_messages + stage._load_agent_runner_history_messages.assert_awaited_once_with( + 'plugin:langbot/local-agent/default', + 'conv-1', + bot_id='bot-1', + workspace_id=None, + thread_id=None, + ) + + +@pytest.mark.asyncio +async def test_preproc_falls_back_to_conversation_messages_when_transcript_empty(): + preproc_module, entities_module = _import_preproc_modules() + + app = _make_app(skill_service=SimpleNamespace()) + legacy_messages = [Message(role='user', content='legacy history')] + app.sess_mgr.get_conversation.return_value.messages = legacy_messages + app.plugin_connector.emit_event = AsyncMock(side_effect=_passthrough_preproc_event) + + stage = preproc_module.PreProcessor(app) + stage._load_agent_runner_history_messages = AsyncMock(return_value=None) + + query = _make_query() + result = await stage.process(query, 'PreProcessor') + + assert result.result_type == entities_module.ResultType.CONTINUE + assert query.messages == legacy_messages async def stage_process_capture(preproc_module, app, query): diff --git a/tests/utils/import_isolation.py b/tests/utils/import_isolation.py index 9f2b3c583..47fce42bc 100644 --- a/tests/utils/import_isolation.py +++ b/tests/utils/import_isolation.py @@ -143,10 +143,6 @@ def make_pipeline_handler_import_mocks() -> dict[str, MagicMock]: # Mock core.app - Application class is referenced but not instantiated mock_app = MagicMock() - # Mock provider.runner - has preregistered_runners attribute - mock_runner = MagicMock() - mock_runner.preregistered_runners = [] # Empty by default, tests override - # Mock utils.importutil - prevents auto-import of runners mock_importutil = MagicMock() mock_importutil.import_modules_in_pkg = lambda pkg: None @@ -158,19 +154,11 @@ def make_pipeline_handler_import_mocks() -> dict[str, MagicMock]: 'langbot.pkg.pipeline.controller': MagicMock(), 'langbot.pkg.pipeline.pipelinemgr': MagicMock(), 'langbot.pkg.pipeline.process.process': MagicMock(), - 'langbot.pkg.provider.runner': mock_runner, 'langbot.pkg.utils.importutil': mock_importutil, } -# Package attributes that need to be updated alongside sys.modules mocking. -# When Python imports a submodule (e.g., langbot.pkg.provider.runner), it -# automatically sets an attribute on the parent package. The import statement -# `from ....provider import runner` gets this attribute, not sys.modules directly. -# This dict maps mock module names to the parent packages that need attribute updates. -_PACKAGE_ATTRIBUTE_UPDATES: dict[str, tuple[str, str]] = { - 'langbot.pkg.provider.runner': ('langbot.pkg.provider', 'runner'), -} +_PACKAGE_ATTRIBUTE_UPDATES: dict[str, tuple[str, str]] = {} def get_handler_modules_to_clear(handler_name: str) -> list[str]: diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index a9c4810b0..36b7a91a8 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -1,6 +1,7 @@ import { IDynamicFormItemSchema, SYSTEM_FIELD_PREFIX, + DynamicFormItemType, } from '@/app/infra/entities/form/dynamic'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -32,6 +33,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; import { systemInfo } from '@/app/infra/http'; +import { parseDynamicFormItemType } from './DynamicFormItemConfig'; /** * Resolve the value referenced by a `show_if.field` string. @@ -290,6 +292,13 @@ function DisabledTooltipIcon({ text }: { text: string }) { ); } +/** + * Normalize plugin manifest type names to frontend-compatible types + */ +function normalizeItemType(type: string): DynamicFormItemType { + return parseDynamicFormItemType(type); +} + export default function DynamicFormComponent({ itemConfigList, onSubmit, @@ -372,8 +381,11 @@ export default function DynamicFormComponent({ const formSchema = z.object( editableItems.reduce( (acc, item) => { + // Normalize type to handle plugin manifest type names + const normalizedType = normalizeItemType(item.type); + let fieldSchema; - switch (item.type) { + switch (normalizedType) { case 'integer': fieldSchema = z.number(); break; @@ -427,6 +439,9 @@ export default function DynamicFormComponent({ }), ); break; + case 'text': + fieldSchema = z.string(); + break; default: fieldSchema = z.string(); } @@ -579,7 +594,14 @@ export default function DynamicFormComponent({ }} /> - {itemConfigList.map((config) => { + {itemConfigList.map((config, index) => { + // Create a normalized config with type converted to frontend format + const normalizedConfig = { + ...config, + type: normalizeItemType(config.type), + }; + const fieldKey = config.id || config.name || `field-${index}`; + if (config.show_if) { const dependValue = resolveShowIfValue( config.show_if.field, @@ -674,7 +696,7 @@ export default function DynamicFormComponent({ } // Webhook URL fields are display-only; render outside of form binding - if (config.type === 'webhook-url') { + if (normalizedConfig.type === 'webhook-url') { const webhookUrl = (systemContext?.webhook_url as string) || ''; const extraWebhookUrl = (systemContext?.extra_webhook_url as string) || ''; @@ -683,7 +705,7 @@ export default function DynamicFormComponent({ return ( +
( @@ -814,7 +836,7 @@ export default function DynamicFormComponent({
@@ -829,7 +851,7 @@ export default function DynamicFormComponent({ return ( ( @@ -851,7 +873,7 @@ export default function DynamicFormComponent({ )} > diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx index ce125c70b..0312bb5d4 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx @@ -251,11 +251,13 @@ export default function DynamicFormItemComponent({ switch (config.type) { case DynamicFormItemType.INT: case DynamicFormItemType.FLOAT: + case DynamicFormItemType.NUMBER: return ( field.onChange(Number(e.target.value))} /> ); @@ -264,7 +266,11 @@ export default function DynamicFormItemComponent({ if (config.options && config.options.length > 0) { return (
- +