Files
beaver_project/app-instance/backend-old/workflow.md

28 KiB
Raw Blame History

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();模板补齐主要发生在 onboardweb 命令里。
  8. create_app() 确实支持 “gateway mode + web_channel” 分支,但当前 CLI 里真正直接启动 Web 后端的是 nanobot web,它走的是 standalone 模式。

1. CLI 单轮模式:nanobot agent -m "你好"

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

1.1 总览树

用户执行: 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_keycli: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 交互模式总览

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 启动总览树

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=Truesend_tool_hints=False:丢弃
      • 如果 _tool_hint=Falsesend_progress=False:丢弃
      • 否则继续发送
    • NO
      • 直接进入正常路由
  2. self.channels.get(msg.channel) 是否存在?

    • YES调用对应 channel.send(msg)
    • NO记录 warning "Unknown channel"
  3. 单条发送失败?

    • YES只记 error不终止整个 dispatcher
    • NO本条发送完成

4.2 来源 Bcron 定时任务

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

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

4.2.1 触发顺序

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=Trueto 非空:
        • bus.publish_outbound(...)
      • 不进入 AgentLoop.process_direct()
  2. 否则视为 agent_turn

    • 先解析 session_key
    • 构造 execution_context
    • 注入 CronActionTool
    • 调用 agent.process_direct(...)
    • 如果 deliver=Trueto 非空:
      • 再把最终结果发到 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 来源 Cheartbeat 心跳任务

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 不能是 clisystem
  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 modebus + web_channel

先说已经真实落地的 nanobot web

6. nanobot webstandalone Web 后端

nanobot web 会调用:

create_app(config=config)

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

6.1 create_app() 的第一层判断

判断条件:

  1. if bus is None
    • YESstandalone mode
    • NOgateway 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 HTTPPOST /api/chat

前端发送:

{
  "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 SSEPOST /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. 调用:

agent.process_direct(
  ...,
  process_event_callback=_process_sink,
)
  1. 执行过程中,前端会陆续收到:

    • process_run_started
    • process_run_progress
    • process_run_status
    • process_run_artifact
    • process_run_finished
    • 以及可能的最终 message
  2. 最终再显式回:

{
  "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. 如果调用方把 busweb_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 HTTPPOST /api/chat

判断:

  1. web_channel is not None
    • YESgateway 分支
    • NOstandalone fallback

gateway 分支执行顺序:

  1. web_channel._handle_message(...)

    • 把前端消息包装成 InboundMessage
    • 再发布到 bus.inbound
  2. await web_channel.notify_thinking(chat_id)

    • 立即通知前端进入 thinking 状态
  3. 立刻返回:

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

这意味着:

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

9.2 SSEPOST /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)

然后消息进入:

前端 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)
      • 如果当前没有本地 agentok 会是 false
    • NO继续
  3. type == "message"

    • YES继续
    • NO忽略
  4. web_channel is not None

    • YESgateway 分支
      • _handle_message(...)
      • notify_thinking(...)
      • 等待异步回推
    • NOstandalone 分支
      • 当前协程内直接 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. busweb_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. 回答再由 ChannelManagermsg.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 默认还没把它作为现成启动方式接起来