diff --git a/docs/agent-runner-pluginization/AGENT_RUNNER_QA_GUIDE.md b/docs/agent-runner-pluginization/AGENT_RUNNER_QA_GUIDE.md index 1b4c3b5f6..7f12f2403 100644 --- a/docs/agent-runner-pluginization/AGENT_RUNNER_QA_GUIDE.md +++ b/docs/agent-runner-pluginization/AGENT_RUNNER_QA_GUIDE.md @@ -22,8 +22,8 @@ event -> binding -> runner.run(ctx) -> result stream 本指南不验证: -- Runtime Control Plane v2。 -- EventGateway / EventRouter 完整落地由外部 EBA 分支联调;本指南只验证本分支 Host 底座。 +- 完整外部 harness daemon 管控和分布式业务队列;已实现的 run ledger / heartbeat / claim 原语仍应执行定向回归。 +- 尚未实现的通用事件订阅和定时自动化;已集成的 Bot 路由、独立 Agent 和 Pipeline 必须纳入当前产品验收。 - 发布级 path isolation、secret filtering、MCP allowlist、资源配额和 workspace cleanup。 - 所有外部服务 runner 的真实凭据联调。 @@ -44,6 +44,16 @@ event -> binding -> runner.run(ctx) -> result stream ## 3. 执行顺序 +2026-09-05 更新:先记录 Core/SDK/Runner 提交、发行版元数据与实际 import 路径,区分 editable 工作区和正式包安装。当前定向测试及前端未通过断言见 [STATUS.md](./STATUS.md)。本指南的步骤不是已执行记录。 + +本轮发布验收至少覆盖: + +- 空白实例从市场安装 Runner,创建 Agent 与 Pipeline,并绑定到 Bot。 +- 同一 Bot 的消息事件走 Pipeline,非消息事件走独立 Agent;dry-run 与保存后的路由结果一致。 +- `event_*` 使用冻结目标;未授权 `platform_*`、额外参数和失效机器人调用被拒绝;SDK/Python 与 MCP gateway 均走 Host 工具授权。 +- `interaction.requested` 回调恢复原处理器,重复/过期/跨作用域提交被拒绝。 +- 分别记录合成事件、mock provider、真实平台和真实 provider 的结果,不能相互替代。 + 推荐按以下顺序执行,前一层失败时不要继续扩大测试面: 1. Host / SDK / runner 单测。 @@ -225,3 +235,25 @@ Dify、n8n、Coze、DashScope、Langflow、Tbox 等外部服务 runner 不作为 ## 10. 历史高价值记录 历史高价值记录与当前 runner 验收状态见 [STATUS.md](./STATUS.md)。本指南只保留可重复执行的测试步骤和证据要求。 + +### Event debug execution trace + +The Agent workbench uses `POST /api/v1/agents/{uuid}/debug/stream` (NDJSON, `runtime.operate`). Verify incremental text and provider-returned reasoning, tool call arguments/results in execution order, automatic scrolling, and preservation of partial output on errors. Thinking is only shown when returned by the runner. Disconnecting cancels the debug task. The existing `/debug` endpoint and MCP `debug_agent` return final text and a bounded `execution_events` snapshot; they are not live transports. + +Platform tools now use Host-owned mock adapters in synthetic debug runs. The model really invokes the authorized tool, validates its parameters, and receives a result; the Host does not call a live platform adapter. Event tools retain their frozen targets and platform tools retain explicit targets. Native, plugin and MCP tools continue to execute normally. A successful mock platform result is completion of that action in the debug run; runners must communicate that context to the model to avoid repeated attempts at real delivery. + +The optional `mock` payload field (also editable under **Mock 场景(JSON)**) supports: + +```json +{ + "errors": {"event_reply": "Simulated permission denied"}, + "results": {"event_get_actor": {"id": "user-42", "nickname": "Fixture User"}}, + "unsupported_apis": ["delete_message"] +} +``` + +`errors` and `results` cannot both override the same tool. Unknown tools/APIs and malformed options are rejected before model execution. Default read fixtures follow SDK `User`, `UserGroup`, `UserGroupMember` and `MessageReceivedEvent` structures; list reads return a synthetic fixture list (override with `[]` to test emptiness). `unsupported_apis` participates in the normal capability intersection, so unsupported tools are not offered to the model. Event payloads should include the intended group/member/request IDs; the built-in presets supply examples. + +Verify a welcome event shows `event_reply`, the expected text and frozen target, and **模拟执行成功 · Mock**. Plain text alone must not count as a successful platform action. A simulated failure must show **模拟执行失败 · Mock**. Also verify **停止调试** retains the partial trace and allows the next run, and that entering a new draft while streaming does not erase it on completion. + +Full event/Mock regression evidence: [2026-09-05 follow-up](./EVENT_DEBUG_FULL_QA_2026-09-05.md). diff --git a/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md b/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md index 7905e8278..81138ad6e 100644 --- a/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md +++ b/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md @@ -1,11 +1,11 @@ # Event Based Agent 接入设计 -> 本文记录 EBA 如何接入当前 AgentRunner Protocol v1 / Host 底座。EventGateway、EventRouter、Event subscription/notification 由外部 EBA 分支实现并联调;本分支只保留 event-first 入口和 envelope/binding models。 +> 更新:2026-09-05。EBA 平台事件、Bot 路由和独立 Agent 已集成到 `dev/4.11.x`。通用事件订阅、通知与定时自动化仍是后续扩展。 > > 数据结构唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)(runner 可见)与 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)(Host 内部模型);本文只讲 EBA 语义,不重抄 schema。 > 与当前 runner 外化分支、后续 Agent Platform / Runtime Control Plane 的边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。 -本文描述 EBA 接入时,事件如何进入 LangBot、如何在平级的 Pipeline / Agent 处理器之间路由,以及 Agent 分支如何复用插件化 AgentRunner 基础设施。本分支不实现完整 EventBus / EventRouter / Platform API;这些能力正在外部 EBA 分支联调。这里的目标是把处理器路由与 runner 协议边界说清楚。 +本文描述当前事件如何进入 LangBot、如何在平级的 Pipeline / Agent 之间路由,以及 Agent 如何复用插件化 AgentRunner。路由逻辑由 `pkg/platform/botmgr.py::RuntimeBot` 承担;文中的 EventRouter 表示职责,不代表独立进程或同名类。 ## 1. 设计目标 @@ -13,7 +13,7 @@ - EventRouter 可以根据 event type、bot、workspace、conversation、actor、subject 选择一个 Pipeline 或 Agent 处理器。 - Pipeline 目标执行完整消息 Stage 链;Agent 目标通过统一 orchestrator 调用 AgentRunner。 - 非消息事件不伪造成用户文本消息。 -- 平台动作执行通过显式 capability / permission / result type 预留,不混入普通文本回复。 +- 平台动作通过已授权的语义工具执行;结构化交互通过 `action.requested` 中的 `interaction.requested` 白名单执行。 ## 2. 事件不是消息 @@ -22,7 +22,7 @@ | event_type | actor | subject | input | | --- | --- | --- | --- | | `message.received` | 发消息的人 | 当前消息 | 文本、图片、文件等 | -| `message.recalled` | 撤回操作者,未知时为系统 | 被撤回消息 | 通常为空 | +| `message.deleted` | 撤回操作者,未知时为系统 | 被删除消息 | 通常为空 | | `group.member_joined` | 新成员或邀请人 | 群/成员关系 | 通常为空 | | `friend.request_received` | 申请人 | 好友申请 | 验证消息或申请理由 | | `schedule.triggered` | 系统 | 定时任务 | 任务 payload | @@ -30,10 +30,10 @@ ## 3. 稳定事件名 -先保留的稳定事件名(作为插件协议的一部分保持稳定): +当前平台事件名示例(定时任务与 API 事件示例仅表示未来入口;实际能力以 SDK 实体和适配器声明为准): - `message.received` -- `message.recalled` +- `message.deleted` - `group.member_joined` - `friend.request_received` @@ -53,33 +53,30 @@ Event Source 可包括:`platform_adapter`(飞书、QQ、微信、Telegram ## 5. EventRouter 调用链 ```text -Platform Adapter / WebUI / API - -> Event Gateway normalize payload - -> EventLog append raw event - -> EventRouter resolve one Processor target +Platform Adapter canonical event + -> RuntimeBot record adapter event + -> Plugin EventListener observer broadcast + -> RuntimeBot match saved event_bindings and resolve one Processor target -> target_type=pipeline: MessageAggregator -> QueryPool -> Pipeline stages -> target_type=agent: resolve AgentBinding -> AgentRunOrchestrator -> AgentRunContextBuilder -> PluginRuntimeConnector.run_agent() -> AgentRunResult stream - -> DeliveryController render / platform action + -> Host result delivery / authorized platform tool ``` 约束:Pipeline 和 Agent 是 EventRouter 的平级目标;Pipeline 仅接受消息事件,Agent 受其事件能力声明约束。任何 AgentRunner 调用都必须复用现有 orchestrator,不能为 EBA 单独实现另一套 plugin runner 协议;非消息事件不能绕过 resource authorization;delivery 和 platform action 走统一权限模型;外部 harness runner 也通过同一套 envelope/binding/context/result 协议接入。observer / fan-out / parallel arbitration 的额外语义仍按 PROTOCOL_V1 §13 处理。 ## 6. 平台动作执行 -EBA 后 `action.requested`(PROTOCOL_V1 §7.3,当前仅 telemetry 不执行)将用于请求 host 执行平台动作: +平台动作走统一工具入口,详见 [PLATFORM_ACTION_TOOLS.md](./PLATFORM_ACTION_TOOLS.md): -```json -{ "type": "action.requested", - "data": { "action": "friend.request.accept", - "target": {"platform": "wechat", "request_id": "..."}, - "payload": {"reason": "policy matched"} } } -``` +- `event_*` 的目标由 Host 从当前事件冻结;Agent 只填写动作参数。 +- `platform_*` 允许填写目标,必须在 `allowed_platform_tools` 中显式选择。 +- 最终资源与 Runner 权限、适配器能力及事件目标求交,执行时再校验运行身份和当前机器人。 +- SDK/Python `call_tool` 和 scoped MCP gateway 使用同一 Host 授权;原始 `call_platform_api` 不作为 Agent 工具开放。 +- `action.requested` 只执行白名单 `interaction.requested`,用于持久化交互和回调恢复;其它 action 仍是 telemetry,不能用于执行好友审核等平台动作。 -Host 必须校验:binding / platform action policy 是否授权该 action、actor / bot / workspace 是否允许、是否需要人工审批,以及当前 run session / caller identity 是否匹配。EBA 还可能预留 `delivery.requested`(请求投递到某 surface)。 - -Delivery 方面,event 不一定回复到当前聊天窗口:消息事件通常带 reply target;系统事件可能没有默认 reply target,需要 runner 返回 `action.requested` 或由 binding 的 delivery policy 决定投递位置(`DeliveryContext` 见 PROTOCOL_V1 §5.7)。 +事件可能没有默认 reply target;Host 不为缺少目标的事件猜测投递对象。Runner 只能使用当前授权允许的工具和投递能力(`DeliveryContext` 见 PROTOCOL_V1 §5.7)。 当前 Host 会把 adapter 声明的通用 API 投影到 `DeliveryContext.platform_capabilities.supported_apis`,并据此设置 `supports_edit` / `supports_reaction`。该投影只供 runner 选择输出形态,不构成 @@ -96,6 +93,4 @@ EBA 事件进入 AgentRunner 时仍遵循 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CO 运行状态和真实 OneBot 非消息事件到 Agent 的闭环。Pipeline 消息链和独立 Agent 均复用同一个 AgentRunner orchestrator / context / result 协议。 -尚未落地的是 platform action permission model 和 `action.requested` 执行器;在显式 -action allowlist、binding policy、adapter capability 和审批模型完成前,该 result 仍只 -记录 telemetry,不执行平台副作用。 +平台动作授权和结构化交互已实现,但真实平台/provider 验收不等同于单测通过。SDK 的 `platform_tools` 分类发现于 2026-09-05 检视时仍是未提交工作区改动。剩余发布工作和历史验证边界见 [STATUS.md](./STATUS.md)。通用订阅、Scheduler、Workflow 和多 Agent 串并联仍未作为产品交付。 diff --git a/docs/agent-runner-pluginization/EVENT_DEBUG_E2E_2026-09-05.md b/docs/agent-runner-pluginization/EVENT_DEBUG_E2E_2026-09-05.md new file mode 100644 index 000000000..6e9f5b5d5 --- /dev/null +++ b/docs/agent-runner-pluginization/EVENT_DEBUG_E2E_2026-09-05.md @@ -0,0 +1,74 @@ +# Event debug end-to-end regression — 2026-09-05 + +Environment: Windows, Vite :3000, Core :5300, standalone Plugin Runtime :5400/:5401, standalone Box :5410, Docker backend. Tests use browser controls in Edge and an isolated unbound Agent `E2E Debug 0905` (`49e52ae0-6a99-46ce-975e-70164b2786ec`). Local plugin maintenance uses the authenticated localhost management API because Edge file upload is disabled. + +## Coverage inventory + +- Message input: empty input, plain text, multi-turn context, draft edits during streaming. +- Structured events: group join/leave/ban, friend request/add, feedback, bot state, message edit/delete/reaction, platform and custom events. +- Payload validation: invalid JSON, non-object JSON, Unicode and nested fields. +- Execution trace: intermediate/final text, returned thinking, tool arguments/results, tool failure, no-output failure, completion without duplication. +- Lifecycle: save-and-run, provider failure and recovery, long output/autoscroll, switch Agent during run, reconnect. +- Visual checks: scroll containment, long tool JSON, error card, narrow viewport. + +## Findings and fixes + +1. Synthetic debug Query omitted public Workspace fields. Native tools failed during skill mount lookup. Project trusted ExecutionContext fields onto synthetic queries; real Docker exec returned `E2E_TOOL_OK` after repair. +2. LocalAgent context assembly omitted structured event data. Add a bounded user-role event facts message before current input, preserving ordinary empty-data message behavior. Browser join/leave/friend/feedback returned unique JSON probes after installing the patched local plugin. +3. A model failure before visible output left an empty Agent card. Hide output entries with no visible execution steps. +4. A completed message request cleared a newly edited draft. Clear only the submitted input value; the browser retained `NEXT_DRAFT_SHOULD_REMAIN`. +5. Tool transport completion was shown as success even with `result.ok=false`. Treat explicit failed tool results as failure; real exec exit 7/stderr is preserved. +6. Native file tools resolved a Core host path before selecting remote Box execution. Choose Box first for remote/no-host-root deployments, validate virtual path boundaries, and cover all five file tools. +7. This local legacy config had no `box.local.host_root`, so Docker had no writable `/workspace` mount. Add an explicit local development root and keep the read-only root filesystem enabled. + +8. LocalAgent repeats previous turns in cumulative chunks after tools. Strip the already displayed prefix for those chunks; real thought text now appears once across write/read. +9. Remote grep generated Python `include = null` when no filter was supplied. Serialize it as a Python literal and test optional/quoted values. + +## External configuration observations + +`gpt-4.1-mini` under LangBot Models returned no available channel; NewAPI rockchin returned invalid token. These are reported by the trace; tests continued with working `claude-opus-4-8` and `deepseek-v4-flash` models. The test Agent now uses the latter. No credentials were changed. + +## Results + +### Browser outcomes + +All 15 preset event types and one named custom event were executed through the UI. These exercise synthetic debug event dispatch, not delivery from real messaging platforms. + +| Events | Observed outcome | +| --- | --- | +| `message.received` | Plain text marker, multi-turn tool execution and recovery succeeded. | +| `message.edited`, `message.deleted`, `message.reaction` | Returned `EDIT_10`, `DELETE_11`, `REACTION_12`. | +| `group.member_joined`, `group.member_left`, `group.member_banned` | Returned Unicode member data/`JOIN_42`, `LEFT_7`, `BANNED_20`. | +| `friend.request_received`, `friend.added` | Returned `FRIEND_8`, `ADDED_19`. | +| `bot.muted`, `bot.unmuted`, `bot.invited_to_group`, `bot.removed_from_group` | Returned `MUTED_13`, `UNMUTED_16`, `INVITED_17`, `REMOVED_18`. | +| `feedback.received` | Returned `FEEDBACK_9` and numeric rating from JSON. | +| `platform.specific`, `custom.e2e_probe` | Read nested JSON; returned `PLATFORM_14` and custom event type/`CUSTOM_15`. | + +- Empty message, malformed JSON and non-object JSON did not start execution. Unicode, nested arrays/objects and an empty event object were exercised. Empty custom event name validation was not conclusively verified. +- Real Docker `exec`, `write`, `read`, `edit`, `glob`, and `grep` all succeeded after fixes. Files were limited to `/workspace/e2e-debug-0905`. Final grep without `include` returned `probe.txt`, line 1, `E2E_EDIT_OK`, total 1. +- Nonzero exec exit preserved exit code 7/stderr and displayed failure. `sleep 3` with `timeout_sec=1` returned `timed_out`, `ok=false`, about 1037 ms, and displayed failure with the actual timeout message. +- Provider-returned thinking, intermediate text, tool arguments/results and final text appeared in order. A repeated prior thought appeared once after the cumulative-prefix fix. Models that do not return thinking are not expected to display it. +- Two browser tabs ran independent debug sessions without mixed transcripts. While `exec sleep 30` showed running, switching Agents cleared the old transcript/running state. Returning and sending a new request produced `AFTER_CANCEL_OK`. This verifies UI cancellation/recovery; termination timing of the already launched container command was not independently measured. +- Core restart and standalone Plugin Runtime reconnection recovered successfully. The development services remain running. +- At the 1280 x 800 test viewport, the final transcript measured height 315, scroll height 5203, distance from bottom 0, and client/scroll widths both 333: automatic scrolling and horizontal containment passed. Screenshot inspected; viewport override reset afterward. + +### Automated verification + +- Core: 130 passed, 24 skipped across orchestrator, execution context, debug controller/service, native tools and skill tools. Skips are POSIX secure-host-filesystem cases unavailable on Windows; they still require Linux verification. Remote Box routing is covered on both capability branches. +- LocalAgent full suite: 185 passed with `PYTHONUTF8=1`. +- Frontend trace reducer: 5 passed; TypeScript `tsc --noEmit` passed. +- Ruff checks on changed Python implementation/tests and Git whitespace checks passed. Existing Pydantic deprecation warnings remain. + +The isolated unbound Agent `E2E Debug 0905` is retained for reproduction. No real platform messages were sent. This run does not claim coverage of every provider, model fallback policy, Linux host file operations, or live platform adapter ingress. + +## Follow-up: mock platform actions + +The original run did not verify platform action tools: the synthetic envelope had no supported platform APIs or reply target. This was a coverage gap. Debug now supplies mock adapter capabilities and a synthetic reply target while retaining tool selection, event compatibility and parameter validation. Event targets remain frozen by the Host. Only platform operations are simulated; native tools still execute normally. + +Verified in the browser on the user's `localagent test` Agent (`0dc5d7d3-07b2-4c4c-bb2b-a4f8e40e3a76`) without changing its welcome system prompt: `group.member_joined` caused a real `event_reply` tool call with `text: Hello,Debug User`. Its mock result contained `api: send_message`, `target_type: group`, `target_id: debug-group`, `mock: true`, and `delivery: simulated`. The UI labels this as mock execution rather than text output or real delivery, and shows the tool count after completion. + +Mock platform responses are deterministic fixtures, not evidence of real adapter support or delivery. Read operations return synthetic information; list operations return empty fixture lists. Service/platform-tool regressions: 44 passed, including mock reply, explicit-target send, identity/group lookup, request rejection, validation and assertions that the real bot manager is never accessed. TypeScript and Ruff checks passed. + +The same browser run subsequently exposed repeated `event_reply` calls after successful mock results, and one invalid `event_get_actor` call with an unexpected `_call` parameter. The invalid call was rejected and shown as failure. The run was cancelled by switching Agents. Therefore this verifies the mock call/result path, but does not establish that this configured model completes the welcome workflow exactly once. At this checkpoint the cause had not been determined. + +For the subsequent investigation, updated SDK-shaped Mock fixtures (including non-empty default lists), cancellation fix, complete event matrix, Linux verification and remaining provider/stream-pressure limits, see [the full follow-up report](./EVENT_DEBUG_FULL_QA_2026-09-05.md). Its results supersede the checkpoint counts and fixture description above. diff --git a/docs/agent-runner-pluginization/EVENT_DEBUG_FULL_QA_2026-09-05.md b/docs/agent-runner-pluginization/EVENT_DEBUG_FULL_QA_2026-09-05.md new file mode 100644 index 000000000..83c75120c --- /dev/null +++ b/docs/agent-runner-pluginization/EVENT_DEBUG_FULL_QA_2026-09-05.md @@ -0,0 +1,99 @@ +# Event debug full QA and release verification — 2026-09-05 + +## Scope and inventory + +This follow-up investigates repeated welcome actions, verifies the debug surface with standalone Box/Plugin Runtime, and checks the changes before committing/pushing Core, SDK, and LocalAgent. Real messaging-platform effects must remain isolated from debug runs. + +| Area | Required checks | Evidence | +| --- | --- | --- | +| Root cause | Follow-up messages/tool IDs preserved; direct model replay before/after mock guidance; original welcome prompt | Sanitized local traces, browser tool count | +| Events | All 15 built-in event types plus custom; nested/Unicode data and identity/target mapping | Browser results and parameterized service tests | +| Platform actions | Every catalog tool; frozen and explicit targets; permission/event/API intersection | Parameterized host boundary tests, browser reply/query/moderation/request cases | +| Mock scenarios | Success, error, query fixture, unsupported API; malformed options rejected | Browser and service tests | +| Input | Empty message/name, invalid/non-object JSON, unsupported event, actor/subject validation | Browser and request tests | +| Trace | Thinking/text/tool ordering, final snapshot deduplication, status/error correctness, no-call summary | Browser and reducer tests | +| Lifecycle | Cancel retains partial record; rerun; tabs/Agent switching; provider failure/fallback; reconnect | Browser and transport/runner tests | +| Native tools | Exec/file operations, nonzero exit, timeout and path escape | Browser and native-tool tests | +| Layout | Long output/parameters, automatic bottom scroll, narrow view, KB card spacing | DOM geometry and screenshots | +| Release | Python/SDK suites, frontend build/lint, whitespace checks, change review, commit and push | Commands, counts and remote SHAs | + +## Investigation + +The real follow-up request contained the assistant tool call and successful tool result with matching IDs. No loss occurred at the LocalAgent → SDK → Host model boundary. Direct replay to the configured provider, bypassing the runner, reproduced `event_reply` after success. Adding explicit mock completion semantics stopped further calls in two direct replays. The previous LocalAgent system context omitted the debug/mock semantics although the tool result said no real platform operation occurred. + +The configured upstream `claude-opus-4-8` also returned an unsolicited CLI identity statement in direct replay. This originates in the provider response, not the debug renderer. It is separate from preserving tool results; it must not be presented as normal LangBot-generated status. + +## Final results + +### Browser event matrix + +Executed through the real Edge WebUI, against Core `:5300`, Vite `:3000`, standalone Plugin Runtime `:5400/:5401`, and Docker-backed standalone Box `:5410`. The unbound `E2E Debug 0905` Agent uses `deepseek-v4-flash`; the user's original Agent and welcome prompt remain unchanged and use `claude-opus-4-8`. + +| Event | Observed result in this pass | +| --- | --- | +| `bot.invited_to_group` | Actor/group queries and `event_respond_group_invite(approve=true)` succeeded; request ID frozen to `debug-group-request`. | +| `bot.muted`, `bot.removed_from_group` | One `event_get_group` call each, correct group target. | +| `bot.unmuted` | One actor query; correct user target. | +| `feedback.received` | One mock reply, `FEEDBACK_MOCK_OK`, person target. | +| `friend.added` | One mock reply, `FRIEND_ADDED_OK`, person target. | +| `friend.request_received` | Acceptance and separate explicit rejection exercised; rejection preserved `approve=false`, Unicode remark and frozen request ID. | +| `group.member_banned` | One member lookup, SDK-compatible nested `user` and `group_id`. | +| `group.member_left` | One group lookup, SDK-compatible `id`/`name`. | +| `group.member_joined` | Welcome reply, configured failure, query fixture, unsupported API, and mute/unmute/kick scenarios. Original-model repeat caveat below. | +| `message.deleted` | One actor lookup, SDK-compatible `id`/`nickname`. | +| `message.edited` | One simulated deletion; frozen group, chat and message IDs. | +| `message.reaction` | One reply, `REACTION_OK`, and correct `👍` event data. | +| `message.received` | Real six-tool file/exec chain, plain-text recovery, cancellation, draft retention, nonzero exit and timeout. | +| `platform.specific` | Preserved nested arrays, booleans, null and `测试🙂`; no tools called. | +| `custom.event`, `custom.e2e` | Empty object and named custom event with `CUSTOM_中文🙂`; no tools called. | + +The first invitation/default friend-request runs submitted the preset text; they are recorded as default-behavior cases, not as explicit one-call/rejection tests. The rejection was separately rerun with the actual submitted instruction verified. Browser automation reads the controlled input after filling before treating a scenario as submitted. + +### Mock and execution behavior + +- Platform Mock runs the actual model/tool selection, authorization, parameter validation and frozen-target resolution; only the adapter boundary is simulated. Tests assert that mock execution never accesses the real bot manager. Native tools retain real Box behavior. +- The platform catalog contains 24 tools. Parameterized tests exercise success and configured failure for all 24, plus permission/event/API filtering, explicit/frozen targets, SDK fixture shapes and invalid options. This is full catalog contract coverage, not 24 separate browser clicks or proof of live adapter support. +- `errors.event_reply = "E2E permission denied"` produced one failed call and the **模拟执行失败 · Mock** status. The model reported the actual error without retrying in this scenario. +- `results.event_get_actor` returned the configured `fixture-user-77` / `测试用户🙂`; the model used these values instead of the original event identity. Default query results now serialize SDK platform models; default lists contain one synthetic entry and can be overridden with `[]`. +- Disabling `send_message` removed `event_reply` from the model's available tools. It returned `REPLY_UNAVAILABLE`, with the explicit no-tool-call summary. Clearing Mock options restored action availability; the three moderation calls succeeded with the correct group/member IDs. +- Mock `[]`, unknown tool names, whitespace-only message/custom name, malformed event JSON and event JSON `[]` were rejected. Browser transcript counts did not increase for client-side validation failures. Backend tests additionally cover falsey/non-object actor, subject and data values, conflicting outcomes and invalid API names. +- Thinking returned by the provider, intermediate text, tool parameters, results and final text remain distinct and ordered. Missing thinking is not fabricated. Cumulative final snapshots and prior tool-turn prefixes are deduplicated without hiding actual repeated tool calls. + +### Lifecycle, native tools and layout + +- A browser cancellation regression exposed a stuck **停止调试** button: the `finally` block skipped resetting state for aborted requests. It now clears the matching controller and resets running state even after user cancellation. Retest retained partial thinking/tool arguments, marked the unfinished call **未返回结果**, showed the cancellation notice and restored **运行测试**. +- Two subsequent recovery requests encountered the bounded-stream error below. A later request in the same tab/session returned `42` successfully; further real tool runs also completed. Cancellation and recovery are therefore verified, but immediate model success after cancellation is not guaranteed. +- Editing `NEXT_DRAFT_FINAL_0905` while a `sleep 5` run was still awaiting its final response preserved the draft after completion. +- Real `write → read → edit → glob → grep → exec` succeeded. `final.txt` changed from `FINAL_WRITE` to `FINAL_EDIT`; grep without `include` found one match; exec returned `FINAL_EXEC` with exit code 0. Writes stayed under `/workspace/e2e-debug-0905`. +- `printf E2E_ERROR >&2; exit 7` preserved stderr and exit code 7 and displayed failure. `sleep 3` with `timeout_sec=1` returned `timed_out` in 1025 ms and displayed failure. Neither was retried. +- Separate tabs retained separate transcripts during concurrent runs. Agent switching reset the debug surface. File workspace sharing remains intentional; transcript isolation does not mean separate Box filesystems. +- The final transcript measured 674 px high with 16,630 px scroll height, distance from bottom 0, and equal client/scroll widths of 468 px. Visual inspection confirmed contained parameters/results and accessible input controls. The earlier pass also verified 1280 × 800; the final attempted viewport override did not change this tab, so that attempt is not counted as an additional narrow-screen result. Temporary overrides were reset. +- Knowledge-base card geometry: retrieval card bottom 1413 px, danger card top 1437 px, giving the expected **24 px** gap. Both adjacent card gaps measured 24 px. +- Core health check returned `ok`; Plugin Runtime was connected and all development service ports remained listening. Previous restart/reconnect evidence is in the earlier report. + +### Automated checks + +| Suite | Result | +| --- | --- | +| Core agent unit directory plus debug service/controller, native/skill tools and model conversion | 659 passed, 24 Windows-only skips | +| Final changed platform/debug service/controller checks | 133 passed | +| Linux Docker: native tools, skill tools and all platform tools | 150 passed, 0 skipped; includes the 24 POSIX cases skipped on Windows | +| SDK API suite plus runtime I/O handler | 411 passed | +| LocalAgent full suite (`PYTHONUTF8=1`) | 187 passed | +| Frontend unit suite | 69 passed | +| Skills CLI suite | 122 passed | +| Frontend TypeScript/Vite build and changed-file ESLint | Passed; existing large-bundle advisory remains | +| Changed Python Ruff; Git whitespace checks | Passed | +| Skills index generation, validate and index consistency | Passed | + +Counts overlap where a final focused/Linux run repeats an earlier suite; do not add them as unique tests. Existing Pydantic deprecation warnings remain. This is the relevant subsystem regression set, not the entire repository/integration matrix. Provider fallback has deterministic LocalAgent tests; live fallback after a partially emitted response is deliberately unsupported and was not claimed as a successful browser fallback. + +The full regression also found Windows portability problems in the skills tooling: LF-only frontmatter parsing, `/cases/` detection and native path separators in generated references. These are fixed and covered by the CLI suite. Two frontend source-contract expectations were stale after the existing three-tab workbench/layout changes; they were updated to the actual product structure. + +### Remaining limits and reproduction + +1. **Original provider is still nondeterministic.** With the latest LocalAgent guidance, one rerun made exactly one successful `event_reply`; another made repeated successful replies before stopping. The trace faithfully records each call. Earlier invalid `_call` arguments were rejected. Direct provider replay proved that repeated calls can originate upstream even with a valid matching tool result. Prompt guidance reduces ambiguity but does not establish exactly-once actions. No silent deduplication or fabricated success was added. +2. **Bounded stream pressure can fail long responses.** Two recovery runs emitted thinking and then `Streaming action consumer is too slow; response buffer full`; later recovery and six-tool runs succeeded. The error originates in the SDK's existing 128-frame response queue and is preserved in the UI. It is not a missing-tool-result or 30-second HTTP timeout. Full end-to-end flow control under sustained overload remains open; queue limits were not removed to conceal the failure. +3. Mock results verify debug behavior, not real platform credentials, permissions, delivery, or every adapter/event Cartesian combination. Already launched external tool termination timing was not independently measured. A development-branch push does not mean these remaining production release gates passed. + +For reproduction, use the retained test Agent, the Mock examples in [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md), and the original welcome Agent. Local diagnostic traces and fixture artifacts are excluded from Git; they contain model responses and local runtime state. This report contains outcomes rather than raw provider reasoning or secrets. diff --git a/docs/agent-runner-pluginization/EXTENSION_SCOPE_MATRIX.md b/docs/agent-runner-pluginization/EXTENSION_SCOPE_MATRIX.md index ea18b56c3..1aa078a39 100644 --- a/docs/agent-runner-pluginization/EXTENSION_SCOPE_MATRIX.md +++ b/docs/agent-runner-pluginization/EXTENSION_SCOPE_MATRIX.md @@ -1,51 +1,41 @@ -# AgentRunner 外化扩展边界矩阵 +# AgentRunner 与产品扩展边界 -本文用于回答一个问题:本分支只做 AgentRunner 外化时,哪些能力已经作为扩展底座完成,哪些由外部 EBA / Agent Platform / Runtime Control Plane 分支接入,后续分支接入时应该走哪个扩展点。 +更新:2026-09-05,适用于 `dev/4.11.x`。EBA、独立 Agent、Bot 事件绑定和处理器 UI 已与 AgentRunner 插件化合并。当前状态和测试证据以 [STATUS.md](./STATUS.md) 为准;runner 可见 schema 与调度基数以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准。 -结论:本分支不实现完整 Agent Platform,也不实现完整 EBA。EBA 完整事件网关与事件路由由外部 EBA 分支联调。本分支必须把 runner 外化的 Host / SDK 边界做干净,让外部分支只需要接入持久模型、事件路由或 runtime task,而不需要重写 `AgentRunner Protocol v1`。 +## 当前职责 -调度基数、Agent 复用、插件实例无状态、Pipeline adapter 和 fan-out 边界的单一事实源是 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13;本矩阵只说明后续能力应该接入哪个扩展点。 - -## 1. 分支边界 - -| 范围 | 本分支职责 | 不在本分支做 | +| 层 | 已实现职责 | 边界 | | --- | --- | --- | -| AgentRunner Protocol v1 | 定义 Host 调用 runner 的稳定合同:discovery、`AgentRunContext`、result stream、Host pull API、错误和权限边界。 | 不定义 Agent Platform 的产品数据库模型;不定义 runtime task queue。 | -| Host runner 外化底座 | 提供 `AgentEventEnvelope`、`AgentBinding` 运行投影、`run(event, binding)`、resource authorization、run-scoped session、EventLog / Transcript / State / sandbox 文件边界。 | 不实现 EventGateway、scheduler、integration provider、Agent 管控面 UI。 | -| Pipeline 的 AgentRunner 接入 | Pipeline 作为一等消息处理器执行完整 Stage 链;仅在 AI Stage 调用 runner 时,`QueryEntryAdapter` 把当前 Query/config 投影成 event + binding。 | 不把整个 Pipeline 当成临时 Agent;不复制 Pipeline 配置来自动创建 Agent。 | -| 官方 runner 插件 | 作为协议消费者验证 local-agent / 外部 harness runner 能接入 Host 基础设施。 | 不让官方 runner 的内部实现反向决定 Host / SDK 协议形态。 | +| LangBot 产品层 | 独立 Agent CRUD、Pipeline、处理器工作台、Bot 事件绑定、Runner 安装与调试 | Agent 与 Pipeline 各自持久化;聚合列表不转换实体 | +| 平台层 | 适配器事件转换、能力声明、observer 广播、路由匹配、平台 API 与回复 | 路由逻辑在 RuntimeBot;不为每个入口重建 runner 协议 | +| Host Agent 底座 | envelope/binding 投影、统一编排、资源授权、run session、EventLog/Transcript/State、run/result ledger | SDK 不持有 Host 私有 Query 或数据库 | +| SDK / Plugin Runtime | typed contract、AgentRunner 组件和脚手架、proxy、MCP bridge、结果流转发、installation worker 管理 | 具体 Agent 执行策略由 Runner 插件承担 | +| Box Runtime | 沙盒会话、文件、托管进程、Skill、资源限制与作用域 | 不等于外部 harness 的通用托管承诺;存储统计不等于硬配额 | -## 2. 扩展矩阵 +## 已有能力与后续扩展 -| 能力 | 当前分支状态 | 后续归属 | 后续接入方式 | 禁止事项 | -| --- | --- | --- | --- | --- | -| Product `Agent` | 已有 `agents` 产品表 / API 和运行期 `AgentConfig` / `AgentBinding` 投影;完整 binding persistence / EventRouter / UI 闭环仍未完成。 | Agent Platform / binding persistence UI。 | 持久 Agent 保存 runner id、runner config、resource/state/delivery policy;运行前投影为 `AgentBinding`。 | 不把持久 Agent schema 加进 SDK 协议;插件实例边界见 PROTOCOL_V1 §13。 | -| Agent 处理器调用 runner | 已有单次运行前的 `AgentBinding` 解析投影;AgentRunner 调度语义见 PROTOCOL_V1 §13。 | EBA / Agent Platform。 | EventRouter 先选中 Agent 处理器,再根据 bot、channel、workspace、conversation、event type 解析有效 `AgentBinding`。Pipeline 目标走独立 Stage 链。 | 不用 `AgentBinding` 取代 EBA 的 Pipeline / Agent 处理器选择;不在本矩阵重定义 fan-out / observer 语义。 | -| Agent session / run | 已有持久 `AgentRun` / `AgentRunEvent` ledger 和 active `AgentRunSessionRegistry`;还没有独立 `AgentSession` / task 产品模型。 | Agent Platform / Runtime Control Plane。 | 如需要可新增 `AgentSession` / task 表,但执行仍回到 `run(event, binding)` 或 runtime-managed 等价入口。 | 不把持久 session 字段塞进 `AgentRunContext` 顶层;不要求所有 runner 长期持有 LangBot session。 | -| EventLog / Transcript / Sandbox files | 已完成 Host-owned store、history pull API 和 sandbox 文件边界;runner 不直接写 DB。 | 本分支持续维护底座;Agent Platform 可复用。 | 外部 EBA、scheduler、integration、runtime task 都写同一套 EventLog / Transcript;当前 run 文件通过 sandbox/workspace staging 共享。 | 不让 runner / sandbox 直接访问 Host DB;不把大 payload 内联进 prompt。 | -| Host-owned state / storage | 已有 state snapshot、`state.updated` 处理和 State API;storage 作为授权能力保留。 | 本分支持续维护底座;Runtime / Platform 可复用。 | 外部 session id、working directory、checkpoint 等小 JSON 用 state;当前 run 大对象用 sandbox/workspace 文件。 | 不把跨轮次状态存在插件实例内;不绕过 run-scoped authorization。 | -| EventGateway / EventRouter | 本分支只提供 event-first envelope 和 `run(event, binding)` 入口。 | EBA 分支(联调中)。 | EventGateway 规范化平台/WebUI/API/scheduler 事件;EventRouter 解析一个 binding;调用现有 orchestrator。 | 不为 EBA 新增另一套 runner 调用协议;不把非消息事件伪装成 user message。 | -| Scheduler / Automation | 不实现。文档中只把 `scheduler` 作为 future event source。 | EBA / Agent Platform。 | 定时任务触发 `schedule.triggered` host event,复用 EventGateway -> EventRouter -> `run(event, binding)`。 | 不直接调用某个 runner 插件;不绕过 EventLog / authorization。 | -| Integration provider | 不实现。IM platform adapter 仍是当前平台接入系统。 | EBA / Agent Platform。 | OAuth/webhook/outbound provider 应先转成 canonical host event 或 platform action,再交给 AgentRunner。 | 不把 Linear/Slack/GitHub 等 provider 私有 payload 扩散到 runner 协议顶层。 | -| Platform action / delivery | `action.requested` 已预留但当前仅 telemetry,不执行。`DeliveryContext` 只作为上下文/策略投影。 | EBA / platform action executor。 | 后续 executor 校验 runner capability、binding policy、actor/bot/workspace 权限和审批后执行。 | 不让 runner 直接调用平台 adapter 私有 API;不把平台动作伪装成文本回复副作用。 | -| Runtime registry / worker / task queue | 已落地 Host-owned `AgentRun` / `AgentRunEvent`、run control primitives、最小 runtime registry / heartbeat / claim lease;当前官方外部 harness 仍通过 ACP、远端 daemon、本机 subprocess 或外部 HTTP API runner 调用目标运行环境,不在本分支维护完整通用 worker 队列。 | Runtime Control Plane v2。 | 后续可在现有 Host 事实源上补 queued run producer、daemon wakeup、claim execution loop、progress/audit 和运维诊断。 | 不把 heartbeat/task/warm pool 放进 Protocol v1;不让管理插件拥有 runtime/task 事实源。 | -| Warm pool / reconcile / diagnose | 不实现。 | Runtime Control Plane v2 / deployment layer。 | 作为 task/runtime 的运维能力,围绕 Host-owned runtime/task/audit 表实现。 | 不把 runtime 运维语义写进普通 runner 协议;不把 pod/task 细节泄漏给普通 runner。 | -| Agent memory | 不实现通用长期记忆产品层;提供 history/state/storage 和 sandbox 文件基础能力。 | Agent Platform 或具体 runner/plugin。 | 平台 memory 可通过 Host storage/state 或独立产品表实现,runner 通过授权 API 拉取。 | 不在 Host core 内置通用 agentic memory 策略;不默认把 memory 全量 inline 到 context。 | -| External harness native session | ACP / Claude Code / Codex 等 runner 支持 external session id state handoff 和 LangBot resource projection。 | 官方 runner 后续增强;Runtime Control Plane v2 可接管执行。 | 外部 harness 调用继续走 `runner.run(ctx)`;如后续引入长连接/daemon 模式,按 external session key 串行 turn,reader 独占 native stream。 | 不把具体 provider native wire 变成 LangBot 协议;全局锁边界见 PROTOCOL_V1 §13。 | +| 能力 | 当前状态 | 后续工作与接入点 | +| --- | --- | --- | +| Agent / binding | Agent 表、API、配置 UI、Bot event_bindings 已存在;AgentBinding 是运行投影 | 新产品模型复用现有投影,不把 Pipeline 持久化成 Agent | +| 事件路由 | observer 广播后按 pattern/filter/priority 选择一个 Pipeline、Agent 或 discard | 通用订阅、通知和其他事件源仍需单独设计 | +| 平台动作 | event_* 冻结目标,platform_* 显式授权;通过 Host 工具调用 | 新动作先定义语义、schema 和授权,不开放任意原始 action | +| 结构化交互 | interaction.requested 白名单、持久回调关联、TTL/作用域/幂等、原处理器恢复 | 补真实 provider/platform 验收;其它 action.requested 仍仅 telemetry | +| Run / runtime | 持久 AgentRun/AgentRunEvent、取消/结果/终态、heartbeat/claim/reconcile 原语 | 业务队列、任务生产、唤醒、跨 Host 执行和运维产品面 | +| Plugin worker | 独立安装进程、依赖环境、supervisor、退避及重启协调器 | 最终部署故障注入、出站网络策略及硬存储配额 | +| External harness | 通过 Runner 消费协议、按 run 访问 Host 资源 | 通用 daemon supervisor、登录态诊断、分布式调度;不要与 Plugin worker 混淆 | +| History / state / storage | Host 事实源、按需读取、state/checkpoint、sandbox 文件能力 | EventLog/Transcript 的定时 retention 接入和完整文件生命周期 | +| Scheduler / Automation | 仅保留可扩展的事件入口 | 用户定时任务必须走事件、授权和运行记录链路,不直调插件绕过 Host | +| Workflow / 多 Agent | 尚无完整产品实现 | 先定义串并联、失败恢复、投递与状态冲突语义 | +| Solution | 尚无导出/导入实现 | 处理器、路由模板、依赖、变量与文档;不导出凭据或已安装 UUID | +| 长期 memory 产品 | 提供 history/state/storage 基础 | 由 Runner 或后续产品定义召回策略,不把全量 memory 默认塞入 context | +| Cloud | 作用域与运行时隔离底座已合入;OSS 为单 Workspace 多成员 | 生产激活独立通过网络、硬配额、事务代次切换和部署验收 | -## 3. 后续分支接入规则 +平台动作详见 [PLATFORM_ACTION_TOOLS.md](./PLATFORM_ACTION_TOOLS.md),控制面规划详见 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md),Cloud 门禁见 [剩余验证清单](../multi-tenant/cloud-v2-pending-verification.md)。 -外部 EBA、Agent Platform 或 Runtime Control Plane 分支接入时,默认遵守以下规则: +## 扩展规则 -- 新入口只生产或解析 Host 内部模型:`AgentEventEnvelope`、持久 Agent 投影出的 `AgentBinding`、以及必要的 delivery/resource/state policy。 -- runner 调用仍走 `AgentRunOrchestrator.run(event, binding)`,除非 Runtime Control Plane 明确引入 runtime-managed 执行模式;即便如此,runner 可见合同仍应保持 Protocol v1。 -- Host-owned facts 继续写入 EventLog / Transcript / State,当前 run 文件继续走 sandbox/workspace;产品层可以新增更高阶视图,但不能替代这些事实源。 -- 新能力如果需要持久化,优先加 Host-owned 表或 service;不要把事实源藏在插件 storage 或 runner subprocess 内。 -- 新 result type 可以按 Protocol v1 的演进规则增加;不能用入口 adapter 私有字段绕过 schema。 -- 任何 fan-out、observer agent、parallel arbitration、platform action execution 都必须单独定义 delivery、state conflict、approval 和 audit 语义。 - -## 4. 与 Agent Platform 产品层的关系 - -这里的 Agent Platform 指面向 agent 产品层的实体拆分:`Agent` 描述可配置 agent,`Session` / `SessionMessage` 描述会话事实,`Automation` 描述自动触发,`IntegrationBinding` 描述外部集成连接,`Memory` 描述长期记忆,`WarmTask` 描述预热/后台任务。这些拆分对 LangBot 后续产品层有参考价值,但不能直接搬进本分支。 - -LangBot 当前分支的对应目标是更底层的:把 IM/WebUI/API 等入口统一投影到 Host event,把 Agent / binding 配置统一投影到 runner binding,把 runner 能力统一收束到 Protocol v1。完整 Agent Platform 可以在这个底座之上构建,而不应反过来污染本分支的 runner 外化边界。 +- 新入口构造 Host event 和有效 binding,继续调用统一 orchestrator;Pipeline AI Stage 使用 QueryEntryAdapter。 +- Host 保持 run/result、授权、事件、状态和历史的事实源;插件负责自己的执行策略及 provider 私有 continuation。 +- 新增业务表、调度或 UI 不要求修改 runner 可见协议;需要协议扩展时先更新 canonical spec,再同步 SDK、Runtime、模板与测试。 +- fan-out、并行仲裁和自动重试必须明确副作用、幂等、状态冲突和审计语义,不能通过多个隐式回复者实现。 +- 外部 harness 自带 shell、文件系统和网络权限由部署环境负责;manifest permissions 约束的是 LangBot 持有的资源。 diff --git a/docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md b/docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md index f862ade25..f04717700 100644 --- a/docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md +++ b/docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md @@ -10,7 +10,7 @@ LangBot 要转为 agent host,而不是内置 runner 容器: -- 接收 IM、WebUI、API 和外部 EBA 分支 EventRouter 产生的事件。 +- 接收 IM、WebUI、API 和当前 RuntimeBot 事件路由产生的事件。 - 接收 EBA 选中的 Agent 处理器,并根据事件、bot、workspace、scope 解析 AgentRunner binding。 - 发现、校验和调用插件提供的 AgentRunner。 - 为每次 run 提供受限资源、状态、存储、上下文引用和生命周期控制。 @@ -23,18 +23,18 @@ LangBot 要转为 agent host,而不是内置 runner 容器: - 不要求官方 local-agent 的旧行为反向塑造 host 协议。 - 不在 host 中实现通用 agentic prompt assembler。 - 不强制 runner 使用 LangBot state / storage;只提供可选、受控的寄宿能力。 -- 不实现 EventGateway / EventRouter:它们由外部 EBA 分支提供并联调。本分支只定义 host-side envelope/binding models 和 `run(event, binding)` 入口。 +- 不在 runner 底座重复实现平台路由;当前分支的 RuntimeBot 已承担 observer 广播和 Bot event_bindings 匹配,runner 层消费其 envelope/binding 投影。 ## 3. 分层架构 ```text -IM / WebUI / API / EventRouter (external EBA branch) +IM platform event | v -Event Gateway (external EBA branch) +RuntimeBot event normalization / observer broadcast | v -EventRouter -> one Processor target +RuntimeBot event_bindings -> one Processor target |-- target_type=pipeline -> Pipeline Stage chain | `-- target_type=agent -> AgentBindingResolver @@ -57,15 +57,15 @@ EventRouter -> one Processor target Delivery / Renderer / Platform API ``` -Pipeline 与 Agent 是 EventRouter 的平级处理器目标。本文只定义 AgentRunner Host 边界:Agent 目标直接解析 `AgentBinding`;Pipeline 目标执行自己的完整 Stage 链,仅在 AI Stage 调用 runner 时通过 Query entry adapter 构造一次性 `AgentConfig` / `AgentBinding`。该 runner 调用投影不改变 Pipeline 的一等处理器地位,也不会把 Pipeline 持久化为 Agent。AgentRunner 的单绑定调度、Agent 复用、插件实例无状态和 fan-out 边界以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13 为准。EventGateway / EventRouter 由外部 EBA 分支实现并联调。 +Pipeline 与 Agent 是事件路由的平级目标。Agent 直接解析 `AgentBinding`;Pipeline 执行完整 Stage 链,仅在 AI Stage 调用 runner 时通过 QueryEntryAdapter 构造一次性运行投影,不会持久化成 Agent。WebUI/API 调试按服务入口构造调试事件或 Query,再使用同一编排器;不要求绕行真实 Bot 回调。调度基数、Agent 复用和 fan-out 边界以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13 为准。 ## 4. LangBot 侧能力 -### 4.1 Event Gateway / EventRouter(External EBA Branch Integration Point) +### 4.1 事件入口与路由(已集成) -> EventGateway / EventRouter 由外部 EBA 分支实现并联调,不在本分支范围。本分支只保留 event-first 入口和 envelope/binding models。 +> 2026-09-05:平台转换、Bot event_bindings、独立 Agent 和配置 UI 已集成。源码入口为 `pkg/platform/botmgr.py`、`pkg/api/http/service/agent.py` 和 `pkg/agent/runner/`。通用订阅与定时自动化仍是后续能力。 -Event Gateway 将把入口统一成 host event(IM 平台消息、WebUI debug chat、API 触发、后续非消息事件),输出稳定的 `AgentEventEnvelope`(Host 内部模型): +各入口将消息或非消息输入投影为 `AgentEventEnvelope`。以下为概念字段摘要,精确字段及默认值以 `pkg/agent/runner/host_models.py` 为准: ```python class AgentEventEnvelope(BaseModel): @@ -221,7 +221,7 @@ SDK 侧本地校验只用于开发体验,host 侧 run authorization snapshot 资源裁剪应通用,不写死 local-agent。selector 与资源的映射示例:`model-fallback-selector` → primary/fallback LLM、`llm-model-selector` → LLM、`rerank-model-selector` → rerank 模型、`knowledge-base-multi-selector` → 知识库;新增 selector 时在 resource builder 中统一扩展。 -构造 `ctx.resources.tools` 时,Host 一次塞齐每个工具的完整 schema(`ToolResource.parameters`),runner 不需再逐个 `get_tool_detail` 拉取,减少 N 次往返。 +构造 `ctx.resources.tools` 时,Host 尽可能一次提供完整 schema(`ToolResource.parameters`),减少逐个 detail 查询。Runner 仍需兼容 `parameters=None` 并按需调用 `get_tool_detail`。平台动作以 `tool_type=platform` 使用同一资源面,授权与冻结目标见 [PLATFORM_ACTION_TOOLS.md](./PLATFORM_ACTION_TOOLS.md)。 执行/文件/skill/MCP 等能力的接入方向:先由 Host / sandbox 封装成普通 scoped tool,再通过 `ctx.resources.tools` 和 SDK runtime 转发进入 runner;runner 不应识别或硬编码执行环境 provider。外部 harness 的 native tools 不能直接访问 LangBot 资源。skill 的整个生命周期都走统一 tool:发现走 `list_skills` / `langbot_list_assets`,激活/注册走 `activate` / `register_skill`,包内操作走 native exec/read/write——runner 不需要独立的 skill 渲染或门控。 diff --git a/docs/agent-runner-pluginization/PROTOCOL_V1.md b/docs/agent-runner-pluginization/PROTOCOL_V1.md index 74decff77..ded258158 100644 --- a/docs/agent-runner-pluginization/PROTOCOL_V1.md +++ b/docs/agent-runner-pluginization/PROTOCOL_V1.md @@ -138,7 +138,7 @@ class AgentRunnerPermissions(BaseModel): 通用交互投递是当前唯一允许执行的 `action.requested` 白名单动作。Runner 必须同时声明 `capabilities.interactions=true` 和 `permissions.interactions=["request"]`,Host 还必须将其与 当前 binding delivery policy、run authorization snapshot 和 adapter delivery capability 求交。 -其它平台动作仍不属于当前 permissions,Host 收到后只记录 telemetry,不得执行。 +其它 `action.requested` 动作仍只记录 telemetry,不得作为任意平台动作执行器。平台语义动作已经通过 `ctx.resources.tools` 中 `tool_type="platform"` 的工具提供:Runner 需具备 `tool_calling` capability 和 `permissions.tools` 的 `call` 操作,Host 再与 Agent 工具策略、适配器能力和当前事件目标求交。`event_*` 的目标由 Host 冻结,`platform_*` 需显式选择;调用仍走统一 `call_tool`。这不新增 manifest permission 字段,规则见 [PLATFORM_ACTION_TOOLS.md](./PLATFORM_ACTION_TOOLS.md)。 Runner 实际可用 LangBot 资源来自 Host 在 run 前冻结的授权快照: diff --git a/docs/agent-runner-pluginization/README.md b/docs/agent-runner-pluginization/README.md index c580b13e1..70766f564 100644 --- a/docs/agent-runner-pluginization/README.md +++ b/docs/agent-runner-pluginization/README.md @@ -28,17 +28,17 @@ - Sandbox/workspace read/write/exec 文件能力,用于当前 run 的上传文件、工具大结果和临时产物 - SDK runtime forwarding pull APIs + `caller_plugin_identity` 验证路径 -## 本分支不实现 +## 当前已集成与后续扩展 -以下能力由其他分支负责,本分支只保留 integration point。EBA 完整事件网关与事件路由当前由外部 EBA 分支联调: +截至 2026-09-05,`dev/4.11.x` 已合并 EBA 与 AgentRunner 插件化。下面按当前代码划分实现边界: -- **EventGateway / EventRouter**:完整事件网关实现、事件路由、事件持久化管理 -- **Event subscription / Event notification**:事件订阅、推送通知 -- **BindingResolver persistence UI**:绑定配置的持久化 UI 和 event router 集成(如由其他模块负责) -- **Scheduler / Background event source**:定时任务、后台事件源 +- **已实现的平台事件路由**:`RuntimeBot` 负责事件转换后的 observer 广播、`event_bindings` 匹配和 Pipeline / Agent / discard 单目标分派;EventRouter 是逻辑职责,不是另一个独立服务。 +- **已实现的持久化与 UI**:独立 Agent、Bot 事件绑定、处理器工作台、Runner 市场安装、事件范围与工具权限、路由诊断和调试入口。 +- **后续的通用事件订阅与通知**:不把当前适配器回调和 Bot 路由理解成通用订阅产品。 +- **后续的 Scheduler / Background event source**:用户可配置的定时自动化和后台任务入口。 - **完整 Agent Platform / daemon control plane**:Host-owned `AgentRun` / `AgentRunEvent`、run control primitives、最小 runtime heartbeat/claim lease 已作为 v2 foundation 落地;业务队列、Platform UI、daemon supervisor、runtime wakeup channel 和分布式 runtime 管控仍不属于 Protocol v1 主线。 -EventGateway / EventRouter 在本文档中描述为 **external EBA branch integration point**,由外部 EBA 分支提供并联调。本分支只定义 host-side envelope/binding models 和 `run(event, binding)` orchestrator 入口。 +平台事件、Agent 配置及 Pipeline AI Stage 已共同使用 host-side envelope/binding models 和 `run(event, binding)`。后续入口应复用这条链路。当前实现与发布验收分别见 [STATUS.md](./STATUS.md),平台动作授权见 [PLATFORM_ACTION_TOOLS.md](./PLATFORM_ACTION_TOOLS.md)。 本分支与外部 EBA / Agent Platform / Runtime Control Plane 的扩展边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。 @@ -67,7 +67,7 @@ EBA 先根据 `target_type` 选择 Pipeline 或 Agent。Pipeline 目标执行完 | AgentBinding / binding | Host 在一次事件运行前解析出的有效绑定,决定调用哪个 runner 以及带什么策略。 | | envelope | Host 内部事件封装,即 `AgentEventEnvelope`;runner 看到的是由它投影出的 `ctx.event`。 | | descriptor / manifest | runner discovery 的能力和配置描述;manifest 来自插件,descriptor 是 Host 校验后的注册表视图。 | -| EBA | Event Based Agent,把消息、撤回、入群、定时任务等都统一成 host event 的接入方向;完整网关和路由在外部 EBA 分支联调。 | +| EBA | Event Based Agent;平台事件路由已集成,定时任务等通用事件源仍是后续扩展。 | | 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 主线。 | @@ -80,12 +80,13 @@ EBA 先根据 `target_type` 选择 Pipeline 或 Agent。Pipeline 目标执行完 | [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) | LangBot 宿主能力与分层架构、Host 内部模型(`AgentEventEnvelope` / `AgentBinding` / Descriptor / 各 Store)、runner 发现、绑定、资源授权、状态、存储、生命周期和调用链。 | | [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) | Agent-owned context 方向:事件到来时 LangBot 传什么,agent 如何按需拉取更多历史 / state、如何访问 sandbox/workspace 文件,以及如何支持 KV cache 友好的上下文管理。 | | [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md) | AgentRunner 外化与外部 EBA / Agent Platform / Runtime Control Plane 的扩展边界矩阵,说明哪些是本分支底座、哪些由外部分支接入。 | -| [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md) | EBA 接入边界:事件模型、事件来源、触发绑定、非消息事件如何复用 AgentRunner 调度;完整 EventGateway / EventRouter 由外部 EBA 分支联调。 | +| [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md) | 已集成的事件路由、处理器分派、平台工具和结构化交互边界。 | | [eba-productization-release.md](./eba-productization-release.md) | EBA 适配器与 AgentRunner 插件化合并后的产品化 / 发布计划,说明非技术用户快速上手差距、Bot 与处理器边界、未来 Solution 分发标的,以及多 namespace SaaS 支持要求。 | | [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md) | Agent Platform v2 / runtime 管控面决策:`AgentRun` / `AgentRunEvent` / run control 已作为 Host 事实源落地,最小 runtime heartbeat/claim lease 已落地;完整 runtime registry / daemon 管控仍是后续可选阶段。 | | [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md) | 官方 runner 插件迁移,包括 local-agent 和外部 runner。它是下游落地计划,不是 LangBot 基础能力设计的前置约束。 | | [RUN_STEERING_AND_CHECKPOINT.md](./RUN_STEERING_AND_CHECKPOINT.md) | 运行中消息注入(steering / follow-up)与压缩摘要持久化(compaction checkpoint)的设计与落地状态记录;schema 仍以 PROTOCOL_V1 为准。 | | [STATUS.md](./STATUS.md) | 当前实现状态、spec 与实现已知差距、runner 验收状态和历史高价值记录。 | +| [PLATFORM_ACTION_TOOLS.md](./PLATFORM_ACTION_TOOLS.md) | 当前事件工具、平台动作和普通工具的配置、投射及执行授权。 | | [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) | Agent Runner QA 指南:保留最高价值测试路径,指导 agent 开展下一轮 WebUI / runner smoke 验证。 | | [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) | 安全发布级 hardening 的后续发布门槛:路径隔离、权限边界、secret、资源配额、MCP / skill 投影和审计。 | @@ -116,13 +117,13 @@ Host 不定义通用历史窗口字段或策略;runner 通过 Host pull API 详见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。 -### 3. Event Based Agent(External Branch) +### 3. Event Based Agent(已集成) -消息只是事件的一种。外部 EBA 分支中的 `message.received`、`message.recalled`、`group.member_joined`、`friend.request_received` 等事件都应能通过统一事件 envelope 触发 AgentRunner。 +消息只是事件的一种。当前平台适配器中的 `message.received`、`message.deleted`、`group.member_joined`、`friend.request_received` 等事件通过统一事件 envelope 进入处理器路由,具体支持范围以适配器能力声明为准。 -EBA dispatch 的基数和 fan-out 边界仍以 PROTOCOL_V1 §13 为准;本文档只列出本分支提供给外部 EBA 分支复用的入口点。 +EBA dispatch 的基数和 fan-out 边界仍以 PROTOCOL_V1 §13 为准;新增事件源复用以下入口点。 -**本分支不实现 EBA 完整能力,只提供:** +**当前共同使用的执行底座:** - event-first envelope (`AgentEventEnvelope`) - AgentBinding model - `run(event, binding)` 入口 diff --git a/docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md b/docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md index e96ec54e9..956fe5d9b 100644 --- a/docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md +++ b/docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md @@ -23,7 +23,8 @@ LangBot 后续定位应更像 **Agent Host / infrastructure provider / transfer LangBot Host Current base: EventLog / runtime AgentBinding / State / Transcript / sandbox files / active run authorization Current v2 foundation: Run / RunEvent / audit / result persistence / control primitives / minimal runtime heartbeat and claim lease - Planned: Agent / Binding persistence / daemon supervisor / wakeup channel / distributed runtime operations + Current product: persisted Agent / Bot event_bindings / processor UI / event routing + Planned: external harness daemon supervisor / wakeup channel / distributed runtime operations Agent Platform plugin Agent management UI / project-task model / event routing policy @@ -73,7 +74,9 @@ Host 负责这些能力的通用事实源和安全边界;Platform 插件负责 - `AgentRunEvent` 保存 runner/result/admin event stream,按 `run_id + sequence` 做可回放分页。 - `AgentRuntime` 保存最小 runtime registry / heartbeat 事实,用于 runtime list、stale mark 和 claim lease reconcile。 -因此本文后续提到的 `AgentRun` / `AgentRunEvent`、`run_append_result`、`run_finalize`、`run_cancel`、`runtime_register`、`runtime_heartbeat`、`run_claim` 等基础原语已经存在。仍未完成的是独立 platform `run_create` action、Host-owned Agent / Binding 持久模型、业务队列产品形态、daemon supervisor、runtime wakeup channel、跨 Host 分布式锁和 provider/runtime 诊断面。 +因此本文后续提到的 `AgentRun` / `AgentRunEvent`、`run_append_result`、`run_finalize`、`run_cancel`、`runtime_register`、`runtime_heartbeat`、`run_claim` 等基础原语已经存在。2026-09-05 核对:独立 Agent 持久模型、Bot `event_bindings` 和处理器 UI 也已实现;`AgentBinding` 仍是单次运行投影,不是独立配置表。仍未完成的是独立 platform `run_create` action、业务队列产品形态、外部 harness daemon supervisor、runtime wakeup channel、跨 Host 分布式锁和 provider/runtime 诊断面。 + +SDK Plugin Runtime 已有 installation worker 的 supervisor、重启退避和 Runtime 重启协调器;这与这里规划的外部 Agent harness 进程托管不同。当前模型和状态以 [STATUS.md](./STATUS.md) 为准,本文后续阶段是能力拆分,不能作为尚未实现功能的清单。 ## 3. 基础概念 diff --git a/docs/agent-runner-pluginization/STATUS.md b/docs/agent-runner-pluginization/STATUS.md index 85b1f1b79..28f3e0a2b 100644 --- a/docs/agent-runner-pluginization/STATUS.md +++ b/docs/agent-runner-pluginization/STATUS.md @@ -2,7 +2,30 @@ 本文档是 `docs/agent-runner-pluginization/` 的状态事实源。协议 schema 仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准;测试步骤以 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) 为准;安全发布门槛以 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) 为准。 -状态快照日期:2026-07-15。 +状态快照日期:2026-09-05。代码基线为 LangBot `dev/4.11.x` / `a4d36aa2d` 和 SDK `dev/4.11.x` / `f1da058`;本文是本地检视快照,不代表远端最新状态或正式发布批准。 + +## 当前版本与验收边界 + +- Core 声明版本 `4.11.0`,`pyproject.toml` / `uv.lock` 仍依赖 `langbot-plugin==0.5.3`;SDK 源码声明版本 `0.5.5`。本机 Core 实际导入旁边 SDK 的可编辑源码,不能用这个组合的测试通过证明 registry 安装可复现。 +- 检视时 SDK 有四个未提交文件:`api/agent_tools/asset_gateway.py`、`api/agent_tools/external_tools.py`、`api/entities/builtin/agent_runner/resources.py`(均位于 `src/langbot_plugin/`),以及 `tests/api/test_agent_tools_mcp_bridge.py`。这些改动增加 `platform_tools` 分类发现、可选 schema 和 gateway 引导。下面的 SDK 测试包含这些工作区改动;正式配套版本尚需冻结。 +- 本轮仅重新执行下列定向测试与 TypeScript 检查,没有重跑全量 backend、真实平台、provider、浏览器 E2E 或 Cloud 部署门禁。后文旧日期的成功记录仍是历史证据。 + +| 2026-09-05 验证 | 结果 | +| --- | --- | +| Core:`tests/unit_tests/agent`、`tests/unit_tests/api/service/test_agent_service.py`、`tests/unit_tests/platform/test_routing_rules.py`、`tests/unit_tests/api/service/test_maintenance_service.py` | 561 passed,74 warnings | +| SDK:`tests/api/entities/builtin/agent_runner`、`tests/api/proxies`、`tests/api/test_agent_tools_mcp_bridge.py`、`tests/runtime/plugin/test_mgr_agent_runner.py`、`tests/runtime/plugin/test_dependency_environment.py`、`tests/runtime/plugin/test_restart_coordinator.py` | 362 passed,10 warnings | +| Web:`pnpm exec tsc --noEmit` | pass | +| Web:`pnpm test:unit` | 62 passed,2 failed | + +前端失败为源码形状断言:`oss-cloud-ui-privacy.test.mjs` 仍要求 fieldset 的精确旧 class;`processor-detail-workbench.test.mjs` 仍要求 Agent 的旧四分区。当前表单已增加布局 class,并使用运行器、运行器配置、事件与工具分区。发布前需按当前设计更新断言或修正实现,不能记为全绿。 + +## 近期已集成内容 + +- **EBA 与独立 Agent**:Bot 事件绑定、observer 广播、Pipeline / Agent / discard 单目标分派、Agent CRUD、处理器工作台与路由诊断已在同一分支。逻辑路由器实现在 `pkg/platform/botmgr.py::RuntimeBot`,不是外部独立 EventRouter 服务。 +- **事件感知工具权限**:Core 9 月 4 日提交已实现 `event_*` 自动事件工具、`allowed_platform_tools` 和 `allowed_tools`。Host 冻结事件目标,结合 Runner 权限、适配器能力和运行快照授权,执行时再次检查。详见 [PLATFORM_ACTION_TOOLS.md](./PLATFORM_ACTION_TOOLS.md)。SDK 分类发现的提交状态见上文。 +- **产品流程**:Runner 市场内联安装、安装恢复和健康状态、事件范围选择、统一详情工作台、Agent 调试及 Bot 平台事件调试已实装。Agent 自身已无 enabled 开关;Bot 路由仍有 enabled。具体页面形态见 [处理器页面](../event-based-agents/08-agent-page-and-event-orchestration.md)。 +- **Runtime 与存储**:SDK 已加入 artifact 对应的独立依赖环境、Windows worker 连接路径、安装期间响应性测试;Core/Plugin Runtime/Box 分别报告拥有的存储目录。存储分析是观测,不提供硬配额。 +- **工作空间**:OSS 单工作空间多人协作和资源作用域已合入。Cloud 隔离基础与生产激活分开验收,见 [Cloud 剩余事项](../multi-tenant/cloud-v2-pending-verification.md)。 ## 实现状态 @@ -25,16 +48,16 @@ ## Spec 与实现已知差距 -- `action.requested` 是严格白名单协议面:当前只执行 `interaction.requested`;其它 action 仍只记录 telemetry,不提供通用 platform action executor。 +- `action.requested` 是严格白名单协议面:当前只执行 `interaction.requested`;其它 action 仍只记录 telemetry。平台语义动作通过已授权的 `event_*` / `platform_*` 工具执行,不通过任意 result action 或原始 `call_platform_api`。 - 结构化交互 SDK typed contract 与 DifyAgent continuation 已实现;SDK 正式发布、真实 Dify 凭据 E2E,以及需要长驻双向进程的 Claude Code 权限确认仍是后续验收项。Host 不持有 provider 私有 token。 - State 与 storage 的长期类型边界仍可继续收窄;当前合同只要求 JSON-safe state 与受控 storage API。 - `ToolResource.parameters` 已作为 best-effort full schema 由 Host 在构造 `ctx.resources` 时一次塞齐;无 schema 时 runner 仍需兼容 `parameters=None` 或按需调用 detail API。 - EventLog / Transcript 已提供显式 cleanup primitive;长期 retention 默认值、TTL 调度接入和 sandbox/workspace 文件清理仍是运维收尾项,应在 Runtime Control Plane 产品化前补齐。 - External harness 的 native shell / filesystem / CLI / MCP 权限不受 manifest permissions 约束;manifest permissions 只约束 LangBot 持有的资源访问。 - LangBot 当前不承诺 managed sandbox;external harness 的 OS/process/network quota、workspace GC、provider-native tool 权限由用户或部署环境承担。 -- Runtime Control Plane v2 当前只落地 Host 事实源和控制原语;还没有内置 Agent Platform UI、业务队列、daemon 进程托管、runtime wakeup channel、跨 Host 分布式锁或 provider 登录态诊断。 +- Runtime Control Plane v2 已有 Host 事实源和控制原语,独立 Agent 与处理器 UI 也已存在;仍缺业务任务队列、外部 harness daemon 托管、wakeup channel、跨 Host 分布式锁及 provider 登录态诊断。SDK installation worker 的 supervisor/重启协调已实现,不能与外部 harness 管控混淆。 -## Runner 验收状态 +## Runner 历史验收记录(未在本轮重跑) | Runner | 状态 | 最近证据 | | --- | --- | --- | @@ -44,7 +67,7 @@ | Dify | Human-input unit-pass; credential E2E pending | `langbot-agent-runner/dify-agent` 已实现 `workflow_paused`、原子字段/确认交互、plugin-storage continuation、Dify submit/events 恢复与再次暂停;真实 Dify 凭据 E2E 待执行。 | | n8n / Coze / DashScope / Langflow / Tbox / DeerFlow / WeKnora | Unit-pass; credential smoke optional | 2026-06-13 plugin layout / parser tests 通过;真实服务凭据 smoke 非每轮必跑。 | -## Host / SDK 验收状态 +## Host / SDK 历史验收记录 | 范围 | 状态 | 最近证据 | | --- | --- | --- | diff --git a/docs/agent-runner-pluginization/eba-productization-release.md b/docs/agent-runner-pluginization/eba-productization-release.md index 803625cee..e4dcd4184 100644 --- a/docs/agent-runner-pluginization/eba-productization-release.md +++ b/docs/agent-runner-pluginization/eba-productization-release.md @@ -1,342 +1,66 @@ # EBA 产品化与发布计划 -> 状态:规划草案,2026-07-01 -> -> 范围:将已经合并的 AgentRunner 插件化和 Event Based Agent 适配器工作产品化,使非技术用户也能快速上手。本文聚焦产品缺口、发布门禁和 SaaS 多命名空间租户能力。本文不引入新的协议 schema;协议事实仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准,Host 模型事实仍以 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) 为准。 +更新:2026-09-05,适用于合并后的 `dev/4.11.x`。本文维护产品边界和剩余交付顺序;具体实现、提交与验证结果统一记录在 [STATUS.md](./STATUS.md)。原 2026-07-01 草案中的功能缺口和 Phase 编号不再作为当前排期。 -## 1. 产品方向 +## 产品模型 -当前技术方向是正确的:LangBot 应该把平台输入视为事件,为每个事件解析出一个有效路由,并通过 AgentRunner Host 边界调用一个处理资产。但这还不是一个非技术用户无需理解内部架构就能采用的产品。 +- **Bot**:平台连接、凭据和事件路由入口。用户在机器人上决定“发生什么时,使用哪个处理器”。 +- **Processor**:可复用处理逻辑的上位概念,当前类型为 Agent 与 Pipeline。 +- **Pipeline**:保留完整 Stage 链的消息处理器,提供预处理、AI、后处理、扩展和输出控制。 +- **Agent**:独立配置对象,选择 AgentRunner 插件并配置事件范围、运行器与工具权限,可被多个 Bot 引用。 +- **Workflow**:后续编排方向,当前尚无完整执行产品。 +- **Solution**:后续分发单元,包含处理器、路由模板、依赖、变量和文档。 -产品层模型应当是: +处理器入口聚合 Agent 与 Pipeline,不转换实体,不复制旧 Pipeline runner 配置生成 Agent。EBA 是内部术语,主要产品流程使用机器人、事件、处理器、流水线和工具等名称。 -- **机器人(Bot)**:平台连接与事件路由入口。机器人拥有适配器凭据、平台权限、入站事件可见性,以及这些事件的路由表。 -- **处理器(Processor)**:可复用的事件处理资产。当前处理器类型包括 **Agent** 和 **Pipeline**。未来可以增加 **Workflow**。 -- **Pipeline**:一等的无代码消息处理器,通过完整 Stage 链提供预处理、AI、后处理、扩展和输出控制。Pipeline 只处理消息,应当只能绑定到 `message.*` 事件。 -- **Agent**:由 runner 驱动的事件优先处理器。Agent 可以根据自身声明的事件支持范围处理消息事件和非消息事件。 -- **Solution(方案包)**:未来的分发/导出单元,包含处理器、路由模板、依赖清单、变量和文档。Solution 不应包含具体机器人凭据、租户密钥或已安装资产的 UUID 绑定。 +## 已完成的产品化基础 -`EBA` 是内部工程术语。它可以出现在内部设计文档中,但不应出现在主要产品流程里。面向用户的语言应优先使用“频道”“事件路由”“处理器”“消息流水线”“自动化”“路由模板”等表达。 - -## 2. 当前基础 - -已合并分支已经具备内部冒烟测试所需的技术基础: - -- EBA 适配器可以把平台活动规范化为稳定的 Host 事件名。 -- 机器人可以持久化 `event_bindings`,并将事件路由到 Agent、Pipeline 或丢弃目标。 -- 旧消息输入可以投射到标准的 `message.received` 事件路径。 -- Pipeline 仍可作为只处理消息的无代码处理器使用。 -- AgentRunner 插件化提供了 Host 与 Runner 的契约、事件优先上下文、结果流和运行时集成边界。 -- 官方 local runner 和 external runner 插件可以验证 runner 行为已经不再硬编码在 LangBot Core 中。 -- WebUI 已具备 Agent/处理器管理页面,以及机器人侧事件路由页面。 -- 机器人事件路由已具备 dry-run 诊断、运行时状态展示,以及安全的合成测试事件派发;测试事件会走已保存的 runtime 路由,但抑制真实平台出站动作。 -- MCP 工具面已暴露机器人事件路由状态查询和合成测试事件派发,便于 QA agent 或外部调试工具复用。 - -这是一个技术收敛里程碑,还不是产品就绪版本。 - -## 3. 距离非技术产品的缺口 - -### 3.1 概念负担 - -当前用户仍需要理解过多内部概念:EBA、适配器事件名、runner 标识、插件运行时健康状态、事件模式、优先级和绑定目标。面向非技术用户的产品应当通过意图和结果来引导: - -- “收到一条消息时,用这个处理器回复。” -- “有新群成员加入时,发送欢迎语。” -- “收到好友请求时,让 Agent 判断是否接受。” - -`group.member_joined` 这类原始事件模式应继续保留在高级模式中,但默认 UI 应按友好名称和平台能力对事件进行分组。 - -### 3.2 上手路径 - -首次使用路径应当从用例开始,而不是从架构开始: - -1. 选择一个频道。 -2. 连接账号或 webhook。 -3. 选择该频道支持的事件预设。 -4. 选择或创建一个处理器。 -5. 发送测试事件。 -6. 如果失败,阅读简单的运行轨迹。 - -当前产品仍假设用户能诊断后端、插件运行时、Box 运行时、适配器和 runner 插件是否都已连接。对于开发者这是可接受的,但对非技术用户不可接受。 - -### 3.3 适配器就绪度 - -每个适配器都需要产品能力清单,而不仅是工程实现: - -- 支持的事件列表及友好标签; -- 支持的出站动作; -- 所需凭据和配置步骤; -- 本地部署、自托管、SaaS 可用性; -- 测试信号可用性; -- 废弃/遗留状态; -- 已知限制。 - -废弃适配器应在产品中明确标记为“已废弃”或“遗留”。新的事件型适配器应按频道名称和能力描述,而不是使用 EBA 缩写。 - -### 3.4 处理器体验 - -处理器页面应管理可复用的 Agent 与 Pipeline,而不是让用户一次性理解所有事件路由决策。 - -- 创建 Agent 时应提供有倾向性的 runner 模板。 -- 创建 Pipeline 时应继续保持无代码消息流水线路径。 -- 未来 Workflow 的执行语义稳定后,可以作为另一种处理器类型引入。 -- 支持的事件范围应作为能力信息和高级约束展示,而不是作为创建流程的主要概念。 -- 依赖健康状态应可见:runner 插件是否安装、运行时是否连接、所需模型是否配置、所需资源是否可访问。 - -### 3.5 机器人事件路由体验 - -机器人页面应成为平台特定事件路由的主要配置位置,因为平台事件在已连接频道的上下文中最容易被用户理解。 - -最低产品要求: - -- 基于适配器能力生成友好的事件选择器; -- 目标选择器按事件兼容性过滤; -- 对非消息事件隐藏 Pipeline; -- 对重叠路由给出冲突警告; -- 优先级先用视觉方式解释,而不是首先展示原始数字; -- 提供路由测试按钮,可以注入或重放样例事件; -- 每条路由展示状态,包括最近一次匹配的 run 和最近失败原因; -- 提供安全的兜底路由,包括显式丢弃。 - -### 3.6 可观测性 - -非技术用户需要的是简短运行轨迹,而不是原始日志: - -```text -收到事件 -> 命中路由 -> 启动处理器 -> 动作已投递 -``` - -当失败发生时,UI 应指出失败层级: - -- 频道未连接; -- 适配器不支持该事件; -- 没有路由命中; -- 处理器已禁用; -- runner 插件不可用; -- 模型/资源缺失; -- 投递权限被拒绝。 - -### 3.7 文档和模板 - -发布需要面向产品场景的文档和模板: - -- 客服机器人; -- 群欢迎和群管理; -- 好友请求审核; -- Dify 支持的外部 Agent; -- 使用 LangBot 模型和知识库的本地 Agent; -- 用于多阶段消息处理的 Pipeline。 - -文档应先描述产品模型,只在高级架构章节中暴露内部术语。 - -## 4. 推荐 UX 边界 - -之前“把所有事件编排都放进 Agent”的方向应当收窄。更好的边界是: - -- **机器人页面负责事件路由**,因为事件面是平台特定的,用户也自然会在机器人上配置频道行为。 -- **处理器页面负责处理器资产**,因为 Agent 与 Pipeline 都应能跨机器人复用,并且未来可以被打包进 Solution。 -- **Pipeline 保持为一种处理器类型**,而不是隐藏在 Agent 术语背后的历史对象。 - -这样可以降低心智负担: - -- 用户在机器人页面问:“当这个机器人遇到某件事时,应该做什么?” -- 用户在处理器页面问:“我想复用什么处理逻辑?” - -这也支持同一个机器人上的不同事件使用不同处理器类型:一个事件可以使用 Pipeline,另一个事件可以使用 Agent,未来另一个事件可以使用 Workflow。 - -## 5. 未来导出、分发和导入单元 - -导出/导入不在当前实现范围内,但产品边界不应阻塞它。 - -正确的未来分发单元是 **Solution**,不是机器人,也不是单独的 Agent。 - -Solution 应包含: - -- 处理器:Agent、Pipeline、未来 Workflow 定义; -- 路由模板:事件模式、友好名称、目标逻辑引用、默认优先级和可选条件; -- 依赖清单:所需 runner 插件、适配器能力要求、模型、工具和资源; -- 变量:用户提供的值,例如 API key、频道选择、模型选择和 prompt 参数; -- 文档:配置意图和预期行为。 - -Solution 不应包含: - -- 具体机器人凭据; -- 已安装运行时 token; -- 租户或命名空间 UUID; -- 密钥; -- 原始平台账号标识; -- 已解析的机器人事件绑定 UUID。 - -导入时,应在目标命名空间内解析路由模板。用户需要先选择机器人/频道,并授予所需权限。 - -## 6. SaaS 多命名空间架构 - -产品在公开 SaaS 发布前必须支持多命名空间 SaaS 架构。事件路由模型很敏感,因为适配器、凭据、处理器、运行时、状态和日志都会跨越信任边界。 - -### 6.1 命名空间模型 - -采用分层命名空间模型: - -| 范围 | 用途 | +| 用户流程 | 当前实现 | | --- | --- | -| 租户(Tenant) | 计费、法律归属、顶层隔离。 | -| 工作空间(Workspace) | 租户内的协作和产品工作区。 | -| 命名空间(Namespace) | 机器人、处理器、运行时 token、资源和日志的可部署隔离边界。自托管部署可以只有一个默认命名空间。 | -| 机器人范围 | 平台适配器实例和事件路由表。 | -| 处理器范围 | Agent、Pipeline、Workflow 及相关配置。 | -| 运行时范围 | 插件运行时、runner 注册、lease 和执行权限。 | -| 资源范围 | 知识库、模型凭据、文件、状态和密钥。 | +| 创建处理器 | Agent/Pipeline 类型选择、独立配置与统一详情工作台 | +| 选择运行器 | 已安装 Runner 动态 metadata、市场安装入口、安装进度/恢复和可用性反馈 | +| 配置 Agent | 运行器、运行器配置、事件与工具;基础信息在详情入口编辑 | +| 配置事件 | 分组事件选择、兼容目标过滤、路由排序、冲突提示和兜底匹配说明 | +| 验证行为 | 路由 dry-run、Agent 调试、Bot 配置中的平台事件调试;合成派发仍需遵循抑制真实出站的后端边界 | +| 理解故障 | 路由匹配/失败轨迹、Runner 状态、模型测试与调试错误反馈 | +| 首次使用 | 场景引导与插件化 Runner 安装流程 | +| 工具权限 | 自动事件工具、显式平台动作、普通工具白名单,运行时再次授权 | +| 存储诊断 | Core、Plugin Runtime、Box 分别报告拥有的目录与不可用状态 | -在 SaaS GA 前,核心持久化对象都应携带 `tenant_id`、`workspace_id` 和 `namespace_id`。自托管部署可以在迁移时种子化一个默认租户/工作空间/命名空间。 +这张表表示代码已实现,不代表所有平台/provider 和首次安装组合均完成当前版本真实验收。页面契约见 [处理器与事件编排](../event-based-agents/08-agent-page-and-event-orchestration.md),动作授权见 [平台工具](./PLATFORM_ACTION_TOOLS.md)。 -### 6.2 事件入口隔离 +## 4.11 发布收尾 -每个入站事件都必须先解析命名空间,再进行路由匹配: +| 顺序 | 工作 | 完成条件 | +| --- | --- | --- | +| 1 | 冻结跨仓库依赖 | 提交/确认 SDK 平台工具发现改动,固定配套 SDK 和 Runner 包,更新 Core 声明及 lock;验证 registry 或精确 commit 的干净安装 | +| 2 | 收敛自动化 | 按当前页面设计处理前端旧断言,执行 backend/SDK 定向测试、前端单测、类型检查及发布所需 lint/build/E2E | +| 3 | 验证用户路径 | 空白实例安装 Runner、创建 Agent/Pipeline、连接 Bot、保存路由、消息/非消息执行、工具允许与拒绝、错误诊断 | +| 4 | 补真实交互与平台证据 | 当前版本的选定平台媒体/回调、Dify continuation、外部 harness;记录支持、未支持、阻塞及未执行 | +| 5 | 完成生命周期 | EventLog/Transcript retention 调度、状态/文件清理、取消和重启后的行为有明确策略与测试 | -```text -adapter ingress -> tenant/workspace/namespace resolution -> event normalization -> event log append -> route match -> processor run -``` +验收记录必须写明 Core/SDK/Runner 版本、操作系统、Runtime 连接方式、Box backend 和是否使用 editable 源码。真实平台测试、合成事件与 mock provider 测试分别记账。过去的成功报告不能自动作为当前 HEAD 的通过记录。 -Webhook 和回调端点应编码或查找命名空间范围内的适配器安装。来自一个命名空间的平台事件,绝不能匹配另一个命名空间的路由,即使适配器名称、机器人名称或原始平台 ID 发生碰撞。 +## Cloud 独立交付边界 -### 6.3 路由目标规则 +当前 OSS 支持单 Workspace 多成员及固定 RBAC;Cloud 目录和计费归控制面,Core 负责资源作用域与执行边界。不能用 edition 字段或普通配置开启 OSS 多 Workspace。 -运行时路由绑定可以使用已安装 UUID,但导出的路由模板必须使用逻辑引用。在 SaaS 中: +当前业务隔离单位是 Workspace 与 execution generation;不要求依据旧草案额外创建 Tenant/Workspace/Namespace 三层同构表。目录、安装绑定、存储与执行作用域以 [多租户架构](../multi-tenant/workspace-multi-user-architecture.md) 为准。 -- 默认情况下,机器人路由只能指向同一命名空间内的处理器; -- 跨命名空间目标默认禁止,除非由策略明确共享; -- Pipeline 目标仍然只允许处理消息; -- 路由冲突评估应限制在命名空间内; -- 路由审计事件必须包含租户、工作空间、命名空间、机器人、路由、目标和 run 标识。 +生产激活仍需关闭: -### 6.4 运行时和插件隔离 +- 插件和可配置出站目标的网络/SSRF 策略。 +- Plugin installation 与 Box 各存储面真正的 byte/inode 硬配额。 +- 普通业务写入贯穿提交的 generation fence、业务 outbox 和持久对象引用切换保障。 +- 最终 Linux/cgroup、持久卷、数据库权限、容量、故障恢复与 24 小时 soak 验收。 -插件运行时和 runner 注册表需要命名空间范围的授权: +完整清单见 [Cloud 剩余验证](../multi-tenant/cloud-v2-pending-verification.md)。存储统计、局部压力测试或 OSS 功能验收不能替代这些门禁。 -- 运行时注册 token 只作用于一个命名空间,或显式允许的一组命名空间; -- runner 发现结果按命名空间权限过滤; -- lease 和 heartbeat 按命名空间隔离; -- run-scoped API token 不能访问 run 所在命名空间之外的对象; -- 插件存储、状态和临时文件的 storage key 应包含命名空间; -- 早期 SaaS 可以接受共享运行时,但每个 API 调用都必须被 scoped 并审计; -- 企业或高风险租户应支持专用运行时。 +## 后续产品方向 -### 6.5 密钥、资源和状态 +- **完整 Agent 管控面**:业务任务队列、唤醒、外部 harness daemon 管理、provider 登录态诊断和分布式执行。已有 run ledger/heartbeat/claim 是底座。 +- **Workflow 与多 Agent**:先确定独立处理器或 Runner 扩展形式,再定义串并联、失败恢复、状态冲突和副作用幂等。 +- **Solution 导入导出**:使用逻辑依赖和路由模板;导入时选择 Bot、模型和资源,不能保留源实例 UUID、凭据、token 或租户密钥。包格式与更新策略待定。 +- **模型能力与评估**:国内 Provider reasoning 专项回归、reasoning token/生效策略记录、质量与延迟成本基线,见 [模型思考控制](../review/model-reasoning-control-design.md)。 -密钥和资源不能全局寻址: - -- 适配器凭据存放在命名空间范围的密钥存储中; -- 模型提供商凭据可以根据策略按租户/工作空间/命名空间设定范围; -- 知识资源声明允许哪些命名空间使用; -- Agent 持久状态按租户/工作空间/命名空间/处理器分区,除非共享策略另有规定; -- 事件日志和会话记录按命名空间隔离,并受保留策略约束。 - -### 6.6 Marketplace 和已安装资产 - -Marketplace 包可见性不等于已安装资产可见性: - -- Marketplace 包可以是公开、租户私有或工作空间私有; -- 安装会创建命名空间本地资产,或命名空间本地引用; -- 已安装处理器和路由模板默认复制,避免意外跨租户变更; -- 更新必须显式且可审计。 - -### 6.7 SaaS 测试要求 - -SaaS beta 前必须验证: - -- 租户 A 的事件不能匹配租户 B 的路由; -- 命名空间 A 的运行时不能 claim 命名空间 B 的 run; -- 命名空间 A 的处理器不能读取命名空间 B 的资源; -- Solution 导入不能保留源租户 UUID 或密钥; -- 路由重放不能暴露另一个命名空间的原始事件 payload; -- 管理员可以查看审计轨迹,但不能访问密钥值。 - -## 7. 发布计划 - -### Phase 0:技术收敛 - -目标:证明合并分支可以基于事件绑定和外置 runner 运行。 - -必要门禁: - -- 机器人事件路由以 `event_bindings` 作为唯一路由来源。 -- 旧机器人 pipeline 路由字段已移除或迁移。 -- Pipeline 可作为只处理消息的处理器运行。 -- Agent runner 插件冒烟测试通过,覆盖 local runner 和 Dify runner。 -- 迁移命名和 downgrade 路径有效。 -- 主 UI 不再在面向用户的适配器名称中暴露 “EBA”。 - -### Phase 1:面向技术用户的私有 Beta - -目标:让贡献者和早期自托管用户可用。 - -必要门禁: - -- 文档和 UI 中存在适配器能力矩阵; -- 路由编辑器会过滤不兼容目标; -- runner/plugin 健康检查可见; -- local Agent 和 Dify Agent 有引导式配置路径; -- 每条路由有运行轨迹; -- 废弃适配器被一致标记; -- 失败信息能指出失败层级。 - -### Phase 2:面向非技术用户的产品 Beta - -目标:让用户无需阅读架构文档也能完成常见场景。 - -必要门禁: - -- 首次使用机器人向导从用例和频道开始; -- 事件预设默认隐藏原始事件模式; -- 存在路由模拟或测试事件能力; -- 存在常见处理器模板; -- 冲突警告和兜底行为清晰; -- 文档先使用产品语言,再介绍高级术语; -- SaaS 命名空间 schema 已实现,或已经具备迁移准备。 - -### Phase 3:SaaS Beta - -目标:安全地为多个租户运行产品。 - -必要门禁: - -- 所有路由、运行时、状态、日志和资源事实都具备租户/工作空间/命名空间字段; -- 命名空间范围的运行时注册和 run claim 已强制执行; -- 命名空间范围的密钥和适配器安装已强制执行; -- 路由匹配和审计限制在命名空间内; -- 配额、保留策略和管理员审计界面存在; -- 自托管默认命名空间迁移已有文档。 - -### Phase 4:GA - -目标:让产品具备广泛采用所需的可靠性。 - -必要门禁: - -- Solution 导出/导入已实现,并支持依赖和变量解析; -- Marketplace 分发支持命名空间本地安装; -- 跨命名空间共享策略是显式的; -- 安全评审覆盖适配器、运行时 token、runner API、密钥、日志和路由重放; -- 升级和回滚流程已有文档; -- 产品遥测可以衡量上手流失和路由失败类别。 - -## 8. 验收清单 - -只有满足以下条件,才可以认为发布版本达到产品就绪: - -- 非技术用户可以连接一个受支持频道,选择场景,绑定处理器,测试它,并在不编辑原始 JSON 的情况下理解结果; -- 产品 UI 在主要流程中避免使用 EBA 这类内部术语; -- 机器人页面负责平台事件路由,处理器页面负责可复用的 Agent 与 Pipeline; -- Pipeline 仍作为无代码消息处理器可见,并且不会出现在非消息事件目标中; -- 同一个机器人的不同事件可以路由到不同处理器类型; -- 路由失败能按层级解释; -- 命名空间隔离通过 schema、service check、运行时 token、storage key 和测试强制执行; -- 导出/导入实现后,使用带路由模板的 Solution 包,而不是具体机器人绑定。 - -## 9. 待决策问题 - -- 组合处理器入口统一使用 “Processor / 处理器”;Agent 与 Pipeline 是其中平级的类型。 -- Workflow 应作为独立持久化处理器类型,还是作为 Agent runner 类别。 -- SaaS 命名空间初期是否与工作空间一一映射,还是高级租户在首个 SaaS beta 就需要一个工作空间下多个命名空间。 -- 哪些适配器允许在 SaaS 共享运行时中运行,哪些需要专用运行时隔离。 -- 未来 Solution 导出/导入的确切包格式。 +这些方向不自动构成 4.11 OSS 发布前置条件,也不承诺旧草案中的 5.0/GA 版本号或日期。 diff --git a/docs/event-based-agents/00-overview.md b/docs/event-based-agents/00-overview.md index 4f1f55c42..1e4708dad 100644 --- a/docs/event-based-agents/00-overview.md +++ b/docs/event-based-agents/00-overview.md @@ -1,5 +1,7 @@ # Event Based Agents 架构设计总览 +> 当前状态(2026-09-05):平台事件、Bot `event_bindings`、独立 Agent、Pipeline / Agent 平级路由及 WebUI 已集成到 `dev/4.11.x`。实现入口为 `pkg/platform/botmgr.py::RuntimeBot` 与 `pkg/agent/runner/`。下文“当前架构的局限性”“现有架构”描述改造前背景;EventBus / EventRouter 图表示职责划分,不表示存在同名独立服务。当前实现和验收以 [STATUS.md](../agent-runner-pluginization/STATUS.md) 为准,平台动作使用[授权工具](../agent-runner-pluginization/PLATFORM_ACTION_TOOLS.md)。 + ## 1. 背景与动机 ### 当前架构的局限性 diff --git a/docs/event-based-agents/06-migration-plan.md b/docs/event-based-agents/06-migration-plan.md index a39d69add..9d7262b65 100644 --- a/docs/event-based-agents/06-migration-plan.md +++ b/docs/event-based-agents/06-migration-plan.md @@ -1,6 +1,6 @@ # EBA 分阶段实施计划 -> 更新:2026-07-12。文件名沿用早期设计,但这里的“迁移”仅指代码架构逐步接入 EBA,不代表 LangBot 3.x 数据库或配置升级。 +> 更新:2026-09-05。P0–P4 的主要实现已落入 `dev/4.11.x`,P5 仍需按当前版本验收;下文工作项用于维护实现边界,不表示全部待开发。文件名沿用早期设计,但这里的“迁移”仅指代码架构逐步接入 EBA,不代表 LangBot 3.x 数据库或配置升级。当前提交、定向测试及发布缺口见 [STATUS.md](../agent-runner-pluginization/STATUS.md)。 ## 1. 发布边界 diff --git a/docs/event-based-agents/07-agent-orchestration.md b/docs/event-based-agents/07-agent-orchestration.md index fecd3c453..4f49c13b2 100644 --- a/docs/event-based-agents/07-agent-orchestration.md +++ b/docs/event-based-agents/07-agent-orchestration.md @@ -1,6 +1,6 @@ # Agent 与 Pipeline 统一编排(产品最终形态) -> **状态**:方向修订稿(2026-06-12),供「适配器改造 / Agent 插件化 / 工作流引擎」三条工作线评审。 +> **状态**:历史方向稿(2026-06-12);2026-09-05 标记归档用途。本文的示意 schema、5.0 发布火车、SDK 0.5.0aX 配套与多租户“预留”描述不再作为实施合同。当前 4.11 产品形态见 [08-agent-page-and-event-orchestration.md](./08-agent-page-and-event-orchestration.md),协议见 [PROTOCOL_V1.md](../agent-runner-pluginization/PROTOCOL_V1.md),已完成与剩余事项见 [STATUS.md](../agent-runner-pluginization/STATUS.md)。保留正文仅用于解释早期设计取舍。 > > 本文档修订 [00-overview.md](./00-overview.md) §3.4 与 [04-event-routing.md](./04-event-routing.md) 中"四种 Handler"的编排模型:**所有编排目标统一进入处理器选择与事件绑定界面,但独立 Agent 与现有 Pipeline 保持不同类型**。事件路由的匹配机制、数据迁移策略、WebUI 交互骨架等内容仍以 04 为准,仅 handler 分类法被本文档取代。 diff --git a/docs/event-based-agents/08-agent-page-and-event-orchestration.md b/docs/event-based-agents/08-agent-page-and-event-orchestration.md index b3ac4693a..e531cc400 100644 --- a/docs/event-based-agents/08-agent-page-and-event-orchestration.md +++ b/docs/event-based-agents/08-agent-page-and-event-orchestration.md @@ -1,6 +1,6 @@ # 处理器页面与事件编排产品设计 -> 状态:实施稿(2026-06-23) +> 状态:当前实现说明(2026-09-05),对应 `dev/4.11.x`。P0–P3 已集成;发布验收见 [STATUS.md](../agent-runner-pluginization/STATUS.md)。 > > 本文档修订 [07-agent-orchestration.md](./07-agent-orchestration.md) 中“Agent 替代 Pipeline”的表述。当前产品形态保留两种长期并存的同级处理器:**Agent** 与 **Pipeline**。处理器页面只是共享入口,不改变二者各自的持久化模型和执行语义。 @@ -34,7 +34,9 @@ LangBot 的处理逻辑分成两种同级形态: - Pipeline:创建一条独立 Pipeline,执行完整消息 Stage 链。 3. 编辑时按类型进入不同表单: - Pipeline:沿用原 Pipeline 配置页,包括 AI、触发、安全、输出、扩展、Debug、Monitoring; - - Agent:配置基础信息、runner、runner config 和事件能力。 + - Agent:基础信息由详情入口编辑,主配置分为运行器、运行器配置、事件与工具;事件范围、自动事件工具、平台级动作和普通工具白名单在同一配置流程内维护。 + +处理器详情复用 `ProcessorDetailWorkbench`;Agent 与 Pipeline 保留各自的配置、调试和日志语义。`AgentRunnerSelect` 提供已安装 Runner 和市场安装入口,安装状态可恢复;Runner 配置来自动态 metadata,不按 LocalAgent id 定制 Host 表单。调试事件选择位于输入区域;Bot 的平台事件调试位于机器人配置中,路由 dry-run 只解释匹配结果。 `/home/pipelines` 继续提供 Pipeline 直接编辑路径;共享处理器入口当前使用 `/home/agents`。URL 是实现路径,不代表 Agent 包含 Pipeline。 @@ -63,13 +65,14 @@ Pipeline 只能被绑定到 `message.*`。如果用户选择非消息事件, ```python class Agent(Base): + workspace_uuid: str # workspace-scoped persistence uuid: str name: str description: str emoji: str kind: str # 首版固定为 "agent" - component_ref: str # runner id / workflow id / future external ref - config: dict # runner 与 runner_config + component_ref: str # runner reference; execution config uses runner.id + config: dict # runner, runner_config, allowed_tools, allowed_platform_tools supported_event_patterns: list[str] created_at: datetime updated_at: datetime @@ -103,7 +106,7 @@ Pipeline 投影时固定: ### 3.2 Bot 事件绑定 -Bot 新增 `event_bindings` JSON 字段,首版作为轻量配置面。后续当 EventRouter 查询、审计和多作用域规则稳定后,再拆成独立表。 +Bot 使用 `event_bindings` JSON 字段持久化路由。当前未引入独立路由表;是否拆表应由查询、审计和多作用域需求决定。 ```json [ @@ -139,12 +142,12 @@ Bot 新增 `event_bindings` JSON 字段,首版作为轻量配置面。后续 ## 5. 并存策略 1. Pipeline 与 Agent 长期并存,各自保存配置并执行自己的运行链路。 -2. 现有 Bot 的 `use_pipeline_uuid` 转换为仍指向原 Pipeline 的消息事件绑定。 -3. 现有 `pipeline_routing_rules` 仍只作用于消息事件。 +2. 数据库升级中的旧路由转换由 Alembic 负责;当前运行时只读取 `event_bindings`,不再读取 `use_pipeline_uuid`。 +3. 旧 `pipeline_routing_rules` 不再作为第二个运行时路由来源;LangBot 4.x 不支持 3.x 数据库或配置升级。 4. `event_bindings` 允许 `target_type=pipeline|agent|discard`;Pipeline 目标只限 `message.*`。 5. Pipeline 与 Agent 保留各自的持久化和编辑语义;处理器聚合入口只负责统一展示和选择。 -## 6. 分阶段落地 +## 6. 已集成阶段与维护范围 ### P0:处理器入口统一 diff --git a/docs/multi-tenant/cloud-v2-pending-verification.md b/docs/multi-tenant/cloud-v2-pending-verification.md index 7b90856b5..1cb22f28d 100644 --- a/docs/multi-tenant/cloud-v2-pending-verification.md +++ b/docs/multi-tenant/cloud-v2-pending-verification.md @@ -4,6 +4,8 @@ 更新日期:2026-07-29 +> 2026-09-05 文档核对:下列版本、测试数量和镜像 pin 属于 7 月交付记录,不代表当前 `dev/4.11.x` 的依赖状态。当前 Core/SDK 配套与定向测试见 [STATUS.md](../agent-runner-pluginization/STATUS.md)。本次未重新验收闭源控制面、最终部署或 24 小时 soak,因此保留本页生产激活门禁;新增 Runtime 存储统计也不关闭 B-02 硬配额要求。 + 本文是 Cloud v2 首期上线前的剩余验证清单。它只记录尚不能由当前代码审查、 单元测试、集成测试、合成容量探针或短时 Linux 容器实验替代的证据。这里的项目 不属于 2026-07-29 代码与本地测试资源审查的完成条件,也不会让该审查持续保持未完成; diff --git a/docs/multi-tenant/implementation-checklist.md b/docs/multi-tenant/implementation-checklist.md index c74c3dd57..cc8487767 100644 --- a/docs/multi-tenant/implementation-checklist.md +++ b/docs/multi-tenant/implementation-checklist.md @@ -1,5 +1,7 @@ # Multi-tenant implementation checklist +> Status interpretation, 2026-09-05: this checklist retains the original multi-tenant implementation and activation evidence. The kernel is now also present on `dev/4.11.x`; the branch names below are historical workstream labels. SDK installation-worker restart coordination is implemented in `runtime/plugin/restart_coordinator.py`, with focused tests passing on the inspected workspace. An unchecked production gate does not necessarily mean its code is absent. Production fault injection, egress, hard storage quotas, and generation-aware business transaction/outbox gates remain separate. See [current 4.11 status](../agent-runner-pluginization/STATUS.md) and [Cloud activation gates](./cloud-v2-pending-verification.md). + This checklist turns the Workspace architecture into implementation and verification gates. Exact commands and observed results are recorded in the [verification report](./verification-report.md). diff --git a/skills/skills.index.json b/skills/skills.index.json index 16aa42801..660ec3766 100644 --- a/skills/skills.index.json +++ b/skills/skills.index.json @@ -549,7 +549,7 @@ }, { "id": "bot-event-routing-product-flow", - "title": "Bot event routing can be configured and tested from the WebUI", + "title": "Bot event routing and adapter input can be inspected from the WebUI", "mode": "agent-browser", "area": "bot", "type": "feature", diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md index ac6b8da38..a7e360412 100644 --- a/skills/skills/langbot-mcp-ops/SKILL.md +++ b/skills/skills/langbot-mcp-ops/SKILL.md @@ -66,6 +66,7 @@ The tools wrap the LangBot service layer. Current tools (v1): | `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) | | `list_bot_event_route_statuses` | Inspect bot event-route runtime status | | `list_processors` / `get_processor` / `create_processor` / `update_processor` / `delete_processor` | Manage the peer Agent and Pipeline processor types | +| `debug_agent` | Execute a synthetic Agent event (`processor_uuid`, `payload`); requires `runtime.operate`. Returns final text and up to 1000 execution events (thinking, text, tool arguments/results). Platform tools use Mock; other configured tools execute normally. Optional `payload.mock`: `errors`/`results` keyed by platform tool name, `unsupported_apis` lists unavailable platform APIs. | | `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines | | `list_llm_models` / `get_llm_model` / `list_embedding_models` / `list_model_providers` | Inspect models & providers | | `list_knowledge_bases` / `get_knowledge_base` / `retrieve_knowledge_base` | RAG knowledge bases (incl. semantic search) | diff --git a/skills/src/commands/validate.ts b/skills/src/commands/validate.ts index 27a4f83e2..96a7ec05e 100644 --- a/skills/src/commands/validate.ts +++ b/skills/src/commands/validate.ts @@ -27,7 +27,7 @@ const refRe = /(?:\]\(|`)(references\/[A-Za-z0-9_.\-/]+\.md)(?:\)|`)/g; function validateStructuredItem(item: StructuredItem, requiredStrings: string[], requiredLists: string[]): string[] { const errors: string[] = []; - const listKeys = item.path.includes("/cases/") && scalar(item.fields, "mode") === "probe" + const listKeys = /[\\/]cases[\\/]/.test(item.path) && scalar(item.fields, "mode") === "probe" ? requiredLists.filter((key) => key !== "env") : requiredLists; for (const key of requiredStrings) { diff --git a/skills/src/fs.ts b/skills/src/fs.ts index 238e253ff..62bf04bcb 100644 --- a/skills/src/fs.ts +++ b/skills/src/fs.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import type { ParsedYaml, Skill, StructuredItem, StructuredItemKind } from "./types.ts"; import { fail } from "./cli.ts"; -const frontmatterRe = /^---\n([\s\S]*?)\n---\n/; +const frontmatterRe = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; export function statIsDirectory(path: string): boolean { try { @@ -107,7 +107,7 @@ export function globMarkdownRefs(skillPath: string): string[] { return readdirSync(refsDir) .filter((name) => name.endsWith(".md")) .sort() - .map((name) => join("references", name)); + .map((name) => `references/${name}`); } export function globYamlFiles(dir: string): string[] { diff --git a/skills/test/lbs-cli.test.ts b/skills/test/lbs-cli.test.ts index 1afab7342..2e6bb8c57 100644 --- a/skills/test/lbs-cli.test.ts +++ b/skills/test/lbs-cli.test.ts @@ -49,7 +49,16 @@ import { import { commandTroubleSearch } from "../src/commands/trouble.ts"; import { commandValidate } from "../src/commands/validate.ts"; import { commandIndex } from "../src/commands/skill.ts"; -import { loadEnv } from "../src/fs.ts"; +import { loadEnv, parseFrontmatter } from "../src/fs.ts"; + +test('frontmatter preserves metadata and body with LF and CRLF checkouts', () => { + for (const newline of ['\n', '\r\n']) { + const source = ['---', 'name: example', 'description: "Example skill"', '---', '# Body', ''].join(newline); + const parsed = parseFrontmatter(source); + assert.deepEqual(parsed.meta, { name: 'example', description: 'Example skill' }); + assert.equal(parsed.body, '# Body' + newline); + } +}); import { repoRoot } from "../src/cli.ts"; import { classifyDebugChatResult, @@ -2779,11 +2788,11 @@ test("test start creates a run handoff with a bounded report command", () => { assert.match(result.output, /bin\/lbs test plan pipeline-debug-chat/); assert.match( result.output, - /bin\/lbs test run pipeline-debug-chat --run-id .+ --output reports\/evidence\/.+pipeline-debug-chat/, + /bin\/lbs test run pipeline-debug-chat --run-id .+ --output reports[\\/]evidence[\\/].+pipeline-debug-chat/, ); assert.match( result.output, - /bin\/lbs test report pipeline-debug-chat --since ".+" --console-log reports\/evidence\/.+\/console\.log --evidence-dir reports\/evidence\/.+ --output reports\/.+pipeline-debug-chat\.md/, + /bin\/lbs test report pipeline-debug-chat --since ".+" --console-log reports[\\/]evidence[\\/].+[\\/]console\.log --evidence-dir reports[\\/]evidence[\\/].+ --output reports[\\/].+pipeline-debug-chat\.md/, ); assert.match(result.output, /Streaming completed/); }); @@ -2891,15 +2900,15 @@ test("test run dry-run exposes case automation script and evidence paths", () => assert.match(result.output, /scripts\/e2e\/pipeline-debug-chat\.mjs/); assert.match( result.output, - /console_log: reports\/evidence\/run-123\/console\.log/, + /console_log: reports[\\/]evidence[\\/]run-123[\\/]console\.log/, ); assert.match( result.output, - /automation_result_json: reports\/evidence\/run-123\/automation-result\.json/, + /automation_result_json: reports[\\/]evidence[\\/]run-123[\\/]automation-result\.json/, ); assert.match( result.output, - /result_json: reports\/evidence\/run-123\/result\.json/, + /result_json: reports[\\/]evidence[\\/]run-123[\\/]result\.json/, ); assert.match(result.output, /LANGBOT_PIPELINE_URL/); }); @@ -3260,7 +3269,7 @@ test("test run setup automation isolates evidence and reloads env", () => { assert.equal(plan.setup_automation.length, 1); assert.match( plan.setup_automation[0].evidence_dir, - /setup\/01-write-setup-env$/, + /setup[\\/]01-write-setup-env$/, ); assert.match( plan.setup_automation[0].command, diff --git a/src/langbot/pkg/agent/runner/orchestrator.py b/src/langbot/pkg/agent/runner/orchestrator.py index c94749199..50ba360e3 100644 --- a/src/langbot/pkg/agent/runner/orchestrator.py +++ b/src/langbot/pkg/agent/runner/orchestrator.py @@ -95,9 +95,7 @@ class AgentRunOrchestrator: if event_workspace_id and event_workspace_id != execution_context.workspace_uuid: raise ValueError('Agent event Workspace does not match its trusted ExecutionContext') if not event_workspace_id: - event = event.model_copy( - update={'workspace_id': execution_context.workspace_uuid} - ) + event = event.model_copy(update={'workspace_id': execution_context.workspace_uuid}) descriptor = await self.registry.get( execution_context, runner_id, @@ -106,6 +104,9 @@ class AgentRunOrchestrator: if execution_query is None: execution_query = build_execution_query(event, []) + # Synthetic events must expose the same trusted scope as pipeline queries. + for field_name in ('instance_uuid', 'workspace_uuid', 'placement_generation', 'query_uuid'): + object.__setattr__(execution_query, field_name, getattr(execution_context, field_name)) project_mcp_resource_config(execution_query, binding.runner_config) object.__setattr__(execution_query, '_execution_context', execution_context) @@ -268,6 +269,11 @@ class AgentRunOrchestrator: sequence=sequence_int, ) + # Trusted Host observers receive validated events before message-only normalization. + result_observer = (adapter_context or {}).get('_result_observer') + if result_observer is not None: + await result_observer(result_dict) + if result_type == 'state.updated': await self.journal.handle_state_updated_event( result_dict, diff --git a/src/langbot/pkg/agent/runner/platform_tools.py b/src/langbot/pkg/agent/runner/platform_tools.py index 6d4b4458e..913bb90fb 100644 --- a/src/langbot/pkg/agent/runner/platform_tools.py +++ b/src/langbot/pkg/agent/runner/platform_tools.py @@ -8,6 +8,8 @@ import typing from dataclasses import dataclass import langbot_plugin.api.entities.builtin.platform.message as platform_message +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events from .host_models import AgentEventEnvelope @@ -388,6 +390,25 @@ def platform_tool_catalog() -> list[dict[str, typing.Any]]: ] +def validate_debug_mock_options(value: typing.Any) -> dict[str, typing.Any]: + if not isinstance(value, dict) or set(value) - {'errors', 'results', 'unsupported_apis'}: + raise ValueError('Mock options must contain only errors, results and unsupported_apis') + for field in ('errors', 'results'): + entries = value.get(field, {}) + if not isinstance(entries, dict) or set(entries) - set(PLATFORM_TOOLS_BY_NAME): + raise ValueError(f'Mock {field} must map platform tool names to outcomes') + if any(not isinstance(error, str) or not error.strip() for error in value.get('errors', {}).values()): + raise ValueError('Mock errors must be non-empty strings') + if set(value.get('errors', {})) & set(value.get('results', {})): + raise ValueError('A mock tool cannot have both an error and a result') + unsupported = value.get('unsupported_apis', []) + if not isinstance(unsupported, list) or any( + not isinstance(api, str) or api not in {tool.api for tool in PLATFORM_TOOL_DEFINITIONS} for api in unsupported + ): + raise ValueError('Mock unsupported_apis must be an array of platform API names') + return copy.deepcopy(value) + + def _event_matches(event_type: str, patterns: tuple[str, ...]) -> bool: return any(fnmatch.fnmatchcase(event_type, pattern) for pattern in patterns) @@ -587,6 +608,14 @@ async def execute_platform_tool( if definition is None: raise ValueError(f'Unknown platform tool: {tool_name}') authorization = session.get('authorization', {}) + context = authorization.get('platform_context') or {} + delivery = context.get('delivery') or {} + normalized = _normalize_platform_params(definition, parameters) + if definition.scope == 'event': + normalized = _event_params(definition, context, normalized) + # This flag is frozen by the Host from the synthetic debug envelope, not tool arguments. + if delivery.get('surface') == 'webui' and (delivery.get('platform_capabilities') or {}).get('debug_mock') is True: + return _execute_mock_platform_tool(definition, context, normalized) bot_id = authorization.get('bot_id') if not bot_id: raise ValueError('This run is not associated with a platform bot') @@ -598,9 +627,6 @@ async def execute_platform_tool( api_func = getattr(bot.adapter, definition.api, None) if not callable(api_func): raise ValueError(f'Platform API {definition.api} is declared but not implemented') - normalized = _normalize_platform_params(definition, parameters) - if definition.scope == 'event': - normalized = _event_params(definition, authorization.get('platform_context') or {}, normalized) if definition.api == 'send_message': normalized = { 'target_type': _require_string(normalized, 'target_type'), @@ -612,6 +638,66 @@ async def execute_platform_tool( return await api_func(**normalized) +def _execute_mock_platform_tool( + definition: PlatformToolDefinition, context: dict[str, typing.Any], parameters: dict[str, typing.Any] +) -> dict[str, typing.Any]: + """Simulate the adapter boundary while preserving real model calls and validated targets.""" + data = context.get('data') or {} + actor = context.get('actor') or {} + options = ((context.get('delivery') or {}).get('platform_capabilities') or {}).get('mock_options') or {} + result: typing.Any = None + user_id = parameters.get('user_id') or actor.get('actor_id') or 'debug-user' + user = platform_entities.User( + id=user_id, + nickname=(actor.get('actor_name') or 'Debug User') + if user_id == actor.get('actor_id') + else f'Mock user {user_id}', + ) + group_id = parameters.get('group_id') or data.get('group_id') or 'debug-group' + group = platform_entities.UserGroup(id=group_id, name=data.get('group_name') or 'Mock group') + member = platform_entities.UserGroupMember(user=user, group_id=group_id) + if definition.api == 'send_message': + for field in ('target_type', 'target_id', 'text'): + _require_string(parameters, field) + result = {'message_id': 'mock-message'} + elif definition.api == 'get_user_info': + result = user.model_dump(mode='json') + elif definition.api == 'get_group_member_info': + result = member.model_dump(mode='json') + elif definition.api == 'get_group_info': + result = group.model_dump(mode='json') + elif definition.api == 'get_group_list': + result = [group.model_dump(mode='json')] + elif definition.api == 'get_group_member_list': + result = [member.model_dump(mode='json')] + elif definition.api == 'get_friend_list': + result = [user.model_dump(mode='json')] + elif definition.api == 'get_message': + result = platform_events.MessageReceivedEvent( + message_id=parameters['message_id'], + chat_id=parameters['chat_id'], + chat_type='group' if parameters['chat_type'] == 'group' else 'private', + sender=user, + message_chain=platform_message.MessageChain( + [platform_message.Plain(text=str(data.get('text') or 'Mock message'))] + ), + ).model_dump(mode='json') + error = options.get('errors', {}).get(definition.name) + if definition.name in options.get('results', {}): + result = copy.deepcopy(options['results'][definition.name]) + return { + 'ok': error is None, + 'mock': True, + 'delivery': 'simulated', + 'tool': definition.name, + 'api': definition.api, + 'parameters': parameters, + 'result': None if error else result, + **({'error': error} if error else {}), + 'notice': 'Simulated platform operation. No real platform API was called.', + } + + __all__ = [ 'PLATFORM_TOOL_DEFINITIONS', 'build_platform_tool_resources', diff --git a/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py b/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py new file mode 100644 index 000000000..48257add2 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py @@ -0,0 +1,68 @@ +"""Bounded, cancellable NDJSON transport for Agent debug execution.""" + +from __future__ import annotations + +import asyncio +import contextlib +import json + +import quart + +from .....agent.runner.errors import ( + AgentRunnerError, + RunnerExecutionError, + RunnerNotAuthorizedError, + RunnerNotFoundError, + RunnerProtocolError, +) + + +def debug_stream_response(service, context, agent_uuid: str, payload: dict) -> quart.Response: + async def stream(): + queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=32) + + async def on_result(result: dict) -> None: + await queue.put({'kind': 'result', 'data': result}) + + async def execute() -> None: + try: + result = await service.debug_agent(context, agent_uuid, payload, on_result=on_result) + await queue.put({'kind': 'completed', 'data': result}) + except Exception as exc: + if isinstance(exc, RunnerExecutionError): + code, message = exc.error_code or 'runner_execution_failed', exc.message + elif isinstance(exc, RunnerNotFoundError): + code, message = 'runner_not_found', 'The configured Agent runner is unavailable' + elif isinstance(exc, RunnerNotAuthorizedError): + code, message = 'runner_not_authorized', 'The configured Agent runner is not authorized' + elif isinstance(exc, RunnerProtocolError): + code, message = 'runner_protocol_error', 'The Agent runner returned an invalid response' + elif isinstance(exc, ValueError): + code, message = 'invalid_request', str(exc) + elif isinstance(exc, AgentRunnerError): + code, message = 'runner_error', 'The Agent runner could not complete this test' + else: + code, message = 'runner_error', 'The Agent debug execution failed' + await queue.put({'kind': 'error', 'code': code, 'msg': message}) + + task = asyncio.create_task(execute()) + try: + while True: + try: + frame = await asyncio.wait_for(queue.get(), timeout=15) + except TimeoutError: + yield '\n' + continue + yield json.dumps(frame, ensure_ascii=False) + '\n' + if frame['kind'] in {'completed', 'error'}: + break + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + response = quart.Response(stream(), content_type='application/x-ndjson; charset=utf-8') + response.timeout = None + response.headers['Cache-Control'] = 'no-store' + response.headers['X-Accel-Buffering'] = 'no' + return response diff --git a/src/langbot/pkg/api/http/controller/groups/agents.py b/src/langbot/pkg/api/http/controller/groups/agents.py index 4722d9135..696aee6e2 100644 --- a/src/langbot/pkg/api/http/controller/groups/agents.py +++ b/src/langbot/pkg/api/http/controller/groups/agents.py @@ -12,11 +12,24 @@ from .....agent.runner.errors import ( from ...authz import Permission, require_permission from ...context import RequestContext from .. import group +from .agent_debug_stream import debug_stream_response @group.group_class('agents', '/api/v1/agents') class AgentsRouterGroup(group.RouterGroup): async def initialize(self) -> None: + @self.route( + '//debug/stream', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + permission=Permission.RUNTIME_OPERATE, + ) + async def stream_debug(agent_uuid: str, request_context: RequestContext): + payload = await quart.request.get_json() + if not isinstance(payload, dict): + return self.http_status(400, -1, 'Debug payload must be an object') + return debug_stream_response(self.ap.agent_service, request_context, agent_uuid, payload) + @self.route( '', methods=['GET', 'POST'], diff --git a/src/langbot/pkg/api/http/service/agent.py b/src/langbot/pkg/api/http/service/agent.py index 2902133a8..1f52cf5a1 100644 --- a/src/langbot/pkg/api/http/service/agent.py +++ b/src/langbot/pkg/api/http/service/agent.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime +import copy import fnmatch import time import uuid @@ -26,8 +27,10 @@ from ....agent.runner.host_models import ( ) from ....agent.runner.resource_policy import ResourcePolicyProjector from ....agent.runner.platform_tools import ( + PLATFORM_TOOL_DEFINITIONS, platform_tool_catalog, resolve_agent_platform_tool_names, + validate_debug_mock_options, ) from ....entity.persistence import agent as persistence_agent from ....workspace.errors import WorkspaceNotFoundError @@ -118,6 +121,8 @@ class AgentService: context: RequestContext, agent_uuid: str, payload: dict[str, typing.Any], + *, + on_result: typing.Callable[[dict[str, typing.Any]], typing.Awaitable[None]] | None = None, ) -> dict[str, typing.Any]: """Execute one synthetic event against a configured Agent. @@ -129,7 +134,7 @@ class AgentService: if agent is None or agent.get('kind') != AGENT_KIND_AGENT: raise ValueError('Agent not found') - event_type = str(payload.get('event_type') or 'message.received').strip() + event_type = str(payload.get('event_type', 'message.received')).strip() if not event_type or len(event_type) > 128: raise ValueError('Invalid event_type') if not self._supports_event_type( @@ -141,9 +146,10 @@ class AgentService: text = str(payload.get('text') or '').strip() if len(text) > 20_000: raise ValueError('Debug input is too long') - event_data = payload.get('data') or {} + event_data = payload.get('data', {}) if not isinstance(event_data, dict): raise ValueError('Debug event data must be an object') + mock_options = validate_debug_mock_options(payload.get('mock', {})) config = agent.get('config') if not isinstance(config, dict): @@ -156,20 +162,39 @@ class AgentService: if not conversation_id or len(conversation_id) > 256: raise ValueError('Invalid debug conversation_id') - actor_payload = payload.get('actor') or { - 'actor_type': 'user', - 'actor_id': 'debug-user', - 'actor_name': 'Debug User', - } - subject_payload = payload.get('subject') or { - 'subject_type': 'message' if event_type.startswith('message.') else event_type.split('.', 1)[0], - 'subject_id': 'debug-subject', - 'data': event_data, - } + actor_payload = payload.get( + 'actor', + { + 'actor_type': 'user', + 'actor_id': str( + event_data.get('member_id') + or event_data.get('requester_id') + or event_data.get('user_id') + or 'debug-user' + ), + 'actor_name': str( + event_data.get('member_name') + or event_data.get('requester_name') + or event_data.get('user_name') + or 'Debug User' + ), + }, + ) + subject_payload = payload.get( + 'subject', + { + 'subject_type': 'message' if event_type.startswith('message.') else event_type.split('.', 1)[0], + 'subject_id': 'debug-subject', + 'data': event_data, + }, + ) if not isinstance(actor_payload, dict) or not isinstance(subject_payload, dict): raise ValueError('Debug actor and subject must be objects') event_id = f'debug:{agent_uuid}:{uuid.uuid4()}' + is_group = event_type.startswith(('group.', 'bot.')) or bool(event_data.get('group_id')) + actor = ActorContext.model_validate(actor_payload) + target_id = str(event_data.get('group_id') or 'debug-group') if is_group else actor.actor_id event = AgentEventEnvelope( event_id=event_id, event_type=event_type, @@ -178,7 +203,7 @@ class AgentService: source_event_type=event_type, workspace_id=context.workspace_uuid, conversation_id=conversation_id, - actor=ActorContext.model_validate(actor_payload), + actor=actor, subject=SubjectContext.model_validate(subject_payload), input=AgentInput.model_validate( { @@ -191,13 +216,27 @@ class AgentService: ), delivery=DeliveryContext( surface='webui', - reply_target=None, - supports_streaming=False, + reply_target={ + 'target_type': 'group' if is_group else 'person', + 'target_id': target_id, + **({'group_id': target_id} if is_group else {}), + **( + {'message_id': str(event_data.get('message_id') or 'debug-message')} + if event_type.startswith('message.') + else {} + ), + }, + supports_streaming=on_result is not None, supports_edit=False, supports_reaction=False, platform_capabilities={ 'event_type': event_type, 'debug': True, + 'debug_mock': True, + 'supported_apis': sorted( + {tool.api for tool in PLATFORM_TOOL_DEFINITIONS} - set(mock_options.get('unsupported_apis', [])) + ), + 'mock_options': mock_options, }, ), raw_ref=RawEventRef(ref_id=event_id, storage_key=None), @@ -219,7 +258,7 @@ class AgentService: state_scopes=['conversation', 'actor', 'subject', 'runner'], ), delivery_policy=DeliveryPolicy( - enable_streaming=False, + enable_streaming=on_result is not None, enable_reply=False, enable_interactions=False, ), @@ -233,11 +272,35 @@ class AgentService: ) output_items: list[dict[str, typing.Any]] = [] + execution_events: list[dict[str, typing.Any]] = [] + + async def observe_result(result: dict[str, typing.Any]) -> None: + if result.get('type') not in { + 'message.delta', + 'message.completed', + 'tool.call.started', + 'tool.call.completed', + 'run.completed', + 'run.failed', + }: + return + visible_result = copy.deepcopy( + { + key: result[key] + for key in ('type', 'data', 'sequence', 'timestamp', 'run_id', 'usage') + if key in result + } + ) + if on_result is not None: + await on_result(visible_result) + elif len(execution_events) < 1000: + execution_events.append(visible_result) + final_text = '' async for output in self.ap.agent_run_orchestrator.run( event, binding, - adapter_context={'_execution_context': execution_context}, + adapter_context={'_execution_context': execution_context, '_result_observer': observe_result}, ): output_text = self._provider_output_to_text(output) if output_text: @@ -256,6 +319,7 @@ class AgentService: 'conversation_id': conversation_id, 'final_text': final_text, 'outputs': output_items, + 'execution_events': execution_events, } @staticmethod diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py index c73c43b9f..b118078c8 100644 --- a/src/langbot/pkg/api/mcp/server.py +++ b/src/langbot/pkg/api/mcp/server.py @@ -208,6 +208,19 @@ class LangBotMCPServer: return _dump({'ok': True}) # ----- Models -------------------------------------------------- # + @mcp.tool( + description=( + 'Run a synthetic event against an Agent processor without platform delivery. ' + 'Returns final text and execution_events containing reported messages/thinking and tool calls. ' + 'Platform tools use mock adapters; other tools execute normally. ' + 'Requires runtime.operate; payload accepts event_type, text, data, conversation_id, actor, subject and ' + 'mock (errors/results keyed by platform tool name; unsupported_apis lists unavailable platform APIs).' + ) + ) + async def debug_agent(processor_uuid: str, payload: dict) -> str: + context = _authorized(Permission.RUNTIME_OPERATE) + return _dump(await ap.agent_service.debug_agent(context, processor_uuid, payload)) + @mcp.tool(description='List all configured LLM models. Secrets are redacted.') async def list_llm_models() -> str: context = _authorized(Permission.RESOURCE_VIEW) diff --git a/src/langbot/pkg/provider/tools/loaders/native.py b/src/langbot/pkg/provider/tools/loaders/native.py index 0e54d4609..bc7963ef4 100644 --- a/src/langbot/pkg/provider/tools/loaders/native.py +++ b/src/langbot/pkg/provider/tools/loaders/native.py @@ -369,7 +369,7 @@ class NativeToolLoader(loader.ToolLoader): *, include_visible: bool, include_activated: bool, - ) -> _HostLocation: + ) -> _HostLocation | None: selected_skill, rewritten_path = skill_loader.resolve_virtual_skill_path( self.ap, query, @@ -378,6 +378,10 @@ class NativeToolLoader(loader.ToolLoader): include_activated=include_activated, ) + # Validate the virtual path before choosing remote or local file operations. + relative_parts = _relative_workspace_parts(rewritten_path) + if self._should_use_box_workspace_files(selected_skill): + return None box_service = self.ap.box_service if selected_skill is not None: if not self._can_interpret_skill_host_paths(): @@ -395,7 +399,7 @@ class NativeToolLoader(loader.ToolLoader): return _HostLocation( root=str(host_root), - relative_parts=_relative_workspace_parts(rewritten_path), + relative_parts=relative_parts, selected_skill=selected_skill, workspace_anchor=str(workspace_anchor) if workspace_anchor else None, ) @@ -440,7 +444,11 @@ class NativeToolLoader(loader.ToolLoader): # host-path fallback. return True default_workspace = getattr(box_service, 'default_workspace', None) - return bool(default_workspace and not os.path.isdir(os.path.realpath(default_workspace))) + return ( + not default_workspace + or getattr(box_service, 'shares_filesystem_with_box', True) is False + or not os.path.isdir(os.path.realpath(default_workspace)) + ) def _read_host_location(self, location: _HostLocation, parameters: dict) -> dict: with _open_host_root(location, create=False) as root_fd: @@ -1004,7 +1012,7 @@ import json, os, re, signal, time from pathlib import Path path = {json.dumps(path)} pattern = {json.dumps(pattern)} -include = {json.dumps(include)} +include = {include!r} skip_dirs = {json.dumps(sorted(_SKIP_DIRS))} def regex_timeout(_signum, _frame): raise TimeoutError @@ -1160,7 +1168,7 @@ else: include_visible=True, include_activated=True, ) - if self._should_use_box_workspace_files(host_location.selected_skill): + if host_location is None: return await self._read_workspace_via_box(path, parameters, query) try: return await asyncio.to_thread(self._read_host_location, host_location, parameters) @@ -1193,7 +1201,7 @@ else: include_visible=False, include_activated=True, ) - if self._should_use_box_workspace_files(host_location.selected_skill): + if host_location is None: return await self._write_workspace_via_box(path, content, parameters, query) try: await run_blocking_atomic(self._write_host_location, host_location, content, parameters) @@ -1253,7 +1261,7 @@ else: include_visible=False, include_activated=True, ) - if self._should_use_box_workspace_files(host_location.selected_skill): + if host_location is None: return await self._edit_workspace_via_box(path, old_string, new_string, query) try: changed, error = await run_blocking_atomic( @@ -1548,7 +1556,7 @@ else: include_visible=True, include_activated=True, ) - if self._should_use_box_workspace_files(host_location.selected_skill): + if host_location is None: return await self._glob_workspace_via_box(path, pattern, query) try: return await asyncio.to_thread(self._glob_host_location, host_location, pattern, path) @@ -1570,7 +1578,7 @@ else: include_visible=True, include_activated=True, ) - if self._should_use_box_workspace_files(host_location.selected_skill): + if host_location is None: return await self._grep_workspace_via_box(path, pattern, include, query) try: return await asyncio.to_thread( diff --git a/tests/unit_tests/agent/test_orchestrator_integration.py b/tests/unit_tests/agent/test_orchestrator_integration.py index 345e33781..3f4cc8d45 100644 --- a/tests/unit_tests/agent/test_orchestrator_integration.py +++ b/tests/unit_tests/agent/test_orchestrator_integration.py @@ -1445,3 +1445,30 @@ class TestQueryEntryAdapterHostCapabilities: assert user_item['attachment_refs'][0]['content'] is None assert 'aGVsbG8=' not in str(user_item) assert 'Pinned documentation' not in str(user_item) + + +@pytest.mark.asyncio +async def test_synthetic_event_query_exposes_trusted_workspace_to_tools(clean_agent_state): + from langbot.pkg.provider.tools.loaders.skill import get_visible_skills + from langbot.pkg.pipeline.pool import get_query_execution_context + + plugin = FakePluginConnector(results=[{'type': 'run.completed', 'data': {'finish_reason': 'stop'}}]) + app = FakeApplication(plugin, clean_agent_state) + orchestrator = AgentRunOrchestrator(app, FakeRegistry(make_descriptor())) + query = make_query() + plan = orchestrator.query_bridge.build_plan(query) + context = get_query_execution_context(query) + outputs = [ + item + async for item in orchestrator.run(plan.event, plan.binding, adapter_context={'_execution_context': context}) + ] + assert outputs == [] + synthetic = plugin.sessions_during_run[0]['execution_query'] + assert synthetic.workspace_uuid == context.workspace_uuid + assert synthetic.instance_uuid == context.instance_uuid + assert synthetic.placement_generation == context.placement_generation + assert synthetic.query_uuid == context.query_uuid + received = [] + app.skill_mgr.get_skills = lambda scope: received.append(scope) or {} + get_visible_skills(app, synthetic) + assert received[0].workspace_uuid == context.workspace_uuid diff --git a/tests/unit_tests/agent/test_platform_tools.py b/tests/unit_tests/agent/test_platform_tools.py index cc180b055..ebf4c15bf 100644 --- a/tests/unit_tests/agent/test_platform_tools.py +++ b/tests/unit_tests/agent/test_platform_tools.py @@ -10,16 +10,107 @@ from langbot_plugin.api.entities.builtin.agent_runner import ( SubjectContext, ) from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events from langbot.pkg.agent.runner.host_models import AgentEventEnvelope from langbot.pkg.agent.runner.platform_tools import ( + PLATFORM_TOOL_DEFINITIONS, build_platform_tool_resources, execute_platform_tool, freeze_platform_context, resolve_agent_platform_tool_names, + validate_debug_mock_options, ) +@pytest.mark.asyncio +@pytest.mark.parametrize('definition', PLATFORM_TOOL_DEFINITIONS, ids=lambda tool: tool.name) +@pytest.mark.parametrize('failure', [False, True]) +async def test_every_platform_tool_mock_preserves_validation_and_never_calls_bot(definition, failure): + pattern = definition.event_patterns[0] + event_type = 'group.member_joined' if pattern == '*' else pattern.replace('*', 'received') + event = _event(event_type) + event.delivery.surface = 'webui' + event.delivery.platform_capabilities = { + 'debug_mock': True, + 'supported_apis': [definition.api], + 'mock_options': {'errors': {definition.name: 'E2E denied'}} if failure else {}, + } + resources, capabilities = build_platform_tool_resources(event, [definition.name], ['call']) + assert capabilities['unavailable_tools'] == [] + assert len(resources) == 1 + parameters = {} + for name in definition.parameters.get('required', []): + schema = definition.parameters['properties'][name] + parameters[name] = schema.get( + 'enum', [False if schema['type'] == 'boolean' else 30 if schema['type'] == 'integer' else 'fixture-' + name] + )[0] + ap = SimpleNamespace( + platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(side_effect=AssertionError('real adapter accessed'))) + ) + session = {'authorization': {'resources': {'tools': resources}, 'platform_context': freeze_platform_context(event)}} + result = await execute_platform_tool(ap, object(), session, definition.name, parameters) + assert result['mock'] is True + assert result['ok'] is not failure + assert result['api'] == definition.api + if failure: + assert result['error'] == 'E2E denied' + else: + schemas = { + 'get_user_info': platform_entities.User, + 'get_group_info': platform_entities.UserGroup, + 'get_group_member_info': platform_entities.UserGroupMember, + 'get_message': platform_events.MessageReceivedEvent, + 'get_friend_list': platform_entities.User, + 'get_group_list': platform_entities.UserGroup, + 'get_group_member_list': platform_entities.UserGroupMember, + } + if definition.api in schemas: + items = result['result'] if isinstance(result['result'], list) else [result['result']] + for item in items: + schemas[definition.api].model_validate(item) + ap.platform_mgr.get_bot_by_uuid.assert_not_awaited() + with pytest.raises(ValueError): + await execute_platform_tool(ap, object(), session, definition.name, {**parameters, 'forged_target': 'other'}) + + +@pytest.mark.parametrize( + 'options', + [ + None, + [], + {'unexpected': True}, + {'errors': []}, + {'errors': {'exec': 'oops'}}, + {'errors': {'event_reply': ''}}, + {'results': {'missing': {}}}, + {'errors': {'event_reply': 'oops'}, 'results': {'event_reply': {}}}, + {'unsupported_apis': 'send_message'}, + {'unsupported_apis': ['unknown']}, + ], +) +def test_invalid_mock_options_are_rejected(options): + with pytest.raises(ValueError): + validate_debug_mock_options(options) + + +@pytest.mark.asyncio +async def test_mock_fixture_does_not_mutate_options(): + event = _event() + fixture = {'name': 'Fixture User', 'nested': {'value': 42}} + event.delivery.surface = 'webui' + event.delivery.platform_capabilities = { + 'debug_mock': True, + 'mock_options': {'results': {'event_get_actor': fixture}}, + } + session = {'authorization': {'platform_context': freeze_platform_context(event)}} + result = await execute_platform_tool(SimpleNamespace(), object(), session, 'event_get_actor', {}) + assert result['result'] == fixture + result['result']['nested']['value'] = 100 + assert fixture['nested']['value'] == 42 + + def _event(event_type: str = 'friend.request_received') -> AgentEventEnvelope: return AgentEventEnvelope( event_id='event-1', @@ -88,6 +179,40 @@ def test_platform_resources_require_runner_call_permission() -> None: assert capabilities['unavailable_tools'] == [{'name': 'event_reply', 'reason': 'runner_call_permission_missing'}] +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('name', 'params', 'api'), + [ + ('event_reply', {'text': 'Hello'}, 'send_message'), + ('event_get_actor', {}, 'get_user_info'), + ('event_get_group', {}, 'get_group_info'), + ('event_respond_friend_request', {'approve': False}, 'approve_friend_request'), + ( + 'platform_send_message', + {'target_type': 'group', 'target_id': 'explicit-group', 'text': 'Hi'}, + 'send_message', + ), + ], +) +async def test_debug_mock_executes_without_accessing_a_real_adapter(name, params, api): + event = _event() + event.delivery.surface = 'webui' + event.delivery.platform_capabilities['debug_mock'] = True + ap = SimpleNamespace(platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock())) + session = {'authorization': {'platform_context': freeze_platform_context(event)}} + result = await execute_platform_tool(ap, object(), session, name, params) + assert result['mock'] is True and result['ok'] is True + assert result['delivery'] == 'simulated' + assert result['api'] == api + if name == 'event_reply': + assert result['parameters'] == {'target_type': 'group', 'target_id': 'group-1', 'text': 'Hello'} + if name == 'platform_send_message': + assert result['parameters']['target_id'] == 'explicit-group' + ap.platform_mgr.get_bot_by_uuid.assert_not_awaited() + with pytest.raises(ValueError): + await execute_platform_tool(ap, object(), session, name, {**params, 'unexpected': True}) + + def test_agent_platform_tools_are_resolved_for_the_current_event() -> None: selected = resolve_agent_platform_tool_names( { diff --git a/tests/unit_tests/api/service/test_agent_service.py b/tests/unit_tests/api/service/test_agent_service.py index 5008dbaf2..8554b034a 100644 --- a/tests/unit_tests/api/service/test_agent_service.py +++ b/tests/unit_tests/api/service/test_agent_service.py @@ -149,7 +149,8 @@ class TestAgentServiceMetadata: class TestAgentServiceDebug: - async def test_debug_agent_runs_configured_runner_with_synthetic_event(self): + @pytest.mark.parametrize('streaming', [False, True]) + async def test_debug_agent_runs_configured_runner_with_synthetic_event(self, streaming): app = _make_app() agent_config = _agent_row().config agent_config['allowed_platform_tools'] = ['platform_get_user_info'] @@ -159,7 +160,16 @@ class TestAgentServiceDebug: } agent_config['allowed_tools'] = ['exec', 'weather'] + visible_event = { + 'type': 'tool.call.started', + 'data': {'tool_name': 'exec', 'parameters': {'command': 'echo hi'}}, + } + observer = AsyncMock() if streaming else None + async def run_agent(event, binding, adapter_context): + assert binding.delivery_policy.enable_streaming is streaming + await adapter_context['_result_observer']({**visible_event, 'private_context': 'must not leak'}) + await adapter_context['_result_observer']({'type': 'state.updated', 'data': {'private': True}}) yield SimpleNamespace( role='assistant', content='debug result', @@ -193,8 +203,14 @@ class TestAgentServiceDebug: 'data': {'member_id': 'user-1'}, 'conversation_id': 'debug-session', }, + on_result=observer, ) + if streaming: + observer.assert_awaited_once_with(visible_event) + assert result['execution_events'] == [] + else: + assert result['execution_events'] == [visible_event] assert result['event_type'] == 'group.member.joined' assert result['conversation_id'] == 'debug-session' assert result['final_text'] == 'debug result' @@ -207,6 +223,10 @@ class TestAgentServiceDebug: ] event, binding = app.agent_run_orchestrator.run.call_args.args assert event.workspace_id == WORKSPACE_UUID + assert event.delivery.platform_capabilities['debug_mock'] is True + assert 'send_message' in event.delivery.platform_capabilities['supported_apis'] + assert event.delivery.reply_target['target_id'] == 'debug-group' + assert binding.delivery_policy.enable_reply is False assert event.data == {'member_id': 'user-1'} assert binding.agent_id == 'agent-1' assert binding.runner_id == 'plugin:test/runner/default' @@ -245,6 +265,100 @@ class TestAgentServiceDebug: ) +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'event_type', + [ + 'message.received', + 'message.edited', + 'message.deleted', + 'message.reaction', + 'group.member_joined', + 'group.member_left', + 'group.member_banned', + 'friend.request_received', + 'friend.added', + 'feedback.received', + 'bot.invited_to_group', + 'bot.muted', + 'bot.unmuted', + 'bot.removed_from_group', + 'platform.specific', + 'custom.probe', + ], +) +async def test_debug_event_matrix_preserves_scope_targets_and_mock_options(event_type): + app = _make_app() + captured = [] + + async def run(event, binding, adapter_context): + captured.append((event, binding)) + if False: + yield + + app.agent_run_orchestrator = SimpleNamespace(run=run) + service = AgentService(app) + service.get_agent = AsyncMock( + return_value={'kind': AGENT_KIND_AGENT, 'supported_event_patterns': ['*'], 'config': _agent_row().config} + ) + context = SimpleNamespace( + instance_uuid='instance-test', + workspace_uuid=WORKSPACE_UUID, + placement_generation=1, + principal=SimpleNamespace(account_uuid='account-test'), + entitlement_revision=0, + ) + data = { + 'group_id': '群组-42', + 'member_id': 'user-42', + 'member_name': '测试用户🙂', + 'request_id': 'request-42', + 'nested': {'value': [1, 2]}, + } + await service.debug_agent( + context, + 'agent-1', + { + 'event_type': event_type, + 'text': 'probe', + 'data': data, + 'mock': {'errors': {'event_reply': 'denied'}, 'unsupported_apis': ['delete_message']}, + }, + ) + event, binding = captured[0] + assert event.data == data + assert event.actor.actor_name == '测试用户🙂' + assert event.delivery.reply_target['target_id'] == '群组-42' + assert 'delete_message' not in event.delivery.platform_capabilities['supported_apis'] + assert event.delivery.platform_capabilities['mock_options']['errors'] == {'event_reply': 'denied'} + assert event.bot_id is None + assert binding.delivery_policy.enable_reply is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'payload', + [ + {'event_type': ''}, + {'data': []}, + {'data': None}, + {'data': False}, + {'mock': []}, + {'mock': {'errors': {'event_reply': None}}}, + {'actor': []}, + ], +) +async def test_debug_rejects_invalid_envelope_before_execution(payload): + app = _make_app() + app.agent_run_orchestrator = SimpleNamespace(run=Mock(side_effect=AssertionError('invalid request executed'))) + service = AgentService(app) + service.get_agent = AsyncMock( + return_value={'kind': AGENT_KIND_AGENT, 'supported_event_patterns': ['*'], 'config': _agent_row().config} + ) + with pytest.raises(ValueError): + await service.debug_agent(SimpleNamespace(workspace_uuid=WORKSPACE_UUID), 'agent-1', payload) + + class TestAgentServiceListAndLookup: async def test_get_agents_merges_agents_and_pipelines_without_leaking_config(self): app = _make_app() diff --git a/tests/unit_tests/api/test_agents_controller.py b/tests/unit_tests/api/test_agents_controller.py index 7f0f111e8..54b092e7b 100644 --- a/tests/unit_tests/api/test_agents_controller.py +++ b/tests/unit_tests/api/test_agents_controller.py @@ -169,3 +169,44 @@ async def test_debug_agent_returns_actionable_runner_error(): 'code': 'dify.config_invalid', 'msg': 'api-key is required', } + + +async def test_debug_stream_preserves_events_before_error(): + import json + + async def debug_agent(context, agent_uuid, payload, *, on_result): + await on_result({'type': 'tool.call.started', 'data': {'tool_name': 'exec'}}) + raise RunnerExecutionError('test/runner', 'partial failure', error_code='runner.timeout') + + client = await _create_test_client(SimpleNamespace(debug_agent=debug_agent)) + response = await client.post( + '/api/v1/agents/agent-1/debug/stream', + headers={'Authorization': 'Bearer test-token'}, + json={'event_type': 'message.received', 'text': 'hello'}, + ) + assert response.status_code == 200 + frames = [json.loads(line) for line in (await response.get_data()).splitlines() if line] + assert frames[0]['kind'] == 'result' + assert frames[1]['kind'] == 'error' + assert frames[1]['code'] == 'runner.timeout' + + +async def test_debug_stream_cancels_execution_when_closed(): + import asyncio + from langbot.pkg.api.http.controller.groups.agent_debug_stream import debug_stream_response + + cancelled = asyncio.Event() + + async def debug_agent(context, agent_uuid, payload, *, on_result): + try: + await on_result({'type': 'message.delta', 'data': {}}) + await asyncio.Event().wait() + finally: + cancelled.set() + + response = debug_stream_response(SimpleNamespace(debug_agent=debug_agent), None, 'agent-1', {}) + iterator = response.response.__aiter__() + first = await anext(iterator) + assert 'message.delta' in first + await iterator.aclose() + await asyncio.wait_for(cancelled.wait(), timeout=1) diff --git a/tests/unit_tests/provider/test_skill_tools.py b/tests/unit_tests/provider/test_skill_tools.py index e96156496..133d926b0 100644 --- a/tests/unit_tests/provider/test_skill_tools.py +++ b/tests/unit_tests/provider/test_skill_tools.py @@ -540,6 +540,16 @@ class TestNativeToolLoaderSkillPaths: ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)}) loader = NativeToolLoader(ap) + if not loader._can_interpret_skill_host_paths(): + # Windows lacks the secure descriptor-relative host file operations. + with pytest.raises(ValueError, match='owned by the Box Runtime'): + await loader.invoke_tool( + 'read', + {'path': '/workspace/.skills/demo/SKILL.md'}, + _make_query(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}), + ) + return + result = await loader.invoke_tool( 'read', {'path': '/workspace/.skills/demo/SKILL.md'}, diff --git a/tests/unit_tests/provider/test_tool_manager_native.py b/tests/unit_tests/provider/test_tool_manager_native.py index ae50e3935..a50e044de 100644 --- a/tests/unit_tests/provider/test_tool_manager_native.py +++ b/tests/unit_tests/provider/test_tool_manager_native.py @@ -246,6 +246,8 @@ async def test_native_tool_loader_rechecks_admission_at_the_final_invoke_boundar def _make_loader_with_workspace(tmpdir: str) -> tuple[NativeToolLoader, Mock]: + if not native_loader._SECURE_HOST_FILE_OPS_AVAILABLE: + pytest.skip('Host file operations require POSIX descriptor-relative APIs; remote Box is tested separately') logger = Mock() box_service = SimpleNamespace( available=True, @@ -508,6 +510,7 @@ async def test_path_escape_blocked(): ], ) @pytest.mark.asyncio +@pytest.mark.skipif(not native_loader._SECURE_HOST_FILE_OPS_AVAILABLE, reason='Requires POSIX descriptor-relative host APIs') async def test_host_workspace_operations_do_not_follow_a_swapped_ancestor( monkeypatch, tool_name: str, @@ -794,3 +797,52 @@ async def test_grep_interrupts_catastrophic_regex(monkeypatch): ) assert result == {'ok': False, 'error': 'Regex search timed out'} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'tool,parameters', + [ + ('read', {'path': '/workspace/probe.txt'}), + ('write', {'path': '/workspace/probe.txt', 'content': 'ok'}), + ('edit', {'path': '/workspace/probe.txt', 'old_string': 'ok', 'new_string': 'done'}), + ('glob', {'path': '/workspace', 'pattern': '*.txt'}), + ('grep', {'path': '/workspace', 'pattern': 'ok'}), + ], +) +@pytest.mark.parametrize('secure_host_apis', [False, True]) +async def test_standalone_box_files_do_not_require_core_host_workspace(monkeypatch, tool, parameters, secure_host_apis): + monkeypatch.setattr(native_loader, '_SECURE_HOST_FILE_OPS_AVAILABLE', secure_host_apis) + box = SimpleNamespace( + available=True, + default_workspace=None, + shares_filesystem_with_box=False, + execute_tool=AsyncMock(), + _tenant_workspace=Mock(side_effect=AssertionError('must not resolve Core path')), + ) + loader = NativeToolLoader(SimpleNamespace(box_service=box, logger=Mock())) + remote = AsyncMock(return_value={'ok': True, 'sentinel': tool}) + monkeypatch.setattr(loader, f'_{tool}_workspace_via_box', remote) + assert await loader.invoke_tool(tool, parameters, _make_query()) == {'ok': True, 'sentinel': tool} + remote.assert_awaited_once() + with pytest.raises(ValueError, match='workspace boundary'): + await loader.invoke_tool(tool, {**parameters, 'path': '/workspace/../outside'}, _make_query()) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('include', [None, '*.txt', 'quote"\n*.md']) +async def test_box_grep_script_serializes_optional_include_as_python(monkeypatch, include): + import ast + + loader = NativeToolLoader(SimpleNamespace(logger=Mock())) + captured = AsyncMock(return_value={'ok': True}) + monkeypatch.setattr(loader, '_run_workspace_file_script', captured) + await loader._grep_workspace_via_box('/workspace', 'probe', include, _make_query()) + script = captured.call_args.args[0] + tree = ast.parse(script) + values = {} + for statement in tree.body: + if isinstance(statement, ast.Assign) and isinstance(statement.targets[0], ast.Name): + values[statement.targets[0].id] = ast.literal_eval(statement.value) + assert values['include'] == include + assert values['path'] == '/workspace' diff --git a/web/src/app/home/agents/components/AgentDebugPanel.tsx b/web/src/app/home/agents/components/AgentDebugPanel.tsx index b4e19f038..37539fcaa 100644 --- a/web/src/app/home/agents/components/AgentDebugPanel.tsx +++ b/web/src/app/home/agents/components/AgentDebugPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { @@ -37,6 +37,8 @@ import { groupEventPatterns, } from '@/app/home/components/event-patterns/event-pattern-groups'; import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent'; +import AgentExecutionTrace from './AgentExecutionTrace'; +import { executionSteps, type DebugExecutionEvent } from './debug-execution'; interface AgentDebugPanelProps { agentId: string; @@ -54,6 +56,8 @@ interface DebugEntry { text: string; errorCode?: string; detail?: string; + events?: DebugExecutionEvent[]; + finished?: boolean; } const EVENT_PRESET_DATA: Record< @@ -85,6 +89,7 @@ const EVENT_PRESET_DATA: Record< data: { requester_id: 'debug-user', requester_name: 'Debug User', + request_id: 'debug-friend-request', message: 'Hello', }, }, @@ -95,6 +100,62 @@ const EVENT_PRESET_DATA: Record< content: 'Debug feedback', }, }, + 'friend.added': { + text: 'A friend was added.', + data: { user_id: 'debug-user', user_name: 'Debug User' }, + }, + 'group.member_banned': { + text: 'A member was banned.', + data: { + group_id: 'debug-group', + member_id: 'debug-user', + member_name: 'Debug User', + }, + }, + 'bot.invited_to_group': { + text: 'The bot was invited to a group.', + data: { + group_id: 'debug-group', + request_id: 'debug-group-request', + requester_id: 'debug-user', + }, + }, + 'bot.muted': { + text: 'The bot was muted.', + data: { group_id: 'debug-group', duration: 60 }, + }, + 'bot.unmuted': { + text: 'The bot was unmuted.', + data: { group_id: 'debug-group' }, + }, + 'bot.removed_from_group': { + text: 'The bot was removed from the group.', + data: { group_id: 'debug-group' }, + }, + 'message.edited': { + text: 'A message was edited.', + data: { + group_id: 'debug-group', + message_id: 'debug-message', + text: 'Edited message', + }, + }, + 'message.deleted': { + text: 'A message was deleted.', + data: { group_id: 'debug-group', message_id: 'debug-message' }, + }, + 'message.reaction': { + text: 'A reaction was added.', + data: { + group_id: 'debug-group', + message_id: 'debug-message', + reaction: '👍', + }, + }, + 'platform.specific': { + text: 'A platform-specific event occurred.', + data: { event_name: 'debug-platform-event' }, + }, }; function createDebugSessionId(agentId: string) { @@ -120,9 +181,21 @@ export default function AgentDebugPanel({ const [customEventType, setCustomEventType] = useState('custom.event'); const [inputText, setInputText] = useState(''); const [eventDataText, setEventDataText] = useState('{}'); + const [mockOptionsText, setMockOptionsText] = useState('{}'); const [running, setRunning] = useState(false); const [entries, setEntries] = useState([]); + const transcriptRef = useRef(null); const sessionIdRef = useRef(createDebugSessionId(agentId)); + const requestRef = useRef(null); + + useEffect(() => () => requestRef.current?.abort(), [agentId]); + + useLayoutEffect(() => { + const transcript = transcriptRef.current; + if (transcript) { + transcript.scrollTop = transcript.scrollHeight; + } + }, [entries]); const eventType = preset === 'custom' ? customEventType.trim() : preset; const isMessageEvent = eventType.startsWith('message.'); @@ -186,6 +259,7 @@ export default function AgentDebugPanel({ } let eventData: Record; + let mockOptions: Record; try { const parsed = JSON.parse(eventDataText || '{}'); if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { @@ -196,6 +270,15 @@ export default function AgentDebugPanel({ toast.error(t('agents.debugInvalidPayload')); return; } + try { + const parsed = JSON.parse(mockOptionsText || '{}'); + if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') + throw new Error('Mock options must be an object'); + mockOptions = parsed; + } catch { + toast.error(t('agents.debugInvalidMock')); + return; + } setRunning(true); if (hasUnsavedChanges && beforeRun && !(await beforeRun())) { @@ -204,6 +287,9 @@ export default function AgentDebugPanel({ } const requestId = globalThis.crypto?.randomUUID?.() ?? String(Date.now()); + const controller = new AbortController(); + requestRef.current = controller; + const outputId = `execution:${requestId}`; setEntries((current) => [ ...current, { @@ -214,23 +300,85 @@ export default function AgentDebugPanel({ }, ]); try { - const result = await httpClient.debugAgent(agentId, { - event_type: eventType, - text: inputText.trim(), - data: eventData, - conversation_id: sessionIdRef.current, - }); - setEntries((current) => [ - ...current, + const result = await httpClient.streamDebugAgent( + agentId, { - id: `output:${result.event_id}`, - direction: 'output', - eventType, - text: result.final_text || t('agents.debugNoTextOutput'), + event_type: eventType, + text: inputText.trim(), + data: eventData, + mock: mockOptions, + conversation_id: sessionIdRef.current, }, - ]); - if (isMessageEvent) setInputText(''); + (event) => { + if (controller.signal.aborted) return; + setEntries((current) => { + const existing = current.find((entry) => entry.id === outputId); + if (existing) + return current.map((entry) => + entry.id === outputId + ? { ...entry, events: [...(entry.events ?? []), event] } + : entry, + ); + return [ + ...current, + { + id: outputId, + direction: 'output', + eventType, + text: '', + events: [event], + }, + ]; + }); + }, + controller.signal, + ); + if (controller.signal.aborted) return; + setEntries((current) => + current.some((entry) => entry.id === outputId) + ? current.map((entry) => + entry.id === outputId + ? { + ...entry, + finished: true, + text: executionSteps(entry.events ?? []).some( + (step) => + step.kind === 'tool' || step.text || step.reasoning, + ) + ? '' + : result.final_text || t('agents.debugNoTextOutput'), + } + : entry, + ) + : [ + ...current, + { + id: outputId, + direction: 'output', + eventType, + text: result.final_text || t('agents.debugNoTextOutput'), + }, + ], + ); + if (isMessageEvent) + setInputText((current) => (current === inputText ? '' : current)); } catch (error) { + if (controller.signal.aborted) { + if (controller.signal.reason === 'user') { + setEntries((current) => [ + ...current.map((entry) => + entry.id === outputId ? { ...entry, finished: true } : entry, + ), + { + id: `cancel:${requestId}`, + direction: 'error', + eventType, + text: t('agents.debugCancelled'), + }, + ]); + } + return; + } const errorCode = typeof error === 'object' && error && 'code' in error ? String((error as { code?: string }).code || '') @@ -255,7 +403,9 @@ export default function AgentDebugPanel({ ? t('agents.debugRunnerTimeoutDescription') : message || t('agents.debugRunFailed'); setEntries((current) => [ - ...current, + ...current.map((entry) => + entry.id === outputId ? { ...entry, finished: true } : entry, + ), { id: `error:${requestId}`, direction: 'error', @@ -269,13 +419,19 @@ export default function AgentDebugPanel({ }, ]); } finally { - setRunning(false); + if (requestRef.current === controller) { + requestRef.current = null; + setRunning(false); + } } } return (
-
+

+ {t('agents.debugPlatformNotice')} +

+

{t('agents.debugTranscript')}

@@ -292,72 +448,90 @@ export default function AgentDebugPanel({ ) : (

- {entries.map((entry) => ( - - {entry.direction === 'error' && } -
-
- - {entry.eventType} - - - {entry.direction === 'output' - ? t('agents.debugAgentOutput') - : entry.direction === 'error' - ? t('common.error') - : t('agents.debugTestInput')} - -
-
-                    {entry.text}
-                  
- {entry.detail && ( - - - - - -
-                          {entry.detail}
-                        
-
-
- )} - {(entry.errorCode?.endsWith('.config_invalid') || - entry.errorCode === 'runner_execution_failed' || - entry.errorCode === 'runner.timeout') && - onOpenRunnerConfig && ( - + {entry.eventType} + + + {entry.direction === 'output' + ? t('agents.debugAgentOutput') + : entry.direction === 'error' + ? t('common.error') + : t('agents.debugTestInput')} + +
+ {entry.events && ( + )} -
- - ))} + {entry.text && ( +
+                        {entry.text}
+                      
+ )} + {entry.detail && ( + + + + + +
+                            {entry.detail}
+                          
+
+
+ )} + {(entry.errorCode?.endsWith('.config_invalid') || + entry.errorCode === 'runner_execution_failed' || + entry.errorCode === 'runner.timeout') && + onOpenRunnerConfig && ( + + )} +
+ + ))}
)}
@@ -469,11 +643,27 @@ export default function AgentDebugPanel({
+
+ + {t('agents.debugMockOptions')} + +

+ {t('agents.debugMockOptionsHelp')} +

+