mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-06 09:37:13 +00:00
fix(agent-debug): stream execution traces with platform mocks and coverage
This commit is contained in:
@@ -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 authorization;delivery 和 platform action 走统一权限模型;外部 harness runner 也通过同一套 envelope/binding/context/result 协议接入。observer / fan-out / parallel arbitration 的额外语义仍按 PROTOCOL_V1 §13 处理。
|
||||
|
||||
## 6. 平台动作执行
|
||||
|
||||
EBA 后 `action.requested`(PROTOCOL_V1 §7.3,当前仅 telemetry 不执行)将用于请求 host 执行平台动作:
|
||||
平台动作走统一工具入口,详见 [PLATFORM_ACTION_TOOLS.md](./PLATFORM_ACTION_TOOLS.md):
|
||||
|
||||
```json
|
||||
{ "type": "action.requested",
|
||||
"data": { "action": "friend.request.accept",
|
||||
"target": {"platform": "wechat", "request_id": "..."},
|
||||
"payload": {"reason": "policy matched"} } }
|
||||
```
|
||||
- `event_*` 的目标由 Host 从当前事件冻结;Agent 只填写动作参数。
|
||||
- `platform_*` 允许填写目标,必须在 `allowed_platform_tools` 中显式选择。
|
||||
- 最终资源与 Runner 权限、适配器能力及事件目标求交,执行时再校验运行身份和当前机器人。
|
||||
- SDK/Python `call_tool` 和 scoped MCP gateway 使用同一 Host 授权;原始 `call_platform_api` 不作为 Agent 工具开放。
|
||||
- `action.requested` 只执行白名单 `interaction.requested`,用于持久化交互和回调恢复;其它 action 仍是 telemetry,不能用于执行好友审核等平台动作。
|
||||
|
||||
Host 必须校验:binding / platform action policy 是否授权该 action、actor / bot / workspace 是否允许、是否需要人工审批,以及当前 run session / caller identity 是否匹配。EBA 还可能预留 `delivery.requested`(请求投递到某 surface)。
|
||||
|
||||
Delivery 方面,event 不一定回复到当前聊天窗口:消息事件通常带 reply target;系统事件可能没有默认 reply target,需要 runner 返回 `action.requested` 或由 binding 的 delivery policy 决定投递位置(`DeliveryContext` 见 PROTOCOL_V1 §5.7)。
|
||||
事件可能没有默认 reply target;Host 不为缺少目标的事件猜测投递对象。Runner 只能使用当前授权允许的工具和投递能力(`DeliveryContext` 见 PROTOCOL_V1 §5.7)。
|
||||
当前 Host 会把 adapter 声明的通用 API 投影到
|
||||
`DeliveryContext.platform_capabilities.supported_apis`,并据此设置
|
||||
`supports_edit` / `supports_reaction`。该投影只供 runner 选择输出形态,不构成
|
||||
@@ -96,6 +93,4 @@ EBA 事件进入 AgentRunner 时仍遵循 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CO
|
||||
运行状态和真实 OneBot 非消息事件到 Agent 的闭环。Pipeline 消息链和独立 Agent
|
||||
均复用同一个 AgentRunner orchestrator / context / result 协议。
|
||||
|
||||
尚未落地的是 platform action permission model 和 `action.requested` 执行器;在显式
|
||||
action allowlist、binding policy、adapter capability 和审批模型完成前,该 result 仍只
|
||||
记录 telemetry,不执行平台副作用。
|
||||
平台动作授权和结构化交互已实现,但真实平台/provider 验收不等同于单测通过。SDK 的 `platform_tools` 分类发现于 2026-09-05 检视时仍是未提交工作区改动。剩余发布工作和历史验证边界见 [STATUS.md](./STATUS.md)。通用订阅、Scheduler、Workflow 和多 Agent 串并联仍未作为产品交付。
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Event debug end-to-end regression — 2026-09-05
|
||||
|
||||
Environment: Windows, Vite :3000, Core :5300, standalone Plugin Runtime :5400/:5401, standalone Box :5410, Docker backend. Tests use browser controls in Edge and an isolated unbound Agent `E2E Debug 0905` (`49e52ae0-6a99-46ce-975e-70164b2786ec`). Local plugin maintenance uses the authenticated localhost management API because Edge file upload is disabled.
|
||||
|
||||
## Coverage inventory
|
||||
|
||||
- Message input: empty input, plain text, multi-turn context, draft edits during streaming.
|
||||
- Structured events: group join/leave/ban, friend request/add, feedback, bot state, message edit/delete/reaction, platform and custom events.
|
||||
- Payload validation: invalid JSON, non-object JSON, Unicode and nested fields.
|
||||
- Execution trace: intermediate/final text, returned thinking, tool arguments/results, tool failure, no-output failure, completion without duplication.
|
||||
- Lifecycle: save-and-run, provider failure and recovery, long output/autoscroll, switch Agent during run, reconnect.
|
||||
- Visual checks: scroll containment, long tool JSON, error card, narrow viewport.
|
||||
|
||||
## Findings and fixes
|
||||
|
||||
1. Synthetic debug Query omitted public Workspace fields. Native tools failed during skill mount lookup. Project trusted ExecutionContext fields onto synthetic queries; real Docker exec returned `E2E_TOOL_OK` after repair.
|
||||
2. LocalAgent context assembly omitted structured event data. Add a bounded user-role event facts message before current input, preserving ordinary empty-data message behavior. Browser join/leave/friend/feedback returned unique JSON probes after installing the patched local plugin.
|
||||
3. A model failure before visible output left an empty Agent card. Hide output entries with no visible execution steps.
|
||||
4. A completed message request cleared a newly edited draft. Clear only the submitted input value; the browser retained `NEXT_DRAFT_SHOULD_REMAIN`.
|
||||
5. Tool transport completion was shown as success even with `result.ok=false`. Treat explicit failed tool results as failure; real exec exit 7/stderr is preserved.
|
||||
6. Native file tools resolved a Core host path before selecting remote Box execution. Choose Box first for remote/no-host-root deployments, validate virtual path boundaries, and cover all five file tools.
|
||||
7. This local legacy config had no `box.local.host_root`, so Docker had no writable `/workspace` mount. Add an explicit local development root and keep the read-only root filesystem enabled.
|
||||
|
||||
8. LocalAgent repeats previous turns in cumulative chunks after tools. Strip the already displayed prefix for those chunks; real thought text now appears once across write/read.
|
||||
9. Remote grep generated Python `include = null` when no filter was supplied. Serialize it as a Python literal and test optional/quoted values.
|
||||
|
||||
## External configuration observations
|
||||
|
||||
`gpt-4.1-mini` under LangBot Models returned no available channel; NewAPI rockchin returned invalid token. These are reported by the trace; tests continued with working `claude-opus-4-8` and `deepseek-v4-flash` models. The test Agent now uses the latter. No credentials were changed.
|
||||
|
||||
## Results
|
||||
|
||||
### Browser outcomes
|
||||
|
||||
All 15 preset event types and one named custom event were executed through the UI. These exercise synthetic debug event dispatch, not delivery from real messaging platforms.
|
||||
|
||||
| Events | Observed outcome |
|
||||
| --- | --- |
|
||||
| `message.received` | Plain text marker, multi-turn tool execution and recovery succeeded. |
|
||||
| `message.edited`, `message.deleted`, `message.reaction` | Returned `EDIT_10`, `DELETE_11`, `REACTION_12`. |
|
||||
| `group.member_joined`, `group.member_left`, `group.member_banned` | Returned Unicode member data/`JOIN_42`, `LEFT_7`, `BANNED_20`. |
|
||||
| `friend.request_received`, `friend.added` | Returned `FRIEND_8`, `ADDED_19`. |
|
||||
| `bot.muted`, `bot.unmuted`, `bot.invited_to_group`, `bot.removed_from_group` | Returned `MUTED_13`, `UNMUTED_16`, `INVITED_17`, `REMOVED_18`. |
|
||||
| `feedback.received` | Returned `FEEDBACK_9` and numeric rating from JSON. |
|
||||
| `platform.specific`, `custom.e2e_probe` | Read nested JSON; returned `PLATFORM_14` and custom event type/`CUSTOM_15`. |
|
||||
|
||||
- Empty message, malformed JSON and non-object JSON did not start execution. Unicode, nested arrays/objects and an empty event object were exercised. Empty custom event name validation was not conclusively verified.
|
||||
- Real Docker `exec`, `write`, `read`, `edit`, `glob`, and `grep` all succeeded after fixes. Files were limited to `/workspace/e2e-debug-0905`. Final grep without `include` returned `probe.txt`, line 1, `E2E_EDIT_OK`, total 1.
|
||||
- Nonzero exec exit preserved exit code 7/stderr and displayed failure. `sleep 3` with `timeout_sec=1` returned `timed_out`, `ok=false`, about 1037 ms, and displayed failure with the actual timeout message.
|
||||
- Provider-returned thinking, intermediate text, tool arguments/results and final text appeared in order. A repeated prior thought appeared once after the cumulative-prefix fix. Models that do not return thinking are not expected to display it.
|
||||
- Two browser tabs ran independent debug sessions without mixed transcripts. While `exec sleep 30` showed running, switching Agents cleared the old transcript/running state. Returning and sending a new request produced `AFTER_CANCEL_OK`. This verifies UI cancellation/recovery; termination timing of the already launched container command was not independently measured.
|
||||
- Core restart and standalone Plugin Runtime reconnection recovered successfully. The development services remain running.
|
||||
- At the 1280 x 800 test viewport, the final transcript measured height 315, scroll height 5203, distance from bottom 0, and client/scroll widths both 333: automatic scrolling and horizontal containment passed. Screenshot inspected; viewport override reset afterward.
|
||||
|
||||
### Automated verification
|
||||
|
||||
- Core: 130 passed, 24 skipped across orchestrator, execution context, debug controller/service, native tools and skill tools. Skips are POSIX secure-host-filesystem cases unavailable on Windows; they still require Linux verification. Remote Box routing is covered on both capability branches.
|
||||
- LocalAgent full suite: 185 passed with `PYTHONUTF8=1`.
|
||||
- Frontend trace reducer: 5 passed; TypeScript `tsc --noEmit` passed.
|
||||
- Ruff checks on changed Python implementation/tests and Git whitespace checks passed. Existing Pydantic deprecation warnings remain.
|
||||
|
||||
The isolated unbound Agent `E2E Debug 0905` is retained for reproduction. No real platform messages were sent. This run does not claim coverage of every provider, model fallback policy, Linux host file operations, or live platform adapter ingress.
|
||||
|
||||
## Follow-up: mock platform actions
|
||||
|
||||
The original run did not verify platform action tools: the synthetic envelope had no supported platform APIs or reply target. This was a coverage gap. Debug now supplies mock adapter capabilities and a synthetic reply target while retaining tool selection, event compatibility and parameter validation. Event targets remain frozen by the Host. Only platform operations are simulated; native tools still execute normally.
|
||||
|
||||
Verified in the browser on the user's `localagent test` Agent (`0dc5d7d3-07b2-4c4c-bb2b-a4f8e40e3a76`) without changing its welcome system prompt: `group.member_joined` caused a real `event_reply` tool call with `text: Hello,Debug User`. Its mock result contained `api: send_message`, `target_type: group`, `target_id: debug-group`, `mock: true`, and `delivery: simulated`. The UI labels this as mock execution rather than text output or real delivery, and shows the tool count after completion.
|
||||
|
||||
Mock platform responses are deterministic fixtures, not evidence of real adapter support or delivery. Read operations return synthetic information; list operations return empty fixture lists. Service/platform-tool regressions: 44 passed, including mock reply, explicit-target send, identity/group lookup, request rejection, validation and assertions that the real bot manager is never accessed. TypeScript and Ruff checks passed.
|
||||
|
||||
The same browser run subsequently exposed repeated `event_reply` calls after successful mock results, and one invalid `event_get_actor` call with an unexpected `_call` parameter. The invalid call was rejected and shown as failure. The run was cancelled by switching Agents. Therefore this verifies the mock call/result path, but does not establish that this configured model completes the welcome workflow exactly once. At this checkpoint the cause had not been determined.
|
||||
|
||||
For the subsequent investigation, updated SDK-shaped Mock fixtures (including non-empty default lists), cancellation fix, complete event matrix, Linux verification and remaining provider/stream-pressure limits, see [the full follow-up report](./EVENT_DEBUG_FULL_QA_2026-09-05.md). Its results supersede the checkpoint counts and fixture description above.
|
||||
@@ -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 API;storage 作为授权能力保留。 | 本分支持续维护底座;Runtime / Platform 可复用。 | 外部 session id、working directory、checkpoint 等小 JSON 用 state;当前 run 大对象用 sandbox/workspace 文件。 | 不把跨轮次状态存在插件实例内;不绕过 run-scoped authorization。 |
|
||||
| EventGateway / EventRouter | 本分支只提供 event-first envelope 和 `run(event, binding)` 入口。 | EBA 分支(联调中)。 | EventGateway 规范化平台/WebUI/API/scheduler 事件;EventRouter 解析一个 binding;调用现有 orchestrator。 | 不为 EBA 新增另一套 runner 调用协议;不把非消息事件伪装成 user message。 |
|
||||
| Scheduler / Automation | 不实现。文档中只把 `scheduler` 作为 future event source。 | EBA / Agent Platform。 | 定时任务触发 `schedule.triggered` host event,复用 EventGateway -> EventRouter -> `run(event, binding)`。 | 不直接调用某个 runner 插件;不绕过 EventLog / authorization。 |
|
||||
| Integration provider | 不实现。IM platform adapter 仍是当前平台接入系统。 | EBA / Agent Platform。 | OAuth/webhook/outbound provider 应先转成 canonical host event 或 platform action,再交给 AgentRunner。 | 不把 Linear/Slack/GitHub 等 provider 私有 payload 扩散到 runner 协议顶层。 |
|
||||
| Platform action / delivery | `action.requested` 已预留但当前仅 telemetry,不执行。`DeliveryContext` 只作为上下文/策略投影。 | EBA / platform action executor。 | 后续 executor 校验 runner capability、binding policy、actor/bot/workspace 权限和审批后执行。 | 不让 runner 直接调用平台 adapter 私有 API;不把平台动作伪装成文本回复副作用。 |
|
||||
| Runtime registry / worker / task queue | 已落地 Host-owned `AgentRun` / `AgentRunEvent`、run control primitives、最小 runtime registry / heartbeat / claim lease;当前官方外部 harness 仍通过 ACP、远端 daemon、本机 subprocess 或外部 HTTP API runner 调用目标运行环境,不在本分支维护完整通用 worker 队列。 | Runtime Control Plane v2。 | 后续可在现有 Host 事实源上补 queued run producer、daemon wakeup、claim execution loop、progress/audit 和运维诊断。 | 不把 heartbeat/task/warm pool 放进 Protocol v1;不让管理插件拥有 runtime/task 事实源。 |
|
||||
| Warm pool / reconcile / diagnose | 不实现。 | Runtime Control Plane v2 / deployment layer。 | 作为 task/runtime 的运维能力,围绕 Host-owned runtime/task/audit 表实现。 | 不把 runtime 运维语义写进普通 runner 协议;不把 pod/task 细节泄漏给普通 runner。 |
|
||||
| Agent memory | 不实现通用长期记忆产品层;提供 history/state/storage 和 sandbox 文件基础能力。 | Agent Platform 或具体 runner/plugin。 | 平台 memory 可通过 Host storage/state 或独立产品表实现,runner 通过授权 API 拉取。 | 不在 Host core 内置通用 agentic memory 策略;不默认把 memory 全量 inline 到 context。 |
|
||||
| External harness native session | ACP / Claude Code / Codex 等 runner 支持 external session id state handoff 和 LangBot resource projection。 | 官方 runner 后续增强;Runtime Control Plane v2 可接管执行。 | 外部 harness 调用继续走 `runner.run(ctx)`;如后续引入长连接/daemon 模式,按 external session key 串行 turn,reader 独占 native stream。 | 不把具体 provider native wire 变成 LangBot 协议;全局锁边界见 PROTOCOL_V1 §13。 |
|
||||
| 能力 | 当前状态 | 后续工作与接入点 |
|
||||
| --- | --- | --- |
|
||||
| Agent / binding | Agent 表、API、配置 UI、Bot event_bindings 已存在;AgentBinding 是运行投影 | 新产品模型复用现有投影,不把 Pipeline 持久化成 Agent |
|
||||
| 事件路由 | observer 广播后按 pattern/filter/priority 选择一个 Pipeline、Agent 或 discard | 通用订阅、通知和其他事件源仍需单独设计 |
|
||||
| 平台动作 | event_* 冻结目标,platform_* 显式授权;通过 Host 工具调用 | 新动作先定义语义、schema 和授权,不开放任意原始 action |
|
||||
| 结构化交互 | interaction.requested 白名单、持久回调关联、TTL/作用域/幂等、原处理器恢复 | 补真实 provider/platform 验收;其它 action.requested 仍仅 telemetry |
|
||||
| Run / runtime | 持久 AgentRun/AgentRunEvent、取消/结果/终态、heartbeat/claim/reconcile 原语 | 业务队列、任务生产、唤醒、跨 Host 执行和运维产品面 |
|
||||
| Plugin worker | 独立安装进程、依赖环境、supervisor、退避及重启协调器 | 最终部署故障注入、出站网络策略及硬存储配额 |
|
||||
| External harness | 通过 Runner 消费协议、按 run 访问 Host 资源 | 通用 daemon supervisor、登录态诊断、分布式调度;不要与 Plugin worker 混淆 |
|
||||
| History / state / storage | Host 事实源、按需读取、state/checkpoint、sandbox 文件能力 | EventLog/Transcript 的定时 retention 接入和完整文件生命周期 |
|
||||
| Scheduler / Automation | 仅保留可扩展的事件入口 | 用户定时任务必须走事件、授权和运行记录链路,不直调插件绕过 Host |
|
||||
| Workflow / 多 Agent | 尚无完整产品实现 | 先定义串并联、失败恢复、投递与状态冲突语义 |
|
||||
| Solution | 尚无导出/导入实现 | 处理器、路由模板、依赖、变量与文档;不导出凭据或已安装 UUID |
|
||||
| 长期 memory 产品 | 提供 history/state/storage 基础 | 由 Runner 或后续产品定义召回策略,不把全量 memory 默认塞入 context |
|
||||
| Cloud | 作用域与运行时隔离底座已合入;OSS 为单 Workspace 多成员 | 生产激活独立通过网络、硬配额、事务代次切换和部署验收 |
|
||||
|
||||
## 3. 后续分支接入规则
|
||||
平台动作详见 [PLATFORM_ACTION_TOOLS.md](./PLATFORM_ACTION_TOOLS.md),控制面规划详见 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md),Cloud 门禁见 [剩余验证清单](../multi-tenant/cloud-v2-pending-verification.md)。
|
||||
|
||||
外部 EBA、Agent Platform 或 Runtime Control Plane 分支接入时,默认遵守以下规则:
|
||||
## 扩展规则
|
||||
|
||||
- 新入口只生产或解析 Host 内部模型:`AgentEventEnvelope`、持久 Agent 投影出的 `AgentBinding`、以及必要的 delivery/resource/state policy。
|
||||
- runner 调用仍走 `AgentRunOrchestrator.run(event, binding)`,除非 Runtime Control Plane 明确引入 runtime-managed 执行模式;即便如此,runner 可见合同仍应保持 Protocol v1。
|
||||
- Host-owned facts 继续写入 EventLog / Transcript / State,当前 run 文件继续走 sandbox/workspace;产品层可以新增更高阶视图,但不能替代这些事实源。
|
||||
- 新能力如果需要持久化,优先加 Host-owned 表或 service;不要把事实源藏在插件 storage 或 runner subprocess 内。
|
||||
- 新 result type 可以按 Protocol v1 的演进规则增加;不能用入口 adapter 私有字段绕过 schema。
|
||||
- 任何 fan-out、observer agent、parallel arbitration、platform action execution 都必须单独定义 delivery、state conflict、approval 和 audit 语义。
|
||||
|
||||
## 4. 与 Agent Platform 产品层的关系
|
||||
|
||||
这里的 Agent Platform 指面向 agent 产品层的实体拆分:`Agent` 描述可配置 agent,`Session` / `SessionMessage` 描述会话事实,`Automation` 描述自动触发,`IntegrationBinding` 描述外部集成连接,`Memory` 描述长期记忆,`WarmTask` 描述预热/后台任务。这些拆分对 LangBot 后续产品层有参考价值,但不能直接搬进本分支。
|
||||
|
||||
LangBot 当前分支的对应目标是更底层的:把 IM/WebUI/API 等入口统一投影到 Host event,把 Agent / binding 配置统一投影到 runner binding,把 runner 能力统一收束到 Protocol v1。完整 Agent Platform 可以在这个底座之上构建,而不应反过来污染本分支的 runner 外化边界。
|
||||
- 新入口构造 Host event 和有效 binding,继续调用统一 orchestrator;Pipeline AI Stage 使用 QueryEntryAdapter。
|
||||
- Host 保持 run/result、授权、事件、状态和历史的事实源;插件负责自己的执行策略及 provider 私有 continuation。
|
||||
- 新增业务表、调度或 UI 不要求修改 runner 可见协议;需要协议扩展时先更新 canonical spec,再同步 SDK、Runtime、模板与测试。
|
||||
- fan-out、并行仲裁和自动重试必须明确副作用、幂等、状态冲突和审计语义,不能通过多个隐式回复者实现。
|
||||
- 外部 harness 自带 shell、文件系统和网络权限由部署环境负责;manifest permissions 约束的是 LangBot 持有的资源。
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
LangBot 要转为 agent host,而不是内置 runner 容器:
|
||||
|
||||
- 接收 IM、WebUI、API 和外部 EBA 分支 EventRouter 产生的事件。
|
||||
- 接收 IM、WebUI、API 和当前 RuntimeBot 事件路由产生的事件。
|
||||
- 接收 EBA 选中的 Agent 处理器,并根据事件、bot、workspace、scope 解析 AgentRunner binding。
|
||||
- 发现、校验和调用插件提供的 AgentRunner。
|
||||
- 为每次 run 提供受限资源、状态、存储、上下文引用和生命周期控制。
|
||||
@@ -23,18 +23,18 @@ LangBot 要转为 agent host,而不是内置 runner 容器:
|
||||
- 不要求官方 local-agent 的旧行为反向塑造 host 协议。
|
||||
- 不在 host 中实现通用 agentic prompt assembler。
|
||||
- 不强制 runner 使用 LangBot state / storage;只提供可选、受控的寄宿能力。
|
||||
- 不实现 EventGateway / EventRouter:它们由外部 EBA 分支提供并联调。本分支只定义 host-side envelope/binding models 和 `run(event, binding)` 入口。
|
||||
- 不在 runner 底座重复实现平台路由;当前分支的 RuntimeBot 已承担 observer 广播和 Bot event_bindings 匹配,runner 层消费其 envelope/binding 投影。
|
||||
|
||||
## 3. 分层架构
|
||||
|
||||
```text
|
||||
IM / WebUI / API / EventRouter (external EBA branch)
|
||||
IM platform event
|
||||
|
|
||||
v
|
||||
Event Gateway (external EBA branch)
|
||||
RuntimeBot event normalization / observer broadcast
|
||||
|
|
||||
v
|
||||
EventRouter -> one Processor target
|
||||
RuntimeBot event_bindings -> one Processor target
|
||||
|-- target_type=pipeline -> Pipeline Stage chain
|
||||
|
|
||||
`-- target_type=agent -> AgentBindingResolver
|
||||
@@ -57,15 +57,15 @@ EventRouter -> one Processor target
|
||||
Delivery / Renderer / Platform API
|
||||
```
|
||||
|
||||
Pipeline 与 Agent 是 EventRouter 的平级处理器目标。本文只定义 AgentRunner Host 边界:Agent 目标直接解析 `AgentBinding`;Pipeline 目标执行自己的完整 Stage 链,仅在 AI Stage 调用 runner 时通过 Query entry adapter 构造一次性 `AgentConfig` / `AgentBinding`。该 runner 调用投影不改变 Pipeline 的一等处理器地位,也不会把 Pipeline 持久化为 Agent。AgentRunner 的单绑定调度、Agent 复用、插件实例无状态和 fan-out 边界以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13 为准。EventGateway / EventRouter 由外部 EBA 分支实现并联调。
|
||||
Pipeline 与 Agent 是事件路由的平级目标。Agent 直接解析 `AgentBinding`;Pipeline 执行完整 Stage 链,仅在 AI Stage 调用 runner 时通过 QueryEntryAdapter 构造一次性运行投影,不会持久化成 Agent。WebUI/API 调试按服务入口构造调试事件或 Query,再使用同一编排器;不要求绕行真实 Bot 回调。调度基数、Agent 复用和 fan-out 边界以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13 为准。
|
||||
|
||||
## 4. LangBot 侧能力
|
||||
|
||||
### 4.1 Event Gateway / EventRouter(External EBA Branch Integration Point)
|
||||
### 4.1 事件入口与路由(已集成)
|
||||
|
||||
> EventGateway / EventRouter 由外部 EBA 分支实现并联调,不在本分支范围。本分支只保留 event-first 入口和 envelope/binding models。
|
||||
> 2026-09-05:平台转换、Bot event_bindings、独立 Agent 和配置 UI 已集成。源码入口为 `pkg/platform/botmgr.py`、`pkg/api/http/service/agent.py` 和 `pkg/agent/runner/`。通用订阅与定时自动化仍是后续能力。
|
||||
|
||||
Event Gateway 将把入口统一成 host event(IM 平台消息、WebUI debug chat、API 触发、后续非消息事件),输出稳定的 `AgentEventEnvelope`(Host 内部模型):
|
||||
各入口将消息或非消息输入投影为 `AgentEventEnvelope`。以下为概念字段摘要,精确字段及默认值以 `pkg/agent/runner/host_models.py` 为准:
|
||||
|
||||
```python
|
||||
class AgentEventEnvelope(BaseModel):
|
||||
@@ -221,7 +221,7 @@ SDK 侧本地校验只用于开发体验,host 侧 run authorization snapshot
|
||||
|
||||
资源裁剪应通用,不写死 local-agent。selector 与资源的映射示例:`model-fallback-selector` → primary/fallback LLM、`llm-model-selector` → LLM、`rerank-model-selector` → rerank 模型、`knowledge-base-multi-selector` → 知识库;新增 selector 时在 resource builder 中统一扩展。
|
||||
|
||||
构造 `ctx.resources.tools` 时,Host 一次塞齐每个工具的完整 schema(`ToolResource.parameters`),runner 不需再逐个 `get_tool_detail` 拉取,减少 N 次往返。
|
||||
构造 `ctx.resources.tools` 时,Host 尽可能一次提供完整 schema(`ToolResource.parameters`),减少逐个 detail 查询。Runner 仍需兼容 `parameters=None` 并按需调用 `get_tool_detail`。平台动作以 `tool_type=platform` 使用同一资源面,授权与冻结目标见 [PLATFORM_ACTION_TOOLS.md](./PLATFORM_ACTION_TOOLS.md)。
|
||||
|
||||
执行/文件/skill/MCP 等能力的接入方向:先由 Host / sandbox 封装成普通 scoped tool,再通过 `ctx.resources.tools` 和 SDK runtime 转发进入 runner;runner 不应识别或硬编码执行环境 provider。外部 harness 的 native tools 不能直接访问 LangBot 资源。skill 的整个生命周期都走统一 tool:发现走 `list_skills` / `langbot_list_assets`,激活/注册走 `activate` / `register_skill`,包内操作走 native exec/read/write——runner 不需要独立的 skill 渲染或门控。
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ class AgentRunnerPermissions(BaseModel):
|
||||
通用交互投递是当前唯一允许执行的 `action.requested` 白名单动作。Runner 必须同时声明
|
||||
`capabilities.interactions=true` 和 `permissions.interactions=["request"]`,Host 还必须将其与
|
||||
当前 binding delivery policy、run authorization snapshot 和 adapter delivery capability 求交。
|
||||
其它平台动作仍不属于当前 permissions,Host 收到后只记录 telemetry,不得执行。
|
||||
其它 `action.requested` 动作仍只记录 telemetry,不得作为任意平台动作执行器。平台语义动作已经通过 `ctx.resources.tools` 中 `tool_type="platform"` 的工具提供:Runner 需具备 `tool_calling` capability 和 `permissions.tools` 的 `call` 操作,Host 再与 Agent 工具策略、适配器能力和当前事件目标求交。`event_*` 的目标由 Host 冻结,`platform_*` 需显式选择;调用仍走统一 `call_tool`。这不新增 manifest permission 字段,规则见 [PLATFORM_ACTION_TOOLS.md](./PLATFORM_ACTION_TOOLS.md)。
|
||||
|
||||
Runner 实际可用 LangBot 资源来自 Host 在 run 前冻结的授权快照:
|
||||
|
||||
|
||||
@@ -28,17 +28,17 @@
|
||||
- Sandbox/workspace read/write/exec 文件能力,用于当前 run 的上传文件、工具大结果和临时产物
|
||||
- SDK runtime forwarding pull APIs + `caller_plugin_identity` 验证路径
|
||||
|
||||
## 本分支不实现
|
||||
## 当前已集成与后续扩展
|
||||
|
||||
以下能力由其他分支负责,本分支只保留 integration point。EBA 完整事件网关与事件路由当前由外部 EBA 分支联调:
|
||||
截至 2026-09-05,`dev/4.11.x` 已合并 EBA 与 AgentRunner 插件化。下面按当前代码划分实现边界:
|
||||
|
||||
- **EventGateway / EventRouter**:完整事件网关实现、事件路由、事件持久化管理
|
||||
- **Event subscription / Event notification**:事件订阅、推送通知
|
||||
- **BindingResolver persistence UI**:绑定配置的持久化 UI 和 event router 集成(如由其他模块负责)
|
||||
- **Scheduler / Background event source**:定时任务、后台事件源
|
||||
- **已实现的平台事件路由**:`RuntimeBot` 负责事件转换后的 observer 广播、`event_bindings` 匹配和 Pipeline / Agent / discard 单目标分派;EventRouter 是逻辑职责,不是另一个独立服务。
|
||||
- **已实现的持久化与 UI**:独立 Agent、Bot 事件绑定、处理器工作台、Runner 市场安装、事件范围与工具权限、路由诊断和调试入口。
|
||||
- **后续的通用事件订阅与通知**:不把当前适配器回调和 Bot 路由理解成通用订阅产品。
|
||||
- **后续的 Scheduler / Background event source**:用户可配置的定时自动化和后台任务入口。
|
||||
- **完整 Agent Platform / daemon control plane**:Host-owned `AgentRun` / `AgentRunEvent`、run control primitives、最小 runtime heartbeat/claim lease 已作为 v2 foundation 落地;业务队列、Platform UI、daemon supervisor、runtime wakeup channel 和分布式 runtime 管控仍不属于 Protocol v1 主线。
|
||||
|
||||
EventGateway / EventRouter 在本文档中描述为 **external EBA branch integration point**,由外部 EBA 分支提供并联调。本分支只定义 host-side envelope/binding models 和 `run(event, binding)` orchestrator 入口。
|
||||
平台事件、Agent 配置及 Pipeline AI Stage 已共同使用 host-side envelope/binding models 和 `run(event, binding)`。后续入口应复用这条链路。当前实现与发布验收分别见 [STATUS.md](./STATUS.md),平台动作授权见 [PLATFORM_ACTION_TOOLS.md](./PLATFORM_ACTION_TOOLS.md)。
|
||||
|
||||
本分支与外部 EBA / Agent Platform / Runtime Control Plane 的扩展边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。
|
||||
|
||||
@@ -67,7 +67,7 @@ EBA 先根据 `target_type` 选择 Pipeline 或 Agent。Pipeline 目标执行完
|
||||
| AgentBinding / binding | Host 在一次事件运行前解析出的有效绑定,决定调用哪个 runner 以及带什么策略。 |
|
||||
| envelope | Host 内部事件封装,即 `AgentEventEnvelope`;runner 看到的是由它投影出的 `ctx.event`。 |
|
||||
| descriptor / manifest | runner discovery 的能力和配置描述;manifest 来自插件,descriptor 是 Host 校验后的注册表视图。 |
|
||||
| EBA | Event Based Agent,把消息、撤回、入群、定时任务等都统一成 host event 的接入方向;完整网关和路由在外部 EBA 分支联调。 |
|
||||
| EBA | Event Based Agent;平台事件路由已集成,定时任务等通用事件源仍是后续扩展。 |
|
||||
| harness runner | ACP、Claude Code、Codex 等已有自身 session / tool loop / MCP / 压缩机制的外部 runtime adapter。 |
|
||||
| projection | Host 把内部事实源、授权资源或配置裁剪成 runner / harness 可消费视图的过程。 |
|
||||
| Runtime Control Plane | v2 Host 能力层,当前已落地 Host-owned run/result ledger、run control primitives、最小 runtime heartbeat/claim lease;完整 daemon worker 管控、task wakeup 和 Agent Platform 产品形态不是 Protocol v1 主线。 |
|
||||
@@ -80,12 +80,13 @@ EBA 先根据 `target_type` 选择 Pipeline 或 Agent。Pipeline 目标执行完
|
||||
| [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) | LangBot 宿主能力与分层架构、Host 内部模型(`AgentEventEnvelope` / `AgentBinding` / Descriptor / 各 Store)、runner 发现、绑定、资源授权、状态、存储、生命周期和调用链。 |
|
||||
| [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) | Agent-owned context 方向:事件到来时 LangBot 传什么,agent 如何按需拉取更多历史 / state、如何访问 sandbox/workspace 文件,以及如何支持 KV cache 友好的上下文管理。 |
|
||||
| [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md) | AgentRunner 外化与外部 EBA / Agent Platform / Runtime Control Plane 的扩展边界矩阵,说明哪些是本分支底座、哪些由外部分支接入。 |
|
||||
| [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md) | EBA 接入边界:事件模型、事件来源、触发绑定、非消息事件如何复用 AgentRunner 调度;完整 EventGateway / EventRouter 由外部 EBA 分支联调。 |
|
||||
| [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md) | 已集成的事件路由、处理器分派、平台工具和结构化交互边界。 |
|
||||
| [eba-productization-release.md](./eba-productization-release.md) | EBA 适配器与 AgentRunner 插件化合并后的产品化 / 发布计划,说明非技术用户快速上手差距、Bot 与处理器边界、未来 Solution 分发标的,以及多 namespace SaaS 支持要求。 |
|
||||
| [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md) | Agent Platform v2 / runtime 管控面决策:`AgentRun` / `AgentRunEvent` / run control 已作为 Host 事实源落地,最小 runtime heartbeat/claim lease 已落地;完整 runtime registry / daemon 管控仍是后续可选阶段。 |
|
||||
| [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md) | 官方 runner 插件迁移,包括 local-agent 和外部 runner。它是下游落地计划,不是 LangBot 基础能力设计的前置约束。 |
|
||||
| [RUN_STEERING_AND_CHECKPOINT.md](./RUN_STEERING_AND_CHECKPOINT.md) | 运行中消息注入(steering / follow-up)与压缩摘要持久化(compaction checkpoint)的设计与落地状态记录;schema 仍以 PROTOCOL_V1 为准。 |
|
||||
| [STATUS.md](./STATUS.md) | 当前实现状态、spec 与实现已知差距、runner 验收状态和历史高价值记录。 |
|
||||
| [PLATFORM_ACTION_TOOLS.md](./PLATFORM_ACTION_TOOLS.md) | 当前事件工具、平台动作和普通工具的配置、投射及执行授权。 |
|
||||
| [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) | Agent Runner QA 指南:保留最高价值测试路径,指导 agent 开展下一轮 WebUI / runner smoke 验证。 |
|
||||
| [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) | 安全发布级 hardening 的后续发布门槛:路径隔离、权限边界、secret、资源配额、MCP / skill 投影和审计。 |
|
||||
|
||||
@@ -116,13 +117,13 @@ Host 不定义通用历史窗口字段或策略;runner 通过 Host pull API
|
||||
|
||||
详见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。
|
||||
|
||||
### 3. Event Based Agent(External Branch)
|
||||
### 3. Event Based Agent(已集成)
|
||||
|
||||
消息只是事件的一种。外部 EBA 分支中的 `message.received`、`message.recalled`、`group.member_joined`、`friend.request_received` 等事件都应能通过统一事件 envelope 触发 AgentRunner。
|
||||
消息只是事件的一种。当前平台适配器中的 `message.received`、`message.deleted`、`group.member_joined`、`friend.request_received` 等事件通过统一事件 envelope 进入处理器路由,具体支持范围以适配器能力声明为准。
|
||||
|
||||
EBA dispatch 的基数和 fan-out 边界仍以 PROTOCOL_V1 §13 为准;本文档只列出本分支提供给外部 EBA 分支复用的入口点。
|
||||
EBA dispatch 的基数和 fan-out 边界仍以 PROTOCOL_V1 §13 为准;新增事件源复用以下入口点。
|
||||
|
||||
**本分支不实现 EBA 完整能力,只提供:**
|
||||
**当前共同使用的执行底座:**
|
||||
- event-first envelope (`AgentEventEnvelope`)
|
||||
- AgentBinding model
|
||||
- `run(event, binding)` 入口
|
||||
|
||||
@@ -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. 基础概念
|
||||
|
||||
|
||||
@@ -2,7 +2,30 @@
|
||||
|
||||
本文档是 `docs/agent-runner-pluginization/` 的状态事实源。协议 schema 仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准;测试步骤以 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) 为准;安全发布门槛以 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) 为准。
|
||||
|
||||
状态快照日期:2026-07-15。
|
||||
状态快照日期:2026-09-05。代码基线为 LangBot `dev/4.11.x` / `a4d36aa2d` 和 SDK `dev/4.11.x` / `f1da058`;本文是本地检视快照,不代表远端最新状态或正式发布批准。
|
||||
|
||||
## 当前版本与验收边界
|
||||
|
||||
- Core 声明版本 `4.11.0`,`pyproject.toml` / `uv.lock` 仍依赖 `langbot-plugin==0.5.3`;SDK 源码声明版本 `0.5.5`。本机 Core 实际导入旁边 SDK 的可编辑源码,不能用这个组合的测试通过证明 registry 安装可复现。
|
||||
- 检视时 SDK 有四个未提交文件:`api/agent_tools/asset_gateway.py`、`api/agent_tools/external_tools.py`、`api/entities/builtin/agent_runner/resources.py`(均位于 `src/langbot_plugin/`),以及 `tests/api/test_agent_tools_mcp_bridge.py`。这些改动增加 `platform_tools` 分类发现、可选 schema 和 gateway 引导。下面的 SDK 测试包含这些工作区改动;正式配套版本尚需冻结。
|
||||
- 本轮仅重新执行下列定向测试与 TypeScript 检查,没有重跑全量 backend、真实平台、provider、浏览器 E2E 或 Cloud 部署门禁。后文旧日期的成功记录仍是历史证据。
|
||||
|
||||
| 2026-09-05 验证 | 结果 |
|
||||
| --- | --- |
|
||||
| Core:`tests/unit_tests/agent`、`tests/unit_tests/api/service/test_agent_service.py`、`tests/unit_tests/platform/test_routing_rules.py`、`tests/unit_tests/api/service/test_maintenance_service.py` | 561 passed,74 warnings |
|
||||
| SDK:`tests/api/entities/builtin/agent_runner`、`tests/api/proxies`、`tests/api/test_agent_tools_mcp_bridge.py`、`tests/runtime/plugin/test_mgr_agent_runner.py`、`tests/runtime/plugin/test_dependency_environment.py`、`tests/runtime/plugin/test_restart_coordinator.py` | 362 passed,10 warnings |
|
||||
| Web:`pnpm exec tsc --noEmit` | pass |
|
||||
| Web:`pnpm test:unit` | 62 passed,2 failed |
|
||||
|
||||
前端失败为源码形状断言:`oss-cloud-ui-privacy.test.mjs` 仍要求 fieldset 的精确旧 class;`processor-detail-workbench.test.mjs` 仍要求 Agent 的旧四分区。当前表单已增加布局 class,并使用运行器、运行器配置、事件与工具分区。发布前需按当前设计更新断言或修正实现,不能记为全绿。
|
||||
|
||||
## 近期已集成内容
|
||||
|
||||
- **EBA 与独立 Agent**:Bot 事件绑定、observer 广播、Pipeline / Agent / discard 单目标分派、Agent CRUD、处理器工作台与路由诊断已在同一分支。逻辑路由器实现在 `pkg/platform/botmgr.py::RuntimeBot`,不是外部独立 EventRouter 服务。
|
||||
- **事件感知工具权限**:Core 9 月 4 日提交已实现 `event_*` 自动事件工具、`allowed_platform_tools` 和 `allowed_tools`。Host 冻结事件目标,结合 Runner 权限、适配器能力和运行快照授权,执行时再次检查。详见 [PLATFORM_ACTION_TOOLS.md](./PLATFORM_ACTION_TOOLS.md)。SDK 分类发现的提交状态见上文。
|
||||
- **产品流程**:Runner 市场内联安装、安装恢复和健康状态、事件范围选择、统一详情工作台、Agent 调试及 Bot 平台事件调试已实装。Agent 自身已无 enabled 开关;Bot 路由仍有 enabled。具体页面形态见 [处理器页面](../event-based-agents/08-agent-page-and-event-orchestration.md)。
|
||||
- **Runtime 与存储**:SDK 已加入 artifact 对应的独立依赖环境、Windows worker 连接路径、安装期间响应性测试;Core/Plugin Runtime/Box 分别报告拥有的存储目录。存储分析是观测,不提供硬配额。
|
||||
- **工作空间**:OSS 单工作空间多人协作和资源作用域已合入。Cloud 隔离基础与生产激活分开验收,见 [Cloud 剩余事项](../multi-tenant/cloud-v2-pending-verification.md)。
|
||||
|
||||
## 实现状态
|
||||
|
||||
@@ -25,16 +48,16 @@
|
||||
|
||||
## Spec 与实现已知差距
|
||||
|
||||
- `action.requested` 是严格白名单协议面:当前只执行 `interaction.requested`;其它 action 仍只记录 telemetry,不提供通用 platform action executor。
|
||||
- `action.requested` 是严格白名单协议面:当前只执行 `interaction.requested`;其它 action 仍只记录 telemetry。平台语义动作通过已授权的 `event_*` / `platform_*` 工具执行,不通过任意 result action 或原始 `call_platform_api`。
|
||||
- 结构化交互 SDK typed contract 与 DifyAgent continuation 已实现;SDK 正式发布、真实 Dify 凭据 E2E,以及需要长驻双向进程的 Claude Code 权限确认仍是后续验收项。Host 不持有 provider 私有 token。
|
||||
- State 与 storage 的长期类型边界仍可继续收窄;当前合同只要求 JSON-safe state 与受控 storage API。
|
||||
- `ToolResource.parameters` 已作为 best-effort full schema 由 Host 在构造 `ctx.resources` 时一次塞齐;无 schema 时 runner 仍需兼容 `parameters=None` 或按需调用 detail API。
|
||||
- EventLog / Transcript 已提供显式 cleanup primitive;长期 retention 默认值、TTL 调度接入和 sandbox/workspace 文件清理仍是运维收尾项,应在 Runtime Control Plane 产品化前补齐。
|
||||
- External harness 的 native shell / filesystem / CLI / MCP 权限不受 manifest permissions 约束;manifest permissions 只约束 LangBot 持有的资源访问。
|
||||
- LangBot 当前不承诺 managed sandbox;external harness 的 OS/process/network quota、workspace GC、provider-native tool 权限由用户或部署环境承担。
|
||||
- Runtime Control Plane v2 当前只落地 Host 事实源和控制原语;还没有内置 Agent Platform UI、业务队列、daemon 进程托管、runtime wakeup channel、跨 Host 分布式锁或 provider 登录态诊断。
|
||||
- Runtime Control Plane v2 已有 Host 事实源和控制原语,独立 Agent 与处理器 UI 也已存在;仍缺业务任务队列、外部 harness daemon 托管、wakeup channel、跨 Host 分布式锁及 provider 登录态诊断。SDK installation worker 的 supervisor/重启协调已实现,不能与外部 harness 管控混淆。
|
||||
|
||||
## Runner 验收状态
|
||||
## Runner 历史验收记录(未在本轮重跑)
|
||||
|
||||
| Runner | 状态 | 最近证据 |
|
||||
| --- | --- | --- |
|
||||
@@ -44,7 +67,7 @@
|
||||
| Dify | Human-input unit-pass; credential E2E pending | `langbot-agent-runner/dify-agent` 已实现 `workflow_paused`、原子字段/确认交互、plugin-storage continuation、Dify submit/events 恢复与再次暂停;真实 Dify 凭据 E2E 待执行。 |
|
||||
| n8n / Coze / DashScope / Langflow / Tbox / DeerFlow / WeKnora | Unit-pass; credential smoke optional | 2026-06-13 plugin layout / parser tests 通过;真实服务凭据 smoke 非每轮必跑。 |
|
||||
|
||||
## Host / SDK 验收状态
|
||||
## Host / SDK 历史验收记录
|
||||
|
||||
| 范围 | 状态 | 最近证据 |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,342 +1,66 @@
|
||||
# EBA 产品化与发布计划
|
||||
|
||||
> 状态:规划草案,2026-07-01
|
||||
>
|
||||
> 范围:将已经合并的 AgentRunner 插件化和 Event Based Agent 适配器工作产品化,使非技术用户也能快速上手。本文聚焦产品缺口、发布门禁和 SaaS 多命名空间租户能力。本文不引入新的协议 schema;协议事实仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准,Host 模型事实仍以 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) 为准。
|
||||
更新:2026-09-05,适用于合并后的 `dev/4.11.x`。本文维护产品边界和剩余交付顺序;具体实现、提交与验证结果统一记录在 [STATUS.md](./STATUS.md)。原 2026-07-01 草案中的功能缺口和 Phase 编号不再作为当前排期。
|
||||
|
||||
## 1. 产品方向
|
||||
## 产品模型
|
||||
|
||||
当前技术方向是正确的:LangBot 应该把平台输入视为事件,为每个事件解析出一个有效路由,并通过 AgentRunner Host 边界调用一个处理资产。但这还不是一个非技术用户无需理解内部架构就能采用的产品。
|
||||
- **Bot**:平台连接、凭据和事件路由入口。用户在机器人上决定“发生什么时,使用哪个处理器”。
|
||||
- **Processor**:可复用处理逻辑的上位概念,当前类型为 Agent 与 Pipeline。
|
||||
- **Pipeline**:保留完整 Stage 链的消息处理器,提供预处理、AI、后处理、扩展和输出控制。
|
||||
- **Agent**:独立配置对象,选择 AgentRunner 插件并配置事件范围、运行器与工具权限,可被多个 Bot 引用。
|
||||
- **Workflow**:后续编排方向,当前尚无完整执行产品。
|
||||
- **Solution**:后续分发单元,包含处理器、路由模板、依赖、变量和文档。
|
||||
|
||||
产品层模型应当是:
|
||||
处理器入口聚合 Agent 与 Pipeline,不转换实体,不复制旧 Pipeline runner 配置生成 Agent。EBA 是内部术语,主要产品流程使用机器人、事件、处理器、流水线和工具等名称。
|
||||
|
||||
- **机器人(Bot)**:平台连接与事件路由入口。机器人拥有适配器凭据、平台权限、入站事件可见性,以及这些事件的路由表。
|
||||
- **处理器(Processor)**:可复用的事件处理资产。当前处理器类型包括 **Agent** 和 **Pipeline**。未来可以增加 **Workflow**。
|
||||
- **Pipeline**:一等的无代码消息处理器,通过完整 Stage 链提供预处理、AI、后处理、扩展和输出控制。Pipeline 只处理消息,应当只能绑定到 `message.*` 事件。
|
||||
- **Agent**:由 runner 驱动的事件优先处理器。Agent 可以根据自身声明的事件支持范围处理消息事件和非消息事件。
|
||||
- **Solution(方案包)**:未来的分发/导出单元,包含处理器、路由模板、依赖清单、变量和文档。Solution 不应包含具体机器人凭据、租户密钥或已安装资产的 UUID 绑定。
|
||||
## 已完成的产品化基础
|
||||
|
||||
`EBA` 是内部工程术语。它可以出现在内部设计文档中,但不应出现在主要产品流程里。面向用户的语言应优先使用“频道”“事件路由”“处理器”“消息流水线”“自动化”“路由模板”等表达。
|
||||
|
||||
## 2. 当前基础
|
||||
|
||||
已合并分支已经具备内部冒烟测试所需的技术基础:
|
||||
|
||||
- EBA 适配器可以把平台活动规范化为稳定的 Host 事件名。
|
||||
- 机器人可以持久化 `event_bindings`,并将事件路由到 Agent、Pipeline 或丢弃目标。
|
||||
- 旧消息输入可以投射到标准的 `message.received` 事件路径。
|
||||
- Pipeline 仍可作为只处理消息的无代码处理器使用。
|
||||
- AgentRunner 插件化提供了 Host 与 Runner 的契约、事件优先上下文、结果流和运行时集成边界。
|
||||
- 官方 local runner 和 external runner 插件可以验证 runner 行为已经不再硬编码在 LangBot Core 中。
|
||||
- WebUI 已具备 Agent/处理器管理页面,以及机器人侧事件路由页面。
|
||||
- 机器人事件路由已具备 dry-run 诊断、运行时状态展示,以及安全的合成测试事件派发;测试事件会走已保存的 runtime 路由,但抑制真实平台出站动作。
|
||||
- MCP 工具面已暴露机器人事件路由状态查询和合成测试事件派发,便于 QA agent 或外部调试工具复用。
|
||||
|
||||
这是一个技术收敛里程碑,还不是产品就绪版本。
|
||||
|
||||
## 3. 距离非技术产品的缺口
|
||||
|
||||
### 3.1 概念负担
|
||||
|
||||
当前用户仍需要理解过多内部概念:EBA、适配器事件名、runner 标识、插件运行时健康状态、事件模式、优先级和绑定目标。面向非技术用户的产品应当通过意图和结果来引导:
|
||||
|
||||
- “收到一条消息时,用这个处理器回复。”
|
||||
- “有新群成员加入时,发送欢迎语。”
|
||||
- “收到好友请求时,让 Agent 判断是否接受。”
|
||||
|
||||
`group.member_joined` 这类原始事件模式应继续保留在高级模式中,但默认 UI 应按友好名称和平台能力对事件进行分组。
|
||||
|
||||
### 3.2 上手路径
|
||||
|
||||
首次使用路径应当从用例开始,而不是从架构开始:
|
||||
|
||||
1. 选择一个频道。
|
||||
2. 连接账号或 webhook。
|
||||
3. 选择该频道支持的事件预设。
|
||||
4. 选择或创建一个处理器。
|
||||
5. 发送测试事件。
|
||||
6. 如果失败,阅读简单的运行轨迹。
|
||||
|
||||
当前产品仍假设用户能诊断后端、插件运行时、Box 运行时、适配器和 runner 插件是否都已连接。对于开发者这是可接受的,但对非技术用户不可接受。
|
||||
|
||||
### 3.3 适配器就绪度
|
||||
|
||||
每个适配器都需要产品能力清单,而不仅是工程实现:
|
||||
|
||||
- 支持的事件列表及友好标签;
|
||||
- 支持的出站动作;
|
||||
- 所需凭据和配置步骤;
|
||||
- 本地部署、自托管、SaaS 可用性;
|
||||
- 测试信号可用性;
|
||||
- 废弃/遗留状态;
|
||||
- 已知限制。
|
||||
|
||||
废弃适配器应在产品中明确标记为“已废弃”或“遗留”。新的事件型适配器应按频道名称和能力描述,而不是使用 EBA 缩写。
|
||||
|
||||
### 3.4 处理器体验
|
||||
|
||||
处理器页面应管理可复用的 Agent 与 Pipeline,而不是让用户一次性理解所有事件路由决策。
|
||||
|
||||
- 创建 Agent 时应提供有倾向性的 runner 模板。
|
||||
- 创建 Pipeline 时应继续保持无代码消息流水线路径。
|
||||
- 未来 Workflow 的执行语义稳定后,可以作为另一种处理器类型引入。
|
||||
- 支持的事件范围应作为能力信息和高级约束展示,而不是作为创建流程的主要概念。
|
||||
- 依赖健康状态应可见:runner 插件是否安装、运行时是否连接、所需模型是否配置、所需资源是否可访问。
|
||||
|
||||
### 3.5 机器人事件路由体验
|
||||
|
||||
机器人页面应成为平台特定事件路由的主要配置位置,因为平台事件在已连接频道的上下文中最容易被用户理解。
|
||||
|
||||
最低产品要求:
|
||||
|
||||
- 基于适配器能力生成友好的事件选择器;
|
||||
- 目标选择器按事件兼容性过滤;
|
||||
- 对非消息事件隐藏 Pipeline;
|
||||
- 对重叠路由给出冲突警告;
|
||||
- 优先级先用视觉方式解释,而不是首先展示原始数字;
|
||||
- 提供路由测试按钮,可以注入或重放样例事件;
|
||||
- 每条路由展示状态,包括最近一次匹配的 run 和最近失败原因;
|
||||
- 提供安全的兜底路由,包括显式丢弃。
|
||||
|
||||
### 3.6 可观测性
|
||||
|
||||
非技术用户需要的是简短运行轨迹,而不是原始日志:
|
||||
|
||||
```text
|
||||
收到事件 -> 命中路由 -> 启动处理器 -> 动作已投递
|
||||
```
|
||||
|
||||
当失败发生时,UI 应指出失败层级:
|
||||
|
||||
- 频道未连接;
|
||||
- 适配器不支持该事件;
|
||||
- 没有路由命中;
|
||||
- 处理器已禁用;
|
||||
- runner 插件不可用;
|
||||
- 模型/资源缺失;
|
||||
- 投递权限被拒绝。
|
||||
|
||||
### 3.7 文档和模板
|
||||
|
||||
发布需要面向产品场景的文档和模板:
|
||||
|
||||
- 客服机器人;
|
||||
- 群欢迎和群管理;
|
||||
- 好友请求审核;
|
||||
- Dify 支持的外部 Agent;
|
||||
- 使用 LangBot 模型和知识库的本地 Agent;
|
||||
- 用于多阶段消息处理的 Pipeline。
|
||||
|
||||
文档应先描述产品模型,只在高级架构章节中暴露内部术语。
|
||||
|
||||
## 4. 推荐 UX 边界
|
||||
|
||||
之前“把所有事件编排都放进 Agent”的方向应当收窄。更好的边界是:
|
||||
|
||||
- **机器人页面负责事件路由**,因为事件面是平台特定的,用户也自然会在机器人上配置频道行为。
|
||||
- **处理器页面负责处理器资产**,因为 Agent 与 Pipeline 都应能跨机器人复用,并且未来可以被打包进 Solution。
|
||||
- **Pipeline 保持为一种处理器类型**,而不是隐藏在 Agent 术语背后的历史对象。
|
||||
|
||||
这样可以降低心智负担:
|
||||
|
||||
- 用户在机器人页面问:“当这个机器人遇到某件事时,应该做什么?”
|
||||
- 用户在处理器页面问:“我想复用什么处理逻辑?”
|
||||
|
||||
这也支持同一个机器人上的不同事件使用不同处理器类型:一个事件可以使用 Pipeline,另一个事件可以使用 Agent,未来另一个事件可以使用 Workflow。
|
||||
|
||||
## 5. 未来导出、分发和导入单元
|
||||
|
||||
导出/导入不在当前实现范围内,但产品边界不应阻塞它。
|
||||
|
||||
正确的未来分发单元是 **Solution**,不是机器人,也不是单独的 Agent。
|
||||
|
||||
Solution 应包含:
|
||||
|
||||
- 处理器:Agent、Pipeline、未来 Workflow 定义;
|
||||
- 路由模板:事件模式、友好名称、目标逻辑引用、默认优先级和可选条件;
|
||||
- 依赖清单:所需 runner 插件、适配器能力要求、模型、工具和资源;
|
||||
- 变量:用户提供的值,例如 API key、频道选择、模型选择和 prompt 参数;
|
||||
- 文档:配置意图和预期行为。
|
||||
|
||||
Solution 不应包含:
|
||||
|
||||
- 具体机器人凭据;
|
||||
- 已安装运行时 token;
|
||||
- 租户或命名空间 UUID;
|
||||
- 密钥;
|
||||
- 原始平台账号标识;
|
||||
- 已解析的机器人事件绑定 UUID。
|
||||
|
||||
导入时,应在目标命名空间内解析路由模板。用户需要先选择机器人/频道,并授予所需权限。
|
||||
|
||||
## 6. SaaS 多命名空间架构
|
||||
|
||||
产品在公开 SaaS 发布前必须支持多命名空间 SaaS 架构。事件路由模型很敏感,因为适配器、凭据、处理器、运行时、状态和日志都会跨越信任边界。
|
||||
|
||||
### 6.1 命名空间模型
|
||||
|
||||
采用分层命名空间模型:
|
||||
|
||||
| 范围 | 用途 |
|
||||
| 用户流程 | 当前实现 |
|
||||
| --- | --- |
|
||||
| 租户(Tenant) | 计费、法律归属、顶层隔离。 |
|
||||
| 工作空间(Workspace) | 租户内的协作和产品工作区。 |
|
||||
| 命名空间(Namespace) | 机器人、处理器、运行时 token、资源和日志的可部署隔离边界。自托管部署可以只有一个默认命名空间。 |
|
||||
| 机器人范围 | 平台适配器实例和事件路由表。 |
|
||||
| 处理器范围 | Agent、Pipeline、Workflow 及相关配置。 |
|
||||
| 运行时范围 | 插件运行时、runner 注册、lease 和执行权限。 |
|
||||
| 资源范围 | 知识库、模型凭据、文件、状态和密钥。 |
|
||||
| 创建处理器 | Agent/Pipeline 类型选择、独立配置与统一详情工作台 |
|
||||
| 选择运行器 | 已安装 Runner 动态 metadata、市场安装入口、安装进度/恢复和可用性反馈 |
|
||||
| 配置 Agent | 运行器、运行器配置、事件与工具;基础信息在详情入口编辑 |
|
||||
| 配置事件 | 分组事件选择、兼容目标过滤、路由排序、冲突提示和兜底匹配说明 |
|
||||
| 验证行为 | 路由 dry-run、Agent 调试、Bot 配置中的平台事件调试;合成派发仍需遵循抑制真实出站的后端边界 |
|
||||
| 理解故障 | 路由匹配/失败轨迹、Runner 状态、模型测试与调试错误反馈 |
|
||||
| 首次使用 | 场景引导与插件化 Runner 安装流程 |
|
||||
| 工具权限 | 自动事件工具、显式平台动作、普通工具白名单,运行时再次授权 |
|
||||
| 存储诊断 | Core、Plugin Runtime、Box 分别报告拥有的目录与不可用状态 |
|
||||
|
||||
在 SaaS GA 前,核心持久化对象都应携带 `tenant_id`、`workspace_id` 和 `namespace_id`。自托管部署可以在迁移时种子化一个默认租户/工作空间/命名空间。
|
||||
这张表表示代码已实现,不代表所有平台/provider 和首次安装组合均完成当前版本真实验收。页面契约见 [处理器与事件编排](../event-based-agents/08-agent-page-and-event-orchestration.md),动作授权见 [平台工具](./PLATFORM_ACTION_TOOLS.md)。
|
||||
|
||||
### 6.2 事件入口隔离
|
||||
## 4.11 发布收尾
|
||||
|
||||
每个入站事件都必须先解析命名空间,再进行路由匹配:
|
||||
| 顺序 | 工作 | 完成条件 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 冻结跨仓库依赖 | 提交/确认 SDK 平台工具发现改动,固定配套 SDK 和 Runner 包,更新 Core 声明及 lock;验证 registry 或精确 commit 的干净安装 |
|
||||
| 2 | 收敛自动化 | 按当前页面设计处理前端旧断言,执行 backend/SDK 定向测试、前端单测、类型检查及发布所需 lint/build/E2E |
|
||||
| 3 | 验证用户路径 | 空白实例安装 Runner、创建 Agent/Pipeline、连接 Bot、保存路由、消息/非消息执行、工具允许与拒绝、错误诊断 |
|
||||
| 4 | 补真实交互与平台证据 | 当前版本的选定平台媒体/回调、Dify continuation、外部 harness;记录支持、未支持、阻塞及未执行 |
|
||||
| 5 | 完成生命周期 | EventLog/Transcript retention 调度、状态/文件清理、取消和重启后的行为有明确策略与测试 |
|
||||
|
||||
```text
|
||||
adapter ingress -> tenant/workspace/namespace resolution -> event normalization -> event log append -> route match -> processor run
|
||||
```
|
||||
验收记录必须写明 Core/SDK/Runner 版本、操作系统、Runtime 连接方式、Box backend 和是否使用 editable 源码。真实平台测试、合成事件与 mock provider 测试分别记账。过去的成功报告不能自动作为当前 HEAD 的通过记录。
|
||||
|
||||
Webhook 和回调端点应编码或查找命名空间范围内的适配器安装。来自一个命名空间的平台事件,绝不能匹配另一个命名空间的路由,即使适配器名称、机器人名称或原始平台 ID 发生碰撞。
|
||||
## Cloud 独立交付边界
|
||||
|
||||
### 6.3 路由目标规则
|
||||
当前 OSS 支持单 Workspace 多成员及固定 RBAC;Cloud 目录和计费归控制面,Core 负责资源作用域与执行边界。不能用 edition 字段或普通配置开启 OSS 多 Workspace。
|
||||
|
||||
运行时路由绑定可以使用已安装 UUID,但导出的路由模板必须使用逻辑引用。在 SaaS 中:
|
||||
当前业务隔离单位是 Workspace 与 execution generation;不要求依据旧草案额外创建 Tenant/Workspace/Namespace 三层同构表。目录、安装绑定、存储与执行作用域以 [多租户架构](../multi-tenant/workspace-multi-user-architecture.md) 为准。
|
||||
|
||||
- 默认情况下,机器人路由只能指向同一命名空间内的处理器;
|
||||
- 跨命名空间目标默认禁止,除非由策略明确共享;
|
||||
- Pipeline 目标仍然只允许处理消息;
|
||||
- 路由冲突评估应限制在命名空间内;
|
||||
- 路由审计事件必须包含租户、工作空间、命名空间、机器人、路由、目标和 run 标识。
|
||||
生产激活仍需关闭:
|
||||
|
||||
### 6.4 运行时和插件隔离
|
||||
- 插件和可配置出站目标的网络/SSRF 策略。
|
||||
- Plugin installation 与 Box 各存储面真正的 byte/inode 硬配额。
|
||||
- 普通业务写入贯穿提交的 generation fence、业务 outbox 和持久对象引用切换保障。
|
||||
- 最终 Linux/cgroup、持久卷、数据库权限、容量、故障恢复与 24 小时 soak 验收。
|
||||
|
||||
插件运行时和 runner 注册表需要命名空间范围的授权:
|
||||
完整清单见 [Cloud 剩余验证](../multi-tenant/cloud-v2-pending-verification.md)。存储统计、局部压力测试或 OSS 功能验收不能替代这些门禁。
|
||||
|
||||
- 运行时注册 token 只作用于一个命名空间,或显式允许的一组命名空间;
|
||||
- runner 发现结果按命名空间权限过滤;
|
||||
- lease 和 heartbeat 按命名空间隔离;
|
||||
- run-scoped API token 不能访问 run 所在命名空间之外的对象;
|
||||
- 插件存储、状态和临时文件的 storage key 应包含命名空间;
|
||||
- 早期 SaaS 可以接受共享运行时,但每个 API 调用都必须被 scoped 并审计;
|
||||
- 企业或高风险租户应支持专用运行时。
|
||||
## 后续产品方向
|
||||
|
||||
### 6.5 密钥、资源和状态
|
||||
- **完整 Agent 管控面**:业务任务队列、唤醒、外部 harness daemon 管理、provider 登录态诊断和分布式执行。已有 run ledger/heartbeat/claim 是底座。
|
||||
- **Workflow 与多 Agent**:先确定独立处理器或 Runner 扩展形式,再定义串并联、失败恢复、状态冲突和副作用幂等。
|
||||
- **Solution 导入导出**:使用逻辑依赖和路由模板;导入时选择 Bot、模型和资源,不能保留源实例 UUID、凭据、token 或租户密钥。包格式与更新策略待定。
|
||||
- **模型能力与评估**:国内 Provider reasoning 专项回归、reasoning token/生效策略记录、质量与延迟成本基线,见 [模型思考控制](../review/model-reasoning-control-design.md)。
|
||||
|
||||
密钥和资源不能全局寻址:
|
||||
|
||||
- 适配器凭据存放在命名空间范围的密钥存储中;
|
||||
- 模型提供商凭据可以根据策略按租户/工作空间/命名空间设定范围;
|
||||
- 知识资源声明允许哪些命名空间使用;
|
||||
- Agent 持久状态按租户/工作空间/命名空间/处理器分区,除非共享策略另有规定;
|
||||
- 事件日志和会话记录按命名空间隔离,并受保留策略约束。
|
||||
|
||||
### 6.6 Marketplace 和已安装资产
|
||||
|
||||
Marketplace 包可见性不等于已安装资产可见性:
|
||||
|
||||
- Marketplace 包可以是公开、租户私有或工作空间私有;
|
||||
- 安装会创建命名空间本地资产,或命名空间本地引用;
|
||||
- 已安装处理器和路由模板默认复制,避免意外跨租户变更;
|
||||
- 更新必须显式且可审计。
|
||||
|
||||
### 6.7 SaaS 测试要求
|
||||
|
||||
SaaS beta 前必须验证:
|
||||
|
||||
- 租户 A 的事件不能匹配租户 B 的路由;
|
||||
- 命名空间 A 的运行时不能 claim 命名空间 B 的 run;
|
||||
- 命名空间 A 的处理器不能读取命名空间 B 的资源;
|
||||
- Solution 导入不能保留源租户 UUID 或密钥;
|
||||
- 路由重放不能暴露另一个命名空间的原始事件 payload;
|
||||
- 管理员可以查看审计轨迹,但不能访问密钥值。
|
||||
|
||||
## 7. 发布计划
|
||||
|
||||
### Phase 0:技术收敛
|
||||
|
||||
目标:证明合并分支可以基于事件绑定和外置 runner 运行。
|
||||
|
||||
必要门禁:
|
||||
|
||||
- 机器人事件路由以 `event_bindings` 作为唯一路由来源。
|
||||
- 旧机器人 pipeline 路由字段已移除或迁移。
|
||||
- Pipeline 可作为只处理消息的处理器运行。
|
||||
- Agent runner 插件冒烟测试通过,覆盖 local runner 和 Dify runner。
|
||||
- 迁移命名和 downgrade 路径有效。
|
||||
- 主 UI 不再在面向用户的适配器名称中暴露 “EBA”。
|
||||
|
||||
### Phase 1:面向技术用户的私有 Beta
|
||||
|
||||
目标:让贡献者和早期自托管用户可用。
|
||||
|
||||
必要门禁:
|
||||
|
||||
- 文档和 UI 中存在适配器能力矩阵;
|
||||
- 路由编辑器会过滤不兼容目标;
|
||||
- runner/plugin 健康检查可见;
|
||||
- local Agent 和 Dify Agent 有引导式配置路径;
|
||||
- 每条路由有运行轨迹;
|
||||
- 废弃适配器被一致标记;
|
||||
- 失败信息能指出失败层级。
|
||||
|
||||
### Phase 2:面向非技术用户的产品 Beta
|
||||
|
||||
目标:让用户无需阅读架构文档也能完成常见场景。
|
||||
|
||||
必要门禁:
|
||||
|
||||
- 首次使用机器人向导从用例和频道开始;
|
||||
- 事件预设默认隐藏原始事件模式;
|
||||
- 存在路由模拟或测试事件能力;
|
||||
- 存在常见处理器模板;
|
||||
- 冲突警告和兜底行为清晰;
|
||||
- 文档先使用产品语言,再介绍高级术语;
|
||||
- SaaS 命名空间 schema 已实现,或已经具备迁移准备。
|
||||
|
||||
### Phase 3:SaaS Beta
|
||||
|
||||
目标:安全地为多个租户运行产品。
|
||||
|
||||
必要门禁:
|
||||
|
||||
- 所有路由、运行时、状态、日志和资源事实都具备租户/工作空间/命名空间字段;
|
||||
- 命名空间范围的运行时注册和 run claim 已强制执行;
|
||||
- 命名空间范围的密钥和适配器安装已强制执行;
|
||||
- 路由匹配和审计限制在命名空间内;
|
||||
- 配额、保留策略和管理员审计界面存在;
|
||||
- 自托管默认命名空间迁移已有文档。
|
||||
|
||||
### Phase 4:GA
|
||||
|
||||
目标:让产品具备广泛采用所需的可靠性。
|
||||
|
||||
必要门禁:
|
||||
|
||||
- Solution 导出/导入已实现,并支持依赖和变量解析;
|
||||
- Marketplace 分发支持命名空间本地安装;
|
||||
- 跨命名空间共享策略是显式的;
|
||||
- 安全评审覆盖适配器、运行时 token、runner API、密钥、日志和路由重放;
|
||||
- 升级和回滚流程已有文档;
|
||||
- 产品遥测可以衡量上手流失和路由失败类别。
|
||||
|
||||
## 8. 验收清单
|
||||
|
||||
只有满足以下条件,才可以认为发布版本达到产品就绪:
|
||||
|
||||
- 非技术用户可以连接一个受支持频道,选择场景,绑定处理器,测试它,并在不编辑原始 JSON 的情况下理解结果;
|
||||
- 产品 UI 在主要流程中避免使用 EBA 这类内部术语;
|
||||
- 机器人页面负责平台事件路由,处理器页面负责可复用的 Agent 与 Pipeline;
|
||||
- Pipeline 仍作为无代码消息处理器可见,并且不会出现在非消息事件目标中;
|
||||
- 同一个机器人的不同事件可以路由到不同处理器类型;
|
||||
- 路由失败能按层级解释;
|
||||
- 命名空间隔离通过 schema、service check、运行时 token、storage key 和测试强制执行;
|
||||
- 导出/导入实现后,使用带路由模板的 Solution 包,而不是具体机器人绑定。
|
||||
|
||||
## 9. 待决策问题
|
||||
|
||||
- 组合处理器入口统一使用 “Processor / 处理器”;Agent 与 Pipeline 是其中平级的类型。
|
||||
- Workflow 应作为独立持久化处理器类型,还是作为 Agent runner 类别。
|
||||
- SaaS 命名空间初期是否与工作空间一一映射,还是高级租户在首个 SaaS beta 就需要一个工作空间下多个命名空间。
|
||||
- 哪些适配器允许在 SaaS 共享运行时中运行,哪些需要专用运行时隔离。
|
||||
- 未来 Solution 导出/导入的确切包格式。
|
||||
这些方向不自动构成 4.11 OSS 发布前置条件,也不承诺旧草案中的 5.0/GA 版本号或日期。
|
||||
|
||||
@@ -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,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-12);2026-09-05 标记归档用途。本文的示意 schema、5.0 发布火车、SDK 0.5.0aX 配套与多租户“预留”描述不再作为实施合同。当前 4.11 产品形态见 [08-agent-page-and-event-orchestration.md](./08-agent-page-and-event-orchestration.md),协议见 [PROTOCOL_V1.md](../agent-runner-pluginization/PROTOCOL_V1.md),已完成与剩余事项见 [STATUS.md](../agent-runner-pluginization/STATUS.md)。保留正文仅用于解释早期设计取舍。
|
||||
>
|
||||
> 本文档修订 [00-overview.md](./00-overview.md) §3.4 与 [04-event-routing.md](./04-event-routing.md) 中"四种 Handler"的编排模型:**所有编排目标统一进入处理器选择与事件绑定界面,但独立 Agent 与现有 Pipeline 保持不同类型**。事件路由的匹配机制、数据迁移策略、WebUI 交互骨架等内容仍以 04 为准,仅 handler 分类法被本文档取代。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 处理器页面与事件编排产品设计
|
||||
|
||||
> 状态:实施稿(2026-06-23)
|
||||
> 状态:当前实现说明(2026-09-05),对应 `dev/4.11.x`。P0–P3 已集成;发布验收见 [STATUS.md](../agent-runner-pluginization/STATUS.md)。
|
||||
>
|
||||
> 本文档修订 [07-agent-orchestration.md](./07-agent-orchestration.md) 中“Agent 替代 Pipeline”的表述。当前产品形态保留两种长期并存的同级处理器:**Agent** 与 **Pipeline**。处理器页面只是共享入口,不改变二者各自的持久化模型和执行语义。
|
||||
|
||||
@@ -34,7 +34,9 @@ LangBot 的处理逻辑分成两种同级形态:
|
||||
- Pipeline:创建一条独立 Pipeline,执行完整消息 Stage 链。
|
||||
3. 编辑时按类型进入不同表单:
|
||||
- Pipeline:沿用原 Pipeline 配置页,包括 AI、触发、安全、输出、扩展、Debug、Monitoring;
|
||||
- Agent:配置基础信息、runner、runner config 和事件能力。
|
||||
- Agent:基础信息由详情入口编辑,主配置分为运行器、运行器配置、事件与工具;事件范围、自动事件工具、平台级动作和普通工具白名单在同一配置流程内维护。
|
||||
|
||||
处理器详情复用 `ProcessorDetailWorkbench`;Agent 与 Pipeline 保留各自的配置、调试和日志语义。`AgentRunnerSelect` 提供已安装 Runner 和市场安装入口,安装状态可恢复;Runner 配置来自动态 metadata,不按 LocalAgent id 定制 Host 表单。调试事件选择位于输入区域;Bot 的平台事件调试位于机器人配置中,路由 dry-run 只解释匹配结果。
|
||||
|
||||
`/home/pipelines` 继续提供 Pipeline 直接编辑路径;共享处理器入口当前使用 `/home/agents`。URL 是实现路径,不代表 Agent 包含 Pipeline。
|
||||
|
||||
@@ -63,13 +65,14 @@ Pipeline 只能被绑定到 `message.*`。如果用户选择非消息事件,
|
||||
|
||||
```python
|
||||
class Agent(Base):
|
||||
workspace_uuid: str # workspace-scoped persistence
|
||||
uuid: str
|
||||
name: str
|
||||
description: str
|
||||
emoji: str
|
||||
kind: str # 首版固定为 "agent"
|
||||
component_ref: str # runner id / workflow id / future external ref
|
||||
config: dict # runner 与 runner_config
|
||||
component_ref: str # runner reference; execution config uses runner.id
|
||||
config: dict # runner, runner_config, allowed_tools, allowed_platform_tools
|
||||
supported_event_patterns: list[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -103,7 +106,7 @@ Pipeline 投影时固定:
|
||||
|
||||
### 3.2 Bot 事件绑定
|
||||
|
||||
Bot 新增 `event_bindings` JSON 字段,首版作为轻量配置面。后续当 EventRouter 查询、审计和多作用域规则稳定后,再拆成独立表。
|
||||
Bot 使用 `event_bindings` JSON 字段持久化路由。当前未引入独立路由表;是否拆表应由查询、审计和多作用域需求决定。
|
||||
|
||||
```json
|
||||
[
|
||||
@@ -139,12 +142,12 @@ Bot 新增 `event_bindings` JSON 字段,首版作为轻量配置面。后续
|
||||
## 5. 并存策略
|
||||
|
||||
1. Pipeline 与 Agent 长期并存,各自保存配置并执行自己的运行链路。
|
||||
2. 现有 Bot 的 `use_pipeline_uuid` 转换为仍指向原 Pipeline 的消息事件绑定。
|
||||
3. 现有 `pipeline_routing_rules` 仍只作用于消息事件。
|
||||
2. 数据库升级中的旧路由转换由 Alembic 负责;当前运行时只读取 `event_bindings`,不再读取 `use_pipeline_uuid`。
|
||||
3. 旧 `pipeline_routing_rules` 不再作为第二个运行时路由来源;LangBot 4.x 不支持 3.x 数据库或配置升级。
|
||||
4. `event_bindings` 允许 `target_type=pipeline|agent|discard`;Pipeline 目标只限 `message.*`。
|
||||
5. Pipeline 与 Agent 保留各自的持久化和编辑语义;处理器聚合入口只负责统一展示和选择。
|
||||
|
||||
## 6. 分阶段落地
|
||||
## 6. 已集成阶段与维护范围
|
||||
|
||||
### P0:处理器入口统一
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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[] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
'再利用可能なプロセッサーを作成し、ボットのイベントルーティングで使用します',
|
||||
|
||||
@@ -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:''},
|
||||
]);
|
||||
});
|
||||
@@ -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=/);
|
||||
|
||||
Reference in New Issue
Block a user