fix(agent-debug): stream execution traces with platform mocks and coverage

This commit is contained in:
Hyu
2026-09-05 15:34:29 +08:00
parent a4d36aa2db
commit 8f0a55a1f4
47 changed files with 1815 additions and 562 deletions
@@ -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).
@@ -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 authorizationdelivery 和 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 串并联仍未作为产品交付。
@@ -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: HelloDebug 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.
@@ -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.
@@ -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 APIstorage 作为授权能力保留。 | 本分支持续维护底座;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 串行 turnreader 独占 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,继续调用统一 orchestratorPipeline AI Stage 使用 QueryEntryAdapter
- Host 保持 run/result、授权、事件、状态和历史的事实源;插件负责自己的执行策略及 provider 私有 continuation
- 新增业务表、调度或 UI 不要求修改 runner 可见协议;需要协议扩展时先更新 canonical spec,再同步 SDK、Runtime、模板与测试
- fan-out、并行仲裁和自动重试必须明确副作用、幂等、状态冲突和审计语义,不能通过多个隐式回复者实现
- 外部 harness 自带 shell、文件系统和网络权限由部署环境负责;manifest permissions 约束的是 LangBot 持有的资源
@@ -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 / EventRouterExternal 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 eventIM 平台消息、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 转发进入 runnerrunner 不应识别或硬编码执行环境 provider。外部 harness 的 native tools 不能直接访问 LangBot 资源。skill 的整个生命周期都走统一 tool:发现走 `list_skills` / `langbot_list_assets`,激活/注册走 `activate` / `register_skill`,包内操作走 native exec/read/write——runner 不需要独立的 skill 渲染或门控。
@@ -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 求交。
其它平台动作仍不属于当前 permissionsHost 收到后只记录 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 前冻结的授权快照:
+14 -13
View File
@@ -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 AgentExternal 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)` 入口
@@ -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. 基础概念
+28 -5
View File
@@ -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 passed74 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 passed10 warnings |
| Web`pnpm exec tsc --noEmit` | pass |
| Web`pnpm test:unit` | 62 passed2 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 sandboxexternal 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 历史验收记录
| 范围 | 状态 | 最近证据 |
| --- | --- | --- |
@@ -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 多成员及固定 RBACCloud 目录和计费归控制面,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 3SaaS Beta
目标:安全地为多个租户运行产品。
必要门禁:
- 所有路由、运行时、状态、日志和资源事实都具备租户/工作空间/命名空间字段;
- 命名空间范围的运行时注册和 run claim 已强制执行;
- 命名空间范围的密钥和适配器安装已强制执行;
- 路由匹配和审计限制在命名空间内;
- 配额、保留策略和管理员审计界面存在;
- 自托管默认命名空间迁移已有文档。
### Phase 4GA
目标:让产品具备广泛采用所需的可靠性。
必要门禁:
- 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 版本号或日期。
+2
View File
@@ -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. 背景与动机
### 当前架构的局限性
+1 -1
View File
@@ -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. 发布边界
@@ -1,6 +1,6 @@
# Agent 与 Pipeline 统一编排(产品最终形态)
> **状态**:方向修订稿(2026-06-12,供「适配器改造 / Agent 插件化 / 工作流引擎」三条工作线评审
> **状态**历史方向稿(2026-06-122026-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 分类法被本文档取代。
@@ -1,6 +1,6 @@
# 处理器页面与事件编排产品设计
> 状态:实施稿2026-06-23
> 状态:当前实现说明2026-09-05),对应 `dev/4.11.x`。P0P3 已集成;发布验收见 [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:处理器入口统一
@@ -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 代码与本地测试资源审查的完成条件,也不会让该审查持续保持未完成;
@@ -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).
+1 -1
View File
@@ -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",
+1
View File
@@ -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) |
+1 -1
View File
@@ -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) {
+2 -2
View File
@@ -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[] {
+16 -7
View File
@@ -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,
+9 -3
View File
@@ -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,
+89 -3
View File
@@ -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',
@@ -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
@@ -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(
'/<agent_uuid>/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'],
+81 -17
View File
@@ -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
+13
View File
@@ -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)
@@ -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(
@@ -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
@@ -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(
{
@@ -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()
@@ -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)
@@ -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'},
@@ -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'
@@ -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<DebugEntry[]>([]);
const transcriptRef = useRef<HTMLDivElement>(null);
const sessionIdRef = useRef(createDebugSessionId(agentId));
const requestRef = useRef<AbortController | null>(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<string, unknown>;
let mockOptions: Record<string, unknown>;
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 (
<div className="flex h-full min-h-0 min-w-0 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<p className="shrink-0 border-b bg-muted/20 px-3 py-2 text-xs leading-relaxed text-muted-foreground">
{t('agents.debugPlatformNotice')}
</p>
<div ref={transcriptRef} className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="mb-3">
<p className="text-sm font-medium">{t('agents.debugTranscript')}</p>
<p className="text-xs text-muted-foreground">
@@ -292,72 +448,90 @@ export default function AgentDebugPanel({
</Alert>
) : (
<div className="space-y-3">
{entries.map((entry) => (
<Alert
key={entry.id}
variant={
entry.direction === 'error' ? 'destructive' : 'default'
}
className={
entry.direction === 'output'
? 'border-primary/20 bg-primary/5'
: entry.direction === 'input'
? 'bg-muted/40'
: undefined
}
>
{entry.direction === 'error' && <AlertCircle />}
<div className="col-start-2 min-w-0">
<div className="mb-2 flex min-w-0 flex-wrap items-center justify-between gap-2">
<Badge
variant="outline"
className="max-w-full overflow-hidden text-ellipsis"
>
{entry.eventType}
</Badge>
<span className="shrink-0 text-xs text-muted-foreground">
{entry.direction === 'output'
? t('agents.debugAgentOutput')
: entry.direction === 'error'
? t('common.error')
: t('agents.debugTestInput')}
</span>
</div>
<pre className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] font-sans text-sm leading-relaxed">
{entry.text}
</pre>
{entry.detail && (
<Collapsible className="mt-3">
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="sm">
{t('agents.debugErrorDetails')}
<ChevronDown className="size-3.5" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-2 max-h-32 overflow-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] rounded-md bg-muted p-2 font-mono text-xs text-muted-foreground">
{entry.detail}
</pre>
</CollapsibleContent>
</Collapsible>
)}
{(entry.errorCode?.endsWith('.config_invalid') ||
entry.errorCode === 'runner_execution_failed' ||
entry.errorCode === 'runner.timeout') &&
onOpenRunnerConfig && (
<Button
type="button"
{entries
.filter(
(entry) =>
entry.direction !== 'output' ||
entry.text ||
executionSteps(entry.events ?? []).some(
(step) =>
step.kind === 'tool' || step.text || step.reasoning,
),
)
.map((entry) => (
<Alert
key={entry.id}
variant={
entry.direction === 'error' ? 'destructive' : 'default'
}
className={
entry.direction === 'output'
? 'border-primary/20 bg-primary/5'
: entry.direction === 'input'
? 'bg-muted/40'
: undefined
}
>
{entry.direction === 'error' && <AlertCircle />}
<div className="col-start-2 min-w-0">
<div className="mb-2 flex min-w-0 flex-wrap items-center justify-between gap-2">
<Badge
variant="outline"
size="sm"
className="mt-3"
onClick={onOpenRunnerConfig}
className="max-w-full overflow-hidden text-ellipsis"
>
{t('agents.debugReviewRunnerConfig')}
</Button>
{entry.eventType}
</Badge>
<span className="shrink-0 text-xs text-muted-foreground">
{entry.direction === 'output'
? t('agents.debugAgentOutput')
: entry.direction === 'error'
? t('common.error')
: t('agents.debugTestInput')}
</span>
</div>
{entry.events && (
<AgentExecutionTrace
events={entry.events}
finished={entry.finished}
/>
)}
</div>
</Alert>
))}
{entry.text && (
<pre className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] font-sans text-sm leading-relaxed">
{entry.text}
</pre>
)}
{entry.detail && (
<Collapsible className="mt-3">
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="sm">
{t('agents.debugErrorDetails')}
<ChevronDown className="size-3.5" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-2 max-h-32 overflow-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] rounded-md bg-muted p-2 font-mono text-xs text-muted-foreground">
{entry.detail}
</pre>
</CollapsibleContent>
</Collapsible>
)}
{(entry.errorCode?.endsWith('.config_invalid') ||
entry.errorCode === 'runner_execution_failed' ||
entry.errorCode === 'runner.timeout') &&
onOpenRunnerConfig && (
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
onClick={onOpenRunnerConfig}
>
{t('agents.debugReviewRunnerConfig')}
</Button>
)}
</div>
</Alert>
))}
</div>
)}
</div>
@@ -469,11 +643,27 @@ export default function AgentDebugPanel({
</div>
</details>
<details className="rounded-md border bg-muted/20 px-3 py-2">
<summary className="cursor-pointer text-xs font-medium">
{t('agents.debugMockOptions')}
</summary>
<p className="my-2 text-xs text-muted-foreground">
{t('agents.debugMockOptionsHelp')}
</p>
<Textarea
aria-label={t('agents.debugMockOptions')}
value={mockOptionsText}
onChange={(event) => setMockOptionsText(event.target.value)}
className="min-h-24 font-mono text-xs"
spellCheck={false}
/>
</details>
<Button
type="button"
className="w-full"
disabled={running}
onClick={runDebugEvent}
onClick={() =>
running ? requestRef.current?.abort('user') : runDebugEvent()
}
>
{running ? (
<LoaderCircle className="size-4 animate-spin" />
@@ -481,7 +671,7 @@ export default function AgentDebugPanel({
<Play className="size-4" />
)}
{running
? t('agents.debugRunning')
? t('agents.debugStop')
: hasUnsavedChanges
? t('agents.debugSaveAndRun')
: t('agents.debugRun')}
@@ -0,0 +1,134 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Brain, MessageSquare, Wrench } from 'lucide-react';
import { executionSteps, type DebugExecutionEvent } from './debug-execution';
function formatValue(value: unknown) {
return typeof value === 'string' ? value : JSON.stringify(value, null, 2);
}
function isMockResult(value: unknown): boolean {
return (
typeof value === 'object' &&
value !== null &&
'mock' in value &&
value.mock === true
);
}
const textClass =
'whitespace-pre-wrap break-words [overflow-wrap:anywhere] font-sans text-sm leading-relaxed';
export default function AgentExecutionTrace({
events,
finished = false,
}: {
events: DebugExecutionEvent[];
finished?: boolean;
}) {
const { t } = useTranslation();
const steps = useMemo(() => executionSteps(events), [events]);
const toolCount = steps.filter((step) => step.kind === 'tool').length;
const ended =
finished ||
events.some((event) =>
['run.completed', 'run.failed'].includes(event.type),
);
return (
<div className="min-w-0 space-y-3">
{ended && (
<p className="text-xs text-muted-foreground">
{t(toolCount ? 'agents.debugToolCount' : 'agents.debugNoToolCalls', {
count: toolCount,
})}
</p>
)}
{steps.map((step, index) =>
step.kind === 'message' ? (
<div key={index} className="space-y-3">
{step.reasoning && (
<details open className="rounded-md border bg-muted/30 p-3">
<summary className="cursor-pointer text-xs font-medium text-muted-foreground">
<Brain className="mr-1.5 inline size-3.5" />
{t('agents.debugReasoning')}
</summary>
<pre className={`${textClass} mt-2 text-muted-foreground`}>
{step.reasoning}
</pre>
</details>
)}
{step.text && (
<section className="space-y-2">
<p className="text-xs font-medium">
<MessageSquare className="mr-1.5 inline size-3.5" />
{t('agents.debugTextOutput')}
</p>
<pre className={textClass}>{step.text}</pre>
</section>
)}
</div>
) : (
<section
key={index}
className="min-w-0 space-y-2 rounded-md border bg-background p-3"
>
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
<span className="min-w-0 break-all font-medium">
<Wrench className="mr-1.5 inline size-3.5" />
{step.name}
</span>
<span
className={
step.status === 'failed'
? 'text-destructive'
: 'text-muted-foreground'
}
>
{isMockResult(step.result)
? t(
step.status === 'failed'
? 'agents.debugToolMockFailed'
: 'agents.debugToolSimulated',
)
: t(
step.status === 'running'
? ended
? 'agents.debugToolInterrupted'
: 'agents.debugToolRunning'
: step.status === 'failed'
? 'agents.debugToolFailed'
: 'agents.debugToolCompleted',
)}
</span>
</div>
{step.parameters !== undefined && (
<div>
<p className="text-xs text-muted-foreground">
{t('agents.debugToolArguments')}
</p>
<pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap break-all rounded bg-muted/40 p-2 font-mono text-xs">
{formatValue(step.parameters)}
</pre>
</div>
)}
{step.result !== undefined && step.result !== null && (
<div>
<p className="text-xs text-muted-foreground">
{t('agents.debugToolResult')}
</p>
<pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap break-all rounded bg-muted/40 p-2 font-mono text-xs">
{formatValue(step.result)}
</pre>
</div>
)}
{step.error && (
<pre className={`${textClass} text-destructive`}>
{step.error}
</pre>
)}
</section>
),
)}
</div>
);
}
@@ -0,0 +1,149 @@
import type { DebugExecutionEvent } from '@/app/infra/entities/api/agent-debug';
export type { DebugExecutionEvent } from '@/app/infra/entities/api/agent-debug';
export type ExecutionStep =
| { kind: 'message'; text: string; reasoning: string }
| {
kind: 'tool';
id: string;
name: string;
parameters?: unknown;
result?: unknown;
error?: string;
status: 'running' | 'completed' | 'failed';
};
function contentText(content: unknown): string {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
return content
.map((part) => (typeof part?.text === 'string' ? part.text : ''))
.join('');
}
function splitMessage(
content: string,
reasoning: string,
): ExecutionStep & { kind: 'message' } {
const thoughts: string[] = [];
const text = content.replace(
/<think>([\s\S]*?)(?:<\/think>|$)/gi,
(_, thought) => {
thoughts.push(thought);
return '';
},
);
return {
kind: 'message',
text: text.trim(),
reasoning: (reasoning || thoughts.join('\n')).trim(),
};
}
/** Preserve execution order while replacing streamed messages with their final snapshot. */
export function executionSteps(events: DebugExecutionEvent[]): ExecutionStep[] {
const steps: ExecutionStep[] = [];
const tools = new Map<string, number>();
let active: number | null = null;
let content = '';
let reasoning = '';
let visiblePrefix = '';
for (const event of events) {
const data = event.data ?? {};
if (
event.type === 'tool.call.started' ||
event.type === 'tool.call.completed'
) {
if (active !== null && content) visiblePrefix = content;
active = null;
content = reasoning = '';
const id = String(
data.tool_call_id ?? `${event.sequence}:${steps.length}`,
);
const index = tools.get(id);
const old = index === undefined ? undefined : steps[index];
const step: ExecutionStep = {
kind: 'tool',
id,
name: String(data.tool_name ?? ''),
...(event.type === 'tool.call.started'
? { parameters: data.parameters }
: {
parameters: old?.kind === 'tool' ? old.parameters : undefined,
result: data.result,
error: data.error,
}),
status:
event.type === 'tool.call.started'
? 'running'
: data.error ||
data.result?.ok === false ||
data.result?.isError === true
? 'failed'
: 'completed',
};
if (index === undefined) {
tools.set(id, steps.length);
steps.push(step);
} else steps[index] = step;
continue;
}
if (
!['message.delta', 'message.completed', 'run.completed'].includes(
event.type,
)
)
continue;
const message = data.chunk ?? data.message;
if (!message || (message.role && message.role !== 'assistant')) continue;
const nextContent = contentText(message.content);
const fields = message.provider_specific_fields ?? {};
const nextReasoning =
contentText(fields.reasoning_content ?? message.reasoning_content) ||
(Array.isArray(fields.thinking_blocks)
? fields.thinking_blocks
.map((block: { thinking?: string }) => block.thinking ?? '')
.join('\n')
: '');
if (event.type === 'message.delta') {
// LocalAgent batches cumulative snapshots with msg_sequence; raw deltas use zero.
if (typeof message.all_content === 'string')
content = message.all_content;
else if (message.msg_sequence > 0) content = nextContent;
else content += nextContent;
if (Array.isArray(fields.thinking_blocks) || message.msg_sequence > 0) {
reasoning = nextReasoning || reasoning;
} else reasoning += nextReasoning;
} else {
content = nextContent;
reasoning = nextReasoning || reasoning;
}
// LocalAgent includes previous model turns in cumulative chunks after tool calls.
const visibleContent =
event.type === 'message.delta' &&
message.msg_sequence > 0 &&
visiblePrefix &&
content.startsWith(visiblePrefix)
? content.slice(visiblePrefix.length)
: content;
const step = splitMessage(visibleContent, reasoning);
const previous = steps.at(-1);
if (
event.type === 'run.completed' &&
active === null &&
previous?.kind === 'message' &&
previous.text === step.text &&
(!step.reasoning || previous.reasoning === step.reasoning)
)
continue;
if (active === null) {
active = steps.length;
steps.push(step);
} else steps[active] = step;
if (event.type !== 'message.delta') {
active = null;
content = reasoning = '';
}
}
return steps;
}
@@ -239,7 +239,7 @@ export default function KBDetailContent({ id }: { id: string }) {
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<div className="mx-auto max-w-3xl space-y-6 pb-8">
<fieldset className="contents" disabled={!canManage}>
<fieldset className="min-w-0" disabled={!canManage}>
<KBForm
key={`${id}-${formVersion}`}
initKbId={id}
@@ -0,0 +1,7 @@
export interface DebugExecutionEvent {
type: string;
data: Record<string, any>;
sequence?: number;
timestamp?: number;
run_id?: string;
}
+49
View File
@@ -1,4 +1,5 @@
import { BaseHttpClient, type RequestConfig } from './BaseHttpClient';
import type { DebugExecutionEvent } from '@/app/infra/entities/api/agent-debug';
import {
ApiRespProviderRequesters,
ApiRespProviderRequester,
@@ -288,6 +289,7 @@ export class BackendClient extends BaseHttpClient {
conversation_id?: string;
actor?: Record<string, unknown>;
subject?: Record<string, unknown>;
mock?: Record<string, unknown>;
},
): Promise<{
event_id: string;
@@ -303,6 +305,53 @@ export class BackendClient extends BaseHttpClient {
return this.post(`/api/v1/agents/${uuid}/debug`, payload);
}
public async streamDebugAgent(
uuid: string,
payload: Parameters<BackendClient['debugAgent']>[1],
onResult: (event: DebugExecutionEvent) => void,
signal: AbortSignal,
): ReturnType<BackendClient['debugAgent']> {
let offset = 0;
let result: Awaited<ReturnType<BackendClient['debugAgent']>> | undefined;
let failure: { code: string; msg: string } | undefined;
const consume = (text: string) => {
let end: number;
while ((end = text.indexOf('\n', offset)) !== -1) {
const line = text.slice(offset, end).trim();
offset = end + 1;
if (!line) continue;
const frame = JSON.parse(line);
if (frame.kind === 'result') onResult(frame.data);
else if (frame.kind === 'completed') result = frame.data;
else if (frame.kind === 'error') failure = frame;
}
};
const response = await this.instance.post<string>(
`/api/v1/agents/${uuid}/debug/stream`,
payload,
{
adapter: 'xhr',
responseType: 'text',
timeout: 0,
signal,
headers: { Accept: 'application/x-ndjson' },
transformResponse: [(data) => data],
onDownloadProgress: (progress) => {
const xhr = progress.event?.target as XMLHttpRequest | undefined;
if (xhr?.status === 200) consume(xhr.responseText);
},
},
);
consume(response.data);
if (failure) throw failure;
if (!result)
throw {
code: 'runner_protocol_error',
msg: 'Debug stream ended before completion',
};
return result;
}
public getGeneralPipelineMetadata(): Promise<GetPipelineMetadataResponseData> {
// as designed, this method will be deprecated, and only for developer to check the prefered config schema
return this.get('/api/v1/pipelines/_/metadata');
+23
View File
@@ -875,6 +875,29 @@ const enUS = {
debugEmptyTranscript:
'Choose an event, enter test content, then select “Run test”. Results stay on this page.',
debugAgentOutput: 'Agent output',
debugReasoning: 'Thinking',
debugTextOutput: 'Text output',
debugPlatformNotice:
'Platform tools use Mock: the Agent makes real tool calls, while platform actions are simulated without sending real messages. Other tools execute as configured.',
debugToolSimulated: 'Simulated successfully · Mock',
debugStop: 'Stop debugging',
debugMockOptions: 'Mock scenario (JSON)',
debugInvalidMock: 'Mock scenario must be a valid JSON object.',
debugToolMockFailed: 'Simulated failure · Mock',
debugMockOptionsHelp:
'Defaults to success. Map tool names to failures in errors or query fixtures in results; list unsupported APIs in unsupported_apis. Example: {"errors":{"event_reply":"Simulated send failure"}}',
debugCancelled:
'Debugging stopped. Earlier execution records are retained.',
debugNoToolCalls:
'No tool calls recorded. Generated text does not mean a message was sent.',
debugToolCount:
'{{count}} tool calls recorded. See their execution status and results below.',
debugToolRunning: 'Running',
debugToolCompleted: 'Completed',
debugToolFailed: 'Failed',
debugToolInterrupted: 'No result returned',
debugToolArguments: 'Arguments',
debugToolResult: 'Result',
debugTestInput: 'Test input',
debugNoTextOutput: 'The run completed without textual output.',
debugEventTypeRequired: 'Enter an event type',
+24
View File
@@ -716,6 +716,30 @@ const jaJP = {
},
},
agents: {
debugReasoning: '思考内容',
debugTextOutput: 'テキスト出力',
debugPlatformNotice:
'プラットフォームツールは Mock を使用します。Agent は実際にツールを呼び出し、返信・送信などは模擬実行されます。他のツールは設定どおりに実行されます。',
debugToolSimulated: '模擬実行成功 · Mock',
debugStop: 'デバッグを停止',
debugMockOptions: 'Mock シナリオ(JSON',
debugInvalidMock:
'Mock シナリオは有効な JSON オブジェクトで指定してください。',
debugToolMockFailed: '模擬実行失敗 · Mock',
debugMockOptionsHelp:
'既定は成功です。errors にツール別エラー、results に結果、unsupported_apis に未対応 API を指定します。例:{"errors":{"event_reply":"送信失敗"}}',
debugCancelled:
'デバッグを停止しました。それまでの実行記録は保持されます。',
debugNoToolCalls:
'ツール呼び出しの記録はありません。テキストの生成は送信完了を意味しません。',
debugToolCount:
'{{count}} 件のツール呼び出しを記録しました。実行状態と結果は以下をご確認ください。',
debugToolRunning: '実行中',
debugToolCompleted: '完了',
debugToolFailed: '失敗',
debugToolInterrupted: '結果なし',
debugToolArguments: '引数',
debugToolResult: '実行結果',
title: 'プロセッサー',
description:
'再利用可能なプロセッサーを作成し、ボットのイベントルーティングで使用します',
+20
View File
@@ -833,6 +833,26 @@ const zhHans = {
debugEmptyTranscript:
'选择事件类型,填写测试内容,然后点击“运行测试”。结果只会显示在这里。',
debugAgentOutput: 'Agent 输出',
debugReasoning: '思考内容',
debugTextOutput: '文本输出',
debugPlatformNotice:
'平台工具使用 Mock:Agent 真实调用工具,回复、发送等平台动作模拟执行,不发送真实消息。其他工具仍按实际配置执行。',
debugToolSimulated: '模拟执行成功 · Mock',
debugStop: '停止调试',
debugMockOptions: 'Mock 场景(JSON',
debugInvalidMock: 'Mock 场景必须是有效的 JSON 对象。',
debugToolMockFailed: '模拟执行失败 · Mock',
debugMockOptionsHelp:
'默认模拟成功。errors 按工具名设置失败原因,results 设置查询结果,unsupported_apis 设置不支持的接口。例如:{"errors":{"event_reply":"模拟发送失败"}}',
debugCancelled: '已停止调试,保留停止前的执行记录。',
debugNoToolCalls: '未记录到工具调用;生成文本不代表消息已发送。',
debugToolCount: '已记录 {{count}} 次工具调用,执行状态和结果见下方。',
debugToolRunning: '执行中',
debugToolCompleted: '已完成',
debugToolFailed: '执行失败',
debugToolInterrupted: '未返回结果',
debugToolArguments: '调用参数',
debugToolResult: '执行结果',
debugTestInput: '测试输入',
debugNoTextOutput: '运行完成,但没有产生文本输出。',
debugEventTypeRequired: '请输入事件类型',
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
const source = fs.readFileSync(new URL('../../src/app/home/agents/components/debug-execution.ts', import.meta.url), 'utf8');
const module = { exports: {} };
new Function('exports', ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText)(module.exports);
const { executionSteps } = module.exports;
const event = (type, data) => ({type, data});
test('separates streamed thinking and text, replaces final snapshot without duplication', () => {
assert.deepEqual(executionSteps([
event('message.delta', {chunk: {content:'<think>plan'}}),
event('message.delta', {chunk: {content:'</think>hello'}}),
event('message.completed', {message: {content:'<think>plan</think>hello'}}),
event('run.completed', {message: {content:'hello'}}),
]), [{kind:'message', text:'hello', reasoning:'plan'}]);
});
test('retains structured reasoning and tool parameters/results in order', () => {
const steps = executionSteps([
event('message.delta', {chunk: {provider_specific_fields:{reasoning_content:'plan'}}}),
event('message.completed', {message: {content:''}}),
event('tool.call.started', {tool_call_id:'1',tool_name:'exec', parameters:{command:'echo hi'}}),
event('tool.call.started', {tool_call_id:'2',tool_name:'exec', parameters:{command:'bad'}}),
event('tool.call.completed', {tool_call_id:'2',tool_name:'exec', error:'failed'}),
event('tool.call.completed', {tool_call_id:'1',tool_name:'exec', result:{stdout:'hi'}}),
event('run.failed', {}),
]);
assert.equal(steps[0].reasoning, 'plan');
assert.equal(steps[1].parameters.command, 'echo hi');
assert.deepEqual(steps[1].result, {stdout:'hi'});
assert.equal(steps[2].status, 'failed');
assert.equal(steps[2].error, 'failed');
});
test('replaces LocalAgent cumulative snapshots instead of repeating text', () => {
assert.deepEqual(executionSteps([
event('message.delta', {chunk: {content:'hello', msg_sequence:1}}),
event('message.delta', {chunk: {content:'hello world', msg_sequence:2}}),
event('message.delta', {chunk: {content:'hello world', msg_sequence:3, is_final:true}}),
]), [{kind:'message', text:'hello world', reasoning:''}]);
});
test('shows failed tool results even when the call transport completed', () => {
const steps = executionSteps([
event('tool.call.started', {tool_call_id:'exit7', tool_name:'exec', parameters:{command:'exit 7'}}),
event('tool.call.completed', {tool_call_id:'exit7', tool_name:'exec', result:{ok:false, exit_code:7, stderr:'expected'}}),
]);
assert.equal(steps[0].status,'failed');
assert.equal(steps[0].result.exit_code,7);
});
test('does not repeat prior thinking across LocalAgent tool turns', () => {
const prefix = '<think>first thought</think>';
const steps = executionSteps([
event('message.delta', {chunk:{content:prefix, msg_sequence:1}}),
event('tool.call.started', {tool_call_id:'w',tool_name:'write',parameters:{path:'/workspace/a'}}),
event('tool.call.completed', {tool_call_id:'w',tool_name:'write',result:{ok:true}}),
event('message.delta', {chunk:{content:prefix+'now read',msg_sequence:1}}),
event('tool.call.started', {tool_call_id:'r',tool_name:'read'}),
event('tool.call.completed', {tool_call_id:'r',tool_name:'read',result:{ok:true}}),
event('message.delta', {chunk:{content:prefix+'now read'+'done',msg_sequence:1}}),
event('message.completed', {message:{content:'done'}}),
]);
const messages = steps.filter(s=>s.kind==='message');
assert.deepEqual(messages, [
{kind:'message',text:'',reasoning:'first thought'},
{kind:'message',text:'now read',reasoning:''},
{kind:'message',text:'done',reasoning:''},
]);
});
+1 -1
View File
@@ -43,7 +43,7 @@ test('hides the entire workspace switcher slot for a singleton local workspace',
test('keeps bot cards at the same vertical spacing as knowledge-base cards', () => {
assert.match(
botFormSource,
/<fieldset className="space-y-6" disabled=\{isLoading\}>/,
/<fieldset\s+className="[^"]*\bspace-y-6\b[^"]*"\s+disabled=\{isLoading\}/,
);
assert.match(kbFormSource, /<form[\s\S]*?className="space-y-6"/);
});
@@ -73,13 +73,13 @@ test('processor forms expose their primary orchestration flow horizontally', ()
assert.match(
agentForm,
/name: 'basic'[\s\S]*name: 'events'[\s\S]*name: 'runner'[\s\S]*name: 'runner_config'/,
/name: 'runner'[\s\S]*name: 'runner_config'[\s\S]*name: 'events_and_tools'/,
);
assert.match(
pipelineForm,
/const primarySectionNames = \['trigger', 'ai', 'output'\]/,
);
assert.match(agentForm, /<TabsList[^>]*grid-cols-4/);
assert.match(agentForm, /<TabsList[^>]*grid-cols-3/);
assert.match(pipelineForm, /<TabsList[^>]*grid-cols-3/);
assert.doesNotMatch(agentForm, /<ol className=/);
assert.doesNotMatch(pipelineForm, /<ol className=/);