diff --git a/docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md b/docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md index e5be9fb5c..9a7b2f5d4 100644 --- a/docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md +++ b/docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md @@ -14,7 +14,7 @@ - Claude Code SDK / Codex 类 runtime 有自己的 session、transcript、tool loop 和上下文压缩。 - Pi Agent SDK 或外部 agent 平台可能只需要当前事件和一个外部 conversation key。 -因此 LangBot 不应强行决定最终传给模型的历史窗口。Host 只提供:当前事件的完整结构化信息、稳定身份和会话引用、可授权读取的 history / event / artifact / state API、可投影给外部 harness 的 scoped context / SDK-owned MCP bridge / resource handles、payload hard cap 和权限 guardrail。 +因此 LangBot 不应强行决定最终传给模型的历史窗口。Host 只提供:当前事件的完整结构化信息、稳定身份和会话引用、可授权读取的 history / event / state API、sandbox/workspace 文件能力、可投影给外部 harness 的 scoped context / SDK-owned MCP bridge / resource handles、payload hard cap 和权限 guardrail。 ### 1.2 Host 不定义通用历史窗口 @@ -44,12 +44,12 @@ LangBot 不提供 host-side inline history window。简单 runner 如果需要 - 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 / artifact / state / storage API when authorized. +- 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 和 artifact ref;actor / subject / conversation / thread / bot / workspace;delivery 能力;已授权资源列表;context cursors 和可用 API 能力;Agent/runner config。这些是 agent 决定下一步所需的最低信息。 +当前 event 的类型/id/时间/source;当前输入文本和结构化内容;附件/文件/图片的 metadata、path 或 URL;actor / subject / conversation / thread / bot / workspace;delivery 能力;已授权资源列表;context cursors 和可用 API 能力;Agent/runner config。这些是 agent 决定下一步所需的最低信息。 ### 2.2 默认不 inline 的内容 @@ -67,19 +67,19 @@ LangBot 不提供 host-side inline history window。简单 runner 如果需要 所有 API 都走 `AgentRunAPIProxy`(PROTOCOL_V1 §8),由 host 用 `run_id` 校验。 -外部 harness 不能直接访问 LangBot 资源。无论是 history、event、artifact、state、model、tool、knowledge base,还是 LangBot skills,都必须通过 SDK runtime 转发到 Host API,并由 Host 按 active `run_id`、runner identity、binding resource policy 和 caller plugin identity 校验。harness 自己的 native tools 只属于 harness 执行环境,不能绕过 SDK runtime 访问 LangBot 内部资源。 +外部 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_artifacts=False) + limit=50, direction="backward", include_attachments=False) ``` 返回 `HistoryPage`(schema 见 PROTOCOL_V1 §8)。 -约束:`limit` 有 host hard cap;默认只能读当前 conversation / thread;跨会话读取需 binding policy / run authorization snapshot 授权;返回 artifact ref,不默认返回大文件内容。 +约束:`limit` 有 host hard cap;默认只能读当前 conversation / thread;跨会话读取需 binding policy / run authorization snapshot 授权;可返回 attachment ref,不默认返回大文件内容。 ### 4.2 Search @@ -91,15 +91,14 @@ await api.history_search(query="用户之前提到的数据库连接信息", Search 可先用数据库全文索引,后续接 embedding recall。它是 host 检索能力,不等于 agent 的长期记忆策略。 -### 4.3 Event / Artifact / State +### 4.3 Event / State - Event API(`events.get` / `events.page`)用于读取非消息事件、工具事件、系统事件。Agent 不应把所有事件都当成 user/assistant message。 -- Artifact API(`artifact_metadata` / `artifact_read` / `artifact_read_range`)必须校验 artifact 所属 conversation / run / binding,校验 MIME / 大小 / 过期 / 权限,大文件按 range/file-key 读取,工具大结果也应 artifact 化。 - State API(`state.get` / `set`)是可选寄宿能力。自管 runtime 可以完全不用;依附 LangBot 的官方 runner 可以使用,例如 `external.session_id`、`summary.checkpoint`。 ### 4.4 大文件与工具协作 -大文件、多模态输入和工具产物不要内联进 prompt 或 tool result:message/content 里只放小文本和必要摘要;大文件、图片、音频、长工具输出返回 artifact ref(`artifact_id`、`mime_type`、`size`、`digest`、`summary`、`expires_at`、`permissions`)。工具之间传递大结果时传 artifact ref,不传完整 blob。Host 校验 artifact 是否属于当前 run / scope,默认不允许插件直接读任意本地路径;临时文件应有 TTL 和清理机制。 +大文件、多模态输入和工具产物不要内联进 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 @@ -114,24 +113,24 @@ Claude Code、Codex、Kimi Code 这类 runtime 通常已有自己的 session、 - `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 路径由 LiteLLM Agent Platform runner 承担(现状见 OFFICIAL_RUNNER_PLUGINS §7)。这类 projection 是"把 LangBot 事实源和授权资源句柄交给 harness",不是"把 LangBot 资源本体或内部权限交给 harness",也不是"由 LangBot 决定最终模型上下文"。 +当前官方外部 harness 路径由 ACP / Claude Code / Codex 等 runner 插件承担(现状见 OFFICIAL_RUNNER_PLUGINS §7)。这类 projection 是"把 LangBot 事实源和授权资源句柄交给 harness",不是"把 LangBot 资源本体或内部权限交给 harness",也不是"由 LangBot 决定最终模型上下文"。 ## 5. Runner 上下文边界 -Host 只给当前事件、当前输入和 context handles。Runner 是否能拉取历史、事件、artifact、state 或 storage,以运行时 `ctx.context.available_apis` 为准;runner 自己决定是否拉取历史、是否搜索、何时摘要、如何构造最终 prompt。 +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、artifact refs、少量 runtime metadata。 +- 每轮只传 delta:当前 event、attachment refs/path、少量 runtime metadata。 - 历史 append-only:不要每轮改写同一段 history 文本。 - Summary checkpoint 稳定:只有压缩发生时产生新 checkpoint。 -- 大文件和工具结果 artifact 化。 +- 大文件和工具结果写入 sandbox/workspace。 - Tool/context API schema 稳定,数据通过 API 拉取而非塞入 prompt。 - 对自管 runtime,优先让它复用自身 session/cache,而不是强制 LangBot 每轮重放 transcript。 -- LiteLLM 接入后,模型窗口元信息应作为 resource/runtime metadata 暴露给 runner,由 runner 决定预算和压缩策略。 +- 模型窗口元信息应作为 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 串行化。 @@ -139,7 +138,7 @@ Host 只给当前事件、当前输入和 context handles。Runner 是否能拉 ## 7. Host guardrail -Agent 自管 context 不代表无限制访问。LangBot 仍必须控制:每次 run 的 active `run_id`、runner identity、当前 binding 的 resource policy、conversation / actor / subject scope、page size / artifact read size / API rate limit、跨会话读取权限、数据脱敏和敏感变量过滤、审计日志。Host 不负责"最佳上下文策略",但负责"不越权、不爆内存、不不可审计"。 +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。 @@ -147,4 +146,4 @@ Agent 自管 context 不代表无限制访问。LangBot 仍必须控制:每次 官方 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 方法保存,图片/文件/工具大结果通过 `api.artifact_metadata()` / `api.artifact_read_range()` 读取,模型/工具/知识库通过 `api.invoke_llm()` / `api.call_tool()` / `api.retrieve_knowledge()` 调用。这样 LangBot 保持为通用 agent host,不变成内置 agent 框架。具体迁移要求见 [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md)。 +官方 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 index 2aa517023..bcd193243 100644 --- a/docs/agent-runner-pluginization/AGENT_RUNNER_QA_GUIDE.md +++ b/docs/agent-runner-pluginization/AGENT_RUNNER_QA_GUIDE.md @@ -16,8 +16,8 @@ event -> binding -> runner.run(ctx) -> result stream - Host 能通过当前 Query entry adapter 进入 event-first `run(event, binding)` 主链路。 - Runner 来自插件 registry,而不是旧内置 runner 分支。 -- `local-agent` 能消费 Host 模型、工具、知识库、history、state、artifact 等基础设施。 -- 外部 harness runner(当前为 LiteLLM Agent Platform 统一入口)能消费 event-first context,并把外部 session 指针写回 host-owned state。 +- `local-agent` 能消费 Host 模型、工具、知识库、history、state、sandbox 文件等基础设施。 +- 外部 harness runner(ACP / Claude Code / Codex 等直接 runner 插件)能消费 event-first context,并把外部 session 指针写回 host-owned state。 - 错误、权限裁剪、无输出、timeout 等路径不会破坏主聊天流程。 本指南不验证: @@ -49,7 +49,7 @@ event -> binding -> runner.run(ctx) -> result stream 1. Host / SDK / runner 单测。 2. WebUI 登录与 Pipeline Debug Chat 基础 smoke。 3. `local-agent` 高价值场景。 -4. LiteLLM Agent Platform 外部 harness smoke。 +4. 外部 code-agent harness smoke。 5. 权限和错误路径补充检查。 6. 汇总 PASS / FAIL / BLOCKED,并给出下一步建议。 @@ -149,29 +149,29 @@ bin/lbs case list Rerank、remove-think、文件输入等场景只在本次改动直接涉及时补测,不作为每轮必跑项。 -## 7. LiteLLM Agent Platform Harness Smoke +## 7. Code-agent Harness Smoke -这些测试用于验证 Claude Code / Codex 这类自管 runtime 经 LiteLLM Agent Platform 能走同一条 Host 协议路径。若 LiteLLM Agent Platform 服务不可用、目标 harness 没有 CLI/登录态/代理配置,标记 BLOCKED,不要伪造 PASS。 +这些测试用于验证 ACP、Claude Code、Codex 这类自管 runtime 能走同一条 Host 协议路径。若目标 harness 没有 CLI/daemon、登录态、代理配置或远端 workspace,标记 BLOCKED,不要伪造 PASS。 -Smoke 前应优先保留一层轻量单测或 fixture 测试:LiteLLM Agent Platform HTTP session、消息发送、结果解析、`run_id` 提示词注入和 LangBot MCP gateway 必须有稳定测试覆盖。WebUI smoke 证明真实链路可用,但不能替代转换层和错误映射测试。 +Smoke 前应优先保留一层轻量单测或 fixture 测试:session 创建/复用、消息发送、结果解析、`run_id` 注入和 LangBot MCP gateway 必须有稳定测试覆盖。WebUI smoke 证明真实链路可用,但不能替代转换层和错误映射测试。 -### 7.1 LiteLLM Agent Platform runner +### 7.1 外部 harness runner 步骤: -1. 确认 LiteLLM Agent Platform 服务可访问,目标 harness(例如 Claude Code 或 Codex)在该服务所在机器上可执行且已登录。 -2. 绑定 `plugin:langbot/litellm-agent-platform-agent/default`。 -3. 配置 `base-url`、`api-mode`、`agent-id` 或 `harness` 等必要字段。 +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。 -- 发送给 LiteLLM 的消息包含当前 LangBot `run_id` 和可访问资源摘要。 +- 发送给 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。 -- LiteLLM 服务错误、timeout、empty output 都转成受控 `run.failed`。 +- 外部 harness 错误、timeout、empty output 都转成受控 `run.failed`。 - resume 到同一 external session 时,全局锁边界符合 PROTOCOL_V1 §13。 ### 7.2 API 型外部 runner diff --git a/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md b/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md index 1fb136181..35fbba0f0 100644 --- a/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md +++ b/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md @@ -83,7 +83,7 @@ Delivery 方面,event 不一定回复到当前聊天窗口:消息事件通 ## 7. 与 Context 协议的关系 -EBA 事件进入 AgentRunner 时仍遵循 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md):inline 当前事件、大 payload 用 raw/artifact ref、不默认 inline 完整 history、agent 按需通过 API 拉取、Host 保留 EventLog 和权限 guardrail。非消息事件可以被投影进 Transcript,但不能强制伪装为 user message;AgentRunner 根据 event type 自己决定是否纳入模型上下文。 +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 分支联调内容 diff --git a/docs/agent-runner-pluginization/EXTENSION_SCOPE_MATRIX.md b/docs/agent-runner-pluginization/EXTENSION_SCOPE_MATRIX.md index a797104b1..eb7904956 100644 --- a/docs/agent-runner-pluginization/EXTENSION_SCOPE_MATRIX.md +++ b/docs/agent-runner-pluginization/EXTENSION_SCOPE_MATRIX.md @@ -11,7 +11,7 @@ | 范围 | 本分支职责 | 不在本分支做 | | --- | --- | --- | | 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 / Artifact / State。 | 不实现 EventGateway、scheduler、integration provider、Agent 管控面 UI。 | +| 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 协议形态。 | @@ -22,16 +22,16 @@ | 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 / Artifact | 已完成 Host-owned store 和 pull API;runner 不直接写 DB。 | 本分支持续维护底座;Agent Platform 可复用。 | 外部 EBA、scheduler、integration、runtime task 都写同一套 EventLog / Transcript / Artifact。 | 不让 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;大对象用 storage / artifact。 | 不把跨轮次状态存在插件实例内;不绕过 run-scoped authorization。 | +| 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 通过 LiteLLM Agent Platform runner 调用外部平台,不在本分支维护本机 subprocess 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 事实源。 | +| 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/artifact 基础能力。 | Agent Platform 或具体 runner/plugin。 | 平台 memory 可通过 Host storage/state 或独立产品表实现,runner 通过授权 API 拉取。 | 不在 Host core 内置通用 agentic memory 策略;不默认把 memory 全量 inline 到 context。 | -| External harness native session | LiteLLM Agent Platform runner 支持 external session id state handoff 和 LangBot resource projection。 | 官方 runner 后续增强;Runtime Control Plane v2 可接管执行。 | 外部平台调用继续走 `runner.run(ctx)`;如后续引入长连接/daemon 模式,按 external session key 串行 turn,reader 独占 native stream。 | 不把具体 provider native wire 变成 LangBot 协议;全局锁边界见 PROTOCOL_V1 §13。 | +| 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. 后续分支接入规则 @@ -39,13 +39,13 @@ - 新入口只生产或解析 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 / Artifact / State;产品层可以新增更高阶视图,但不能替代这些事实源。 +- 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. 与 LiteLLM Agent Platform 的关系 +## 4. 与 Agent Platform 产品层的关系 -这里的 LiteLLM Agent Platform 指面向 agent 产品层的实体拆分:`Agent` 描述可配置 agent,`Session` / `SessionMessage` 描述会话事实,`Automation` 描述自动触发,`IntegrationBinding` 描述外部集成连接,`Memory` 描述长期记忆,`WarmTask` 描述预热/后台任务。这些拆分对 LangBot 后续产品层有参考价值,但不能直接搬进本分支。 +这里的 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 index b8f3e2862..b8ba6fb7b 100644 --- a/docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md +++ b/docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md @@ -42,7 +42,8 @@ AgentRunOrchestrator |-- AgentResourceBuilder |-- AgentContextBuilder |-- AgentRunSessionRegistry - |-- PersistentStateStore / EventLogStore / TranscriptStore / ArtifactStore + |-- PersistentStateStore / EventLogStore / TranscriptStore + |-- Sandbox / workspace file tools v Plugin Runtime / AgentRunner | @@ -81,7 +82,7 @@ class AgentEventEnvelope(BaseModel): metadata: dict[str, Any] = {} ``` -`AgentEventEnvelope` 是 Host 内部入口模型;投影给 runner 的是 `ctx.event`(PROTOCOL_V1 §5.4)。原始平台 payload 存为 raw event 或 artifact ref,不扩散到 runner 协议顶层。 +`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`。 @@ -188,7 +189,8 @@ QueryEntryAdapter / EventRouter -> Host API / Store <- AgentRunResult stream -> apply state.updated to PersistentStateStore - -> write message.completed / artifact.created to Transcript / ArtifactStore + -> 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) ``` @@ -222,19 +224,19 @@ SDK 侧本地校验只用于开发体验,host 侧 run authorization snapshot 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 / Artifact(事实源) +### 4.7 EventLog / Transcript / Sandbox Files(事实源) - `EventLog`: durable append-only,保存原始事件、系统事件、工具调用、投递结果、错误。 - `Transcript`: 从 EventLog 投影出的对话视图,用于 UI、审计和按需历史读取。 -- `ArtifactStore`: 保存大文件、多模态输入、工具大结果、平台附件。 +- `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/ArtifactStore 和审计;Host 或 binding policy 决定哪些 MCP bridge、skill-backed tool、artifact、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 授权。 +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;当前 LiteLLM Agent Platform runner 形态见 OFFICIAL_RUNNER_PLUGINS §7。发布级隔离要求见 SECURITY_HARDENING。 +投影的具体形态(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 侧协议 diff --git a/docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md b/docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md index f3464b83b..bb232f221 100644 --- a/docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md +++ b/docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md @@ -16,7 +16,7 @@ langbot-app/ manifest.yaml components/agent_runner/default.{yaml,py} langbot-agent-runner/ # 外部服务 runner 仓库 - litellm-agent-platform-agent/ dify-agent/ n8n-agent/ ... + acp-agent-runner/ claude-code-agent/ codex-agent/ dify-agent/ n8n-agent/ ... ``` 后续可聚合进 monorepo,也可继续独立发布——这个选择不影响协议设计。重复逻辑优先沉淀到 SDK 或明确的共享 helper 包,不要把宿主私有结构泄漏给插件。旧 `src/langbot/pkg/provider/runners/*` 只作为历史行为对齐基准;当前未发布分支不提供旧内置 runner 的运行时 fallback。 @@ -29,7 +29,9 @@ langbot-app/ | `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/litellm-agent-platform-agent` | `plugin:langbot/litellm-agent-platform-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` | @@ -40,7 +42,7 @@ langbot-app/ ## 3. 迁移批次 -- **Batch 1(打通协议)**:`local-agent`(能力最完整基准)、`litellm-agent-platform-agent`(外部 code-agent harness 统一入口)、`dify-agent`(传统 service API runner)。 +- **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 状态)。 @@ -67,7 +69,7 @@ execution: ## 5. local-agent 插件方向 -`local-agent` 是官方插件中能力最完整的消费者,但不是宿主协议的设计中心。它需要证明:一个主要依附 LangBot host 能力的 agent runner 可以通过公开协议完成模型、工具、知识库、状态、history、artifact、上下文压缩和消息投递。 +`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。 @@ -93,13 +95,13 @@ Claude Code、Codex、Kimi Code 这类 runner 不一定通过 LangBot 的模型/ 本文件只补充官方 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`、`artifact.created`、`run.completed` / `run.failed`)。未知 native event 不应导致 run 崩溃;应记录诊断 metadata 或 warning。新增 harness 时优先补 native fixture -> `AgentRunResult` 的转换测试,再接 WebUI smoke。 +实现结构应把 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 资源。当前 LiteLLM Agent Platform runner 通过稳定 HTTP MCP gateway 把 harness 的工具请求转回 SDK runtime / Host API: +外部 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 和授权快照校验。 @@ -107,20 +109,20 @@ Claude Code、Codex、Kimi Code 这类 runner 不一定通过 LangBot 的模型/ 第一批工具保持很小:history page、knowledge retrieve、authorized tool call。新增工具必须先有 Host action 权限与 run-scoped authorization,再由 gateway 投影。 -## 7. LiteLLM Agent Platform runner 当前形态 +## 7. Code-agent harness runner 当前形态 -`litellm-agent-platform-agent` 是当前外部 harness runner 的统一入口,用来把 Claude Code、Codex 等具体执行器交给 LiteLLM Agent Platform / lite-harness 管理,而不是在 LangBot 官方 runner 仓库中维护每个 CLI provider 的独立适配器。本地 smoke 验收入口与记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。 +外部 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/litellm-agent-platform-agent/default`。 -- Runner 通过 HTTP 调用 LiteLLM Agent Platform,外部 harness 的安装、登录态、workspace 和 provider-native 权限由该平台所在运行环境负责。 +- 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 写回 Host state,后续轮次可复用目标平台会话。 +- 外部 session id / workspace / checkpoint 写回 Host state 或 plugin storage,后续轮次可复用目标 harness 会话。 ### 7.1 当前限制 -这不是发布级安全边界实现;LangBot 只约束 LangBot 持有资产的访问,外部 harness 的文件、进程、workspace、provider-native MCP 和模型凭据由 LiteLLM Agent Platform 部署侧承担。当前 `run_id` 由系统提示词传递给 harness 并由 gateway 校验,后续若 LiteLLM 原生支持 run-scoped MCP session,可切换为平台级传递。runtime 管控面方向见 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md)。 +这不是发布级安全边界实现;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. 发布和安装策略 @@ -132,5 +134,5 @@ Claude Code、Codex、Kimi Code 这类 runner 不一定通过 LangBot 的模型/ - LangBot 主聊天路径不再通过 `RequestRunner` 执行业务 runner。 - 官方插件测试覆盖非流式、流式、错误、timeout、配置缺失。 - `local-agent` 能完成模型 fallback、tool calling、知识库检索、多模态输入、静态绑定 prompt 消费、history API 拉取、rerank。 -- `litellm-agent-platform-agent` 或同类 code-agent harness runner 能消费 event-first context、投影 scoped resources、保存 external session state,并通过 WebUI Debug Chat smoke。 +- 外部 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 index 35ab7c1d6..b7e410c68 100644 --- a/docs/agent-runner-pluginization/PROTOCOL_V1.md +++ b/docs/agent-runner-pluginization/PROTOCOL_V1.md @@ -39,7 +39,7 @@ Protocol v1 **不定义**: `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/artifact API 保存跨轮次指针。它们内部可以继续使用自己的 session、tool loop、MCP、上下文压缩和权限模型。 +外部 harness runner(Claude Code、Codex、Kimi Code 等)也是 `AgentRunner`:它们消费 event-first `AgentRunContext`、返回 `AgentRunResult`,并通过 Host 授权的 state/storage API 保存跨轮次指针;当前运行文件和工具大结果进入 sandbox/workspace。它们内部可以继续使用自己的 session、tool loop、MCP、上下文压缩和权限模型。 ## 3. 协议演进 @@ -64,17 +64,11 @@ class AgentRunnerDiscovery(BaseModel): plugin_author: str plugin_name: str runner_name: str - runner_description: I18nObject | None = None manifest: AgentRunnerManifest - capabilities: AgentRunnerCapabilities # compatibility alias of manifest.capabilities - permissions: AgentRunnerPermissions # compatibility alias of manifest.permissions - config: list[DynamicFormItemSchema] = [] ``` `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。 -`capabilities` / `permissions` 顶层字段是兼容旧 discovery 消费方的冗余别名;新代码必须以 `manifest.capabilities` / `manifest.permissions` 为准。 - ### 4.2 AgentRunnerManifest 这里的 manifest 指 Runtime 返回给 Host 的 typed runner manifest: @@ -116,7 +110,7 @@ class AgentRunnerCapabilities(BaseModel): - `streaming`: runner 可以返回 `message.delta`。 - `tool_calling`: runner 可能调用 Host tool API。 - `knowledge_retrieval`: runner 可能调用 Host knowledge API。 -- `multimodal_input`: runner 可以处理非纯文本 input / artifact。 +- `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 在途追加消息。 @@ -132,7 +126,6 @@ class AgentRunnerPermissions(BaseModel): knowledge_bases: list[Literal["list", "retrieve"]] = [] history: list[Literal["page", "search"]] = [] events: list[Literal["get", "page"]] = [] - artifacts: list[Literal["metadata", "read"]] = [] storage: list[Literal["plugin", "workspace"]] = [] files: list[Literal["config", "knowledge"]] = [] @@ -161,7 +154,7 @@ effective_access = manifest.permissions ∩ binding.resource_policy ∩ current - Host 不得默认 inline 全量历史。 - Host 只 inline 当前 event / input 和 context handles。 - Runner 拥有 working context assembly。 -- Runner 可在授权后通过 Host history / event / artifact / state API 拉取更多上下文。 +- Runner 可在授权后通过 Host history / event / state API 拉取更多上下文,并通过授权 sandbox/workspace 工具访问当前运行文件。 - 历史窗口策略不属于 Protocol v1 字段,也不属于 Host 通用语义。 context 边界的设计理由见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。 @@ -242,7 +235,7 @@ class AgentEventContext(BaseModel): - `event_type` 使用 LangBot 稳定协议名,例如 `message.received`。稳定事件名清单见 [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md)。 - 平台原始事件名放入 `source_event_type`。 -- 大型原始 payload 必须放入 `raw_ref` 或 artifact,不应直接塞入 `data`。 +- 大型原始 payload 必须放入 `raw_ref` 或 staged file,不应直接塞入 `data`。 ### 5.5 Conversation / Actor / Subject @@ -281,11 +274,11 @@ class SubjectContext(BaseModel): class AgentInput(BaseModel): text: str | None = None contents: list[ContentElement] = [] - attachments: list[ArtifactRef] = [] + attachments: list[InputAttachment] = [] ``` - 文本、多模态、附件都属于当前 event input。 -- 大文件、图片、音频、工具大结果应以 artifact ref 传递。 +- 大文件、图片、音频、工具大结果应进入授权 sandbox/workspace,input attachment 只携带轻量 metadata/path/url/content。 - 平台原始消息链不属于 SDK `AgentInput`;需要诊断时放在 Host 内部 envelope 或 `ctx.adapter.extra` 的一次性兼容字段中,不作为长期 runner 合同。 ### 5.7 DeliveryContext @@ -329,8 +322,6 @@ class ContextAPICapabilities(BaseModel): history_search: bool = False event_get: bool = False event_page: bool = False - artifact_metadata: bool = False - artifact_read: bool = False state: bool = False storage: bool = False steering_pull: bool = False @@ -373,14 +364,13 @@ class AgentResources(BaseModel): tools: list[ToolResource] = [] knowledge_bases: list[KnowledgeBaseResource] = [] skills: list[SkillResource] = [] - files: list[FileResource] = [] 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 / Artifact 访问通过 `ctx.context.available_apis` 和 Host 侧 run session 校验控制,不作为可枚举 resource list 暴露。Runner 只能通过 `AgentRunAPIProxy` 访问这些能力。 +资源列表是本次 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 @@ -394,7 +384,6 @@ ResultType = Literal[ "message.completed", "tool.call.started", "tool.call.completed", - "artifact.created", "state.updated", "action.requested", "run.completed", @@ -432,7 +421,7 @@ class LLMTokenUsage(BaseModel): Host 边界分级校验: -- `message.delta`、`message.completed`、`artifact.created`、`state.updated`、`action.requested`、`run.completed`、`run.failed` 属于会影响投递或 Host 副作用的严格 payload;校验失败时丢弃该 result 并记录 warning。 +- `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。 @@ -444,13 +433,12 @@ Host 边界分级校验: | `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 }` | -| `artifact.created` | `{ "artifact_type": str, "artifact_id"?: str, "mime_type"?: str, "name"?: str, "size_bytes"?: int, "sha256"?: str, "metadata"?: dict, "content_base64"?: str }` | | `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 }` | -`artifact.created.content_base64` 是小 artifact 的 inline 通道;Host 解码后写入 ArtifactStore,当前 hard cap 是 1 MiB。大 artifact 应使用外部存储 / file key / 后续上传通道,不应塞入 result event。 +Runner 生成的大文件、工具输出和临时产物不通过 result event 回传;应写入当前 run 的授权 sandbox/workspace,再用消息文本、metadata 或 attachment reference 指向它们。 ### 7.3 稳定 result types @@ -460,7 +448,6 @@ Host 边界分级校验: | `message.completed` | 完整消息。 | ✅ | | `tool.call.started` | 工具调用开始的可观测事件。 | telemetry | | `tool.call.completed` | 工具调用完成的可观测事件。 | telemetry | -| `artifact.created` | runner 生成 artifact。 | ✅ | | `state.updated` | runner 请求更新 host-owned state。 | ✅ | | `action.requested` | runner 请求 Host 执行平台动作。 | **reserved / 仅 telemetry,不执行** | | `run.completed` | run 正常结束。 | ✅ | @@ -511,7 +498,7 @@ 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_artifacts=False) + limit=50, direction="backward", include_attachments=False) await api.history_search(query, filters=None, top_k=10) # Event(返回稳定 event envelope 或受限 raw ref,不默认返回大 payload) @@ -519,11 +506,6 @@ 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) -# Artifact(必须支持大小限制、MIME 校验、过期时间和授权范围) -await api.artifact_metadata(artifact_id) -await api.artifact_read(artifact_id, offset=0, limit=None) -await api.artifact_read_range(artifact_id, offset=0, length=65536) - # 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) @@ -532,8 +514,7 @@ 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() -# Files / Host info -await api.get_file(file_key) +# Host info await api.get_langbot_version() ``` @@ -593,7 +574,7 @@ class TranscriptItem(BaseModel): item_type: str = "message" content: str | None = None content_json: dict[str, Any] | None = None - artifact_refs: list[dict[str, Any]] = [] + attachment_refs: list[dict[str, Any]] = [] seq: int | None = None cursor: str | None = None created_at: int | None = None @@ -653,31 +634,6 @@ class SteeringInputItem(BaseModel): class SteeringPullResult(BaseModel): items: list[SteeringInputItem] = [] - -class ArtifactMetadata(BaseModel): - artifact_id: str - artifact_type: str - mime_type: str | None = None - name: str | None = None - size_bytes: int | None = None - sha256: str | None = None - source: str - conversation_id: str | None = None - run_id: str | None = None - runner_id: str | None = None - created_at: int | None = None - expires_at: int | None = None - metadata: dict[str, Any] = {} - -class ArtifactReadResult(BaseModel): - artifact_id: str - mime_type: str | None = None - size_bytes: int | None = None - offset: int = 0 - length: int | None = None - content_base64: str | None = None - file_key: str | None = None - has_more: bool = False ``` ## 9. 错误模型 @@ -720,11 +676,11 @@ Runner 失败使用 `run.failed`: Protocol v1 的安全边界在 Host: -- Runner 不能直接访问未授权 model/tool/kb/history/artifact/storage。 +- Runner 不能直接访问未授权 model/tool/kb/history/storage/sandbox。 - SDK 本地校验只提升开发体验,不能替代 Host 校验。 - 所有 resource id 对 runner 来说都是 opaque。 - 默认只能访问当前 conversation / thread 的 history;跨会话、workspace 级访问必须额外授权。 -- 大 payload 必须 artifact 化;`artifact.created.content_base64` 只用于小 artifact,当前 Host hard cap 是 1 MiB。 +- 大 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 插件实现。 @@ -764,7 +720,6 @@ entry adapter 只是迁移桥。它负责: ## 14. 开放问题 - `AgentBinding` 是否需要进入 SDK 文档作为只读诊断信息,还是完全 Host 内部。 -- ArtifactStore 是否复用现有 BinaryStorage backend,还是引入独立实体。 - 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 index 4b1aa527a..7aa6657ed 100644 --- a/docs/agent-runner-pluginization/README.md +++ b/docs/agent-runner-pluginization/README.md @@ -24,8 +24,9 @@ - Host-side `AgentEventEnvelope` / `AgentBinding` 模型 - `run(event, binding)` event-first 入口 - `QueryEntryAdapter`:Query → AgentEventEnvelope + AgentBinding -- EventLog / Transcript / ArtifactStore / PersistentStateStore -- History / Event / Artifact / State pull APIs +- EventLog / Transcript / PersistentStateStore +- History / Event / State pull APIs +- Sandbox/workspace read/write/exec 文件能力,用于当前 run 的上传文件、工具大结果和临时产物 - SDK runtime forwarding pull APIs + `caller_plugin_identity` 验证路径 ## 本分支不实现 @@ -52,7 +53,7 @@ EventGateway / EventRouter 在本文档中描述为 **external EBA branch integr **当前 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 / ArtifactStore / PersistentStateStore 写入,History / Event / Artifact / State pull API 可用)。 +主入口仍可由 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)。 @@ -67,7 +68,7 @@ EventGateway / EventRouter 在本文档中描述为 **external EBA branch integr | envelope | Host 内部事件封装,即 `AgentEventEnvelope`;runner 看到的是由它投影出的 `ctx.event`。 | | descriptor / manifest | runner discovery 的能力和配置描述;manifest 来自插件,descriptor 是 Host 校验后的注册表视图。 | | EBA | Event Based Agent,把消息、撤回、入群、定时任务等都统一成 host event 的接入方向;完整网关和路由在外部 EBA 分支联调。 | -| harness runner | LiteLLM Agent Platform、Claude Code、Codex 等已有自身 session / tool loop / MCP / 压缩机制的外部 runtime adapter。 | +| 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 主线。 | @@ -77,7 +78,7 @@ EventGateway / EventRouter 在本文档中描述为 **external EBA branch integr | --- | --- | | [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 如何按需拉取更多历史 / artifact / state,以及如何支持 KV cache 友好的上下文管理。 | +| [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 管控仍是后续可选阶段。 | @@ -98,7 +99,8 @@ EventGateway / EventRouter 在本文档中描述为 **external EBA branch integr - Agent / binding 配置解析 - run orchestration 和生命周期管理 - resource authorization 与 `run_id` 级权限校验 -- host-owned state / storage / event log / transcript / artifact 能力 +- host-owned state / storage / event log / transcript 能力 +- sandbox/workspace 文件 staging 与 read/write/exec 能力 - SDK `AgentRunner`、`AgentRunContext`、`AgentRunResult`、`AgentRunAPIProxy` 协议合同详见 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。 diff --git a/docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md b/docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md index a3a730016..4b4bb6f60 100644 --- a/docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md +++ b/docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md @@ -4,7 +4,7 @@ > 本文是当前决策版。协议数据结构仍以 [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 / Artifact / State 等 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) 为准。 +> 实现状态说明:本文描述的是 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. 当前决策 @@ -13,7 +13,7 @@ LangBot 后续定位应更像 **Agent Host / infrastructure provider / transfer 结论: - **Agent Platform 产品形态做成插件**。插件负责 agent 管理、策略、业务队列、UI、编排、多 agent 协作和产品体验。 -- **Agent Platform 所需的基础事实源做进 Host**。当前 Host 已保存 event、artifact、state、transcript、active run 权限快照、持久 run/result ledger、审计关联和通用控制状态。 +- **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。 @@ -21,7 +21,7 @@ LangBot 后续定位应更像 **Agent Host / infrastructure provider / transfer ```text LangBot Host - Current base: EventLog / runtime AgentBinding / Artifact / State / Transcript / active run authorization + 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 @@ -30,7 +30,7 @@ Agent Platform plugin Business queue / multi-agent orchestration / runtime selection policy AgentRunner plugin / external harness runtime - Connects LiteLLM Agent Platform / remote agent / subprocess / HTTP API + Connects ACP / remote daemon / local subprocess / HTTP API Executes and converts provider-native events to AgentRunResult ``` @@ -41,14 +41,14 @@ AgentRunner plugin / external harness runtime - 抹平不同 AgentRunner。 - 从 IM / Pipeline 入口触发 runner。 - 有 event-first context 方向。 -- 有 Host-owned EventLog / Transcript / Artifact / State。 +- 有 Host-owned EventLog / Transcript / State 和 sandbox/workspace 文件边界。 - 有 runner config 下发和 active run-scoped authorization。 -- 有 `run_id` 串联 event、transcript、artifact、state 和内存授权上下文。 +- 有 `run_id` 串联 event、transcript、state、sandbox 文件和内存授权上下文。 这还不是完整 Agent Platform。完整 Platform 至少还需要: - 可管理的 agent 资产:agent profile、binding、resource policy、runner config、可用状态。 -- 可观察的执行生命周期:run status、result stream、失败原因、artifact、审计、回放。 +- 可观察的执行生命周期:run status、result stream、失败原因、文件引用、审计、回放。 - 可运营的控制面:取消、重试、排队、并发、超时、恢复、诊断。 - 可产品化的调度体验:事件订阅、路由策略、任务板、多 agent 协作、项目/工作区视图。 @@ -66,7 +66,7 @@ Host 负责这些能力的通用事实源和安全边界;Platform 插件负责 - `EventLog` 保存输入事件和审计入口,并记录 `run_id` / `runner_id`。 - `Transcript` 保存对话历史投影,并用 `run_id` 关联 assistant 输出。 -- `ArtifactStore` 保存输入和 runner 产物,并用 `run_id` 做访问边界的一部分。 +- 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。 @@ -120,14 +120,13 @@ message.delta message.completed tool.call.started tool.call.completed -artifact.created state.updated action.requested run.completed run.failed ``` -Host 应保存这些输出事件,按 `run_id + sequence` 可回放。Transcript、Artifact、State 可以由这些 result event 触发写入现有 store,并保留能回溯到 `AgentRunEvent` 的关联。 +Host 应保存这些输出事件,按 `run_id + sequence` 可回放。Transcript、State 可以由这些 result event 触发写入现有 store,并保留能回溯到 `AgentRunEvent` 的关联。文件和工具大结果留在当前 run 的 sandbox/workspace 中,不作为 result event blob 回传。 ### 3.4 Queue @@ -211,7 +210,6 @@ data_json usage_json created_at source -artifact_refs_json metadata_json ``` @@ -220,7 +218,7 @@ metadata_json - 同一 `run_id` 内 `sequence` 单调递增。 - append 必须幂等,支持远程 daemon / plugin 重试。 - 未知 result type 可保存但 Host 只对已知类型执行副作用。 -- 大 payload 仍应转 artifact,不直接塞入 result event。 +- 大 payload 仍应进入 sandbox/workspace,不直接塞入 result event。 - `usage_json` 保存 `AgentRunResult.usage` 原样结构;缺失表示 unknown,不等于 0。 ### 4.3 Run Control API @@ -240,7 +238,7 @@ run.finalize 语义: - `run.create` 创建 Host-owned run 和授权快照。 -- `run.append_result` 只允许受信 SDK/runtime 路径调用,必须绑定 run 创建时固化的授权快照,写入 `AgentRunEvent` 并触发 transcript/artifact/state/delivery 副作用。 +- `run.append_result` 只允许受信 SDK/runtime 路径调用,必须绑定 run 创建时固化的授权快照,写入 `AgentRunEvent` 并触发 transcript/state/delivery 副作用。 - `run.finalize` 关闭 run,更新 terminal status。 - `run.cancel` 设置取消意图;同步 runner 通过 context/deadline 感知,远程 runner 通过插件/daemon 通道感知。 @@ -261,7 +259,7 @@ event -> binding -> context -> runner invocation -> result normalization - `run.completed` / 正常 generator 结束时标记 completed。 - `run.failed` / exception / timeout 标记 failed 或 timeout。 - terminal result 携带 usage 时,写入 `AgentRunEvent.usage_json` 并汇总到 `AgentRun.usage_json`。 -- `state.updated`、`artifact.created`、transcript 写入继续走现有 journal,但应与 `AgentRunEvent` 有可追踪关系。 +- `state.updated`、transcript 写入继续走现有 journal,但应与 `AgentRunEvent` 有可追踪关系。 ### 4.5 Usage / Cost Accounting @@ -275,7 +273,7 @@ SDK 侧 `AgentRunResult` 已提供可选 `usage` 字段,用于把不同 runner - 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、LiteLLM、remote daemon、local subprocess runner 如何从 native event 中抽取 usage,可在各插件后续适配。 +这层约束先解决协议位置和持久化位置;具体 ACP、remote daemon、local subprocess runner 如何从 native event 中抽取 usage,可在各插件后续适配。 ### 4.6 Authorization Snapshot @@ -289,7 +287,7 @@ SDK 侧 `AgentRunResult` 已提供可选 `usage` 字段,用于把不同 runner - state scopes - conversation/thread/workspace scope -后续 append result、state API、artifact API、history API 都以这个 snapshot 校验,不重新扩大权限。 +后续 append result、state API、history API 和 sandbox/workspace 文件访问都以这个 snapshot 校验,不重新扩大权限。 ## 5. SDK 侧应新增的最小能力 @@ -357,12 +355,12 @@ Agent Platform 插件可以负责: - 维护业务 queue:优先级、重试策略、人工审批、分配规则。 - 选择 runner / runtime / daemon。 - 在 Run Control API 落地后,调用 Host run API 创建、取消、查询执行。 -- 展示 run status、result stream、artifact、失败原因和审计。 +- 展示 run status、result stream、文件引用、失败原因和审计。 Platform 插件不应负责: - 在 Host Run Ledger 落地后,私有保存通用 run/result 事实源。 -- 绕过 Host 直接写 transcript/artifact/state。 +- 绕过 Host 直接写 transcript/state 或越权访问 sandbox/workspace 文件。 - 让外部 harness 直接访问 LangBot DB 或 Host 内部资源。 - 把某个业务队列语义强塞进 AgentRunner Protocol v1。 @@ -392,7 +390,7 @@ EventGateway -> plugin displays / Host delivers ``` -这两条路径最终应共享 Host run/result/artifact/state 事实源。当前阶段可共享的是 event/transcript/artifact/state 和同步执行链路;持久 run/result ledger 需要 Runtime Control Plane v2 Phase 1 补齐。区别在于是否有 Platform 插件参与产品化调度和业务队列。 +这两条路径最终应共享 Host run/result/state 事实源和 sandbox/workspace 文件边界。当前阶段可共享的是 event/transcript/state、sandbox 文件和同步执行链路;持久 run/result ledger 需要 Runtime Control Plane v2 Phase 1 补齐。区别在于是否有 Platform 插件参与产品化调度和业务队列。 ## 8. 与 AgentRunner Protocol v1 的关系 diff --git a/docs/agent-runner-pluginization/SECURITY_HARDENING.md b/docs/agent-runner-pluginization/SECURITY_HARDENING.md index 41fa8a760..43f74518c 100644 --- a/docs/agent-runner-pluginization/SECURITY_HARDENING.md +++ b/docs/agent-runner-pluginization/SECURITY_HARDENING.md @@ -8,7 +8,7 @@ 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、artifact、state、plugin/workspace storage 等。只要这些资源访问是 run-scoped、permission-scoped、可校验、可诊断的,当前阶段即可接受。 +LangBot 需要负责的是保护 **LangBot 自己持有的资源**,包括模型、知识库、LangBot tools、history、event、state、plugin/workspace storage、sandbox/workspace 文件访问等。只要这些资源访问是 run-scoped、permission-scoped、可校验、可诊断的,当前阶段即可接受。 这意味着: @@ -24,11 +24,11 @@ LangBot 需要负责的是保护 **LangBot 自己持有的资源**,包括模 - **资源授权**:根据 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、artifact ref、state snapshot 投影给 runner。 -- **LangBot artifact 路径约束**:LangBot 自己登记和读取的 file artifact 必须限制在声明 root 内,防止 path escape。 +- **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/artifact/transcript 事实。 +- **audit-lite**:记录 event、run id、runner id、binding、资源授权摘要、关键失败、state/file/transcript 事实。 ### Runner Plugin 负责 @@ -72,7 +72,7 @@ Claude Code、Codex、OpenCode、Kimi Code、Gemini CLI 等外部工具继续使 - 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、artifact、state、storage 等 action 做运行期校验。 +当前实现方向是正确的:`AgentRunSessionRegistry` 保存 run-scoped snapshot,`plugin/handler.py` 对模型、工具、知识库、history、state、storage 等 action 做运行期校验,sandbox/workspace 文件访问由 scoped tool 边界控制。 ### MCP / Asset Gateway Boundary @@ -93,9 +93,9 @@ LangBot MCP / asset gateway 只暴露当前 run 授权的工具面: LangBot 只需要约束自己管理的路径: -- Host 生成或登记的 file artifact 必须校验 `realpath` 和 root containment。 -- Artifact metadata 不应暴露 Host-only storage key / host path。 -- Context 文件、artifact 文件如由 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 用户权限和用户自担风险。 @@ -107,7 +107,7 @@ LangBot 只需要约束自己管理的路径: - LangBot 不主动把自己持有的 secret 投影给 runner,除非这是 runner config 明确需要的外部服务凭据。 - run token 是短期、run-scoped 的,不应长期保存。 -- 日志、错误、transcript、artifact metadata 尽量避免打印常见 secret 字段。 +- 日志、错误、transcript、attachment/file metadata 尽量避免打印常见 secret 字段。 - 配置 UI / API 返回时继续沿用现有 secret masking 规则。 不要求当前阶段实现完整 DLP、全链路敏感数据追踪、secret lineage 或自动轮换体系。 @@ -119,7 +119,7 @@ LangBot 需要提供基本可控性: - Host run deadline / runner timeout。 - runner 侧请求 timeout。 - generator close / cancel 传播。 -- 输出和 artifact inline size 上限。 +- 输出和 inline payload size 上限。 - 错误映射为受控 runner failure。 不要求 LangBot 为外部 harness 实现 CPU、内存、磁盘、网络、进程树强隔离。需要这些能力时由 Docker/K8s、systemd、容器平台或用户机器策略提供。 @@ -144,7 +144,7 @@ LangBot 需要提供基本可控性: - run id、runner id、binding、event。 - 授权资源摘要。 -- state update、artifact created、transcript message。 +- state update、file write/read event、transcript message。 - MCP / pull API 拒绝时的 warning。 - steering queued / injected / dropped。 @@ -154,7 +154,7 @@ LangBot 需要提供基本可控性: | 项目 | 当前要求 | 状态判断 | | --- | --- | --- | -| Path isolation | 只约束 LangBot 管理的 artifact/context 路径;runner workspace 归用户/部署环境。 | Minimal required | +| 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 | @@ -163,7 +163,7 @@ LangBot 需要提供基本可控性: | 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、artifact path、state size。 | Focused tests | +| Test matrix | 覆盖 run auth、MCP token、permission deny、timeout、sandbox path、state size。 | Focused tests | ## 当前实现快照 @@ -172,8 +172,8 @@ LangBot 需要提供基本可控性: - SDK typed AgentRunner manifest、capabilities、permissions。 - Host resource builder 按 manifest permissions 和 binding policy 生成 `ctx.resources`。 - Active run session snapshot 和 `caller_plugin_identity` 校验。 -- History / event / artifact / state / tool / knowledge runtime action 的 run-scoped 校验。 -- Artifact file path `realpath` + root containment。 +- 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 的接入。 @@ -183,7 +183,7 @@ LangBot 需要提供基本可控性: - 前端展示 runner LangBot 资源权限摘要。 - 常见 secret 字段 redaction 收敛成统一 helper。 -- Artifact/context TTL cleanup 调度。 +- Context/sandbox file TTL cleanup 调度。 - 更完整的 MCP 调用 audit。 - 更好的文档提示:ACP runner 是 operator-owned execution。 diff --git a/docs/agent-runner-pluginization/STATUS.md b/docs/agent-runner-pluginization/STATUS.md index ba5b3d977..ad9d8ea90 100644 --- a/docs/agent-runner-pluginization/STATUS.md +++ b/docs/agent-runner-pluginization/STATUS.md @@ -14,7 +14,7 @@ | 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`、LiteLLM Agent Platform、外部服务 runner 已重新声明真实生效的 LangBot resource permissions。 | +| 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` 审计终态。 | @@ -25,7 +25,7 @@ - `action.requested` 仍只作为 telemetry / reserved surface;platform action executor 不在本分支执行。 - EventGateway / EventRouter 完整实现由外部 EBA 分支联调;本分支只提供 event-first host envelope / binding / run 入口。 - State 与 storage 的长期类型边界仍可继续收窄;当前合同只要求 JSON-safe state 与受控 storage API。 -- Artifact 读取路径已检查 `expires_at`,EventLog / Transcript / Artifact 已提供显式 cleanup primitive;长期 retention 默认值、TTL 调度接入和大 payload 去重仍是运维收尾项,应在 Runtime Control Plane 产品化前补齐。 +- 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 登录态诊断。 @@ -35,7 +35,7 @@ | Runner | 状态 | 最近证据 | | --- | --- | --- | | `plugin:langbot/local-agent/default` | Unit-pass; UI smoke pending | 2026-06-10 本地 pytest / ruff 通过;WebUI smoke 由人工统一执行。 | -| `plugin:langbot/litellm-agent-platform-agent/default` | Unit-pass; E2E pending | 通过 runner 仓库单测覆盖 HTTP session、run_id prompt 注入和 LangBot MCP gateway;真实 harness E2E 取决于 LiteLLM Agent Platform 部署和 provider 登录态。 | +| `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 验收状态 @@ -52,6 +52,6 @@ 截至 2026-05-29,已有本地 smoke 证明: - `local-agent` 可以通过 Pipeline Debug Chat 走插件化 `AgentRunOrchestrator` 主链路。 -- 外部 harness runner 可以通过同一条 `run(event, binding)` 路径执行;当前官方实现已收敛到 LiteLLM Agent Platform runner,具体 Claude Code / Codex CLI provider 不再由本仓库直接维护。 +- 外部 harness runner 可以通过同一条 `run(event, binding)` 路径执行;当前官方实现已收敛到 ACP / Claude Code / Codex 等直接 runner 插件。 这些记录只证明本地协议闭环可用,不代表 LangBot 提供 managed sandbox 或 external harness OS 级隔离。 diff --git a/src/langbot/pkg/agent/runner/artifact_store.py b/src/langbot/pkg/agent/runner/artifact_store.py deleted file mode 100644 index 9afdbce81..000000000 --- a/src/langbot/pkg/agent/runner/artifact_store.py +++ /dev/null @@ -1,535 +0,0 @@ -"""Artifact store for managing Host-owned artifacts.""" -from __future__ import annotations - -import json -import datetime -import typing -import uuid -import base64 -import os - -import aiofiles -import sqlalchemy -from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession -from sqlalchemy.orm import sessionmaker - -from ...entity.persistence.artifact import AgentArtifact -from ...entity.persistence.bstorage import BinaryStorage - -_FILE_ARTIFACT_METADATA_KEY = '_langbot_file_artifact' -_ARTIFACT_THREAD_METADATA_KEY = '_langbot_thread_id' -UTC = datetime.timezone.utc - - -def _utc_now() -> datetime.datetime: - return datetime.datetime.now(UTC) - - -def _as_utc(value: datetime.datetime) -> datetime.datetime: - 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 - return int(_as_utc(value).timestamp()) - - -class ArtifactStore: - """Store for AgentArtifact records. - - Handles artifact metadata registration and content retrieval. - Actual blob storage is delegated to BinaryStorage or external storage. - - All methods are async and use the provided database engine. - """ - - engine: AsyncEngine - - # Hard limits - MAX_INLINE_READ_BYTES = 1024 * 1024 # 1MB max for inline base64 - MAX_RANGE_READ_BYTES = 10 * 1024 * 1024 # 10MB max for range reads - - def __init__(self, engine: AsyncEngine): - self.engine = engine - self._session_factory = sessionmaker( - engine, class_=AsyncSession, expire_on_commit=False - ) - - async def register_file_artifact( - self, - *, - artifact_id: str | None, - host_path: str, - host_root: str, - artifact_type: str = 'file', - source: str = 'tool', - mime_type: str | None = None, - name: str | None = None, - size_bytes: int | None = None, - sha256: str | None = None, - conversation_id: str | None = None, - run_id: str | None = None, - runner_id: str | None = None, - bot_id: str | None = None, - workspace_id: str | None = None, - thread_id: str | None = None, - expires_at: datetime.datetime | None = None, - metadata: dict[str, typing.Any] | None = None, - ) -> str: - """Register a Host-owned artifact backed by a bounded local file path. - - The public metadata intentionally excludes the real host path. Reads go - through read_artifact(), which revalidates the path against host_root. - """ - real_path, real_root = self._validate_file_artifact_path(host_path, host_root) - if not os.path.isfile(real_path): - raise ValueError('file artifact path must point to a file') - - public_metadata = dict(metadata or {}) - public_metadata[_FILE_ARTIFACT_METADATA_KEY] = { - 'path': real_path, - 'root': real_root, - } - - if size_bytes is None: - size_bytes = os.path.getsize(real_path) - - return await self.register_artifact( - artifact_id=artifact_id, - artifact_type=artifact_type, - source=source, - storage_key=f'file:{uuid.uuid4().hex}', - storage_type='file', - mime_type=mime_type, - name=name or os.path.basename(real_path), - size_bytes=size_bytes, - sha256=sha256, - conversation_id=conversation_id, - run_id=run_id, - runner_id=runner_id, - bot_id=bot_id, - workspace_id=workspace_id, - thread_id=thread_id, - expires_at=expires_at, - metadata=public_metadata, - content=None, - ) - - async def register_artifact( - self, - artifact_id: str | None, - artifact_type: str, - source: str, - storage_key: str | None = None, - storage_type: str = 'binary_storage', - mime_type: str | None = None, - name: str | None = None, - size_bytes: int | None = None, - sha256: str | None = None, - conversation_id: str | None = None, - run_id: str | None = None, - runner_id: str | None = None, - bot_id: str | None = None, - workspace_id: str | None = None, - thread_id: str | None = None, - expires_at: datetime.datetime | None = None, - metadata: dict[str, typing.Any] | None = None, - content: bytes | None = None, - ) -> str: - """Register a new artifact. - - If content is provided and storage_key is None, stores content - in BinaryStorage automatically. - - Args: - artifact_id: Unique artifact ID (generated if None) - artifact_type: Type of artifact (image, file, voice, tool_result, etc.) - source: Source of artifact (platform, runner, tool, system) - storage_key: Key in BinaryStorage or external reference - storage_type: Storage type (binary_storage, file, url) - mime_type: MIME type - name: Original file name - size_bytes: Size in bytes - sha256: SHA256 hash - conversation_id: Conversation ID - run_id: Run ID that created this - runner_id: Runner ID that created this - bot_id: Bot UUID - workspace_id: Workspace ID - thread_id: Thread ID stored as Host-only metadata - expires_at: Expiration time - metadata: Additional metadata - content: Optional content to store in BinaryStorage - - Returns: - The artifact_id - """ - if artifact_id is None: - artifact_id = str(uuid.uuid4()) - - metadata_payload = dict(metadata or {}) - if thread_id is not None: - metadata_payload[_ARTIFACT_THREAD_METADATA_KEY] = thread_id - - # If content provided, store in BinaryStorage - if content is not None and storage_key is None: - storage_key = f"artifact:{artifact_id}" - storage_type = 'binary_storage' - if size_bytes is None: - size_bytes = len(content) - - async with self._session_factory() as session: - # Store content in BinaryStorage if provided - if content is not None: - binary_storage = BinaryStorage( - unique_key=f'artifact:{artifact_id}', - key=storage_key, - owner_type='artifact', - owner='host', - value=content, - ) - session.add(binary_storage) - - # Store artifact metadata - artifact = AgentArtifact( - artifact_id=artifact_id, - artifact_type=artifact_type, - mime_type=mime_type, - name=name, - size_bytes=size_bytes, - sha256=sha256, - source=source, - storage_key=storage_key, - storage_type=storage_type, - conversation_id=conversation_id, - run_id=run_id, - runner_id=runner_id, - bot_id=bot_id, - workspace_id=workspace_id, - created_at=_utc_now(), - expires_at=expires_at, - metadata_json=json.dumps(metadata_payload) if metadata_payload else None, - ) - session.add(artifact) - await session.commit() - - return artifact_id - - async def get_metadata( - self, - artifact_id: str, - ) -> dict[str, typing.Any] | None: - """Get artifact metadata (public fields only, no internal storage info). - - Args: - artifact_id: Artifact ID - - Returns: - Artifact metadata dict compatible with SDK ArtifactMetadata, or None if not found - """ - async with self._session_factory() as session: - result = await session.execute( - sqlalchemy.select(AgentArtifact).where( - AgentArtifact.artifact_id == artifact_id - ) - ) - row = result.scalars().first() - if row is None: - return None - if self._is_expired(row): - return None - return self._row_to_public_dict(row) - - async def get_authorization_metadata( - self, - artifact_id: str, - ) -> dict[str, typing.Any] | None: - """Get artifact metadata with Host-only scope fields for authorization.""" - row = await self._get_internal_record(artifact_id) - if row is None: - return None - metadata = self._row_to_public_dict(row) - metadata.update({ - 'bot_id': row.bot_id, - 'workspace_id': row.workspace_id, - 'thread_id': self._load_metadata(row.metadata_json).get(_ARTIFACT_THREAD_METADATA_KEY), - }) - return metadata - - async def _get_internal_record( - self, - artifact_id: str, - ) -> AgentArtifact | None: - """Get full artifact record including internal fields. - - Used internally by read_artifact to access storage_key/storage_type. - - Args: - artifact_id: Artifact ID - - Returns: - AgentArtifact ORM instance, or None if not found - """ - async with self._session_factory() as session: - result = await session.execute( - sqlalchemy.select(AgentArtifact).where( - AgentArtifact.artifact_id == artifact_id - ) - ) - record = result.scalars().first() - if record is not None and self._is_expired(record): - return None - return record - - async def read_artifact( - self, - artifact_id: str, - offset: int = 0, - limit: int | None = None, - ) -> dict[str, typing.Any] | None: - """Read artifact content. - - For small artifacts, returns content_base64 directly. - For large artifacts, returns file_key for chunked transfer. - - Args: - artifact_id: Artifact ID - offset: Byte offset to start reading from (must be >= 0) - limit: Maximum bytes to read (must be > 0 if provided) - - Returns: - ArtifactReadResult dict, or None if not found - - Raises: - ValueError: If offset < 0 or limit <= 0 - """ - # Validate offset and limit - if offset < 0: - raise ValueError("offset must be >= 0") - - if limit is not None and limit <= 0: - raise ValueError("limit must be > 0") - - # Get internal record (includes storage_key/storage_type) - record = await self._get_internal_record(artifact_id) - if record is None: - return None - - storage_type = record.storage_type or 'binary_storage' - storage_key = record.storage_key - size_bytes = record.size_bytes or 0 - - # Cap limit at hard limit - if limit is None: - limit = self.MAX_INLINE_READ_BYTES - limit = min(limit, self.MAX_RANGE_READ_BYTES) - - # For binary_storage, read content - if storage_type == 'binary_storage' and storage_key: - content = await self._read_binary_storage(storage_key) - if content is None: - return None - - # Apply offset and limit - if offset > 0: - content = content[offset:] - if limit and len(content) > limit: - content = content[:limit] - has_more = True - else: - has_more = False - - return { - 'artifact_id': artifact_id, - 'mime_type': record.mime_type, - 'size_bytes': size_bytes, - 'offset': offset, - 'length': len(content), - 'content_base64': base64.b64encode(content).decode('utf-8'), - 'file_key': None, - 'has_more': has_more, - } - - if storage_type == 'file': - return await self._read_file_storage(record, artifact_id, offset, limit) - - # For other storage types, return storage reference - # (caller can use file_key for chunked transfer) - return { - 'artifact_id': artifact_id, - 'mime_type': record.mime_type, - 'size_bytes': size_bytes, - 'offset': offset, - 'length': None, - 'content_base64': None, - 'file_key': storage_key, - 'has_more': False, - } - - async def cleanup_expired_artifacts( - self, - *, - now: datetime.datetime | None = None, - ) -> int: - """Delete expired artifact metadata and Host-owned binary blobs. - - Returns the number of artifact metadata rows removed. External/file - storage references are only dereferenced from LangBot metadata; their - backing lifecycle remains owned by the storage provider. - """ - if now is None: - now = _utc_now() - - async with self._session_factory() as session: - result = await session.execute( - sqlalchemy.select(AgentArtifact).where( - AgentArtifact.expires_at.is_not(None), - AgentArtifact.expires_at <= now, - ) - ) - expired = result.scalars().all() - if not expired: - return 0 - - binary_storage_keys = [ - artifact.storage_key - for artifact in expired - if artifact.storage_type == 'binary_storage' and artifact.storage_key - ] - if binary_storage_keys: - await session.execute( - sqlalchemy.delete(BinaryStorage).where( - BinaryStorage.unique_key.in_(binary_storage_keys) - ) - ) - - await session.execute( - sqlalchemy.delete(AgentArtifact).where( - AgentArtifact.id.in_([artifact.id for artifact in expired]) - ) - ) - await session.commit() - return len(expired) - - async def _read_binary_storage(self, key: str) -> bytes | None: - """Read content from BinaryStorage. - - Uses unique_key for isolation to prevent cross-artifact access. - - Args: - key: The unique_key used when storing the artifact - - Returns: - Content bytes, or None if not found - """ - async with self._session_factory() as session: - result = await session.execute( - sqlalchemy.select(BinaryStorage).where(BinaryStorage.unique_key == key) - ) - row = result.scalars().first() - if row is None: - return None - return row.value - - async def _read_file_storage( - self, - record: AgentArtifact, - artifact_id: str, - offset: int, - limit: int, - ) -> dict[str, typing.Any] | None: - metadata = self._load_metadata(record.metadata_json) - file_info = metadata.get(_FILE_ARTIFACT_METADATA_KEY) - if not isinstance(file_info, dict): - return None - - host_path = file_info.get('path') - host_root = file_info.get('root') - if not isinstance(host_path, str) or not isinstance(host_root, str): - return None - - real_path, _ = self._validate_file_artifact_path(host_path, host_root) - if not os.path.isfile(real_path): - return None - - file_size = os.path.getsize(real_path) - if offset >= file_size: - content = b'' - else: - async with aiofiles.open(real_path, 'rb') as f: - await f.seek(offset) - content = await f.read(limit) - - return { - 'artifact_id': artifact_id, - 'mime_type': record.mime_type, - 'size_bytes': file_size, - 'offset': offset, - 'length': len(content), - 'content_base64': base64.b64encode(content).decode('utf-8'), - 'file_key': None, - 'has_more': offset + len(content) < file_size, - } - - @staticmethod - def _validate_file_artifact_path(host_path: str, host_root: str) -> tuple[str, str]: - real_path = os.path.realpath(host_path) - real_root = os.path.realpath(host_root) - if not real_root: - raise ValueError('file artifact root is required') - if not (real_path == real_root or real_path.startswith(real_root + os.sep)): - raise ValueError('file artifact path escapes allowed root') - return real_path, real_root - - @staticmethod - def _load_metadata(metadata_json: str | None) -> dict[str, typing.Any]: - if not metadata_json: - return {} - try: - metadata = json.loads(metadata_json) - except Exception: - return {} - return metadata if isinstance(metadata, dict) else {} - - @staticmethod - def _public_metadata(metadata_json: str | None) -> dict[str, typing.Any]: - metadata = ArtifactStore._load_metadata(metadata_json) - metadata.pop(_FILE_ARTIFACT_METADATA_KEY, None) - metadata.pop(_ARTIFACT_THREAD_METADATA_KEY, None) - return metadata - - @staticmethod - def _is_expired( - row: AgentArtifact, - now: datetime.datetime | None = None, - ) -> bool: - if row.expires_at is None: - return False - if now is None: - now = _utc_now() - return _as_utc(row.expires_at) <= _as_utc(now) - - def _row_to_public_dict(self, row: AgentArtifact) -> dict[str, typing.Any]: - """Convert an AgentArtifact row to public dict. - - Returns only fields that match SDK ArtifactMetadata entity. - Host-only fields (bot_id, workspace_id, storage_key, storage_type) are excluded. - """ - return { - 'artifact_id': row.artifact_id, - 'artifact_type': row.artifact_type, - 'mime_type': row.mime_type, - 'name': row.name, - 'size_bytes': row.size_bytes, - 'sha256': row.sha256, - 'source': row.source, - 'conversation_id': row.conversation_id, - 'run_id': row.run_id, - 'runner_id': row.runner_id, - 'created_at': _datetime_to_epoch(row.created_at), - 'expires_at': _datetime_to_epoch(row.expires_at), - 'metadata': self._public_metadata(row.metadata_json), - } diff --git a/src/langbot/pkg/agent/runner/config_schema.py b/src/langbot/pkg/agent/runner/config_schema.py index f89d523f5..ba841e59b 100644 --- a/src/langbot/pkg/agent/runner/config_schema.py +++ b/src/langbot/pkg/agent/runner/config_schema.py @@ -13,7 +13,6 @@ FORM_ITEM_TYPE_ALIASES = { LLM_MODEL_SELECTOR_TYPES = {'model-fallback-selector', 'llm-model-selector'} KB_SELECTOR_TYPES = {'knowledge-base-multi-selector'} PROMPT_EDITOR_TYPES = {'prompt-editor'} -FILE_SELECTOR_TYPES = {'file', 'array[file]'} NONE_SENTINELS = {'', '__none__', '__none'} @@ -132,44 +131,6 @@ def extract_knowledge_base_uuids( return list(dict.fromkeys(kb_uuids)) -def extract_config_file_resources( - descriptor: AgentRunnerDescriptor | None, - runner_config: dict[str, typing.Any], -) -> list[dict[str, typing.Any]]: - """Extract uploaded config file resources from schema-defined file fields.""" - files: list[dict[str, typing.Any]] = [] - - def append_file(value: typing.Any) -> None: - if not isinstance(value, dict): - return - file_key = value.get('file_key') or value.get('file_id') - if not isinstance(file_key, str) or file_key in NONE_SENTINELS: - return - files.append({ - 'file_id': file_key, - 'file_name': value.get('file_name') or value.get('name'), - 'mime_type': value.get('mime_type') or value.get('mimetype'), - 'source': 'config', - }) - - for item in iter_schema_items(descriptor, FILE_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 == 'file': - append_file(value) - elif isinstance(value, list): - for entry in value: - append_file(entry) - - deduped: dict[str, dict[str, typing.Any]] = {} - for file_resource in files: - deduped.setdefault(file_resource['file_id'], file_resource) - return list(deduped.values()) - - def iter_config_model_refs( descriptor: AgentRunnerDescriptor, runner_config: dict[str, typing.Any], diff --git a/src/langbot/pkg/agent/runner/context_builder.py b/src/langbot/pkg/agent/runner/context_builder.py index 0b99a52a6..7da30b40f 100644 --- a/src/langbot/pkg/agent/runner/context_builder.py +++ b/src/langbot/pkg/agent/runner/context_builder.py @@ -94,16 +94,6 @@ class SkillResource(typing.TypedDict): description: str | None -class FileResource(typing.TypedDict): - """File resource payload.""" - - file_id: str - file_name: str | None - mime_type: str | None - source: str | None - operations: list[str] - - class StorageResource(typing.TypedDict): """Storage resource payload.""" @@ -118,7 +108,6 @@ class AgentResources(typing.TypedDict): tools: list[ToolResource] knowledge_bases: list[KnowledgeBaseResource] skills: list[SkillResource] - files: list[FileResource] storage: StorageResource platform_capabilities: dict[str, typing.Any] @@ -411,15 +400,12 @@ class AgentRunContextBuilder: permissions = descriptor.permissions history_perms = set(permissions.history) event_perms = set(permissions.events) - artifact_perms = set(permissions.artifacts) 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 - artifact_metadata_enabled = 'metadata' in artifact_perms - artifact_read_enabled = 'read' in artifact_perms steering_pull_enabled = ( bool(getattr(descriptor.capabilities, 'steering', False)) and conversation_id is not None ) @@ -485,8 +471,6 @@ class AgentRunContextBuilder: 'history_search': history_search_enabled, 'event_get': event_get_enabled, 'event_page': event_page_enabled, - 'artifact_metadata': artifact_metadata_enabled, - 'artifact_read': artifact_read_enabled, 'state': state_enabled, 'storage': storage_enabled, 'steering_pull': steering_pull_enabled, diff --git a/src/langbot/pkg/agent/runner/host_models.py b/src/langbot/pkg/agent/runner/host_models.py index 42f5894b5..389f47cb0 100644 --- a/src/langbot/pkg/agent/runner/host_models.py +++ b/src/langbot/pkg/agent/runner/host_models.py @@ -66,7 +66,7 @@ class AgentEventEnvelope(pydantic.BaseModel): """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/artifacts.""" + """Small structured event payload. Large payloads should be referenced via raw_ref.""" # Binding scope types diff --git a/src/langbot/pkg/agent/runner/orchestrator.py b/src/langbot/pkg/agent/runner/orchestrator.py index 3205840b1..c3a92d34e 100644 --- a/src/langbot/pkg/agent/runner/orchestrator.py +++ b/src/langbot/pkg/agent/runner/orchestrator.py @@ -18,13 +18,13 @@ from .query_bridge import QueryRunBridge from .registry import AgentRunnerRegistry from .resource_builder import AgentResourceBuilder from .result_normalizer import AgentResultNormalizer -from .run_journal import AgentRunJournal, MAX_ARTIFACT_INLINE_BYTES as _MAX_ARTIFACT_INLINE_BYTES +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 -MAX_ARTIFACT_INLINE_BYTES = _MAX_ARTIFACT_INLINE_BYTES +ACTIVATED_SKILL_NAMES_STATE_KEY = 'host.activated_skills' class AgentRunOrchestrator: @@ -121,7 +121,6 @@ class AgentRunOrchestrator: 'state_context': state_context, } - pending_artifact_refs: list[dict[str, typing.Any]] = [] seen_sequences: set[int] = set() last_sequence = 0 assistant_transcript_written = False @@ -161,11 +160,6 @@ class AgentRunOrchestrator: run_id=run_id, runner_id=descriptor.id, ) - await self.journal.register_input_artifacts( - event=event, - 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, @@ -217,23 +211,6 @@ class AgentRunOrchestrator: ): continue - if result_type == 'artifact.created': - artifact_ref = await self.journal.handle_artifact_created( - result_dict=result_dict, - event=event, - run_id=run_id, - runner_id=descriptor.id, - ) - pending_artifact_refs.append(artifact_ref) - await self.journal.append_run_result( - result_dict=result_dict, - run_id=run_id, - sequence=sequence_int, - artifact_refs=[artifact_ref], - ) - await self.result_normalizer.normalize(result_dict, descriptor) - continue - await self.journal.append_run_result( result_dict=result_dict, run_id=run_id, @@ -275,18 +252,11 @@ class AgentRunOrchestrator: and bool(result_dict['data'].get('message')) ) if has_completed_message and event.conversation_id and not assistant_transcript_written: - merged_refs = self.journal.merge_artifact_refs( - pending_artifact_refs, - result_dict, - ) - pending_artifact_refs.clear() - await self.journal.write_assistant_transcript( result_dict=result_dict, event=event, run_id=run_id, runner_id=descriptor.id, - artifact_refs=merged_refs if merged_refs else None, ) assistant_transcript_written = True @@ -396,11 +366,6 @@ class AgentRunOrchestrator: }, }, ) - await self.journal.register_input_artifacts( - event=event, - run_id=target_run_id, - runner_id=descriptor.id, - ) await self.journal.write_user_transcript(event, event_log_id) except Exception as exc: self.ap.logger.warning( @@ -503,20 +468,6 @@ class AgentRunOrchestrator: ) -> str: return await self.journal.write_event_log(event, binding, run_id, runner_id) - async def _register_input_artifacts( - self, - event: AgentEventEnvelope, - run_id: str, - runner_id: str, - ) -> None: - await self.journal.register_input_artifacts(event, run_id, runner_id) - - def _decode_attachment_content( - self, - content: typing.Any, - ) -> tuple[bytes | None, str | None]: - return self.journal.decode_attachment_content(content) - async def _write_user_transcript( self, event: AgentEventEnvelope, @@ -524,34 +475,16 @@ class AgentRunOrchestrator: ) -> None: await self.journal.write_user_transcript(event, event_log_id) - async def _handle_artifact_created( - self, - result_dict: dict[str, typing.Any], - event: AgentEventEnvelope, - run_id: str, - runner_id: str, - ) -> dict[str, typing.Any]: - return await self.journal.handle_artifact_created(result_dict, event, run_id, runner_id) - - def _merge_artifact_refs( - self, - pending_refs: list[dict[str, typing.Any]], - result_dict: dict[str, typing.Any], - ) -> list[dict[str, typing.Any]]: - return self.journal.merge_artifact_refs(pending_refs, result_dict) - async def _write_assistant_transcript( self, result_dict: dict[str, typing.Any], event: AgentEventEnvelope, run_id: str, runner_id: str, - artifact_refs: list[dict[str, typing.Any]] | None = None, ) -> None: await self.journal.write_assistant_transcript( result_dict=result_dict, event=event, run_id=run_id, runner_id=runner_id, - artifact_refs=artifact_refs, ) diff --git a/src/langbot/pkg/agent/runner/query_entry_adapter.py b/src/langbot/pkg/agent/runner/query_entry_adapter.py index ad15730c0..a5540bb64 100644 --- a/src/langbot/pkg/agent/runner/query_entry_adapter.py +++ b/src/langbot/pkg/agent/runner/query_entry_adapter.py @@ -448,8 +448,6 @@ class QueryEntryAdapter: contents: list[dict[str, typing.Any]], ) -> list[dict[str, typing.Any]]: """Extract attachments from query.""" - import uuid - attachments: list[dict[str, typing.Any]] = [] seen_keys: dict[tuple[str, str, str], set[str]] = {} @@ -466,35 +464,30 @@ class QueryEntryAdapter: for elem in contents: elem_type = elem.get('type') - artifact_id = str(uuid.uuid4()) # Generate unique ID if elem_type == 'image_url': image_url = elem.get('image_url') or {} add_attachment({ - 'artifact_id': artifact_id, - 'artifact_type': 'image', + '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({ - 'artifact_id': artifact_id, - 'artifact_type': 'image', + 'type': 'image', 'source': 'base64', 'content': elem.get('image_base64'), }) elif elem_type == 'file_url': add_attachment({ - 'artifact_id': artifact_id, - 'artifact_type': 'file', + 'type': 'file', 'source': 'url', 'url': elem.get('file_url'), 'name': elem.get('file_name'), }) elif elem_type == 'file_base64': add_attachment({ - 'artifact_id': artifact_id, - 'artifact_type': 'file', + 'type': 'file', 'source': 'base64', 'content': elem.get('file_base64'), 'name': elem.get('file_name'), @@ -508,15 +501,12 @@ class QueryEntryAdapter: message_components = iter(()) for component in message_components: - artifact_id = str(uuid.uuid4()) # Generate unique ID - 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({ - 'artifact_id': artifact_id, - 'artifact_type': 'image', + 'type': 'image', 'source': 'message_chain', 'id': image_id, 'url': image_url, @@ -524,8 +514,7 @@ class QueryEntryAdapter: }) elif isinstance(component, platform_message.File): add_attachment({ - 'artifact_id': artifact_id, - 'artifact_type': 'file', + 'type': 'file', 'source': 'message_chain', 'id': component.id or None, 'name': component.name or None, @@ -534,8 +523,7 @@ class QueryEntryAdapter: }) elif isinstance(component, platform_message.Voice): add_attachment({ - 'artifact_id': artifact_id, - 'artifact_type': 'voice', + 'type': 'voice', 'source': 'message_chain', 'id': component.voice_id or None, 'url': component.url or None, @@ -550,15 +538,15 @@ class QueryEntryAdapter: attachment: dict[str, typing.Any], ) -> tuple[str, str, str] | None: """Return a stable key for the same attachment across content sources.""" - artifact_type = attachment.get('artifact_type') - if not artifact_type: + 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(artifact_type), field, str(value) + return str(attachment_type), field, str(value) return None @classmethod diff --git a/src/langbot/pkg/agent/runner/registry.py b/src/langbot/pkg/agent/runner/registry.py index dc174bd50..1a76de81b 100644 --- a/src/langbot/pkg/agent/runner/registry.py +++ b/src/langbot/pkg/agent/runner/registry.py @@ -5,7 +5,6 @@ from __future__ import annotations import typing import asyncio -import pydantic from langbot_plugin.api.entities.builtin.agent_runner.manifest import ( AgentRunnerManifest, ) @@ -84,8 +83,7 @@ class AgentRunnerRegistry: Args: runner_data: Raw runner data from plugin runtime with fields: - plugin_author, plugin_name, runner_name - - manifest (typed AgentRunnerManifest or legacy component manifest) - - capabilities, permissions, config (extracted from spec) + - manifest (typed AgentRunnerManifest) Returns: AgentRunnerDescriptor if valid, None if invalid @@ -105,31 +103,16 @@ class AgentRunnerRegistry: runner_name=runner_name, ) - is_typed_manifest = self._looks_like_typed_manifest(manifest) - if is_typed_manifest: - typed_manifest = AgentRunnerManifest.model_validate(manifest) - else: - typed_manifest = self._build_typed_manifest_from_legacy_data( - runner_id=runner_id, - runner_name=runner_name, - runner_data=runner_data, - manifest=manifest, - ) - - if runner_data.get('config'): - config_schema = runner_data['config'] - elif not is_typed_manifest and isinstance(manifest.get('spec'), dict): - config_schema = manifest['spec'].get('config', []) - else: - config_schema = [ - item.model_dump(mode='json') for item in typed_manifest.config_schema - ] + 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 or runner_data.get('runner_description'), + description=typed_manifest.description, plugin_author=plugin_author, plugin_name=plugin_name, runner_name=runner_name, @@ -140,64 +123,6 @@ class AgentRunnerRegistry: raw_manifest=manifest, ) - def _looks_like_typed_manifest(self, manifest: dict[str, typing.Any]) -> bool: - """Return whether manifest is the SDK typed AgentRunnerManifest shape.""" - return ( - isinstance(manifest, dict) - and 'id' in manifest - and 'name' in manifest - and 'label' in manifest - ) - - def _build_typed_manifest_from_legacy_data( - self, - *, - runner_id: str, - runner_name: str, - runner_data: dict[str, typing.Any], - manifest: dict[str, typing.Any], - ) -> AgentRunnerManifest: - """Validate legacy raw component manifest data as typed runner manifest.""" - - # Validate kind - kind = manifest.get('kind', '') - if kind != 'AgentRunner': - raise ValueError(f'Invalid AgentRunner kind: {kind or ""}') - - # Validate metadata - metadata = manifest.get('metadata', {}) - name = metadata.get('name', '') - if not name: - raise ValueError('Missing AgentRunner metadata.name') - - # metadata.label must exist - label = metadata.get('label', {}) - if not label: - label = {name: name} # fallback - - spec = manifest.get('spec', {}) - - # SDK now provides these directly extracted from spec. Fall back to - # manifest.spec for older runtimes/tests that return the raw manifest. - config_schema = runner_data.get('config') or spec.get('config', []) - capabilities = runner_data.get('capabilities') or spec.get('capabilities', {}) - permissions = runner_data.get('permissions') or spec.get('permissions', {}) - - try: - return AgentRunnerManifest( - id=runner_id, - name=runner_name, - label=label, - description=metadata.get('description') or runner_data.get('runner_description'), - capabilities=capabilities, - permissions=permissions, - config_schema=config_schema, - ) - except pydantic.ValidationError: - raise - except Exception as exc: - raise ValueError(f'Invalid AgentRunner manifest: {exc}') from exc - async def refresh(self) -> None: """Refresh runner cache. diff --git a/src/langbot/pkg/agent/runner/resource_builder.py b/src/langbot/pkg/agent/runner/resource_builder.py index 02f270173..1abc3cf1c 100644 --- a/src/langbot/pkg/agent/runner/resource_builder.py +++ b/src/langbot/pkg/agent/runner/resource_builder.py @@ -11,7 +11,6 @@ from .context_builder import ( ToolResource, KnowledgeBaseResource, SkillResource, - FileResource, StorageResource, ) from . import config_schema @@ -26,7 +25,7 @@ class AgentResourceBuilder: - Build models list from authorized models - Build tools list from bound plugins/MCP servers - Build knowledge_bases list from config - - Build storage and files access summary + - 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. @@ -80,14 +79,12 @@ class AgentResourceBuilder: resource_policy, descriptor ) storage = self._build_storage_from_binding(manifest_perms, binding) - files = self._build_files_from_binding(manifest_perms, descriptor, runner_config) return { 'models': models, 'tools': tools, 'knowledge_bases': knowledge_bases, 'skills': skills, - 'files': files, 'storage': storage, 'platform_capabilities': {}, # Reserved for EBA } @@ -231,27 +228,6 @@ class AgentResourceBuilder: }) return skills - def _build_files_from_binding( - self, - manifest_perms: typing.Any, - descriptor: AgentRunnerDescriptor, - runner_config: dict[str, typing.Any], - ) -> list[FileResource]: - """Build config/knowledge file resources selected in runner config.""" - file_perms = set(manifest_perms.files) - operations = [operation for operation in ('config', 'knowledge') if operation in file_perms] - if not operations: - return [] - - files: list[FileResource] = [] - if 'config' in file_perms: - for file_resource in config_schema.extract_config_file_resources(descriptor, runner_config): - files.append({ - **file_resource, - 'operations': ['config'], - }) - return files - def _build_storage_from_binding( self, manifest_perms: typing.Any, diff --git a/src/langbot/pkg/agent/runner/result_normalizer.py b/src/langbot/pkg/agent/runner/result_normalizer.py index 9a5d0b16a..0e99ef81e 100644 --- a/src/langbot/pkg/agent/runner/result_normalizer.py +++ b/src/langbot/pkg/agent/runner/result_normalizer.py @@ -6,7 +6,6 @@ import typing import pydantic from langbot_plugin.api.entities.builtin.agent_runner.result import ( ActionRequestedPayload, - ArtifactCreatedPayload, MessageCompletedPayload, MessageDeltaPayload, RunCompletedPayload, @@ -27,7 +26,6 @@ STRICT_RESULT_PAYLOADS: dict[str, type[pydantic.BaseModel]] = { 'message.delta': MessageDeltaPayload, 'message.completed': MessageCompletedPayload, 'state.updated': StateUpdatedPayload, - 'artifact.created': ArtifactCreatedPayload, 'action.requested': ActionRequestedPayload, 'run.completed': RunCompletedPayload, 'run.failed': RunFailedPayload, @@ -166,15 +164,6 @@ class AgentResultNormalizer: ) return None - elif result_type == 'artifact.created': - # Log for telemetry, consumed by orchestrator - artifact_id = data.get('artifact_id', 'unknown') - artifact_type = data.get('artifact_type', 'unknown') - self.ap.logger.debug( - f'Runner {descriptor.id} artifact.created logged: artifact_id={artifact_id}, type={artifact_type}' - ) - return None - else: # Unknown type - warn and ignore. self.ap.logger.warning( diff --git a/src/langbot/pkg/agent/runner/run_journal.py b/src/langbot/pkg/agent/runner/run_journal.py index 985ca643c..fdfdfed16 100644 --- a/src/langbot/pkg/agent/runner/run_journal.py +++ b/src/langbot/pkg/agent/runner/run_journal.py @@ -12,12 +12,8 @@ from .persistent_state_store import PersistentStateStore, get_persistent_state_s from .run_ledger_store import RunLedgerStore -# Maximum inline artifact content size (1MB) -MAX_ARTIFACT_INLINE_BYTES = 1 * 1024 * 1024 - - class AgentRunJournal: - """Persist run events, transcript records, artifacts, and state updates.""" + """Persist run events, transcript records, and state updates.""" ap: app.Application @@ -107,7 +103,6 @@ class AgentRunJournal: run_id: str, sequence: int, source: str = 'runner', - artifact_refs: list[dict[str, typing.Any]] | None = None, metadata: dict[str, typing.Any] | None = None, ) -> dict[str, typing.Any]: """Persist one AgentRunResult in the run ledger.""" @@ -121,7 +116,6 @@ class AgentRunJournal: data=result_dict.get('data') if isinstance(result_dict.get('data'), dict) else {}, usage=usage if isinstance(usage, dict) else None, source=source, - artifact_refs=artifact_refs, metadata=metadata, ) @@ -248,97 +242,6 @@ class AgentRunJournal: metadata=metadata, ) - async def register_input_artifacts( - self, - event: AgentEventEnvelope, - run_id: str, - runner_id: str, - ) -> None: - """Register current-event attachments referenced by AgentInput.""" - if not event.input or not event.input.attachments: - return - - from .artifact_store import ArtifactStore - - store = ArtifactStore(self.ap.persistence_mgr.get_db_engine()) - - for attachment in event.input.attachments: - data = attachment.model_dump(mode='json') if hasattr(attachment, 'model_dump') else attachment - if not isinstance(data, dict): - continue - - artifact_id = data.get('artifact_id') - artifact_type = data.get('artifact_type') or 'file' - if not artifact_id: - continue - - content, parsed_mime_type = self.decode_attachment_content(data.get('content')) - url = data.get('url') - platform_ref_id = data.get('id') - storage_key = None - storage_type = 'metadata_only' - if content is None: - if url: - storage_key = url - storage_type = 'url' - elif platform_ref_id: - storage_key = platform_ref_id - storage_type = 'platform_ref' - - metadata = { - 'input_attachment': True, - 'input_source': data.get('source') or 'platform', - } - if url: - metadata['url'] = url - if platform_ref_id: - metadata['platform_ref_id'] = platform_ref_id - - try: - await store.register_artifact( - artifact_id=artifact_id, - artifact_type=artifact_type, - source='platform', - storage_key=storage_key, - storage_type=storage_type, - mime_type=data.get('mime_type') or parsed_mime_type, - name=data.get('name'), - size_bytes=data.get('size') or (len(content) if content is not None else None), - conversation_id=event.conversation_id, - run_id=run_id, - runner_id=runner_id, - bot_id=event.bot_id, - workspace_id=event.workspace_id, - thread_id=event.thread_id, - metadata=metadata, - content=content, - ) - except Exception as e: - self.ap.logger.warning(f'Failed to register input artifact {artifact_id}: {e}') - - def decode_attachment_content( - self, - content: typing.Any, - ) -> tuple[bytes | None, str | None]: - """Decode base64 attachment content, including data URLs.""" - if not isinstance(content, str) or not content: - return None, None - - import base64 - import binascii - - mime_type = None - payload = content - if content.startswith('data:') and ',' in content: - header, payload = content.split(',', 1) - if ';base64' in header: - mime_type = header[5:].split(';', 1)[0] or None - - try: - return base64.b64decode(payload, validate=False), mime_type - except (binascii.Error, ValueError): - return None, mime_type - async def write_user_transcript( self, event: AgentEventEnvelope, @@ -357,10 +260,10 @@ class AgentRunJournal: 'content': self._sanitize_contents(event.input.contents) if event.input.contents else [], } - artifact_refs = [] + attachment_refs = [] if event.input and event.input.attachments: for a in event.input.attachments: - artifact_refs.append(self._sanitize_attachment_ref(a)) + attachment_refs.append(self._sanitize_attachment_ref(a)) await store.append_transcript( transcript_id=None, @@ -371,7 +274,7 @@ class AgentRunJournal: workspace_id=event.workspace_id, content=content, content_json=content_json, - artifact_refs=artifact_refs if artifact_refs else None, + attachment_refs=attachment_refs if attachment_refs else None, thread_id=event.thread_id, item_type='message', metadata={ @@ -380,139 +283,6 @@ class AgentRunJournal: }, ) - async def handle_artifact_created( - self, - result_dict: dict[str, typing.Any], - event: AgentEventEnvelope, - run_id: str, - runner_id: str, - ) -> dict[str, typing.Any]: - """Handle artifact.created result, register artifact, and write EventLog.""" - import base64 - import uuid - - from .artifact_store import ArtifactStore - from .event_log_store import EventLogStore - - data = result_dict.get('data', {}) - - result_run_id = result_dict.get('run_id') - if result_run_id and result_run_id != run_id: - raise RunnerProtocolError( - runner_id, - f'artifact.created run_id mismatch: expected {run_id}, got {result_run_id}', - ) - - artifact_id = data.get('artifact_id') or str(uuid.uuid4()) - artifact_type = data.get('artifact_type') - if not artifact_type: - raise RunnerProtocolError( - runner_id, - 'artifact.created missing required field: artifact_type', - ) - - mime_type = data.get('mime_type') - name = data.get('name') - size_bytes = data.get('size_bytes') - sha256 = data.get('sha256') - metadata = data.get('metadata') - content_base64 = data.get('content_base64') - - content: bytes | None = None - if content_base64: - try: - content = base64.b64decode(content_base64, validate=True) - except Exception as e: - raise RunnerProtocolError( - runner_id, - f'artifact.created invalid base64 content: {e}', - ) - - if len(content) > MAX_ARTIFACT_INLINE_BYTES: - raise RunnerProtocolError( - runner_id, - f'artifact.created content size {len(content)} bytes exceeds limit {MAX_ARTIFACT_INLINE_BYTES} bytes', - ) - - artifact_store = ArtifactStore(self.ap.persistence_mgr.get_db_engine()) - try: - registered_id = await artifact_store.register_artifact( - artifact_id=artifact_id, - artifact_type=artifact_type, - source='runner', - mime_type=mime_type, - name=name, - size_bytes=size_bytes, - sha256=sha256, - conversation_id=event.conversation_id, - run_id=run_id, - runner_id=runner_id, - bot_id=event.bot_id, - workspace_id=event.workspace_id, - thread_id=event.thread_id, - metadata=metadata, - content=content, - ) - except Exception as e: - raise RunnerProtocolError( - runner_id, - f'artifact.created failed to register artifact: {e}', - ) - - event_log_store = EventLogStore(self.ap.persistence_mgr.get_db_engine()) - await event_log_store.append_event( - event_id=str(uuid.uuid4()), - event_type='artifact.created', - source='runner', - 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, - input_summary=f'Artifact created: {artifact_type}', - input_json={ - 'artifact_id': registered_id, - 'artifact_type': artifact_type, - 'mime_type': mime_type, - 'name': name, - 'size_bytes': size_bytes, - }, - run_id=run_id, - runner_id=runner_id, - ) - - return { - 'artifact_id': registered_id, - 'artifact_type': artifact_type, - 'mime_type': mime_type, - 'name': name, - } - - def merge_artifact_refs( - self, - pending_refs: list[dict[str, typing.Any]], - result_dict: dict[str, typing.Any], - ) -> list[dict[str, typing.Any]]: - """Merge pending artifact refs with a message's own refs.""" - merged = list(pending_refs) - seen_ids = {ref.get('artifact_id') for ref in pending_refs if ref.get('artifact_id')} - - data = result_dict.get('data', {}) - message = data.get('message', {}) - message_refs = message.get('artifact_refs', []) - - if isinstance(message_refs, list): - for ref in message_refs: - if isinstance(ref, dict): - artifact_id = ref.get('artifact_id') - if artifact_id and artifact_id not in seen_ids: - merged.append(ref) - seen_ids.add(artifact_id) - - return merged - async def write_steering_dropped_audits( self, items: list[dict[str, typing.Any]], @@ -592,7 +362,6 @@ class AgentRunJournal: event: AgentEventEnvelope, run_id: str, runner_id: str, - artifact_refs: list[dict[str, typing.Any]] | None = None, ) -> None: """Write assistant message to Transcript.""" import uuid @@ -632,7 +401,6 @@ class AgentRunJournal: workspace_id=event.workspace_id, content=content, content_json=content_json, - artifact_refs=artifact_refs, thread_id=event.thread_id, item_type='message', run_id=run_id, diff --git a/src/langbot/pkg/agent/runner/run_ledger_store.py b/src/langbot/pkg/agent/runner/run_ledger_store.py index 8f89df941..1f54629b7 100644 --- a/src/langbot/pkg/agent/runner/run_ledger_store.py +++ b/src/langbot/pkg/agent/runner/run_ledger_store.py @@ -258,7 +258,6 @@ class RunLedgerStore: data: dict[str, typing.Any] | None = None, usage: dict[str, typing.Any] | None = None, source: str = 'runner', - artifact_refs: list[dict[str, typing.Any]] | None = None, metadata: dict[str, typing.Any] | None = None, ) -> dict[str, typing.Any]: """Append one run result event. @@ -285,7 +284,6 @@ class RunLedgerStore: usage_json=_json_dumps(usage), created_at=_utc_now(), source=source, - artifact_refs_json=_json_dumps(artifact_refs or []), metadata_json=_json_dumps(metadata), ) session.add(row) @@ -320,7 +318,6 @@ class RunLedgerStore: usage_json=None, created_at=_utc_now(), source='host', - artifact_refs_json=_json_dumps([]), metadata_json=_json_dumps(metadata or {}), ) session.add(row) @@ -741,7 +738,6 @@ class RunLedgerStore: 'usage': _json_loads(row.usage_json, None), 'created_at': _datetime_to_epoch(row.created_at), 'source': row.source, - 'artifact_refs': _json_loads(row.artifact_refs_json, []), 'metadata': _json_loads(row.metadata_json, {}), } diff --git a/src/langbot/pkg/agent/runner/session_registry.py b/src/langbot/pkg/agent/runner/session_registry.py index b03e2fc7d..2d2a316bb 100644 --- a/src/langbot/pkg/agent/runner/session_registry.py +++ b/src/langbot/pkg/agent/runner/session_registry.py @@ -16,7 +16,6 @@ DEFAULT_RESOURCE_OPERATIONS: dict[str, set[str]] = { 'model': {'invoke', 'stream', 'rerank'}, 'tool': {'detail', 'call'}, 'knowledge_base': {'list', 'retrieve'}, - 'file': {'config', 'knowledge'}, 'skill': {'activate'}, } @@ -173,7 +172,6 @@ class AgentRunSessionRegistry: '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', [])}, - 'file': {f.get('file_id') for f in resources.get('files', [])}, } def _build_authorized_operations( @@ -202,11 +200,6 @@ class AgentRunSessionRegistry: for s in resources.get('skills', []) if s.get('skill_name') }, - 'file': { - f.get('file_id'): self._resource_operations('file', f) - for f in resources.get('files', []) - if f.get('file_id') - }, } @staticmethod @@ -346,8 +339,8 @@ class AgentRunSessionRegistry: Args: session: AgentRunSession to check - resource_type: Resource type ('model', 'tool', 'knowledge_base', 'storage', 'file') - resource_id: Resource identifier (model_id, tool_name, kb_id, 'plugin'/'workspace', file_key) + 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: @@ -357,7 +350,7 @@ class AgentRunSessionRegistry: authorized_ids = authorization['authorized_ids'] resources = authorization['resources'] - if resource_type in ('model', 'tool', 'knowledge_base', 'skill', 'file'): + 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: diff --git a/src/langbot/pkg/agent/runner/transcript_store.py b/src/langbot/pkg/agent/runner/transcript_store.py index 49613ed9f..bd47e690d 100644 --- a/src/langbot/pkg/agent/runner/transcript_store.py +++ b/src/langbot/pkg/agent/runner/transcript_store.py @@ -60,7 +60,7 @@ class TranscriptStore: workspace_id: str | None = None, content: str | None = None, content_json: dict[str, typing.Any] | None = None, - artifact_refs: list[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, @@ -78,7 +78,7 @@ class TranscriptStore: workspace_id: Workspace scope content: Text content content_json: Full structured content - artifact_refs: Artifact references + attachment_refs: Attachment references thread_id: Thread ID item_type: Item type run_id: Run ID that generated this @@ -107,7 +107,7 @@ class TranscriptStore: item_type=item_type, content=content, content_json=json.dumps(content_json) if content_json else None, - artifact_refs_json=json.dumps(artifact_refs) if artifact_refs else None, + attachment_refs_json=json.dumps(attachment_refs) if attachment_refs else None, seq=0, run_id=run_id, runner_id=runner_id, @@ -128,7 +128,7 @@ class TranscriptStore: after_seq: int | None = None, limit: int = 50, direction: str = "backward", - include_artifacts: bool = False, + include_attachments: bool = False, bot_id: str | None = None, workspace_id: str | None = None, thread_id: str | None = None, @@ -142,7 +142,7 @@ class TranscriptStore: after_seq: Get items after this sequence (forward) limit: Maximum items to return (capped at 100) direction: 'backward' (older) or 'forward' (newer) - include_artifacts: Include artifact refs + include_attachments: Include attachment refs bot_id: Optional bot scope filter workspace_id: Optional workspace scope filter thread_id: Optional thread scope filter @@ -174,7 +174,7 @@ class TranscriptStore: result = await session.execute(query) rows = result.scalars().all() - items = [self._row_to_dict(row, include_artifacts) for row in rows[:limit]] + items = [self._row_to_dict(row, include_attachments) for row in rows[:limit]] has_more = len(rows) > limit # Calculate cursors @@ -241,7 +241,7 @@ class TranscriptStore: result = await session.execute(query) rows = result.scalars().all() - return [self._row_to_dict(row, include_artifacts=True) for row in rows] + return [self._row_to_dict(row, include_attachments=True) for row in rows] async def get_latest_cursor( self, @@ -372,7 +372,7 @@ class TranscriptStore: def _row_to_dict( self, row: Transcript, - include_artifacts: bool = False, + include_attachments: bool = False, ) -> dict[str, typing.Any]: """Convert a Transcript row to dict.""" result = { @@ -392,10 +392,10 @@ class TranscriptStore: 'metadata': json.loads(row.metadata_json) if row.metadata_json else {}, } - if include_artifacts and row.artifact_refs_json: - result['artifact_refs'] = json.loads(row.artifact_refs_json) + if include_attachments and row.attachment_refs_json: + result['attachment_refs'] = json.loads(row.attachment_refs_json) else: - result['artifact_refs'] = [] + result['attachment_refs'] = [] return result diff --git a/src/langbot/pkg/entity/persistence/agent_run.py b/src/langbot/pkg/entity/persistence/agent_run.py index e6cfa2a18..e735ea692 100644 --- a/src/langbot/pkg/entity/persistence/agent_run.py +++ b/src/langbot/pkg/entity/persistence/agent_run.py @@ -191,9 +191,6 @@ class AgentRunEvent(Base): source = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) """Source that appended the event.""" - artifact_refs_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) - """Artifact references associated with this event.""" - metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) """Additional metadata JSON.""" diff --git a/src/langbot/pkg/entity/persistence/artifact.py b/src/langbot/pkg/entity/persistence/artifact.py deleted file mode 100644 index 2d4683e87..000000000 --- a/src/langbot/pkg/entity/persistence/artifact.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Artifact persistence entity for Host-owned artifact store.""" -from __future__ import annotations - -import sqlalchemy -import datetime - -from .base import Base - - -class AgentArtifact(Base): - """AgentArtifact stores metadata for large files, images, tool results, etc. - - This table only stores metadata. The actual blob content is stored in - BinaryStorage or external storage, referenced by storage_key. - - Artifacts are accessed via artifact_metadata and artifact_read APIs - with run_id authorization. - """ - - __tablename__ = 'agent_artifact' - - id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) - """Auto-increment ID for sequencing.""" - - artifact_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True) - """Unique artifact identifier.""" - - artifact_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) - """Artifact type: 'image', 'file', 'voice', 'tool_result', 'platform_attachment', etc.""" - - mime_type = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) - """MIME type of the content.""" - - name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) - """Original file name (if applicable).""" - - size_bytes = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=True) - """Size in bytes.""" - - sha256 = sqlalchemy.Column(sqlalchemy.String(64), nullable=True) - """SHA256 hash of content (for integrity verification).""" - - source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) - """Source of artifact: 'platform', 'runner', 'tool', 'system'.""" - - # Storage reference (points to BinaryStorage or external storage) - storage_key = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) - """Key in BinaryStorage or external storage reference.""" - - storage_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='binary_storage') - """Storage type: 'binary_storage', 'file', 'url', etc.""" - - # Context - conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) - """Conversation this artifact belongs to.""" - - run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) - """Run ID that created this artifact.""" - - runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) - """Runner ID that created this artifact.""" - - bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) - """Bot UUID that handled this artifact.""" - - workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) - """Workspace ID for multi-tenant deployments.""" - - # Lifecycle - created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow) - """When this artifact was created.""" - - expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) - """When this artifact expires (optional).""" - - metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) - """Additional metadata as JSON string.""" diff --git a/src/langbot/pkg/entity/persistence/event_log.py b/src/langbot/pkg/entity/persistence/event_log.py index d29510e23..d8203f8af 100644 --- a/src/langbot/pkg/entity/persistence/event_log.py +++ b/src/langbot/pkg/entity/persistence/event_log.py @@ -11,8 +11,8 @@ 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 as artifacts; this table stores - references and summaries. + Large payloads are stored separately; this table stores references and + summaries. """ __tablename__ = 'event_log' @@ -56,7 +56,7 @@ class EventLog(Base): # Subject information subject_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) - """Subject type (message, tool_call, artifact).""" + """Subject type (message, tool_call, attachment, etc.).""" subject_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) """Subject identifier.""" @@ -70,7 +70,7 @@ class EventLog(Base): # Raw event reference raw_ref = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) - """Reference to raw event payload in ArtifactStore.""" + """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.""" diff --git a/src/langbot/pkg/entity/persistence/transcript.py b/src/langbot/pkg/entity/persistence/transcript.py index 3bbdf1e6b..5d66454e7 100644 --- a/src/langbot/pkg/entity/persistence/transcript.py +++ b/src/langbot/pkg/entity/persistence/transcript.py @@ -11,7 +11,7 @@ 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 artifact refs, but not raw platform payloads. + It includes message content and attachment refs, but not raw platform payloads. """ __tablename__ = 'transcript' @@ -50,9 +50,9 @@ class Transcript(Base): content_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) """Full structured content as JSON string (Message model dump).""" - # Artifact references - artifact_refs_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) - """Artifact references as JSON string (list of ArtifactRef).""" + # 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) diff --git a/src/langbot/pkg/persistence/alembic/env.py b/src/langbot/pkg/persistence/alembic/env.py index 42a44d029..a6295ff7f 100644 --- a/src/langbot/pkg/persistence/alembic/env.py +++ b/src/langbot/pkg/persistence/alembic/env.py @@ -19,7 +19,6 @@ from langbot.pkg.entity.persistence import ( agent_run, # noqa: F401 agent_runner_state, # noqa: F401 apikey, # noqa: F401 - artifact, # noqa: F401 bot, # noqa: F401 bstorage, # noqa: F401 event_log, # noqa: F401 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 index 6784e8380..378ba2c5a 100644 --- 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 @@ -97,7 +97,7 @@ def upgrade() -> None: 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('artifact_refs_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), 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 index ddca20504..1682acdda 100644 --- 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 @@ -2,7 +2,7 @@ """add agent_runner_state table for host-owned persistent state Revision ID: 6dfd3dd7f0c7 -Revises: a1b2c3d4e5f6 +Revises: 58846a8d7a81 Create Date: 2026-05-23 19:49:08.529110 """ from alembic import op @@ -11,7 +11,7 @@ import sqlalchemy as sa # revision identifiers revision = '6dfd3dd7f0c7' -down_revision = 'a1b2c3d4e5f6' +down_revision = '58846a8d7a81' branch_labels = None depends_on = None 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 index d658f7f81..88773c1b1 100644 --- 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 @@ -133,7 +133,6 @@ def upgrade() -> None: 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('artifact_refs_json', sa.Text(), nullable=True), sa.Column('metadata_json', sa.Text(), nullable=True), sa.UniqueConstraint('run_id', 'sequence', name='uq_agent_run_event_run_sequence'), ) diff --git a/src/langbot/pkg/persistence/alembic/versions/a1b2c3d4e5f6_add_agent_artifact_table.py b/src/langbot/pkg/persistence/alembic/versions/a1b2c3d4e5f6_add_agent_artifact_table.py deleted file mode 100644 index 527083a53..000000000 --- a/src/langbot/pkg/persistence/alembic/versions/a1b2c3d4e5f6_add_agent_artifact_table.py +++ /dev/null @@ -1,77 +0,0 @@ -"""add_agent_artifact_table - -Revision ID: a1b2c3d4e5f6 -Revises: 58846a8d7a81 -Create Date: 2026-05-23 20:00:00.000000 -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers -revision = 'a1b2c3d4e5f6' -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: - # Create agent_artifact table - if not _table_exists('agent_artifact'): - op.create_table( - 'agent_artifact', - sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), - sa.Column('artifact_id', sa.String(255), nullable=False, unique=True), - sa.Column('artifact_type', sa.String(50), nullable=False), - sa.Column('mime_type', sa.String(255), nullable=True), - sa.Column('name', sa.String(255), nullable=True), - sa.Column('size_bytes', sa.BigInteger(), nullable=True), - sa.Column('sha256', sa.String(64), nullable=True), - sa.Column('source', sa.String(50), nullable=False), - sa.Column('storage_key', sa.String(255), nullable=True), - sa.Column('storage_type', sa.String(50), nullable=False, server_default='binary_storage'), - sa.Column('conversation_id', 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('bot_id', sa.String(255), nullable=True), - sa.Column('workspace_id', sa.String(255), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')), - sa.Column('expires_at', sa.DateTime(), nullable=True), - sa.Column('metadata_json', sa.Text(), nullable=True), - ) - - # Create indexes for agent_artifact - _create_index_if_missing('agent_artifact', 'ix_agent_artifact_artifact_id', ['artifact_id'], unique=True) - _create_index_if_missing('agent_artifact', 'ix_agent_artifact_conversation_id', ['conversation_id']) - _create_index_if_missing('agent_artifact', 'ix_agent_artifact_run_id', ['run_id']) - - -def downgrade() -> None: - # Drop agent_artifact table - _drop_index_if_exists('agent_artifact', 'ix_agent_artifact_run_id') - _drop_index_if_exists('agent_artifact', 'ix_agent_artifact_conversation_id') - _drop_index_if_exists('agent_artifact', 'ix_agent_artifact_artifact_id') - - if _table_exists('agent_artifact'): - op.drop_table('agent_artifact') diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index 05e9ad195..588f2d035 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -212,74 +212,11 @@ def _build_tool_detail(tool: Any, requested_tool_name: str | None = None) -> dic } -def _validate_artifact_access( - session: dict[str, Any], - artifact_metadata: dict[str, Any], - operation: str, -) -> tuple[bool, str | None]: - """Validate artifact access for a run session. - - Authorization rules (evaluated in order, first match wins): - 1. Artifact run_id matches session run_id → ALLOW (created by this run) - 2. Artifact has conversation_id AND matches session conversation_id → ALLOW (same conversation) - 3. Otherwise → DENY - - Note: Artifacts without conversation_id are NOT globally accessible by default. - Without an explicit scope field, we enforce strict access control. - - Args: - session: AgentRunSession dict with run_id and authorization snapshot - artifact_metadata: Artifact metadata dict with conversation_id, run_id, - and Host-only scope fields when available - operation: Operation name for error messages ('metadata' or 'read') - - Returns: - Tuple of (is_allowed, error_message). If is_allowed is False, error_message contains reason. - """ - authorization = session['authorization'] - artifact_conversation_id = artifact_metadata.get('conversation_id') - artifact_run_id = artifact_metadata.get('run_id') - session_conversation_id = authorization.get('conversation_id') - session_run_id = session.get('run_id') - - # Rule 1: Created by this run (allows cross-conversation access for self-created artifacts) - if artifact_run_id and artifact_run_id == session_run_id: - return True, None - - # Rule 2: Same conversation (requires artifact to have conversation_id) - if artifact_conversation_id and session_conversation_id: - if artifact_conversation_id == session_conversation_id and _artifact_matches_run_scope( - session, artifact_metadata - ): - return True, None - - # Rule 3: Deny - no matching authorization rule - return ( - False, - f'Artifact {operation} access denied: artifact not in session conversation and not created by this run', - ) - - def _get_run_authorization(session: dict[str, Any]) -> dict[str, Any]: """Return the run-scoped authorization snapshot.""" return session['authorization'] -def _artifact_matches_run_scope(session: dict[str, Any], artifact_metadata: dict[str, Any]) -> bool: - authorization = _get_run_authorization(session) - for scope_key in ('bot_id', 'workspace_id', 'thread_id'): - if authorization.get(scope_key) != artifact_metadata.get(scope_key): - return False - return True - - -def _public_artifact_metadata(artifact_metadata: dict[str, Any]) -> dict[str, Any]: - public_metadata = dict(artifact_metadata) - for scope_key in ('bot_id', 'workspace_id', 'thread_id'): - public_metadata.pop(scope_key, None) - return public_metadata - - 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') @@ -605,12 +542,12 @@ async def _validate_run_authorization( """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/file actions. + 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', 'file'). - resource_id: Resource identifier (model_uuid, tool_name, kb_id, 'plugin'/'workspace', file_key). + 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. @@ -1399,20 +1336,10 @@ class RuntimeConnectionHandler(handler.Handler): async def get_config_file(data: dict[str, Any]) -> handler.ActionResponse: """Get a config file by file key - For AgentRunner calls: validates file_key against session.resources.files. - For regular plugin calls: unrestricted access (backward compatibility). + Regular plugin config files are still host storage files. AgentRunner + file access goes through sandbox tools, not this action. """ file_key = data['file_key'] - 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, 'file', file_key, self.ap, caller_plugin_identity, operation='config' - ) - if error: - return error try: # Load file from storage @@ -1820,7 +1747,7 @@ class RuntimeConnectionHandler(handler.Handler): after_cursor = data.get('after_cursor') limit = data.get('limit', 50) direction = data.get('direction', 'backward') - include_artifacts = data.get('include_artifacts', False) + include_attachments = data.get('include_attachments', False) caller_plugin_identity = data.get('caller_plugin_identity') if not run_id: @@ -1870,7 +1797,7 @@ class RuntimeConnectionHandler(handler.Handler): after_seq=after_seq, limit=limit, direction=direction, - include_artifacts=include_artifacts, + include_attachments=include_attachments, **_run_scope_filters(session), ) @@ -2453,7 +2380,6 @@ class RuntimeConnectionHandler(handler.Handler): 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') - artifact_refs = data.get('artifact_refs') if isinstance(data.get('artifact_refs'), list) else None metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else None session, error = await _validate_agent_run_session( @@ -2486,7 +2412,6 @@ class RuntimeConnectionHandler(handler.Handler): data=event_data if isinstance(event_data, dict) else {}, usage=usage if isinstance(usage, dict) else None, source=str(data.get('source') or result.get('source') or 'runner'), - artifact_refs=artifact_refs, metadata=metadata, ) if is_admin: @@ -3222,137 +3147,6 @@ class RuntimeConnectionHandler(handler.Handler): ) return handler.ActionResponse.success(data={'items': items}) - # ================= Artifact APIs ================= - - @self.action(PluginToRuntimeAction.ARTIFACT_METADATA) - async def artifact_metadata(data: dict[str, Any]) -> handler.ActionResponse: - """Get artifact metadata. - - Requires run_id authorization. Only allows access to artifacts - in current run's conversation or created by current run. - """ - run_id = data.get('run_id') - artifact_id = data.get('artifact_id') - caller_plugin_identity = data.get('caller_plugin_identity') - - if not run_id: - return handler.ActionResponse.error(message='run_id is required') - - if not artifact_id: - return handler.ActionResponse.error(message='artifact_id is required') - - session, error = await _validate_agent_run_session( - run_id, - caller_plugin_identity, - self.ap, - 'Artifact metadata', - api_capability='artifact_metadata', - ) - if error: - return error - - # Get artifact metadata - from ..agent.runner.artifact_store import ArtifactStore - - store = ArtifactStore(self.ap.persistence_mgr.get_db_engine()) - - try: - metadata = await store.get_authorization_metadata(artifact_id) - if not metadata: - return handler.ActionResponse.error(message=f'Artifact {artifact_id} not found') - - # Validate artifact access scope - is_allowed, error_msg = _validate_artifact_access(session, metadata, 'metadata') - if not is_allowed: - return handler.ActionResponse.error(message=error_msg) - - return handler.ActionResponse.success(data=_public_artifact_metadata(metadata)) - except Exception as e: - self.ap.logger.error(f'ARTIFACT_METADATA error: {e}', exc_info=True) - return handler.ActionResponse.error(message=f'Artifact metadata error: {e}') - - @self.action(PluginToRuntimeAction.ARTIFACT_READ) - async def artifact_read(data: dict[str, Any]) -> handler.ActionResponse: - """Read artifact content. - - Requires run_id authorization. Only allows access to artifacts - in current run's conversation or created by current run. - Supports range reads with offset/limit. - """ - run_id = data.get('run_id') - artifact_id = data.get('artifact_id') - caller_plugin_identity = data.get('caller_plugin_identity') - - if not run_id: - return handler.ActionResponse.error(message='run_id is required') - - if not artifact_id: - return handler.ActionResponse.error(message='artifact_id is required') - - # Validate and parse offset - offset = data.get('offset', 0) - if not isinstance(offset, int): - try: - offset = int(offset) - except (TypeError, ValueError): - return handler.ActionResponse.error(message='offset must be an integer') - if offset < 0: - return handler.ActionResponse.error(message='offset must be >= 0') - - # Validate and parse limit if provided - limit = data.get('limit') - if limit is not None: - if not isinstance(limit, int): - 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') - - session, error = await _validate_agent_run_session( - run_id, - caller_plugin_identity, - self.ap, - 'Artifact read', - api_capability='artifact_read', - ) - if error: - return error - - # Get artifact metadata first to validate access - from ..agent.runner.artifact_store import ArtifactStore - - store = ArtifactStore(self.ap.persistence_mgr.get_db_engine()) - - try: - metadata = await store.get_authorization_metadata(artifact_id) - if not metadata: - return handler.ActionResponse.error(message=f'Artifact {artifact_id} not found') - - # Validate artifact access scope - is_allowed, error_msg = _validate_artifact_access(session, metadata, 'read') - if not is_allowed: - return handler.ActionResponse.error(message=error_msg) - - # Read artifact content (validates offset/limit internally) - result = await store.read_artifact( - artifact_id=artifact_id, - offset=offset, - limit=limit, - ) - - if not result: - return handler.ActionResponse.error(message=f'Failed to read artifact {artifact_id}') - - return handler.ActionResponse.success(data=result) - except ValueError as e: - # Offset/limit validation error - return handler.ActionResponse.error(message=str(e)) - except Exception as e: - self.ap.logger.error(f'ARTIFACT_READ error: {e}', exc_info=True) - return handler.ActionResponse.error(message=f'Artifact read error: {e}') - # ================= State APIs (run-scoped, policy-enforced) ================= @self.action(PluginToRuntimeAction.STATE_GET) @@ -3717,9 +3511,7 @@ class RuntimeConnectionHandler(handler.Handler): - plugin_author - plugin_name - runner_name - - runner_description - manifest - - config """ result = await self.call_action( LangBotToRuntimeAction.LIST_AGENT_RUNNERS, diff --git a/tests/unit_tests/agent/conftest.py b/tests/unit_tests/agent/conftest.py index 1396657b8..a55dccf1c 100644 --- a/tests/unit_tests/agent/conftest.py +++ b/tests/unit_tests/agent/conftest.py @@ -10,7 +10,6 @@ def make_resources( knowledge_bases: list[dict] | None = None, skills: list[dict] | None = None, storage: dict | None = None, - files: list[dict] | None = None, ) -> dict[str, typing.Any]: """Create a minimal AgentResources dict for testing. @@ -20,8 +19,6 @@ def make_resources( knowledge_bases: List of KB dicts with 'kb_id' key skills: List of skill dicts with 'skill_name' key storage: Storage permissions dict - files: List of file dicts with 'file_id' key - Returns: AgentResources dict with all required fields """ @@ -30,7 +27,6 @@ def make_resources( 'tools': tools or [], 'knowledge_bases': knowledge_bases or [], 'skills': skills or [], - 'files': files or [], 'storage': storage or {'plugin_storage': False, 'workspace_storage': False}, 'platform_capabilities': {}, } @@ -78,7 +74,6 @@ def make_session( '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', [])}, - 'file': {f.get('file_id') for f in res.get('files', [])}, } authorized_operations: dict[str, dict[str, set[str]]] = { 'model': { @@ -101,11 +96,6 @@ def make_session( for s in res.get('skills', []) if s.get('skill_name') }, - 'file': { - f.get('file_id'): set(f.get('operations') or ['config', 'knowledge']) - for f in res.get('files', []) - if f.get('file_id') - }, } return { diff --git a/tests/unit_tests/agent/test_artifact_store.py b/tests/unit_tests/agent/test_artifact_store.py deleted file mode 100644 index be1c1cf99..000000000 --- a/tests/unit_tests/agent/test_artifact_store.py +++ /dev/null @@ -1,774 +0,0 @@ -"""Tests for ArtifactStore and artifact action handlers.""" -from __future__ import annotations - -import pytest -from unittest.mock import MagicMock, AsyncMock, patch -import base64 -import datetime - -from langbot.pkg.agent.runner.artifact_store import ArtifactStore -from langbot.pkg.agent.runner.session_registry import ( - get_session_registry, -) -from .conftest import make_session - - -class TestArtifactStore: - """Test ArtifactStore operations.""" - - def _make_mock_engine(self): - """Create a mock database engine for AsyncSession-based store. - - Note: The new store uses AsyncSession, so we need to mock - the session factory behavior. - """ - from sqlalchemy.ext.asyncio import AsyncEngine - - engine = MagicMock(spec=AsyncEngine) - return engine - - @pytest.mark.asyncio - async def test_register_artifact_generates_id(self): - """Test register_artifact generates ID if not provided.""" - engine = self._make_mock_engine() - store = ArtifactStore(engine) - - # Mock the session factory - 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 - - artifact_id = await store.register_artifact( - artifact_id=None, - artifact_type="image", - source="platform", - ) - - assert artifact_id is not None - assert len(artifact_id) == 36 # UUID format - - @pytest.mark.asyncio - async def test_register_artifact_with_content(self): - """Test register_artifact stores content in BinaryStorage.""" - engine = self._make_mock_engine() - store = ArtifactStore(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 - - content = b"test image content" - artifact_id = await store.register_artifact( - artifact_id="art_001", - artifact_type="image", - source="platform", - content=content, - ) - - assert artifact_id == "art_001" - - @pytest.mark.asyncio - async def test_register_artifact_with_storage_key(self): - """Test register_artifact with pre-existing storage_key.""" - engine = self._make_mock_engine() - store = ArtifactStore(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 - - artifact_id = await store.register_artifact( - artifact_id="art_002", - artifact_type="file", - source="runner", - storage_key="existing_key", - storage_type="binary_storage", - size_bytes=1024, - ) - - assert artifact_id == "art_002" - - @pytest.mark.asyncio - async def test_get_metadata_not_found(self): - """Test get_metadata returns None if not found.""" - engine = self._make_mock_engine() - store = ArtifactStore(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 - - metadata = await store.get_metadata("nonexistent") - - assert metadata is None - - @pytest.mark.asyncio - async def test_read_artifact_validates_offset(self): - """Test read_artifact rejects negative offset.""" - engine = self._make_mock_engine() - store = ArtifactStore(engine) - - with pytest.raises(ValueError, match="offset must be >= 0"): - await store.read_artifact("art_001", offset=-1) - - @pytest.mark.asyncio - async def test_read_artifact_validates_limit(self): - """Test read_artifact rejects zero or negative limit.""" - engine = self._make_mock_engine() - store = ArtifactStore(engine) - - with pytest.raises(ValueError, match="limit must be > 0"): - await store.read_artifact("art_001", limit=0) - - with pytest.raises(ValueError, match="limit must be > 0"): - await store.read_artifact("art_001", limit=-5) - - @pytest.mark.asyncio - async def test_read_artifact_not_found(self): - """Test read_artifact returns None if not found.""" - engine = self._make_mock_engine() - store = ArtifactStore(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 - - result = await store.read_artifact("nonexistent") - assert result is None - - -class TestArtifactAuthorization: - """Test artifact action handler authorization.""" - - @pytest.fixture - def mock_session_registry(self): - """Create a fresh session registry for testing.""" - # Reset global registry - import langbot.pkg.agent.runner.session_registry as reg - reg._global_registry = None - return get_session_registry() - - @pytest.fixture - def mock_handler(self): - """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() - - @pytest.mark.asyncio - async def test_artifact_metadata_requires_run_id(self, mock_handler): - """Test artifact_metadata requires run_id.""" - result = await mock_handler.call_action( - "artifact_metadata", - {"run_id": None, "artifact_id": "art_001"}, - ) - - assert result.get("ok") is False or "error" in str(result).lower() - - @pytest.mark.asyncio - async def test_artifact_read_requires_run_id(self, mock_handler): - """Test artifact_read requires run_id.""" - result = await mock_handler.call_action( - "artifact_read", - {"run_id": None, "artifact_id": "art_001"}, - ) - - assert result.get("ok") is False or "error" in str(result).lower() - - -class TestArtifactAccessValidation: - """Test _validate_artifact_access authorization rules.""" - - def _make_session( - self, - conversation_id: str | None, - *, - bot_id: str | None = None, - workspace_id: str | None = None, - thread_id: str | None = None, - ): - return make_session( - run_id="run_001", - conversation_id=conversation_id, - bot_id=bot_id, - workspace_id=workspace_id, - thread_id=thread_id, - available_apis={"artifact_metadata": True, "artifact_read": True}, - ) - - def _call_validate(self, session, metadata, operation="metadata"): - """Helper to call the validation function.""" - from langbot.pkg.plugin.handler import _validate_artifact_access - return _validate_artifact_access(session, metadata, operation) - - def test_global_artifact_denied_by_default(self): - """Artifacts without conversation_id are denied by default (no global access).""" - session = self._make_session("conv_001") - metadata = { - "artifact_id": "art_global", - "conversation_id": None, # No conversation scope - "run_id": None, # Not created by any run - } - - is_allowed, error = self._call_validate(session, metadata) - assert is_allowed is False - assert "denied" in error.lower() - - def test_own_run_artifact_allowed(self): - """Artifacts created by same run are allowed (even cross-conversation).""" - session = self._make_session("conv_001") - metadata = { - "artifact_id": "art_001", - "conversation_id": "conv_other", # Different conversation - "run_id": "run_001", # Same run - } - - is_allowed, error = self._call_validate(session, metadata) - assert is_allowed is True - assert error is None - - def test_same_conversation_allowed(self): - """Artifacts in same conversation are allowed.""" - session = self._make_session("conv_001") - metadata = { - "artifact_id": "art_001", - "conversation_id": "conv_001", # Same as session - "run_id": "run_other", # Different run - } - - is_allowed, error = self._call_validate(session, metadata) - assert is_allowed is True - assert error is None - - def test_same_conversation_and_scope_allowed(self): - """Artifacts in the same run scope are allowed across runs.""" - session = self._make_session( - "conv_001", - bot_id="bot_001", - workspace_id="workspace_001", - thread_id="thread_001", - ) - metadata = { - "artifact_id": "art_001", - "conversation_id": "conv_001", - "run_id": "run_other", - "bot_id": "bot_001", - "workspace_id": "workspace_001", - "thread_id": "thread_001", - } - - is_allowed, error = self._call_validate(session, metadata) - assert is_allowed is True - assert error is None - - def test_same_conversation_different_scope_denied(self): - """Artifacts in another bot/thread scope are denied even in the same conversation.""" - session = self._make_session( - "conv_001", - bot_id="bot_001", - workspace_id="workspace_001", - thread_id="thread_001", - ) - metadata = { - "artifact_id": "art_001", - "conversation_id": "conv_001", - "run_id": "run_other", - "bot_id": "bot_002", - "workspace_id": "workspace_001", - "thread_id": "thread_001", - } - - is_allowed, error = self._call_validate(session, metadata) - assert is_allowed is False - assert "denied" in error.lower() - - def test_same_conversation_missing_scope_denied_for_scoped_session(self): - """Scoped runs should not read legacy-scope artifacts from other runs.""" - session = self._make_session("conv_001", bot_id="bot_001") - metadata = { - "artifact_id": "art_001", - "conversation_id": "conv_001", - "run_id": "run_other", - "bot_id": None, - "workspace_id": None, - "thread_id": None, - } - - is_allowed, error = self._call_validate(session, metadata) - assert is_allowed is False - assert "denied" in error.lower() - - def test_different_conversation_and_run_denied(self): - """Artifacts in different conversation and different run are denied.""" - session = self._make_session("conv_001") - metadata = { - "artifact_id": "art_001", - "conversation_id": "conv_other", # Different conversation - "run_id": "run_other", # Different run - } - - is_allowed, error = self._call_validate(session, metadata) - assert is_allowed is False - assert "denied" in error.lower() - - def test_session_without_conversation_denied_for_conversation_artifact(self): - """Session without conversation_id cannot access conversation-scoped artifacts.""" - session = self._make_session(None) - metadata = { - "artifact_id": "art_001", - "conversation_id": "conv_001", # Has conversation - "run_id": "run_other", # Different run - } - - is_allowed, error = self._call_validate(session, metadata) - assert is_allowed is False - - def test_session_without_conversation_allowed_for_own_artifact(self): - """Session without conversation can access artifacts it created.""" - session = self._make_session(None) - metadata = { - "artifact_id": "art_001", - "conversation_id": "conv_001", # Has conversation - "run_id": "run_001", # Same run (created by this run) - } - - is_allowed, error = self._call_validate(session, metadata) - assert is_allowed is True - - -class TestContextAccessArtifactAPIs: - """Test ContextAccess reflects runtime artifact API availability.""" - - @pytest.mark.asyncio - async def test_context_access_has_artifact_apis_when_permitted(self): - """Artifact APIs are exposed through run-scoped available_apis.""" - available_apis = {"artifact_metadata": True, "artifact_read": True} - - assert available_apis["artifact_metadata"] is True - assert available_apis["artifact_read"] is True - - @pytest.mark.asyncio - async def test_context_access_no_artifact_apis_without_permission(self): - """Artifact APIs are absent when the run did not receive them.""" - available_apis = {} - - assert available_apis.get("artifact_metadata", False) is False - assert available_apis.get("artifact_read", False) is False - - -class TestArtifactMetadataFieldAlignment: - """Test that Host returns metadata compatible with SDK ArtifactMetadata.""" - - def test_row_to_public_dict_excludes_host_only_fields(self): - """_row_to_public_dict should not return Host-only fields.""" - from langbot.pkg.agent.runner.artifact_store import ArtifactStore - from langbot.pkg.entity.persistence.artifact import AgentArtifact - from unittest.mock import MagicMock - - # Create a mock row - mock_row = MagicMock(spec=AgentArtifact) - mock_row.artifact_id = "art_001" - mock_row.artifact_type = "image" - mock_row.mime_type = "image/png" - mock_row.name = "test.png" - mock_row.size_bytes = 1024 - mock_row.sha256 = "abc123" - mock_row.source = "platform" - mock_row.conversation_id = "conv_001" - mock_row.run_id = "run_001" - mock_row.runner_id = "plugin:test/plugin/runner" - mock_row.created_at = datetime.datetime(2024, 1, 1, 0, 0, 0) - mock_row.expires_at = None - mock_row.metadata_json = None - - # These are Host-only fields that should NOT be in output - # (they don't exist in SDK ArtifactMetadata) - mock_row.bot_id = "bot_001" - mock_row.workspace_id = "ws_001" - mock_row.storage_key = "artifact:art_001" - mock_row.storage_type = "binary_storage" - - store = ArtifactStore(MagicMock()) - result = store._row_to_public_dict(mock_row) - - # SDK-compatible fields should be present - assert result["artifact_id"] == "art_001" - assert result["artifact_type"] == "image" - assert result["source"] == "platform" - assert result["conversation_id"] == "conv_001" - assert result["run_id"] == "run_001" - - # Host-only fields should NOT be present - assert "bot_id" not in result - assert "workspace_id" not in result - assert "storage_key" not in result - assert "storage_type" not in result - - -class TestSessionRegistryAvailableAPIs: - """Test that session registry stores and retrieves available APIs correctly.""" - - @pytest.fixture - def session_registry(self): - """Create a fresh session registry for testing.""" - import langbot.pkg.agent.runner.session_registry as reg - reg._global_registry = None - return get_session_registry() - - @pytest.mark.asyncio - async def test_register_stores_available_apis(self, session_registry): - """Test that register() stores runtime API availability.""" - await session_registry.register( - run_id="run_001", - runner_id="plugin:author/plugin/runner", - query_id=None, - plugin_identity="author/plugin", - resources={ - "models": [], - "tools": [], - "knowledge_bases": [], - "files": [], - "storage": {"plugin_storage": True, "workspace_storage": False}, - "platform_capabilities": {}, - }, - available_apis={ - "artifact_metadata": True, - "artifact_read": True, - "history_page": True, - "event_get": True, - }, - conversation_id="conv_001", - ) - - session = await session_registry.get("run_001") - assert session is not None - available_apis = session["authorization"]["available_apis"] - assert available_apis["artifact_metadata"] is True - assert available_apis["artifact_read"] is True - assert available_apis["history_page"] is True - assert available_apis["event_get"] is True - - @pytest.mark.asyncio - async def test_register_with_empty_available_apis(self, session_registry): - """Test that register() handles empty API availability.""" - await session_registry.register( - run_id="run_002", - runner_id="plugin:author/plugin/runner", - query_id=None, - plugin_identity="author/plugin", - resources={ - "models": [], - "tools": [], - "knowledge_bases": [], - "files": [], - "storage": {"plugin_storage": True, "workspace_storage": False}, - "platform_capabilities": {}, - }, - available_apis={}, - conversation_id="conv_001", - ) - - session = await session_registry.get("run_002") - assert session is not None - assert session["authorization"]["available_apis"] == {} - - -class TestArtifactStoreRealSQLite: - """Test ArtifactStore 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: - # Create tables manually for in-memory DB - await conn.run_sync(Base.metadata.create_all) - - yield engine - - await engine.dispose() - - @pytest.mark.asyncio - async def test_register_get_metadata_round_trip(self, db_engine): - """Test register_artifact -> get_metadata round trip with real DB.""" - store = ArtifactStore(db_engine) - - # Register artifact with content - content = b"test image content for round trip" - artifact_id = await store.register_artifact( - artifact_id="art_real_001", - artifact_type="image", - source="platform", - mime_type="image/png", - name="test.png", - content=content, - conversation_id="conv_001", - run_id="run_001", - bot_id="bot_001", - workspace_id="workspace_001", - thread_id="thread_001", - ) - - assert artifact_id == "art_real_001" - - # Get metadata - metadata = await store.get_metadata(artifact_id) - assert metadata is not None - assert metadata["artifact_id"] == "art_real_001" - assert metadata["artifact_type"] == "image" - assert metadata["mime_type"] == "image/png" - assert metadata["source"] == "platform" - assert metadata["conversation_id"] == "conv_001" - assert metadata["run_id"] == "run_001" - - # Verify Host-only fields are NOT in public metadata - assert "storage_key" not in metadata - assert "storage_type" not in metadata - assert "bot_id" not in metadata - assert "workspace_id" not in metadata - assert "thread_id" not in metadata - assert "_langbot_thread_id" not in metadata.get("metadata", {}) - - auth_metadata = await store.get_authorization_metadata(artifact_id) - assert auth_metadata is not None - assert auth_metadata["bot_id"] == "bot_001" - assert auth_metadata["workspace_id"] == "workspace_001" - assert auth_metadata["thread_id"] == "thread_001" - - @pytest.mark.asyncio - async def test_read_artifact_round_trip(self, db_engine): - """Test register_artifact -> read_artifact round trip with real DB.""" - store = ArtifactStore(db_engine) - - # Register artifact with content - content = b"test file content for read test" - artifact_id = await store.register_artifact( - artifact_id="art_real_002", - artifact_type="file", - source="runner", - mime_type="text/plain", - name="test.txt", - content=content, - conversation_id="conv_001", - run_id="run_001", - ) - - # Read artifact - result = await store.read_artifact(artifact_id) - assert result is not None - assert result["artifact_id"] == "art_real_002" - assert result["mime_type"] == "text/plain" - assert result["offset"] == 0 - assert result["length"] == len(content) - assert result["has_more"] is False - - # Verify content - decoded_content = base64.b64decode(result["content_base64"]) - assert decoded_content == content - - @pytest.mark.asyncio - async def test_read_artifact_with_offset_limit(self, db_engine): - """Test read_artifact with offset and limit.""" - store = ArtifactStore(db_engine) - - # Register artifact with content - content = b"0123456789" * 100 # 1000 bytes - artifact_id = await store.register_artifact( - artifact_id="art_real_003", - artifact_type="file", - source="runner", - mime_type="application/octet-stream", - content=content, - ) - - # Read with offset - result = await store.read_artifact(artifact_id, offset=100, limit=100) - assert result is not None - assert result["offset"] == 100 - assert result["length"] == 100 - - # Verify content - decoded_content = base64.b64decode(result["content_base64"]) - assert decoded_content == content[100:200] - - @pytest.mark.asyncio - async def test_read_artifact_has_more(self, db_engine): - """Test read_artifact sets has_more correctly.""" - store = ArtifactStore(db_engine) - - # Register artifact with content - content = b"0123456789" * 100 # 1000 bytes - artifact_id = await store.register_artifact( - artifact_id="art_real_004", - artifact_type="file", - source="runner", - content=content, - ) - - # Read with limit smaller than content - result = await store.read_artifact(artifact_id, offset=0, limit=100) - assert result is not None - assert result["has_more"] is True - assert result["length"] == 100 - - @pytest.mark.asyncio - async def test_expired_artifact_is_not_readable_before_cleanup(self, db_engine): - """Expired artifacts are hidden even before a cleanup job deletes rows.""" - store = ArtifactStore(db_engine) - await store.register_artifact( - artifact_id="art_expired_hidden", - artifact_type="file", - source="runner", - content=b"expired", - expires_at=datetime.datetime.utcnow() - datetime.timedelta(seconds=1), - ) - - assert await store.get_metadata("art_expired_hidden") is None - assert await store.read_artifact("art_expired_hidden") is None - - @pytest.mark.asyncio - async def test_cleanup_expired_artifacts_deletes_binary_storage(self, db_engine): - """Expired artifacts and their Host-owned binary blobs are removed.""" - from sqlalchemy import select - from langbot.pkg.entity.persistence.bstorage import BinaryStorage - - store = ArtifactStore(db_engine) - now = datetime.datetime.utcnow() - await store.register_artifact( - artifact_id="art_expired", - artifact_type="file", - source="runner", - content=b"expired", - expires_at=now - datetime.timedelta(seconds=1), - ) - await store.register_artifact( - artifact_id="art_fresh", - artifact_type="file", - source="runner", - content=b"fresh", - expires_at=now + datetime.timedelta(days=1), - ) - - removed = await store.cleanup_expired_artifacts(now=now) - - assert removed == 1 - assert await store.get_metadata("art_expired") is None - assert await store.get_metadata("art_fresh") is not None - async with store._session_factory() as session: - result = await session.execute( - select(BinaryStorage).where(BinaryStorage.unique_key == "artifact:art_expired") - ) - assert result.scalars().first() is None - - @pytest.mark.asyncio - async def test_file_artifact_range_read_and_public_metadata(self, db_engine, tmp_path): - """File-backed artifacts read ranges without exposing host paths.""" - store = ArtifactStore(db_engine) - content = b"0123456789" * 20 - file_path = tmp_path / "large.txt" - file_path.write_bytes(content) - - artifact_id = await store.register_file_artifact( - artifact_id="art_file_001", - host_path=str(file_path), - host_root=str(tmp_path), - source="tool", - mime_type="text/plain", - name="large.txt", - conversation_id="conv_001", - run_id="run_001", - metadata={"sandbox_path": "/workspace/large.txt"}, - ) - - metadata = await store.get_metadata(artifact_id) - assert metadata is not None - assert metadata["artifact_id"] == "art_file_001" - assert metadata["metadata"] == {"sandbox_path": "/workspace/large.txt"} - assert str(file_path) not in str(metadata) - - result = await store.read_artifact(artifact_id, offset=10, limit=15) - assert result is not None - assert result["offset"] == 10 - assert result["length"] == 15 - assert result["size_bytes"] == len(content) - assert result["has_more"] is True - assert base64.b64decode(result["content_base64"]) == content[10:25] - - @pytest.mark.asyncio - async def test_register_file_artifact_rejects_path_escape(self, db_engine, tmp_path): - """File-backed artifacts must stay inside their declared host root.""" - store = ArtifactStore(db_engine) - root = tmp_path / "root" - root.mkdir() - outside = tmp_path / "outside.txt" - outside.write_text("outside") - - with pytest.raises(ValueError, match="escapes"): - await store.register_file_artifact( - artifact_id="art_file_escape", - host_path=str(outside), - host_root=str(root), - ) - - @pytest.mark.asyncio - async def test_metadata_sdk_validation(self, db_engine): - """Test that metadata can be validated by SDK ArtifactMetadata.""" - from langbot_plugin.api.entities.builtin.agent_runner.artifact import ArtifactMetadata - - store = ArtifactStore(db_engine) - - # Register artifact - artifact_id = await store.register_artifact( - artifact_id="art_real_005", - artifact_type="file", - source="runner", - mime_type="application/pdf", - name="document.pdf", - size_bytes=1024, - conversation_id="conv_001", - run_id="run_001", - runner_id="plugin:test/plugin/runner", - ) - - # Get metadata - metadata = await store.get_metadata(artifact_id) - assert metadata is not None - - # Should not raise ValidationError - validated = ArtifactMetadata.model_validate(metadata) - assert validated.artifact_id == "art_real_005" - assert validated.artifact_type == "file" diff --git a/tests/unit_tests/agent/test_context_builder_state.py b/tests/unit_tests/agent/test_context_builder_state.py index 6819f0e0d..9c9f17cf1 100644 --- a/tests/unit_tests/agent/test_context_builder_state.py +++ b/tests/unit_tests/agent/test_context_builder_state.py @@ -41,7 +41,6 @@ def make_descriptor( else { 'history': ['page', 'search'], 'events': ['get', 'page'], - 'artifacts': ['metadata', 'read'], 'storage': ['plugin'], }, ) @@ -310,29 +309,6 @@ class TestContextAccessOtherAPIs: assert context_access['available_apis']['event_get'] is True assert context_access['available_apis']['event_page'] is True - @pytest.mark.asyncio - async def test_artifact_apis_enabled_by_default(self, mock_app): - """Artifact 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']['artifact_metadata'] is True - assert context_access['available_apis']['artifact_read'] 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.""" @@ -357,8 +333,6 @@ class TestContextAccessOtherAPIs: 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']['artifact_metadata'] is True - assert context_access['available_apis']['artifact_read'] is True assert context_access['available_apis']['state'] is False @pytest.mark.asyncio @@ -384,6 +358,4 @@ class TestContextAccessOtherAPIs: 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']['artifact_metadata'] is False - assert context_access['available_apis']['artifact_read'] 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 index 3cc36791d..5188aefda 100644 --- a/tests/unit_tests/agent/test_context_validation.py +++ b/tests/unit_tests/agent/test_context_validation.py @@ -100,7 +100,6 @@ class TestContextValidation: permissions={ "history": ["page", "search"], "events": ["get", "page"], - "artifacts": ["metadata", "read"], "storage": ["plugin", "workspace"], }, ) diff --git a/tests/unit_tests/agent/test_event_first_protocol.py b/tests/unit_tests/agent/test_event_first_protocol.py index 3ed93ec49..a18a64dcc 100644 --- a/tests/unit_tests/agent/test_event_first_protocol.py +++ b/tests/unit_tests/agent/test_event_first_protocol.py @@ -286,18 +286,6 @@ class TestSDKResultProtocolV1: assert result.run_id == "run_1" - def test_artifact_created_result_type(self): - """Test artifact.created result type.""" - result = AgentRunResult.artifact_created( - run_id="run_1", - artifact_id="artifact_1", - artifact_type="image", - ) - - assert result.type == AgentRunResultType.ARTIFACT_CREATED - assert result.data["artifact_id"] == "artifact_1" - - # Fixtures @pytest.fixture def mock_query(): diff --git a/tests/unit_tests/agent/test_event_log_transcript.py b/tests/unit_tests/agent/test_event_log_transcript.py index 4799d0e8d..450b4111d 100644 --- a/tests/unit_tests/agent/test_event_log_transcript.py +++ b/tests/unit_tests/agent/test_event_log_transcript.py @@ -211,8 +211,8 @@ class TestTranscriptStore: assert transcript_id is not None @pytest.mark.asyncio - async def test_append_transcript_with_artifacts(self, mock_db_engine): - """Test appending transcript with artifact refs.""" + 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) @@ -231,8 +231,8 @@ class TestTranscriptStore: conversation_id="conv_1", role="assistant", content="Here's an image", - artifact_refs=[ - {"artifact_id": "art_1", "artifact_type": "image", "url": "http://example.com/img.png"} + attachment_refs=[ + {"id": "att_1", "type": "image", "url": "http://example.com/img.png"} ], ) diff --git a/tests/unit_tests/agent/test_handler_auth.py b/tests/unit_tests/agent/test_handler_auth.py index 61bbd537a..18516951e 100644 --- a/tests/unit_tests/agent/test_handler_auth.py +++ b/tests/unit_tests/agent/test_handler_auth.py @@ -1510,36 +1510,6 @@ class TestStorageResourcePermissionHelper: assert registry.is_resource_allowed(session, 'storage', 'workspace') is False -class TestFilesResourcePermission: - """Tests for session_registry.is_resource_allowed for files resource type. - - Phase 6: 'files' resource type is now implemented in is_resource_allowed. - """ - - @pytest.mark.asyncio - async def test_files_resource_type_now_implemented(self): - """'files' resource type is now implemented in is_resource_allowed.""" - from langbot.pkg.agent.runner.session_registry import get_session_registry - - registry = get_session_registry() - resources = make_resources(files=[{'file_id': 'file_001'}]) - - await registry.register( - run_id='run_files_implemented', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - session = await registry.get('run_files_implemented') - - # 'files' resource type is now implemented - assert registry.is_resource_allowed(session, 'file', 'file_001') is True - assert registry.is_resource_allowed(session, 'file', 'file_999') is False - - await registry.unregister('run_files_implemented') - class TestRealActionHandlerSimulation: """Tests that simulate real RuntimeConnectionHandler action registration and execution. @@ -1797,84 +1767,6 @@ class TestStoragePermissionValidation: await registry.unregister('run_workspace_storage_denied') -class TestFilePermissionValidation: - """Tests for Host-side file permission validation via _validate_run_authorization. - - Phase 6: GET_CONFIG_FILE action now validates file permissions - via _validate_run_authorization when run_id is present. - """ - - @pytest.mark.asyncio - async def test_file_allowed_when_in_resources(self): - """_validate_run_authorization allows file when in resources.""" - from langbot.pkg.agent.runner.session_registry import get_session_registry - - registry = get_session_registry() - resources = make_resources(files=[{'file_id': 'file_001'}]) - - await registry.register( - run_id='run_file_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_file_auth', - 'file', - 'file_001', - mock_ap, - caller_plugin_identity='test/runner', - ) - - assert session is not None - assert error is None - - await registry.unregister('run_file_auth') - - @pytest.mark.asyncio - async def test_file_denied_when_not_in_resources(self): - """_validate_run_authorization denies file when not in resources.""" - from langbot.pkg.agent.runner.session_registry import get_session_registry - - registry = get_session_registry() - resources = make_resources(files=[{'file_id': 'file_001'}]) - - await registry.register( - run_id='run_file_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_file_denied', - 'file', - 'file_999', # Not in resources - 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_file_denied') - - class TestOperationPermissionValidation: """Tests operation-level Host-side run authorization.""" diff --git a/tests/unit_tests/agent/test_orchestrator_artifact.py b/tests/unit_tests/agent/test_orchestrator_artifact.py deleted file mode 100644 index bf0deefb1..000000000 --- a/tests/unit_tests/agent/test_orchestrator_artifact.py +++ /dev/null @@ -1,657 +0,0 @@ -"""Tests for artifact.created handling in orchestrator.""" -import pytest -import base64 -from unittest.mock import AsyncMock, MagicMock, patch -import uuid - -from langbot.pkg.agent.runner.orchestrator import ( - AgentRunOrchestrator, - MAX_ARTIFACT_INLINE_BYTES, -) -from langbot.pkg.agent.runner.host_models import AgentEventEnvelope -from langbot.pkg.agent.runner.errors import RunnerProtocolError -from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput -from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext -from langbot.pkg.core import app - - -class TestArtifactCreatedValidation: - """Test artifact.created validation and protocol errors.""" - - @pytest.fixture - def mock_app(self): - """Create mock application.""" - ap = MagicMock(spec=app.Application) - ap.logger = MagicMock() - ap.plugin_connector = MagicMock() - ap.plugin_connector.is_enable_plugin = True - ap.persistence_mgr = MagicMock() - ap.persistence_mgr.get_db_engine = MagicMock() - return ap - - @pytest.fixture - def mock_registry(self): - """Create mock registry.""" - registry = MagicMock() - registry.get = AsyncMock() - return registry - - @pytest.fixture - def mock_event(self): - """Create mock event envelope.""" - event = MagicMock(spec=AgentEventEnvelope) - event.event_id = str(uuid.uuid4()) - event.event_type = 'message.received' - event.source = 'test' - event.bot_id = str(uuid.uuid4()) - event.workspace_id = str(uuid.uuid4()) - event.conversation_id = str(uuid.uuid4()) - event.thread_id = None - event.event_time = 1700000000 - event.actor = MagicMock(spec=ActorContext) - event.actor.actor_type = 'user' - event.actor.actor_id = 'user-123' - event.actor.actor_name = 'Test User' - event.subject = None - event.input = MagicMock(spec=AgentInput) - event.input.text = 'Hello' - event.input.contents = [] - event.input.attachments = [] - return event - - @pytest.mark.asyncio - async def test_run_id_mismatch_raises_protocol_error( - self, mock_app, mock_registry, mock_event - ): - """Test that run_id mismatch raises RunnerProtocolError.""" - orchestrator = AgentRunOrchestrator(mock_app, mock_registry) - run_id = str(uuid.uuid4()) - wrong_run_id = str(uuid.uuid4()) - - result_dict = { - 'type': 'artifact.created', - 'run_id': wrong_run_id, - 'data': { - 'artifact_type': 'image', - }, - } - - with pytest.raises(RunnerProtocolError) as exc_info: - await orchestrator._handle_artifact_created( - result_dict=result_dict, - event=mock_event, - run_id=run_id, - runner_id='test-runner', - ) - - assert 'run_id mismatch' in str(exc_info.value) - - @pytest.mark.asyncio - async def test_missing_artifact_type_raises_protocol_error( - self, mock_app, mock_registry, mock_event - ): - """Test that missing artifact_type raises RunnerProtocolError.""" - orchestrator = AgentRunOrchestrator(mock_app, mock_registry) - run_id = str(uuid.uuid4()) - - result_dict = { - 'type': 'artifact.created', - 'run_id': run_id, - 'data': { - 'artifact_id': str(uuid.uuid4()), - # missing artifact_type - }, - } - - with pytest.raises(RunnerProtocolError) as exc_info: - await orchestrator._handle_artifact_created( - result_dict=result_dict, - event=mock_event, - run_id=run_id, - runner_id='test-runner', - ) - - assert 'missing required field' in str(exc_info.value) - - @pytest.mark.asyncio - async def test_invalid_base64_raises_protocol_error( - self, mock_app, mock_registry, mock_event - ): - """Test that invalid base64 raises RunnerProtocolError.""" - orchestrator = AgentRunOrchestrator(mock_app, mock_registry) - run_id = str(uuid.uuid4()) - - result_dict = { - 'type': 'artifact.created', - 'run_id': run_id, - 'data': { - 'artifact_type': 'image', - 'content_base64': '!!!invalid-base64!!!', - }, - } - - with pytest.raises(RunnerProtocolError) as exc_info: - await orchestrator._handle_artifact_created( - result_dict=result_dict, - event=mock_event, - run_id=run_id, - runner_id='test-runner', - ) - - assert 'invalid base64' in str(exc_info.value) - - @pytest.mark.asyncio - async def test_oversized_content_raises_protocol_error( - self, mock_app, mock_registry, mock_event - ): - """Test that content exceeding limit raises RunnerProtocolError.""" - orchestrator = AgentRunOrchestrator(mock_app, mock_registry) - run_id = str(uuid.uuid4()) - - # Create content larger than limit - oversized_content = b'x' * (MAX_ARTIFACT_INLINE_BYTES + 1) - content_base64 = base64.b64encode(oversized_content).decode('utf-8') - - result_dict = { - 'type': 'artifact.created', - 'run_id': run_id, - 'data': { - 'artifact_type': 'image', - 'content_base64': content_base64, - }, - } - - with pytest.raises(RunnerProtocolError) as exc_info: - await orchestrator._handle_artifact_created( - result_dict=result_dict, - event=mock_event, - run_id=run_id, - runner_id='test-runner', - ) - - assert 'exceeds limit' in str(exc_info.value) - - @pytest.mark.asyncio - async def test_artifact_store_failure_raises_protocol_error( - self, mock_app, mock_registry, mock_event - ): - """Test that ArtifactStore failure raises RunnerProtocolError.""" - orchestrator = AgentRunOrchestrator(mock_app, mock_registry) - run_id = str(uuid.uuid4()) - - result_dict = { - 'type': 'artifact.created', - 'run_id': run_id, - 'data': { - 'artifact_type': 'image', - }, - } - - with patch('langbot.pkg.agent.runner.artifact_store.ArtifactStore') as MockArtifactStore: - mock_artifact_store = MagicMock() - mock_artifact_store.register_artifact = AsyncMock( - side_effect=Exception('DB connection failed') - ) - MockArtifactStore.return_value = mock_artifact_store - - with pytest.raises(RunnerProtocolError) as exc_info: - await orchestrator._handle_artifact_created( - result_dict=result_dict, - event=mock_event, - run_id=run_id, - runner_id='test-runner', - ) - - assert 'failed to register artifact' in str(exc_info.value) - - -class TestArtifactCreatedSuccess: - """Test successful artifact.created handling.""" - - @pytest.fixture - def mock_app(self): - """Create mock application.""" - ap = MagicMock(spec=app.Application) - ap.logger = MagicMock() - ap.plugin_connector = MagicMock() - ap.plugin_connector.is_enable_plugin = True - ap.persistence_mgr = MagicMock() - ap.persistence_mgr.get_db_engine = MagicMock() - return ap - - @pytest.fixture - def mock_registry(self): - """Create mock registry.""" - registry = MagicMock() - registry.get = AsyncMock() - return registry - - @pytest.fixture - def mock_event(self): - """Create mock event envelope.""" - event = MagicMock(spec=AgentEventEnvelope) - event.event_id = str(uuid.uuid4()) - event.event_type = 'message.received' - event.source = 'test' - event.bot_id = str(uuid.uuid4()) - event.workspace_id = str(uuid.uuid4()) - event.conversation_id = str(uuid.uuid4()) - event.thread_id = None - event.event_time = 1700000000 - event.actor = MagicMock(spec=ActorContext) - event.actor.actor_type = 'user' - event.actor.actor_id = 'user-123' - event.actor.actor_name = 'Test User' - event.subject = None - return event - - @pytest.mark.asyncio - async def test_handle_artifact_created_registers_artifact( - self, mock_app, mock_registry, mock_event - ): - """Test that artifact.created registers artifact via ArtifactStore.""" - orchestrator = AgentRunOrchestrator(mock_app, mock_registry) - run_id = str(uuid.uuid4()) - runner_id = 'test-runner' - - # Create artifact.created result - content = b'test artifact content' - content_base64 = base64.b64encode(content).decode('utf-8') - artifact_id = str(uuid.uuid4()) - - result_dict = { - 'type': 'artifact.created', - 'run_id': run_id, - 'data': { - 'artifact_id': artifact_id, - 'artifact_type': 'image', - 'mime_type': 'image/png', - 'name': 'test.png', - 'size_bytes': len(content), - 'content_base64': content_base64, - }, - } - - with patch('langbot.pkg.agent.runner.artifact_store.ArtifactStore') as MockArtifactStore: - with patch('langbot.pkg.agent.runner.event_log_store.EventLogStore') as MockEventLogStore: - mock_artifact_store = MagicMock() - mock_artifact_store.register_artifact = AsyncMock(return_value=artifact_id) - MockArtifactStore.return_value = mock_artifact_store - - mock_event_log_store = MagicMock() - mock_event_log_store.append_event = AsyncMock() - MockEventLogStore.return_value = mock_event_log_store - - # Call _handle_artifact_created - result = await orchestrator._handle_artifact_created( - result_dict=result_dict, - event=mock_event, - run_id=run_id, - runner_id=runner_id, - ) - - # Verify artifact was registered - mock_artifact_store.register_artifact.assert_called_once() - call_kwargs = mock_artifact_store.register_artifact.call_args.kwargs - assert call_kwargs['artifact_id'] == artifact_id - assert call_kwargs['artifact_type'] == 'image' - assert call_kwargs['mime_type'] == 'image/png' - assert call_kwargs['name'] == 'test.png' - assert call_kwargs['content'] == content - assert call_kwargs['conversation_id'] == mock_event.conversation_id - assert call_kwargs['run_id'] == run_id - assert call_kwargs['runner_id'] == runner_id - - # Verify EventLog was written - mock_event_log_store.append_event.assert_called_once() - event_kwargs = mock_event_log_store.append_event.call_args.kwargs - assert event_kwargs['event_type'] == 'artifact.created' - assert event_kwargs['run_id'] == run_id - - # Verify artifact ref returned - assert result is not None - assert result['artifact_id'] == artifact_id - assert result['artifact_type'] == 'image' - - @pytest.mark.asyncio - async def test_handle_artifact_created_metadata_only( - self, mock_app, mock_registry, mock_event - ): - """Test artifact.created without content (metadata-only).""" - orchestrator = AgentRunOrchestrator(mock_app, mock_registry) - run_id = str(uuid.uuid4()) - artifact_id = str(uuid.uuid4()) - - result_dict = { - 'type': 'artifact.created', - 'run_id': run_id, - 'data': { - 'artifact_id': artifact_id, - 'artifact_type': 'file', - 'mime_type': 'application/pdf', - 'name': 'document.pdf', - 'size_bytes': 1024, - 'sha256': 'abc123', - 'metadata': {'source': 'external'}, - }, - } - - with patch('langbot.pkg.agent.runner.artifact_store.ArtifactStore') as MockArtifactStore: - with patch('langbot.pkg.agent.runner.event_log_store.EventLogStore') as MockEventLogStore: - mock_artifact_store = MagicMock() - mock_artifact_store.register_artifact = AsyncMock(return_value=artifact_id) - MockArtifactStore.return_value = mock_artifact_store - - mock_event_log_store = MagicMock() - mock_event_log_store.append_event = AsyncMock() - MockEventLogStore.return_value = mock_event_log_store - - result = await orchestrator._handle_artifact_created( - result_dict=result_dict, - event=mock_event, - run_id=run_id, - runner_id='test-runner', - ) - - # Verify artifact was registered without content - call_kwargs = mock_artifact_store.register_artifact.call_args.kwargs - assert call_kwargs['content'] is None - assert call_kwargs['sha256'] == 'abc123' - assert call_kwargs['metadata'] == {'source': 'external'} - - assert result is not None - assert result['artifact_id'] == artifact_id - - -class TestArtifactRefsLifecycle: - """Test artifact refs lifecycle in event-first flow.""" - - @pytest.fixture - def mock_app(self): - """Create mock application.""" - ap = MagicMock(spec=app.Application) - ap.logger = MagicMock() - ap.plugin_connector = MagicMock() - ap.plugin_connector.is_enable_plugin = True - ap.persistence_mgr = MagicMock() - ap.persistence_mgr.get_db_engine = MagicMock() - return ap - - @pytest.fixture - def mock_registry(self): - """Create mock registry.""" - registry = MagicMock() - registry.get = AsyncMock() - return registry - - def test_merge_artifact_refs_deduplicates( - self, mock_app, mock_registry - ): - """Test that _merge_artifact_refs deduplicates by artifact_id.""" - orchestrator = AgentRunOrchestrator(mock_app, mock_registry) - - pending_refs = [ - {'artifact_id': 'artifact-1', 'artifact_type': 'image'}, - {'artifact_id': 'artifact-2', 'artifact_type': 'file'}, - ] - - result_dict = { - 'type': 'message.completed', - 'data': { - 'message': { - 'content': 'Hello', - 'artifact_refs': [ - {'artifact_id': 'artifact-2', 'artifact_type': 'file'}, # duplicate - {'artifact_id': 'artifact-3', 'artifact_type': 'voice'}, - ], - }, - }, - } - - merged = orchestrator._merge_artifact_refs(pending_refs, result_dict) - - # Should have 3 unique artifacts - assert len(merged) == 3 - artifact_ids = {ref['artifact_id'] for ref in merged} - assert artifact_ids == {'artifact-1', 'artifact-2', 'artifact-3'} - - def test_merge_artifact_refs_empty_pending( - self, mock_app, mock_registry - ): - """Test merge with empty pending refs.""" - orchestrator = AgentRunOrchestrator(mock_app, mock_registry) - - pending_refs = [] - - result_dict = { - 'type': 'message.completed', - 'data': { - 'message': { - 'content': 'Hello', - 'artifact_refs': [ - {'artifact_id': 'artifact-1', 'artifact_type': 'image'}, - ], - }, - }, - } - - merged = orchestrator._merge_artifact_refs(pending_refs, result_dict) - - assert len(merged) == 1 - assert merged[0]['artifact_id'] == 'artifact-1' - - def test_merge_artifact_refs_empty_message_refs( - self, mock_app, mock_registry - ): - """Test merge with no message artifact_refs.""" - orchestrator = AgentRunOrchestrator(mock_app, mock_registry) - - pending_refs = [ - {'artifact_id': 'artifact-1', 'artifact_type': 'image'}, - ] - - result_dict = { - 'type': 'message.completed', - 'data': { - 'message': { - 'content': 'Hello', - # no artifact_refs - }, - }, - } - - merged = orchestrator._merge_artifact_refs(pending_refs, result_dict) - - assert len(merged) == 1 - assert merged[0]['artifact_id'] == 'artifact-1' - - -class TestResultNormalizerArtifactCreated: - """Test ResultNormalizer handling of artifact.created.""" - - @pytest.fixture - def mock_app(self): - """Create mock application.""" - ap = MagicMock(spec=app.Application) - ap.logger = MagicMock() - return ap - - @pytest.fixture - def mock_descriptor(self): - """Create mock descriptor.""" - descriptor = MagicMock() - descriptor.id = 'test-runner' - return descriptor - - @pytest.mark.asyncio - async def test_normalize_artifact_created_returns_none( - self, mock_app, mock_descriptor - ): - """Test that artifact.created is consumed (returns None).""" - from langbot.pkg.agent.runner.result_normalizer import AgentResultNormalizer - - normalizer = AgentResultNormalizer(mock_app) - - result_dict = { - 'type': 'artifact.created', - 'run_id': 'test-run-id', - 'data': { - 'artifact_id': 'artifact-123', - 'artifact_type': 'image', - }, - } - - result = await normalizer.normalize(result_dict, mock_descriptor) - - # Should return None (consumed) - assert result is None - - # Debug log should be written - mock_app.logger.debug.assert_called() - - @pytest.mark.asyncio - async def test_normalize_unknown_type_warning( - self, mock_app, mock_descriptor - ): - """Test that unknown result types still produce warnings.""" - from langbot.pkg.agent.runner.result_normalizer import AgentResultNormalizer - - normalizer = AgentResultNormalizer(mock_app) - - result_dict = { - 'type': 'unknown.type', - 'data': {}, - } - - result = await normalizer.normalize(result_dict, mock_descriptor) - - # Should return None - assert result is None - - # Warning should be logged - mock_app.logger.warning.assert_called() - - -class TestEventLogTranscriptIntegration: - """Test EventLog and Transcript integration with artifact.created.""" - - @pytest.fixture - def mock_app(self): - """Create mock application.""" - ap = MagicMock(spec=app.Application) - ap.logger = MagicMock() - ap.plugin_connector = MagicMock() - ap.plugin_connector.is_enable_plugin = True - ap.persistence_mgr = MagicMock() - ap.persistence_mgr.get_db_engine = MagicMock() - return ap - - @pytest.fixture - def mock_registry(self): - """Create mock registry.""" - registry = MagicMock() - registry.get = AsyncMock() - return registry - - @pytest.fixture - def mock_event(self): - """Create mock event envelope.""" - event = MagicMock(spec=AgentEventEnvelope) - event.event_id = str(uuid.uuid4()) - event.event_type = 'message.received' - event.source = 'test' - event.bot_id = str(uuid.uuid4()) - event.workspace_id = str(uuid.uuid4()) - event.conversation_id = str(uuid.uuid4()) - event.thread_id = None - event.event_time = 1700000000 - event.actor = MagicMock(spec=ActorContext) - event.actor.actor_type = 'user' - event.actor.actor_id = 'user-123' - event.actor.actor_name = 'Test User' - event.subject = None - return event - - @pytest.mark.asyncio - async def test_event_log_written_with_correct_event_type( - self, mock_app, mock_registry, mock_event - ): - """Test that EventLog is written with event_type='artifact.created'.""" - orchestrator = AgentRunOrchestrator(mock_app, mock_registry) - run_id = str(uuid.uuid4()) - artifact_id = str(uuid.uuid4()) - - result_dict = { - 'type': 'artifact.created', - 'run_id': run_id, - 'data': { - 'artifact_id': artifact_id, - 'artifact_type': 'image', - }, - } - - with patch('langbot.pkg.agent.runner.artifact_store.ArtifactStore') as MockArtifactStore: - with patch('langbot.pkg.agent.runner.event_log_store.EventLogStore') as MockEventLogStore: - mock_artifact_store = MagicMock() - mock_artifact_store.register_artifact = AsyncMock(return_value=artifact_id) - MockArtifactStore.return_value = mock_artifact_store - - mock_event_log_store = MagicMock() - mock_event_log_store.append_event = AsyncMock() - MockEventLogStore.return_value = mock_event_log_store - - await orchestrator._handle_artifact_created( - result_dict=result_dict, - event=mock_event, - run_id=run_id, - runner_id='test-runner', - ) - - # Verify EventLog.append_event was called with correct event_type - mock_event_log_store.append_event.assert_called_once() - call_kwargs = mock_event_log_store.append_event.call_args.kwargs - assert call_kwargs['event_type'] == 'artifact.created' - assert call_kwargs['source'] == 'runner' - assert call_kwargs['conversation_id'] == mock_event.conversation_id - assert call_kwargs['run_id'] == run_id - - @pytest.mark.asyncio - async def test_assistant_transcript_receives_artifact_refs( - self, mock_app, mock_registry, mock_event - ): - """Test that assistant transcript receives artifact refs from artifact.created.""" - orchestrator = AgentRunOrchestrator(mock_app, mock_registry) - run_id = str(uuid.uuid4()) - artifact_id = str(uuid.uuid4()) - - # Create pending artifact refs - pending_refs = [ - {'artifact_id': artifact_id, 'artifact_type': 'image', 'mime_type': 'image/png'}, - ] - - result_dict = { - 'type': 'message.completed', - 'data': { - 'message': { - 'content': 'Here is your image', - }, - }, - } - - with patch('langbot.pkg.agent.runner.transcript_store.TranscriptStore') as MockTranscriptStore: - mock_transcript_store = MagicMock() - mock_transcript_store.append_transcript = AsyncMock() - MockTranscriptStore.return_value = mock_transcript_store - - await orchestrator._write_assistant_transcript( - result_dict=result_dict, - event=mock_event, - run_id=run_id, - runner_id='test-runner', - artifact_refs=pending_refs, - ) - - # Verify transcript was written with artifact_refs - mock_transcript_store.append_transcript.assert_called_once() - call_kwargs = mock_transcript_store.append_transcript.call_args.kwargs - assert call_kwargs['artifact_refs'] == pending_refs diff --git a/tests/unit_tests/agent/test_orchestrator_integration.py b/tests/unit_tests/agent/test_orchestrator_integration.py index 05fb2ecc8..703701348 100644 --- a/tests/unit_tests/agent/test_orchestrator_integration.py +++ b/tests/unit_tests/agent/test_orchestrator_integration.py @@ -168,7 +168,6 @@ def make_descriptor() -> AgentRunnerDescriptor: 'knowledge_bases': ['list', 'retrieve'], 'history': ['page', 'search'], 'events': ['get', 'page'], - 'artifacts': ['metadata', 'read'], 'storage': ['plugin'], }, config_schema=[ @@ -270,8 +269,8 @@ def test_context_builder_includes_consumable_base64_attachments(): assert input_data.contents[1].image_base64 == 'data:image/png;base64,aGVsbG8=' assert input_data.contents[2].file_base64 == 'data:text/plain;base64,aGVsbG8=' - artifact_types = [attachment.artifact_type for attachment in input_data.attachments] - assert artifact_types == ['image', 'file', 'image'] + attachment_types = [attachment.type for attachment in input_data.attachments] + assert attachment_types == ['image', 'file', 'image'] assert input_data.attachments[1].name == 'hello.txt' @@ -286,7 +285,7 @@ def test_context_builder_deduplicates_message_chain_attachments(): assert [content.type for content in input_data.contents] == ['image_base64'] assert len(input_data.attachments) == 1 - assert input_data.attachments[0].artifact_type == 'image' + assert input_data.attachments[0].type == 'image' assert input_data.attachments[0].content == 'data:image/jpeg;base64,aGVsbG8=' @@ -303,7 +302,7 @@ def test_context_builder_preserves_same_source_duplicate_attachments(): input_data = QueryEntryAdapter._build_input(query) - assert [attachment.artifact_type for attachment in input_data.attachments] == ['image', 'image'] + assert [attachment.type for attachment in input_data.attachments] == ['image', 'image'] @pytest.fixture(autouse=True) @@ -1139,7 +1138,7 @@ class TestQueryEntryAdapterHostCapabilities: transcripts, _, _, _ = await transcript_store.page_transcript( conversation_id=query.session.using_conversation.uuid, limit=10, - include_artifacts=True, + include_attachments=True, ) assert len(transcripts) >= 2 # Find user and assistant messages @@ -1148,60 +1147,5 @@ class TestQueryEntryAdapterHostCapabilities: 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['artifact_refs'][0]['content'] is None + assert user_item['attachment_refs'][0]['content'] is None assert 'aGVsbG8=' not in str(user_item) - - @pytest.mark.asyncio - async def test_artifact_created_via_event_first_path(self, clean_agent_state): - """artifact.created via Pipeline path uses event-first ArtifactStore and EventLog.""" - import base64 - from langbot.pkg.agent.runner.artifact_store import ArtifactStore - from langbot.pkg.agent.runner.event_log_store import EventLogStore - - db_engine = clean_agent_state - descriptor = make_descriptor() - artifact_id = 'artifact_001' - content = b'test artifact content' - content_base64 = base64.b64encode(content).decode('utf-8') - plugin_connector = FakePluginConnector( - results=[ - { - 'type': 'artifact.created', - 'data': { - 'artifact_id': artifact_id, - 'artifact_type': 'file', - 'mime_type': 'text/plain', - 'name': 'test.txt', - 'content_base64': content_base64, - }, - }, - { - 'type': 'message.completed', - 'data': {'message': {'role': 'assistant', 'content': 'artifact created'}}, - }, - ] - ) - 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 == 'artifact created' - - # Verify artifact was registered in ArtifactStore - artifact_store = ArtifactStore(db_engine) - artifact = await artifact_store.get_metadata(artifact_id) - assert artifact is not None - assert artifact['artifact_type'] == 'file' - assert artifact['name'] == 'test.txt' - - # Verify artifact.created event was written to EventLog - event_log_store = EventLogStore(db_engine) - event_logs, _, _ = await event_log_store.page_events( - conversation_id=query.session.using_conversation.uuid, - limit=10, - ) - artifact_events = [e for e in event_logs if e['event_type'] == 'artifact.created'] - assert len(artifact_events) >= 1 diff --git a/tests/unit_tests/agent/test_registry.py b/tests/unit_tests/agent/test_registry.py index 146ab4a0b..92fb0a2ff 100644 --- a/tests/unit_tests/agent/test_registry.py +++ b/tests/unit_tests/agent/test_registry.py @@ -39,16 +39,12 @@ class FakeApplication: 'plugin_name': 'local-agent', 'runner_name': 'default', 'manifest': { - 'kind': 'AgentRunner', - 'metadata': { - 'name': 'default', - 'label': {'en_US': 'Local Agent'}, - }, - 'spec': { - 'config': [], - 'capabilities': {'streaming': True}, - 'permissions': {}, - }, + 'id': 'plugin:langbot/local-agent/default', + 'name': 'default', + 'label': {'en_US': 'Local Agent'}, + 'capabilities': {'streaming': True}, + 'permissions': {}, + 'config_schema': [], }, }, { @@ -56,16 +52,12 @@ class FakeApplication: 'plugin_name': 'my-agent', 'runner_name': 'custom', 'manifest': { - 'kind': 'AgentRunner', - 'metadata': { - 'name': 'custom', - 'label': {'en_US': 'Custom Agent'}, - }, - 'spec': { - 'config': [{'name': 'param1', 'type': 'string'}], - 'capabilities': {}, - 'permissions': {}, - }, + '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 @@ -237,15 +229,12 @@ class TestRegistryMetadataForPipeline: assert 'plugin:langbot/local-agent/default' in option_ids assert 'plugin:alice/my-agent/custom' in option_ids - # Should fall back to manifest.spec.config when runtime does not return - # extracted config at top level. + # Config comes from the typed manifest. assert len(stages) == 1 assert stages[0]['name'] == 'plugin:alice/my-agent/custom' - assert stages[0]['config'] == [{ - 'name': 'param1', - 'type': 'string', - 'id': 'plugin:alice/my-agent/custom.param1', - }] + 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: diff --git a/tests/unit_tests/agent/test_resource_builder.py b/tests/unit_tests/agent/test_resource_builder.py index 814e60df1..e3fb9420b 100644 --- a/tests/unit_tests/agent/test_resource_builder.py +++ b/tests/unit_tests/agent/test_resource_builder.py @@ -19,9 +19,7 @@ FULL_PERMISSIONS = { 'knowledge_bases': ['list', 'retrieve'], 'history': ['page', 'search'], 'events': ['get', 'page'], - 'artifacts': ['metadata', 'read'], 'storage': ['plugin', 'workspace'], - 'files': ['config', 'knowledge'], } @@ -384,42 +382,6 @@ async def test_build_knowledge_bases_manifest_permission_denies_binding_kbs(app) assert resources['knowledge_bases'] == [] -@pytest.mark.asyncio -async def test_build_files_authorizes_config_declared_file_fields(app): - descriptor = make_descriptor( - config_schema=[ - {'name': 'avatar', 'type': 'file'}, - {'name': 'references', 'type': 'array[file]'}, - ], - ) - query = make_query({ - 'avatar': {'file_key': 'plugin_config_avatar.png', 'mimetype': 'image/png'}, - 'references': [ - {'file_key': 'plugin_config_doc.txt', 'mimetype': 'text/plain', 'file_name': 'doc.txt'}, - {'file_key': 'plugin_config_doc.txt', 'mimetype': 'text/plain', 'file_name': 'doc.txt'}, - ], - }) - - resources = await build_resources(app, query, descriptor) - - assert resources['files'] == [ - { - 'file_id': 'plugin_config_avatar.png', - 'file_name': None, - 'mime_type': 'image/png', - 'source': 'config', - 'operations': ['config'], - }, - { - 'file_id': 'plugin_config_doc.txt', - 'file_name': 'doc.txt', - 'mime_type': 'text/plain', - 'source': 'config', - 'operations': ['config'], - }, - ] - - @pytest.mark.asyncio async def test_build_storage_intersects_manifest_and_binding_policy(app): descriptor = make_descriptor( diff --git a/tests/unit_tests/agent/test_result_normalizer.py b/tests/unit_tests/agent/test_result_normalizer.py index 55030fa6b..882369dab 100644 --- a/tests/unit_tests/agent/test_result_normalizer.py +++ b/tests/unit_tests/agent/test_result_normalizer.py @@ -290,29 +290,6 @@ class TestNormalizeNonMessageResults: assert result is None assert app.logger.warnings - @pytest.mark.asyncio - async def test_invalid_artifact_created_payload_is_dropped(self): - """Invalid artifact.created payload returns None with a warning.""" - app = FakeApplication() - normalizer = AgentResultNormalizer(app) - descriptor = make_descriptor() - - result = await normalizer.normalize( - { - 'type': 'artifact.created', - 'data': { - 'artifact_id': 'artifact-1', - 'artifact_type': 'file', - 'content_base64': 'not base64', - }, - }, - descriptor, - ) - - assert result is None - assert app.logger.warnings - - class TestNormalizeInvalidResults: """Tests for handling invalid results.""" diff --git a/tests/unit_tests/agent/test_run_ledger_api_auth.py b/tests/unit_tests/agent/test_run_ledger_api_auth.py index d1f6cb19a..ff3e75762 100644 --- a/tests/unit_tests/agent/test_run_ledger_api_auth.py +++ b/tests/unit_tests/agent/test_run_ledger_api_auth.py @@ -287,24 +287,6 @@ async def test_persistent_run_get_requires_capability(db_engine): assert 'not authorized' in result.message.lower() -@pytest.mark.asyncio -async def test_persistent_authorization_does_not_reopen_artifact_api(db_engine): - await _create_run(db_engine, available_apis={'artifact_metadata': True}) - handler = _handler(db_engine) - artifact_metadata = handler.actions[PluginToRuntimeAction.ARTIFACT_METADATA.value] - - result = await artifact_metadata( - { - 'run_id': 'run_1', - 'artifact_id': 'artifact_1', - 'caller_plugin_identity': 'test/runner', - } - ) - - assert result.code != 0 - assert 'not found or expired' 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={}) diff --git a/tests/unit_tests/agent/test_session_registry.py b/tests/unit_tests/agent/test_session_registry.py index 992184845..3c2f05ef4 100644 --- a/tests/unit_tests/agent/test_session_registry.py +++ b/tests/unit_tests/agent/test_session_registry.py @@ -534,36 +534,6 @@ class TestIsResourceAllowed: assert registry.is_resource_allowed(session, 'unknown_type', 'something') is False - def test_file_allowed(self): - """File in resources should be allowed.""" - registry = AgentRunSessionRegistry() - resources = make_resources( - files=[ - {'file_id': 'file_001'}, - {'file_id': 'file_002'}, - ] - ) - session = make_session(resources=resources) - - assert registry.is_resource_allowed(session, 'file', 'file_001') is True - assert registry.is_resource_allowed(session, 'file', 'file_002') is True - - def test_file_not_allowed(self): - """File not in resources should be denied.""" - registry = AgentRunSessionRegistry() - resources = make_resources(files=[{'file_id': 'file_001'}]) - session = make_session(resources=resources) - - assert registry.is_resource_allowed(session, 'file', 'file_999') is False - - def test_file_empty_resources(self): - """Empty files list should deny all.""" - registry = AgentRunSessionRegistry() - resources = make_resources(files=[]) - session = make_session(resources=resources) - - assert registry.is_resource_allowed(session, 'file', 'file_001') is False - def test_missing_resources_field(self): """Missing resources field should not raise.""" registry = AgentRunSessionRegistry() diff --git a/tests/unit_tests/provider/test_requester_base.py b/tests/unit_tests/provider/test_requester_base.py index 0026f3881..ba86f8366 100644 --- a/tests/unit_tests/provider/test_requester_base.py +++ b/tests/unit_tests/provider/test_requester_base.py @@ -430,7 +430,7 @@ async def test_runtime_provider_invoke_llm_stream_stashes_usage(runtime_provider } async def fake_stream(**kwargs): - kwargs['query'].variables[requester.STREAM_USAGE_QUERY_VARIABLE] = usage + kwargs['query'].variables[requester.LLM_USAGE_QUERY_VARIABLE] = usage yield provider_message.MessageChunk(role='assistant', content='ok') provider.requester.invoke_llm_stream = fake_stream @@ -446,7 +446,6 @@ async def test_runtime_provider_invoke_llm_stream_stashes_usage(runtime_provider assert len(chunks) == 1 assert query.variables[requester.LLM_USAGE_QUERY_VARIABLE] == usage - assert requester.STREAM_USAGE_QUERY_VARIABLE not in query.variables @pytest.mark.asyncio