# Boardware Genius Workflow

本文按当前仓库代码，整理 Boardware Genius 的主要运行链路。下文的技术命令名仍沿用 `nanobot`，重点说明：

1. 用户执行 `nanobot agent -m "你好"` 时，CLI 单轮模式到底走了什么路径。
2. `nanobot gateway` 常驻模式下，外部渠道、cron、heartbeat 如何进入同一套工作流。
3. Web 前端在 standalone 模式和 `create_app()` 预留的 gateway mode 下，分别如何判断并跳转不同链路。
4. 每个关键判断点的条件、分支结果和后续跳转。


## 0. 先纠正几个常见误解

在开始看流程前，先把几个和旧文档不一致的点说清楚：

1. `nanobot agent -m "你好"` 的默认 session 不是 `cli:default`，而是 `cli:direct`。
2. `agent -m` 单轮模式不会启动 `AgentLoop.run()` 主循环，也不会走 `bus.consume_inbound()` 常驻消费，而是直接调用 `process_direct()`。
3. `agent_loop.process_direct(message, session_id, ...)` 的第 2 个位置参数是 `session_key`，不是 `chat_id`。
4. 所以 CLI 单轮模式里：
   - 会话 key 默认是 `cli:direct`
   - `InboundMessage.channel` 默认是 `cli`
   - `InboundMessage.chat_id` 默认是 `direct`
5. Agent 最大循环轮数不是固定写死 20，而是来自 `config.agents.defaults.max_tool_iterations`。
6. 工具数量也不是固定“9 个”：
   - 固定基础工具会注册一批
   - `cron_service` 存在时才注册 `cron`
   - MCP 工具是运行时连接成功后动态追加
7. `nanobot agent` 不会自动执行 `_create_workspace_templates()`；模板补齐主要发生在 `onboard` 和 `web` 命令里。
8. `create_app()` 确实支持 “gateway mode + web_channel” 分支，但当前 CLI 里真正直接启动 Web 后端的是 `nanobot web`，它走的是 standalone 模式。


## 1. CLI 单轮模式：`nanobot agent -m "你好"`

这是当前最直接、最短的一条链路。

### 1.1 总览树

```text
用户执行: nanobot agent -m "你好"
│
├─ typer 解析命令行
│  ├─ 命中 @app.command() -> agent(...)
│  ├─ --message/-m 存在？
│  │  ├─ YES -> 单轮模式
│  │  └─ NO  -> 进入交互模式（见第 2 章补充）
│
├─ load_config()
│  ├─ 默认读取 ~/.nanobot/config.json
│  ├─ 文件存在？
│  │  ├─ YES -> json.load()
│  │  ├─ 迁移旧字段 _migrate_config()
│  │  └─ Config.model_validate(data)
│  └─ NO / 读取失败 -> 返回默认 Config()
│
├─ _make_provider(config)
│  ├─ provider_name == openai_codex 或 model 以 openai-codex/ 开头？
│  │  ├─ YES -> OpenAICodexProvider
│  │  └─ NO
│  ├─ provider_name == custom？
│  │  ├─ YES -> CustomProvider
│  │  └─ NO
│  ├─ model 不是 bedrock/* 且 provider 没 API key 且 provider 也不是 OAuth？
│  │  ├─ YES -> console.print 错误并 typer.Exit(1)
│  │  └─ NO  -> LiteLLMProvider
│
├─ MessageBus()
├─ CronService(jobs.json)
├─ AgentLoop(...)
│  ├─ PluginLoader
│  ├─ SkillsLoader
│  ├─ AgentRegistry
│  ├─ ContextBuilder
│  ├─ SessionManager
│  ├─ ToolRegistry
│  ├─ SubagentManager
│  ├─ DelegationManager
│  └─ _register_default_tools()
│
├─ asyncio.run(run_once())
│  └─ await agent_loop.process_direct("你好", session_key="cli:direct")
│
├─ process_direct(...)
│  ├─ await _connect_mcp()
│  ├─ InboundMessage(channel="cli", chat_id="direct", content="你好")
│  └─ await _process_message(msg, session_key="cli:direct")
│
├─ _process_message(...)
│  ├─ msg.channel == "system"？
│  │  ├─ YES -> 走 system 内部任务分支
│  │  └─ NO  -> 走普通用户消息分支
│  ├─ sessions.get_or_create("cli:direct")
│  ├─ cmd == "/new"？
│  │  ├─ YES -> 强制归档 + clear + 返回 "New session started."
│  │  └─ NO
│  ├─ cmd == "/help"？
│  │  ├─ YES -> 直接返回帮助文本
│  │  └─ NO
│  ├─ 未归档消息 >= memory_window 且当前未在归档中？
│  │  ├─ YES -> create_task 后台归档
│  │  └─ NO
│  ├─ _set_tool_context()
│  ├─ build_messages()
│  ├─ _run_agent_loop()
│  ├─ _save_turn()
│  ├─ message 工具本轮已直接发送过消息？
│  │  ├─ YES -> return None
│  │  └─ NO  -> return OutboundMessage(final_content)
│
├─ process_direct() 拿到 OutboundMessage.content
├─ console.print("Boardware Genius ...")
└─ await agent_loop.close_mcp() -> 程序退出
```

### 1.2 关键步骤展开

#### Step 1: `typer` 进入 `agent(...)`

入口函数是 `nanobot/cli/commands.py` 里的 `agent()`。

关键判断：

1. `message` 参数是否存在
2. `logs` 是否开启

分支结果：

1. `message` 存在：
   - 进入 `run_once()`
   - 直接 `await agent_loop.process_direct(...)`
   - 不启动 `bus` 常驻消费循环
2. `message` 不存在：
   - 进入交互模式
   - 单独启动 `agent_loop.run()` 和 CLI 的 inbound/outbound 桥接

#### Step 2: 配置加载 `load_config()`

入口在 `nanobot/config/loader.py`。

判断顺序：

1. 是否传入显式 `config_path`
   - 没传则默认 `~/.nanobot/config.json`
2. 文件是否存在
   - 不存在：直接返回默认 `Config()`
3. JSON 是否可解析
   - 失败：打印 warning，回退默认 `Config()`
4. 旧字段是否需要迁移
   - 例如把 `tools.exec.restrictToWorkspace` 搬到 `tools.restrictToWorkspace`
5. `Config.model_validate(data)` 是否通过
   - 通过：得到结构化配置对象
   - 不通过或出错：回退默认配置

#### Step 3: Provider 选择 `_make_provider(config)`

这里是第一处显式“多分支跳转”。

判断顺序如下：

1. `provider_name == "openai_codex"` 或 `model.startswith("openai-codex/")`
   - 结果：创建 `OpenAICodexProvider`
   - 跳转：后续统一交给 `AgentLoop`

2. `provider_name == "custom"`
   - 结果：创建 `CustomProvider`
   - 跳转：后续统一交给 `AgentLoop`

3. 其余 provider
   - 先查 provider registry
   - 判断是否需要 API key

4. API key 校验条件：
   - `model` 不是 `bedrock/*`
   - 并且 provider 配置里没有 `api_key`
   - 并且 provider spec 也不是 OAuth provider

5. API key 校验结果：
   - 条件成立：报错并 `typer.Exit(1)`
   - 条件不成立：创建 `LiteLLMProvider`

#### Step 4: `AgentLoop(...)` 初始化

当前版本的 `AgentLoop` 初始化不再只是 `ContextBuilder + SessionManager + ToolRegistry + SubagentManager`，而是已经扩成多 agent 运行时。

初始化顺序大致如下：

1. 保存基础配置：
   - `bus`
   - `provider`
   - `workspace`
   - `model`
   - `max_iterations`
   - `temperature`
   - `max_tokens`
   - `memory_window`
   - `exec_config`
   - `a2a_config`

2. 创建运行时依赖：
   - `PluginLoader`
   - `SkillsLoader`
   - `AgentRegistry`
   - `ContextBuilder`
   - `SessionManager`
   - `ToolRegistry`
   - `SubagentManager`
   - `DelegationManager`

3. 注册默认工具 `_register_default_tools()`

当前注册逻辑是“条件式”的：

1. 一定注册：
   - `read_file`
   - `write_file`
   - `edit_file`
   - `list_dir`
   - `exec`
   - `web_search`
   - `web_fetch`
   - `message`
   - `spawn`

2. 条件注册：
   - `cron_service` 存在时，注册 `cron`

3. 运行时动态注册：
   - MCP server 连接成功后，额外注册 `mcp_<server>_<tool>` 包装工具

#### Step 5: `process_direct(...)`

CLI 单轮模式走的是这条直连链路。

执行顺序：

1. `_connect_mcp()`
   - 如果 `_mcp_connected=True`：直接返回
   - 如果 `_mcp_connecting=True`：直接返回
   - 如果没有 MCP 配置：直接返回
   - 否则尝试连接 MCP server，并把远端工具注册进当前 `ToolRegistry`

2. 构造 `InboundMessage`
   - `channel="cli"`
   - `sender_id="user"`
   - `chat_id="direct"`
   - `content="你好"`

3. 进入 `_process_message(msg, session_key="cli:direct")`

注意：

1. 这里 `session_key` 是 `cli:direct`
2. 但消息对象本身的 `chat_id` 仍然是默认 `"direct"`
3. 所以单轮 CLI 的会话持久化 key 和当前消息路由上下文，是同时存在的两个概念

#### Step 6: `_process_message(...)`

这是整个运行时的主入口。

判断顺序如下：

1. `msg.channel == "system"`？
   - YES：
     - 把 `msg.chat_id` 解释成 `"{channel}:{chat_id}"`
     - 走内部任务分支
     - 常见来源：委派结果回流、后台公告等
   - NO：
     - 继续普通消息分支

2. 会话 key 选择：
   - 如果显式传了 `session_key`，优先用它
   - 否则用 `msg.session_key`
   - `msg.session_key` 的规则是：
     - 若有 `session_key_override`，用 override
     - 否则用 `f"{channel}:{chat_id}"`

3. 内建命令拦截：
   - `cmd == "/new"`：
     - 先强制做记忆归档
     - 成功后清空会话
     - 直接返回 `"New session started."`
   - `cmd == "/help"`：
     - 直接返回帮助文本
   - 其他内容：
     - 继续进入模型链路

4. 归档触发判断：
   - `unconsolidated >= memory_window`
   - 并且当前会话不在 `_consolidating`
   - 成立则异步 `create_task` 做后台归档

5. 工具上下文注入 `_set_tool_context(...)`
   - `message` 工具拿到 `channel/chat_id/message_id`
   - `spawn` 工具拿到 `channel/chat_id/announce_via_bus`
   - `cron` 工具拿到 `channel/chat_id/session_key`

6. 附加工具判断：
   - `extra_tools` 是否存在
   - 存在：`self.tools.clone()` 后再注册临时工具
   - 不存在：直接用 `self.tools`

7. 构造 prompt `context.build_messages(...)`
   - `system prompt`
   - `history`
   - `current user message`
   - `media`

#### Step 7: `ContextBuilder.build_messages(...)`

这里的判断主要发生在 `build_system_prompt(...)` 内。

拼装顺序：

1. `_get_identity()`
   - 当前时间
   - 时区
   - 运行平台
   - workspace 路径

2. `_load_bootstrap_files()`
   - 按顺序读取：
     - `AGENTS.md`
     - `SOUL.md`
     - `USER.md`
     - `TOOLS.md`
     - `IDENTITY.md`
   - 文件存在才追加

3. `memory.get_memory_context()`
   - 有内容才追加 `# Memory`

4. `skills.get_always_skills()`
   - 有 always skills 才把全文注入

5. `skills.build_skills_summary()`
   - 有技能摘要才注入 `# Skills`

6. `agent_registry.build_agents_summary()`
   - 仅当 `ContextBuilder` 持有 `agent_registry`
   - 且当前有可用 agent
   - 才注入 `# Available Agents`

7. `execution_context`
   - 只在 cron/system task 等场景显式传入
   - 普通 CLI 对话通常为空

8. 最终 message 拼装：
   - 第 1 条固定 `system`
   - 后面追加历史消息
   - 最后一条追加当前 `user`

#### Step 8: Agent 循环 `_run_agent_loop(...)`

这是第二个最核心的判断分支。

循环条件：

1. `iteration < self.max_iterations`
2. 每一轮都执行 `provider.chat(messages, tools, model, ...)`

分支判断：

1. `response.has_tool_calls == True`
   - 如果有 `on_progress`：
     - 先发清洗后的文本进度
     - 再发工具提示 `_tool_hint(...)`
   - 把 assistant 的 tool call 意图写入 messages
   - 逐个执行工具
   - 每个工具结果再写回 messages
   - 回到下一轮继续问模型

2. `response.has_tool_calls == False`
   - `final_content = response.content`
   - break，循环结束

3. 超过最大轮数仍未收敛
   - 使用兜底文本
   - 也会把兜底回复追加进 messages

#### Step 9: 会话保存和最终返回

执行顺序：

1. `_save_turn(session, all_msgs, skip=1+len(history))`
   - 把本轮新增 assistant/tool/final 消息写进 session
   - 工具结果过长会截断

2. `sessions.save(session)`
   - 持久化到 `<workspace>/sessions/*.jsonl`

3. `message_tool._sent_in_turn` 判断
   - YES：
     - 说明模型已经主动通过 `message` 工具把消息发出
     - 为避免重复发，返回 `None`
   - NO：
     - 返回 `OutboundMessage(content=final_content)`

4. `process_direct()` 只取 `response.content`
   - CLI 单轮模式最终直接 `console.print(...)`


## 2. CLI 交互模式：`nanobot agent`（无 `-m`）

这条链路和单轮模式最大的区别是：

1. 单轮模式直接 `process_direct()`
2. 交互模式走完整 `MessageBus` 工作流

### 2.1 分支判断

`agent()` 里判断条件很简单：

1. `if message:`
   - 进入单轮模式
2. `else:`
   - 进入交互模式

### 2.2 交互模式总览

```text
nanobot agent
│
├─ asyncio.create_task(agent_loop.run())
├─ asyncio.create_task(_consume_outbound())
│
├─ 用户输入一行
├─ bus.publish_inbound(InboundMessage(...))
│
├─ agent_loop.run()
│  ├─ bus.consume_inbound()
│  ├─ _process_message()
│  └─ bus.publish_outbound(response)
│
├─ _consume_outbound()
│  ├─ _progress 消息？ -> 按配置打印中间态
│  ├─ 当前轮正式回复？ -> 收集到 turn_response
│  └─ 轮外消息？ -> 直接打印
│
└─ turn_done.set() -> 当前轮结束
```

### 2.3 为什么这里要走 bus

因为 CLI 交互模式想尽量模拟真实外部渠道的行为：

1. 用户输入先进入 `inbound`
2. Agent 常驻消费
3. 回复写入 `outbound`
4. CLI 再消费 `outbound`

这样本地就能复现：

1. 进度消息
2. 工具提示
3. 轮外异步通知
4. `message` 工具主动发消息时的行为差异


## 3. Gateway 常驻模式：`nanobot gateway`

`gateway` 是常驻服务入口，它把多种“事件来源”统一接入同一个 `AgentLoop`。

这些来源包括：

1. 外部聊天渠道
2. cron 定时任务
3. heartbeat 心跳任务

### 3.1 启动总览树

```text
nanobot gateway
│
├─ load_config()
├─ MessageBus()
├─ _make_provider(config)
├─ SessionManager(workspace)
├─ CronService(jobs.json)
├─ AgentLoop(...)
├─ cron.on_job = on_cron_job
├─ ChannelManager(config, bus)
├─ HeartbeatService(...)
│
└─ asyncio.run(run())
   ├─ await cron.start()
   ├─ await heartbeat.start()
   └─ await asyncio.gather(
      │    agent.run(),
      │    channels.start_all(),
      │ )
```

### 3.2 `gateway` 启动时的关键判断

#### 3.2.1 provider 选择

和 CLI 完全一样，仍由 `_make_provider(config)` 决定。

#### 3.2.2 `ChannelManager._init_channels()`

每个渠道都有一层配置判断：

1. `config.channels.telegram.enabled == True`
   - 尝试实例化 `TelegramChannel`
   - ImportError 只记 warning，不中断 gateway

2. 其他渠道同理：
   - whatsapp
   - discord
   - feishu
   - mochat
   - dingtalk
   - email
   - slack
   - qq

结果：

1. 启用且成功导入：放进 `self.channels`
2. 未启用：跳过
3. 缺依赖或初始化失败：warning，继续其他渠道

#### 3.2.3 `channels.start_all()`

这里还有一个重要判断：

1. `if not self.channels`
   - 结果：warning `"No channels enabled"`，然后 return
   - 跳转：`asyncio.gather()` 里只剩 `agent.run()` 常驻

2. 如果存在已启用渠道
   - 先创建 `_dispatch_outbound()` 任务
   - 再并发启动所有 `channel.start()`


## 4. Gateway 下的三种消息来源

### 4.1 来源 A：外部聊天渠道 -> bus -> agent -> outbound -> 渠道发送

这是最标准的生产链路。

#### 4.1.1 入站：渠道收到用户消息

每个渠道实现最终都会调用 `BaseChannel._handle_message(...)`。

判断顺序：

1. `is_allowed(sender_id)`？
   - `allow_from` 为空：默认允许
   - `sender_id` 完整匹配 allow list：允许
   - `sender_id` 含 `|`，拆开任一部分匹配：允许
   - 其他情况：拒绝

2. 判断结果：
   - 允许：
     - 构造 `InboundMessage`
     - `await bus.publish_inbound(msg)`
   - 拒绝：
     - 只记 warning
     - 消息被丢弃

#### 4.1.2 中段：`AgentLoop.run()`

`agent.run()` 会一直循环：

1. `await self.bus.consume_inbound()`，带 1 秒 timeout
2. 拿到消息后调用 `_process_message(msg)`
3. 判断返回值

返回值分支：

1. `response is not None`
   - `await bus.publish_outbound(response)`

2. `response is None and msg.channel == "cli"`
   - 发一个空 `OutboundMessage`
   - 作用：通知 CLI 当前轮结束

3. `_process_message()` 抛异常
   - 捕获异常
   - 发一条 `Sorry, I encountered an error: ...`

#### 4.1.3 出站：`ChannelManager._dispatch_outbound()`

判断顺序：

1. `msg.metadata["_progress"] == True`？
   - YES：
     - 如果 `_tool_hint=True` 且 `send_tool_hints=False`：丢弃
     - 如果 `_tool_hint=False` 且 `send_progress=False`：丢弃
     - 否则继续发送
   - NO：
     - 直接进入正常路由

2. `self.channels.get(msg.channel)` 是否存在？
   - YES：调用对应 `channel.send(msg)`
   - NO：记录 warning `"Unknown channel"`

3. 单条发送失败？
   - YES：只记 error，不终止整个 dispatcher
   - NO：本条发送完成


### 4.2 来源 B：cron 定时任务

gateway 启动时，会先创建 `CronService`，再把 `cron.on_job` 绑定到 `run_cron_job(...)`。

也就是说，定时器本身不直接调用模型，而是统一交给 `run_cron_job()`。

#### 4.2.1 触发顺序

```text
cron.start()
│
├─ 计时器到点
├─ CronService 选出到期 job
├─ await on_job(job)
│  └─ run_cron_job(job, agent=agent, bus=bus, ...)
│
└─ 根据 job.payload.kind 分支执行
```

#### 4.2.2 `run_cron_job()` 的关键判断

1. `job.payload.kind == "system_event"`？
   - YES：
     - 直接把 `job.payload.message` 当结果
     - 如果 `deliver=True` 且 `to` 非空：
       - `bus.publish_outbound(...)`
     - 不进入 `AgentLoop.process_direct()`

2. 否则视为 `agent_turn`
   - 先解析 `session_key`
   - 构造 `execution_context`
   - 注入 `CronActionTool`
   - 调用 `agent.process_direct(...)`
   - 如果 `deliver=True` 且 `to` 非空：
     - 再把最终结果发到 `outbound`

#### 4.2.3 `CronActionTool` 的结果分支

模型在 cron task 内可以调用 `cron_action(...)` 给出结构化决策：

1. `none`
2. `remove`
3. `disable`
4. `complete_today`
5. `reschedule`

后续由 `CronService` 读取 `CronExecutionResult.action` 决定如何处理任务的后续调度状态。


### 4.3 来源 C：heartbeat 心跳任务

heartbeat 的入口和 cron 不同，它不走 `bus.consume_inbound()`，而是直接调用 `agent.process_direct(...)`。

#### 4.3.1 `_pick_heartbeat_target()` 的判断

选择顺序：

1. 从 `session_manager.list_sessions()` 找最近活跃会话
2. 会话 key 必须能拆出 `channel:chat_id`
3. `channel` 不能是 `cli` 或 `system`
4. `channel` 必须在 `enabled_channels` 里

结果：

1. 找到可用外部会话：
   - heartbeat 结果可以回到真实外部渠道
2. 找不到：
   - 回退到 `cli:direct`

#### 4.3.2 heartbeat 执行链路

1. `on_heartbeat(prompt)`
   - 直接 `agent.process_direct(...)`
   - `session_key="heartbeat"`
   - `on_progress=_silent`
   - 不向外部渠道发送中间进度

2. `on_heartbeat_notify(response)`
   - 如果目标仍是 `cli`：不投递
   - 否则：`bus.publish_outbound(...)`


## 5. Web 前端：当前代码真实可执行的链路

这里要分成两个概念：

1. 当前 CLI 能直接启动的 Web 后端：`nanobot web`
2. `create_app()` 代码里预留的 gateway mode：`bus + web_channel`

先说已经真实落地的 `nanobot web`。


## 6. `nanobot web`：standalone Web 后端

`nanobot web` 会调用：

```text
create_app(config=config)
```

这里没有传 `bus`，所以会进入 standalone 分支。

### 6.1 `create_app()` 的第一层判断

判断条件：

1. `if bus is None`
   - YES：standalone mode
   - NO：gateway mode

当前 `nanobot web` 的结果一定是：

1. 创建本地 `MessageBus`
2. 创建本地 `provider`
3. 创建本地 `SessionManager`
4. 创建本地 `CronService`
5. 创建本地 `AgentLoop`
6. `app.state.agent = agent`
7. `app.state.web_channel = None`

也就是说，Web 请求会直接落到本地 `AgentLoop.process_direct(...)`，而不是先发到 bus 再异步回传。


## 7. Standalone Web 下的三条前端入口

### 7.1 HTTP：`POST /api/chat`

前端发送：

```json
{
  "message": "你好",
  "session_id": "web:default",
  "attachments": []
}
```

执行顺序：

1. `_resolve_attachment_paths(...)`
   - 有 `attachments` 才解析本地文件路径
   - 没有则返回空列表

2. 计算 `chat_id`
   - `session_id` 包含 `:`：
     - 取冒号后半部分
     - 例如 `web:default -> default`
   - 否则直接用整个 `session_id`

3. 判断 `web_channel is not None`？
   - standalone 下固定是 `None`
   - 所以会走 fallback 分支

4. fallback 分支：
   - `agent.process_direct(...)`
   - `channel="web"`
   - `chat_id=解析后的 chat_id`
   - `session_key=session_id`

5. 返回：
   - `ChatResponse(response=..., session_id=...)`

特点：

1. 同步等待模型完整完成
2. HTTP 请求返回时已经拿到最终答案
3. 不返回实时中间进度


### 7.2 SSE：`POST /api/chat/stream`

这条链路只允许 standalone 使用。

判断条件：

1. `agent = app.state.agent`
2. `if agent is None`
   - YES：说明当前是 gateway mode
   - 结果：抛 400，提示 `"Streaming not available in gateway mode. Use WebSocket."`
   - 跳转结束

3. `agent is not None`
   - 进入 standalone SSE 分支

执行顺序：

1. 先 `yield {"type":"start"}`
2. 调用 `agent.process_direct(...)`
3. 拿到完整文本后按 20 字符一段切块
4. 按顺序 `yield {"type":"content","content": chunk}`
5. 结束时 `yield {"type":"done"}`

注意：

1. 这里不是“真正 token 级流式”
2. 而是“先拿完整答案，再假流式切块回放”


### 7.3 WebSocket：`/ws/{session_id}`

这条链路是当前 Web 前端最复杂的一条，因为它同时支持：

1. ping/pong
2. 取消委派
3. 普通消息
4. 直连模式下的结构化过程事件

#### 7.3.1 首层判断

连接建立后：

1. `await websocket.accept()`
2. `send_lock = asyncio.Lock()`
3. 判断 `web_channel is not None`

结果：

1. gateway mode：
   - `web_channel.register_connection(session_id, websocket)`
2. standalone mode：
   - 不注册外部 channel
   - 后续直接在当前 handler 内调用 `agent.process_direct()`

#### 7.3.2 收到客户端消息后的判断

每次 `await websocket.receive_text()` 后，会按以下顺序判断：

1. JSON 可解析？
   - NO：忽略，继续下一条
   - YES：继续判断 `type`

2. `type == "ping"`？
   - YES：立刻回 `{"type":"pong"}`
   - NO：继续

3. `type == "cancel_process"`？
   - YES：
     - 取 `run_id`
     - 判断当前是否有本地 `agent`
     - 如果有：`await agent.delegation.cancel(run_id)`
     - 返回 `{"type":"process_cancel_ack","run_id":...,"ok":...}`
   - NO：继续

4. `type == "message"`？
   - YES：进入消息处理分支
   - NO：忽略

#### 7.3.3 standalone WebSocket 消息链路

当 `web_channel is None` 时：

1. 先回 `{"type":"status","status":"thinking"}`
2. 定义 `_process_sink(event)`：
   - 把 `process_event_callback` 推来的结构化事件直接发给前端
   - 自动补上 `session_id`

3. 调用：

```text
agent.process_direct(
  ...,
  process_event_callback=_process_sink,
)
```

4. 执行过程中，前端会陆续收到：
   - `process_run_started`
   - `process_run_progress`
   - `process_run_status`
   - `process_run_artifact`
   - `process_run_finished`
   - 以及可能的最终 `message`

5. 最终再显式回：

```json
{
  "type": "message",
  "role": "assistant",
  "content": "..."
}
```

这个模式的优势是：

1. 前端可以看到多 agent / MCP / A2A 的中间态树状过程
2. 还可以在拿到 `run_id` 后主动发 `cancel_process`


## 8. `create_app()` 预留的 gateway mode：前端如何接到 bus

这一部分是你特别关心的“前端 + gateway”链路，但要先说明一个事实：

当前仓库的 `create_app()` 已经写好了 gateway mode 的判断分支，但现有 CLI 命令没有直接把一个具体的 `web_channel` 实例传进去。

所以这一节描述的是：

1. 代码里已经定义好的分支规则
2. 如果调用方把 `bus` 和 `web_channel` 注入进来，会怎么跳转

### 8.1 gateway mode 进入条件

`create_app(...)` 的判断条件是：

1. `bus is None`
   - NO：说明调用方已经提供了总线
2. 同时还可以提供 `web_channel`

结果：

1. `app.state.agent = None`
2. `app.state.web_channel = web_channel`
3. Web API 本身不创建本地 `AgentLoop`
4. 所有前端消息都应该转发给 `web_channel` / `bus`


## 9. Gateway mode 下前端的不同链路

### 9.1 HTTP：`POST /api/chat`

判断：

1. `web_channel is not None`
   - YES：gateway 分支
   - NO：standalone fallback

gateway 分支执行顺序：

1. `web_channel._handle_message(...)`
   - 把前端消息包装成 `InboundMessage`
   - 再发布到 `bus.inbound`

2. `await web_channel.notify_thinking(chat_id)`
   - 立即通知前端进入 thinking 状态

3. 立刻返回：

```json
{
  "status": "accepted",
  "session_id": "..."
}
```

这意味着：

1. HTTP 这里不等待 LLM 完成
2. 真正结果要靠后续 WebSocket 或 `web_channel` 自己的回推机制返回给前端


### 9.2 SSE：`POST /api/chat/stream`

在 gateway mode 下，这条路会被显式禁止。

判断条件：

1. `agent is None`
   - YES：说明当前是 gateway mode
   - 结果：抛 400
   - 原因：gateway mode 不在当前进程里直接跑 `process_direct()`，所以这里没法同步拉一条 SSE 直流


### 9.3 WebSocket：`/ws/{session_id}`

在 gateway mode 下，收到 `type="message"` 后会走：

1. `web_channel.register_connection(session_id, websocket)`
2. `web_channel._handle_message(...)`
3. `web_channel.notify_thinking(session_id)`

然后消息进入：

```text
前端 WebSocket
  -> web server websocket handler
  -> web_channel._handle_message(...)
  -> bus.publish_inbound(...)
  -> agent.run()
  -> _process_message()
  -> bus.publish_outbound(...)
  -> web_channel / outbound consumer
  -> websocket.send_text(...)
```

### 9.4 这里真正的“判断 + 跳转”关系

前端发一条 WebSocket 消息时，handler 的判断顺序是：

1. `type == "ping"`？
   - YES：直接 `pong`
   - NO：继续

2. `type == "cancel_process"`？
   - YES：
     - 如果当前有本地 `agent`，则走 `agent.delegation.cancel(run_id)`
     - 如果当前没有本地 `agent`，`ok` 会是 `false`
   - NO：继续

3. `type == "message"`？
   - YES：继续
   - NO：忽略

4. `web_channel is not None`？
   - YES：gateway 分支
     - `_handle_message(...)`
     - `notify_thinking(...)`
     - 等待异步回推
   - NO：standalone 分支
     - 当前协程内直接 `process_direct(...)`
     - 当场把过程事件和最终答案发回客户端


## 10. 前端 + gateway 这件事，当前代码的真实状态

这一点必须明确写清楚，避免 workflow 文档误导：

1. 当前仓库中，`create_app()` 已经支持 “传入 `bus` + `web_channel`” 的 gateway mode。
2. 但是当前 CLI 命令里：
   - `nanobot gateway` 启动的是常驻渠道服务
   - `nanobot web` 启动的是 standalone FastAPI
3. 也就是说，仓库当前“直接可运行”的默认前端后端链路，其实是 `nanobot web` 的 standalone 模式。
4. “gateway + Web 前端共用同一 bus/web_channel” 目前在代码层属于预留集成点，而不是现成的一条 CLI 启动链。

如果未来要把这条链路真正跑起来，最少需要：

1. 在某个入口里创建共享的 `MessageBus`
2. 创建 `AgentLoop`
3. 创建具体 `WebChannel` 实现
4. 把 `bus` 和 `web_channel` 传给 `create_app(...)`
5. 同时启动：
   - `agent.run()`
   - Web server
   - 以及 `web_channel` 对 outbound 的回推逻辑


## 11. 一页版总结

### 11.1 `nanobot agent -m "你好"`

1. 直接 `process_direct()`
2. 不走常驻 `agent.run()`
3. 不走 inbound/outbound 总线消费循环
4. 同步拿最终答案后打印退出

### 11.2 `nanobot agent` 交互模式

1. 启动 `agent.run()`
2. CLI 自己把输入写入 `bus.inbound`
3. 再从 `bus.outbound` 取结果显示

### 11.3 `nanobot gateway`

1. 常驻启动 `agent.run()`
2. 渠道、cron、heartbeat 都是消息生产者
3. 最终统一回到 `AgentLoop._process_message()`
4. 回答再由 `ChannelManager` 按 `msg.channel` 分发出去

### 11.4 `nanobot web`

1. 当前默认是 standalone
2. `/api/chat` 和 `/ws` 直接调用本地 `agent.process_direct()`
3. `/api/chat/stream` 只在 standalone 可用

### 11.5 `create_app()` 的 gateway mode

1. 判断条件是 `bus is not None` 且通常还会有 `web_channel`
2. Web 请求不直接跑本地 agent
3. 而是把前端消息丢进 bus，再由外部运行中的 `agent.run()` 处理
4. 当前仓库有这个分支，但 CLI 默认还没把它作为现成启动方式接起来
