跳到主要内容

数据截至 (上游 commit ee230f304a1a)

多入口与常驻:CLI、headless、远程环境、消息渠道与定时

30 秒导读: 前面几章讲的是"一个 agent 怎么想、怎么动手"。这一章讲的是谁来按下开始键——同一个 agent,可以被终端界面、管道脚本、桌面端 WebSocket、Slack/Telegram 消息、或者一条 cron 表达式接管。Letta Code 让这五条入口最终汇到同一个内核上,而不是各写一套。

本章属于 Letta Code — 架构与原理 系列。回合本身怎么跑见 01-stateful-turn.md,批准环路见 03-permissions.md,记忆同步见 04-memory.md;这里只讲入口常驻


1. 这是什么(零基础也能懂)

一句话定义: 把"跑 agent"这件事从"人坐在终端前"解耦成"任何前端都能触发",并且让进程能一直挂着等活干。

为什么需要: 一个只能在终端里手打的 agent,做不了这些事:

  • 你在 CI 里想让它改一个 PR —— 没有 TTY,不能有交互 UI。
  • 你在手机上想接着刚才的对话 —— 得有个常驻进程替你在笔记本上执行。
  • 你想在 Slack 里 @ 它 —— 得有人监听 Slack 并把消息投进正确的会话。
  • 你想让它每天早上八点跑一次巡检 —— 得有个调度器,而且不能因为你关了终端就停。

入口一览:

入口命令有没有 UI谁在触发
交互式 TUIlettaInk 全屏界面坐在终端前的人
headlessletta -p "..."无,纯 stdout脚本 / CI / 父 agent
远程环境(出站)letta server状态行Letta Cloud 推来的消息
App Server(入站)letta server --listen桌面端 / 浏览器 / OpenAI 客户端
消息渠道letta server --channels slackSlack/Telegram/... 的用户
定时letta cron add ...时钟

一句话直觉: 把 letta-code 想成一台机床。TUI 是机床自带的操作面板,headless 是数控程序输入口,远程环境是给机床装了一根网线,渠道是把网线接到 Slack,cron 是定时器开关。机床本身(工具层、权限、记忆)只有一套。


2. 顶层全景

2.1 一次启动怎么分流

src/index.tsmain() 是唯一入口。它的骨架只有四步,而且顺序有讲究:

letta <argv>

① 抽 --backend 标志 extractBackendFlag(index.ts:604)
│ └─ 部分子命令还需要提前定后端模式 subcommandNeedsEarlyBackendMode(router.ts:34)

② runSubcommand(argv) ──┬─ 返回数字 ─► 子命令跑完,process.exit
│ (index.ts:649) └─ 返回 null ─► 不是子命令,继续

③ 初始化 settings / 校验凭据 / 解析完整参数

④ isHeadlessStartup? ──┬─ 是 ─► loadTools + handleHeadlessCommand(index.ts:1414)
(index.ts:834) └─ 否 ─► 懒加载 React + Ink + App(index.ts:1429)

为什么子命令要排在第二步:源码注释写得很直白——"Subcommands exit before TUI initialization and tool bootstrapping"(src/index.ts:648)。letta agents list 这种纯 JSON 查询不该为了打印一行结果去加载 Ink、加载全部工具、校验一遍 OAuth。

分流的判据只有一个函数:isHeadlessStartup(src/cli/startup-mode.ts:1)。规则三条,按优先级:

  1. 显式 -p / --run → headless;
  2. 还剩正位参数(子命令已经路由过了)→ 不是 headless,是"未知命令"报错;
  3. 否则看 stdin 是不是 TTY —— 管道进来就走 headless。

2.2 五种前端,一个内核

这张图从左到右读:前端 → 接入模块 → 共用内核。

终端 TUI ──► AppCoordinator.tsx ────┐
管道 / CI ──► headless.ts ───────────┤
├──► 本机工具层 + 权限 + MemFS
Cloud / 桌面 ──► listener runtime ──────┤ │
Slack / TG ──► ChannelGateway(子进程)─┤ ▼
定时任务 ──► cron scheduler ────────┘ Letta 后端(Cloud / 本地)

注意后三条的收敛点更靠内:远程环境、消息渠道、定时任务全都落到同一个 ListenerRuntime。渠道的 gateway 通过本机 App Server 的公开协议接进来,cron 的 fireCronTask 直接把一条 cron_prompt 塞进会话队列(src/cron/scheduler.ts:294-333)。它们不是三套并行实现,是同一个回合入口的三个上游。

2.3 部件职责

部件干什么在哪个文件
CLI 分发抽后端标志、路由子命令、判 headlesssrc/index.tssrc/cli/subcommands/router.ts
TUIInk 状态机 + 渲染 + 斜杠命令src/cli/app/
headless一次性 / 流式 JSON 会话src/headless.tssrc/stream-json-writer.ts
listener多连接、回合生命周期、批准、恢复src/websocket/listener/
App Server把本机 harness 暴露成 WS + OpenAI 兼容 HTTPsrc/websocket/app-server*.ts
渠道插件注册、路由、门禁、配对、消息工具src/channels/
定时租约、tick、抖动、错过判定src/cron/

3. 入口分发:一个 switch 就是全部路由

3.1 router.ts 的两张表

src/cli/subcommands/router.ts 只有 130 行,却是整个 CLI 的路由表。它导出两个函数:

  • subcommandNeedsEarlyBackendMode(command)(router.ts:34)—— 一张白名单,列出那些"还没解析完参数就得知道用哪个后端"的子命令(serverchannel-gatewayagentsmemoryteleport……)。
  • runSubcommand(argv)(router.ts:62)—— 一个 switch,命中返回退出码,不命中返回 null 让主流程继续。

返回类型 Promise<number | null> 是关键设计:null 不是"失败",是"这不归我管"。这让"letta 裸跑进 TUI"和"letta cron list 打印 JSON 退出"共用同一条入口,不需要在 index.ts 里再维护一份子命令名单。

3.2 子命令一览

子命令干什么实现
server二级分发:远程环境 或 App Serverserver.ts:107
server --listen本机开 WS/HTTP 服务器app-server.ts:56
server(裸) / remote注册成远程环境并反连 Cloudlisten.tsx:272
channel-gateway渠道子进程(内部命令,不给人手打)channel-gateway.ts:35
channels安装/配置/路由/配对,输出 JSONchannels.ts:576
cron增删查改定时任务cron.ts:954
environments / envs列出可用远程环境、查当前环境environments.ts:137
teleport把当前会话搬到另一个环境teleport.ts:163
agents / messages只读查询,JSON-onlyagents.ts:99messages.ts:166
local-backend本地后端 transcript 迁移local-backend.ts:32

letta server 是个二级路由。 resolveServerCommand(src/cli/subcommands/server.ts:48)扫一遍 argv:出现 --listen 就分成 { kind: "app-server" },否则 { kind: "remote" }。老的 letta app-server 保留成 deprecated 别名,靠 asLegacyAppServerCommand(server.ts:103)在前面补一个 --listen 再转发(router.ts:80-84)。

远程环境不是哪里都能起。 resolveListenerStartupMode(src/cli/subcommands/listen.tsx:138)返回三态:

返回什么时候后果
remote桌面模式,或 base URL 指向 Letta Cloud注册设备 + 反向 WS
local-channels本地后端或自托管 server,且带了 --channels跳过环境注册,只跑渠道
unsupported-self-hosted自托管 server 且没带 --channels直接报错退出(listen.tsx:448-457)

4. 交互式 TUI:状态与渲染分家

4.1 三层薄壳

公开入口 src/cli/App.tsx 只有 7 行,src/cli/app/App.tsx 只有 9 行,都是纯 re-export。真正的实现在 AppCoordinator.tsx。这层薄壳是故意的:src/cli/app/README.md 说明了整个目录按职责切分,好让"小上下文的 agent 能先打开最小的有用文件"。

按 README 的划分,主干是三个文件:

文件职责关键符号
AppCoordinator.tsxInk 状态、副作用、overlay 接线、组装渲染树App(:349)
AppView.tsx只渲染,不持有状态AppView(:348)
use-submit-handler.ts用户提交 + 斜杠命令路由useSubmitHandler(:602)

其余按关切拆成 hook:use-conversation-loop.ts(流式回合循环)、use-approval-flow.ts(批准恢复与批量应答)、use-interrupt-handler.ts(ESC 中断)、use-conversation-switching.ts(/btw 与切 agent)等,点到为止即可——README 里每个都有一句话说明。

README 自己点出了一个对称关系:use-conversation-loop.ts 是 listener 那边 turn.ts 的交互版对应物。同一套回合语义,两个宿主各实现一遍,这是后面理解"为什么远程环境不是简单地复用 TUI"的前提。

4.2 斜杠命令路由:为什么有的命令能插队

用户敲的每一行都先过 useSubmitHandler。斜杠命令的分支在 use-submit-handler.ts:838-875,顺序是:

输入以 "/" 开头

├─ parseModSlashCommand → 查自定义命令 / mod 命令(它们覆盖内置)

├─ shouldSlashCommandBypassQueue(命令, {hasCustomCommand, modCommand})
│ (command-routing.ts:71)

└─ agent 正忙?
├─ 能插队 ─► 立刻执行
└─ 不能插队 ─► 报 "'/xxx' is disabled while the agent is running."

command-routing.ts 里只有两个集合和三个函数,判据非常朴素:

  • INTERACTIVE_SLASH_COMMANDS —— 会开浮层的命令(/model/agents/memory……)。理由写在文件头:让用户在 agent 干活时也能浏览,浮层里的修改会排队到回合结束。
  • NON_STATE_COMMANDS —— 不改 agent 状态的命令(/usage/export/exit……),所以随时能跑。

自定义命令一律不能插队(hasCustomCommand 直接返回 false),mod 命令则自己声明 runWhenBusy

4.3 两个取舍:内联 Ink 与 <Static>

取舍一:Ink 被就地打补丁。

package.json:156 依赖的仍是官方 ink@^5.0.0,但仓库带了一份 vendor/ink/,scripts/postinstall-patches.js:91-109 在 postinstall 阶段把它逐个文件复制进 node_modules/ink/build/。被替换的有:

被替换的文件补什么
components/App.js抬高 stdin 监听上限、错误概览
hooks/use-input.jsKitty CSI-u 序列、Linux 上 Shift+Enter 后抑制裸 Enter
log-update.js满宽行跳过 eraseEndLine,绕开终端的延迟换行 bug
wrap-text.jsdevtools.js换行与调试
ink-text-input/build/index.js支持 externalCursorOffset

脚本头一行就说了动机:"vendoring our Ink modifications without patch-package"。代价node_modules 被就地改写(装完才生效,bun install --frozen 之外的路径要小心),而且上游升 Ink 大版本时这几个文件得重新对齐。收益是不引入 patch-package 依赖,补丁内容是可读的完整文件而不是 diff。

取舍二:<Static> 转录只写一次。

StaticTranscript.tsx:44 把整个历史转录塞进 Ink 的 <Static>:

<Static
key={`${renderEpoch}-${hiddenToolCallId ?? ""}`}
items={items}
style={{ flexDirection: "column" }}
>

Ink 的 <Static> 一旦提交就冻结 props,永不重绘。好处是几百条历史消息不会随每次流式 token 重排——终端 scrollback 承担了存储,React 只管新增。坏处是想改历史就只能换 key 整体重挂,那会把全部历史重新打印一遍。

于是有了这段注释(StaticTranscript.tsx:36-41):lastShellToolCallId 故意key。结果是"最后一条工具调用上的 ctrl+o 提示"在刚提交时正确,在更老的条目上可能过期——项目明确接受这个化妆品级的不一致,换取"不因为每次工具调用就重印历史"。


5. 非交互:headless 与 stream-json

5.1 两种形态

handleHeadlessCommand(src/headless.ts:709)按 --input-format 分岔:

形态触发行为
一次性默认从正位参数或 stdin 读一段 prompt,跑完退出
双向流式--input-format stream-json从 stdin 逐行读 JSON 消息,持续跑(headless.ts:3455 runBidirectionalMode)

输出格式由 --output-format 定:text(默认)、jsonstream-json(src/cli/args.ts:150-158)。

一次性形态里,没给 prompt 且 stdin 不是 TTY 时会把整个 stdin 读干当 prompt(headless.ts:778-786)——这正是"父 agent 用管道喂子 agent"的路径。

5.2 一个出口盖时间戳

src/stream-json-writer.ts 全文只有 61 行,却是个值得抄的小设计。它的存在理由写在文件头注释里(:1-22):

  • 单一choke point:stream-json 模式下每一行都必须走 writeWireMessage(:31),不准散着写 console.log(JSON.stringify(...));
  • 统一盖 timestamp:ISO 8601 UTC,字段名和格式刻意对齐 Claude Code 与 Codex 的 stream-json,好让下游归一化器一视同仁;
  • 调用方不能忘:因为盖戳在写入函数里,新增调用点自动合规。

异步版 writeWireMessageAsync(:42)多返回一个 boolean:false 表示 stdout 已经 destroyed 或 ended,这一行根本没上线。注释点名了适用场景——子 agent 的最终 result 信封,父进程要靠它判断成败,不能把静默丢弃当成功。

5.3 headless 的凭据门槛

headless 跑 Cloud 时要求环境变量里有 LETTA_API_KEY,不接受保存下来的 OAuth(src/index.ts:1126-1138)。例外是 --ephemeral(临时会话可以复用已保存的 OAuth)、--dev-backend、以及本地后端。交互模式没有这条限制,没凭据会直接进 setup 流程。


6. 远程环境:同一套 runtime,两个方向

6.1 出站 vs 入站

出站(letta server):
本机 letta ──反向 WS──► Letta Cloud ──► ADE / 手机 / 桌面端
「我是一台可用的执行环境,有活派给我」

入站(letta server --listen):
桌面端 / 浏览器 / OpenAI 客户端 ──WS 或 HTTP──► 本机 letta(127.0.0.1:port)
「我在本机开了个口,你来连我」

两条路复用同一套东西:createRuntime()(src/websocket/listener/lifecycle.ts:290)造出 ListenerRuntime,attachOpenListenerSocket()(:510)把一条 socket 挂上去。startAppServeroptions.runtime ?? createRuntime()(app-server.ts:177)——如果调用方已经有一个活的 runtime,就复用它。渠道网关正是靠这一点,把自己接进正在跑的 listener(见 §7.2)。

6.2 listener 的分层契约

src/websocket/listener/AGENTS.md 用几条硬规矩把这个目录钉住了,读代码前先读它能省很多力气:

  • 唯一所有者:活跃回合状态只由 TurnLifecycle(turn-lifecycle.ts:86)持有,ConversationRuntime 上的 isProcessingloopStatusactiveRunId 全是只读投影——看 runtime.ts:265-292,它们真的写成了 getter,转发给 turnLifecycle。不准加 setter,不准加平行标志位。
  • 四态互斥:idle / command / active / cancellingcancelling 是个专门状态:UI 上投影成 idle,但队列仍然被堵住,直到那个租约结算。
  • 租约规则:每个活跃回合有一个 TurnLease = { id, signal }。跨 await 之后必须重新 isCurrent(lease) 才能改状态或发事件;过期租约必须一声不吭。
  • 终态规则:finishListenerTurn() 恰好收尾一次。分支结果用可辨识联合(continue | interrupted | terminal | error),不许退回 terminated: boolean 这种"调用方得猜谁已经收尾了"的形状。

requires_approval 被明确定义为延续边界而非终态:待批准仍然在同一个 active 租约里。这一条直接决定了批准环路的实现(见 03-permissions.md)。

6.3 关键模块

模块职责关键符号
runtime.ts进程级单例 + 按 (agent, conversation) 分片的会话 runtimegetActiveRuntime(:22)、createConversationRuntime(:235)
connection.ts一个 runtime 挂多条连接,带订阅集与断线挂起openListenerConnection(:78)、suspendListenerConnection(:330)
turn.ts一次入站消息的完整回合编排handleIncomingMessage(:89)
turn-lifecycle.ts状态机本体、租约、转移TurnLifecycle(:86)
queue.ts入站排队与"能否直通"的门禁shouldProcessInboundMessageDirectly(:219)
approval.ts把批准请求发给订阅方并等应答requestApprovalOverWS(:371)
recovery.ts进程重启 / 陈旧批准的恢复resolveRecoveredApprovalResponse(:388)
memfs-sync.ts首次触达某 agent 时惰性同步记忆仓库ensureMemfsSyncedForAgent(:89)
secrets-sync.ts服务端 secrets 的本地缓存水合ensureSecretsHydratedForAgent(:109)
teleport.ts在环境之间搬会话handleTeleportRequest(:177)
external-tools.ts把远端声明的工具桥成本地工具installExternalToolBridge(:100)

下面挑四个不显然的说透。

排队门禁写成了一长串否定。 shouldProcessInboundMessageDirectly(queue.ts:245)不是"看起来空闲就直通",而是列了十来个条件,任何一个非零就走队列:队列长度、pump 是否在跑、pendingTurns、生命周期非 idle、待批准 resolver、待批准批次、恢复态、被中断的结果 / 上下文 / toolCallIds。宁可多排一次队,也不让直通路径撞上半收尾的状态。

批准要发给"订阅了这个 scope 的所有连接"。 requestApprovalOverWS(approval.ts:371)先取订阅者,空了就退回发起消息的那条连接,再空就退回 legacy connectionId(:388-404)。这是多前端同看一个会话的必然要求:你在桌面端发起,可能想在手机上点批准。

记忆同步是惰性的、可重试的。 memfs-sync.ts 的注释点名了一个真实事故:拉 agent 时忘了 include: ["agent.tags"],API 会对一个标了 tag 的 agent 返回空 tags,于是 listener 跳过 clone,把 agent 当白板跑(:26-31)。修法是硬编码这个 include。同一模块还做了两件事:per-agent 的 promise 合流保证并发触发只 clone 一次(:93-110),失败时从 map 里删掉让下一回合重试。

secrets 缓存用 while(true) 处理"取的时候被弄脏"。 ensureSecretsHydratedForAgent(secrets-sync.ts:109)有 60 秒新鲜窗、一个 dirty 集合、一个 in-flight map。妙处在于:等到别人的 in-flight promise 之后,它会再查一次 dirty,脏了就 continue 重跑,而不是把可能陈旧的结果交给调用方(:124-133)。

6.4 App Server:把本机 harness 变成端点

startAppServer(src/websocket/app-server.ts:162)默认绑 ws://127.0.0.1:0(端口随机)。它的安全姿势值得单列:

防护做法位置
非 loopback 必须带认证没配 auth 就拒绝启动,直接抛错:168-174
拒绝带 Origin 的 HTTP 请求一律 403:255-262
认证模式capability-token 或 signed-bearer-token(JWT)app-server-auth.ts:66:220
半开连接回收30 秒 ping,90 秒没 pong 就 terminate:42-43

拒绝 Origin 头是针对浏览器的:一个恶意网页可以向 127.0.0.1 发跨源请求,但它没法伪造/去掉 Origin。所以"带 Origin = 来自浏览器页面 = 拒绝"。

--openai-api 把每个 agent 当成一个 model。 打开后 isOpenAiCompatPath(app-server-openai.ts:63)接管三条路由:

路由实现
/v1/modelshandleListModels(app-server-openai-common.ts:219)
/v1/chat/completionshandleChatCompletions(app-server-openai.ts:87)
/v1/responseshandleResponses(app-server-openai-responses.ts:523)

模型 id 的去歧义规则很讲究(buildAdvertisedModelMap,app-server-openai-common.ts:196-217):只有当 agent 名字在所有名字里唯一不与任何 agent id 相同时,才用名字对外;否则用 agent id。这样"每个对外 id 恰好解析到一个 agent",而且用 agent id 直接查询永远不会被别人的名字劫持。

列表还刻意用异步迭代把所有页读完(上限 1000),注释解释了原因:只读第一页会在 agent 超过一页时静默丢失(:174-190)。

外部工具是反向的。 installExternalToolBridge(external-tools.ts:100)注册一个执行器:当模型调用一个由远端声明的工具时,listener 反向发 external_tool_call_request 给那条连接,然后等——超时 5 分钟(:22)。桌面端由此可以把自己的能力(比如 UI 操作)注入 agent 的工具集,而不用改 letta-code。


7. 消息渠道:插件契约 + 独立进程

7.1 插件契约

src/channels/README.md 定义了用户自定义渠道的落盘形状:

~/.letta/channels/whatsapp/
channel.json # 注册元数据
plugin.mjs # 实现
accounts.json # 账号(含 dmPolicy / allowedUsers / plugin 私有 config)
routing.yaml # 路由表
pairing.yaml # 配对状态
runtime/ # 由 letta channels install 装进来的运行时依赖

channel.json 四个字段承重:

字段约束
id必须等于目录名,只能小写字母数字 _ -
entry相对渠道目录解析
runtimePackagesletta channels install <id> 装进 runtime/
runtimeModules先找内置一方运行时,再找用户 runtime/

plugin.mjs 导出 channelPlugin(或 default),两个能力面:createAdapter(account) 返回一个有 start/stop/isRunning/sendMessage/onMessage 的适配器;messageActions 用来给共享的 MessageChannel 工具贡献动作和 schema 片段。加载在 plugin-registry.ts:325 loadChannelPlugin

一方渠道(Slack/Telegram/Discord)住在 src/channels/<id>/,由内置注册表登记,可以有桌面端专属 UI 和兼容垫片;用户插件在这个 MVP 里刻意是 headless 的(README :302-310)。

7.2 进程模型:为什么要多一个子进程

letta server --channels telegram

├─ 主进程:listener runtime
│ └─ 起一个只绑 loopback 的 app-server ◄──── WS ─────┐
│ (listen.tsx:538 startAppServer) │
│ │
└─ spawn 子进程:letta channel-gateway --app-server-url … │
├─ ChannelGateway ───────────────────────────────┘
└─ Telegram / Slack / … 适配器 + 凭据
▲ 父子之间:子进程 stdout 的行前缀协议
CHANNEL_GATEWAY_READY / _RESPONSE {json} / _EVENT {json}

动机写在 gateway-core.ts:205-207 的类注释里:ChannelGateway 是"进程中立的桥,只说公开的 App Server 协议;渠道适配器和凭据留在注入的 hooks 后面"。落到工程上有三个好处:

  1. 凭据隔离 —— Telegram bot token、Slack app token 只活在子进程里;
  2. 崩溃隔离 —— 子进程异常退出触发 onUnexpectedExit(listen.tsx:558-561),不会把 listener 一起带走;
  3. 协议是真的公开协议 —— gateway 用的接口和桌面端用的是同一套,不存在"内部捷径"。

监督者 startChannelGatewaySupervisor(gateway-supervisor.ts:66)负责拼命令行、spawn、按行解析三种前缀、30 秒命令超时、5 秒关闭超时。启动成功后,listener 把 runtime.serviceCommandHandler 接到监督者上(listen.tsx:575-587),于是"从 ADE 发一条 /channels 命令"能一路穿到子进程。

letta channel-gateway 本身(channel-gateway.ts:35)是内部命令:它要求 --app-server-url,起 startLocalChannelGateway,打印 CHANNEL_GATEWAY_READY 然后待命读 stdin 里的命令信封。

7.3 一条消息从平台到 agent

平台事件

① adapter.onMessage(msg)

② 门禁:evaluateChannelSenderAccess → allow | deny | pair
│ (access-control.ts:176)

③ 去抖:同 key 的连发合成一批
│ (inbound-debounce.ts:63)

④ 路由:getRouteForInboundMessage → {agentId, conversationId}
│ (routing.ts:209);没有路由就发配对码(pairing.ts:139)

⑤ 成形:buildChannelTurnSource + <channel-notification> XML
│ (processor.ts:34 / :53)

⑥ ChannelGateway.submit → App Server submit_input → 回合队列
(gateway-core.ts:248)

门禁的顺序是"最宽松优先",注释把它列成了五步(access-control.ts:153-175):allow-all 环境变量 → 有效allowlist(账号 allowedUsers + adminUsers + 两级环境变量,支持 *)→ 配对批准 → 才轮到 scope 策略。配对授权和 allowlist 是并集关系,不是二选一。

WhatsApp 和 Signal 的身份有多种写法(JID vs 手机号、UUID vs E.164),所以 allowlistMatches 对这两个渠道走归一化匹配(:120-138)。

路由的 key 是四元组:channel:account:chatId:threadId(routing.ts:42-48),thread 为空时归到 __root__。Telegram 私聊有个特例回退:带 threadId 找不到就退回 root 路由(:224-233)。

去抖保序。 createInboundDebouncer(inbound-debounce.ts:63)是从 openclaw 移植的"预留槽位"模型(文件头注释 :10-12):同 key 的立即项不能越过该 key 上等待 flush 的缓冲。这样"用户连发三条短消息"能合成一次回合,又不会让第四条乱序插到前面。

批量消息包了标记。 formatBatchedChannelMessagesForAgent(processor.ts:68)在多条时包 --- Batched Channel Messages (n) ---,单条时原样透出。

7.4 回消息是一个工具

关键认知:渠道消息进来是"投递",出去是"agent 主动调工具"。 README 特意加了一段说明(:136-143):transcript 里能看到 <channel-notification> 但没有 MessageChannel 调用,通常是模型/提示词问题,不是适配器故障。

这条工具是动态拼出来的:

文件干什么
message-channel-tool-definition.ts按当前活跃渠道拼 description 与 schema(比如没 Telegram 就删掉 send-rich 段落)
message-channel-gateway-tool.ts:10buildGatewayMessageChannelTool:有路由 sources 就 scoped;没有就用可主动发起的 Slack 账号
message-channel-executor.ts:371executeMessageChannel:真正调适配器
message-channel-idempotency.ts:31幂等域

幂等域的设计很克制:只压紧邻的重复(:26-29)。一旦有不同动作插进来,记忆就清空。而且压掉的重复会抛 MessageChannelDuplicateActionError,错误文案直接告诉模型"这条没发出去,别重试,继续你的回合"(:9-20)——给模型一个明确的失败,好过一个虚假的成功

7.5 五个一方适配器

渠道传输一句话
SlackBolt + Socket Mode应用级 WebSocket 收事件与斜杠命令;线程内首次参与后默认免 @,可按频道设 mention_only_channels
TelegramgrammY 长轮询不需要 webhook,配置最简单,支持 send-rich
Discorddiscord.js有独立的频道门禁与打字状态控制
WhatsAppBaileys(装进 runtime/)JID/LID 身份归一化、媒体策略、重连调度
Signalsignal-cli JSON-RPC + SSE需要外部 daemon;self_chat_mode 是个人号的安全模式,只路由 Note to Self

8. 定时:一台机器一个调度器

8.1 状态在文件里,不在内存里

定时任务存 ~/.letta/crons.json(cron-file.ts:124 getCronFilePath),读写走目录锁(acquireLock :343withLock :401)。文件里除了任务数组,还有一个 scheduler_owner:

claimSchedulerLease() verifySchedulerLease(token) releaseSchedulerLease
(cron-file.ts:640) (:668) (:682)
│ │ │
写入 {pid, token, 每次 tick 都校验 停止时清空
started_at, 进程身份} pid+token 是否还是我

已有 owner 且进程还活着 → 抛错(拿不到租约)
owner 进程已死 / 就是自己 → 接管

isProcessAlive(:258)不只看 pid 存在,还比对捕获的进程身份,避免 pid 复用导致误判"别人还占着"。

8.2 谁来跑 tick

调度器挂在 listener 上:startConnectedListenerRuntime 在连接建立时调 startCronScheduler(lifecycle.ts:475)。也就是说 letta serverletta server --listen 起来了,cron 才会跑。LETTA_DISABLE_CRON_SCHEDULER=1 可以整体关掉(lifecycle.ts:396-399),注释解释了场景:同一目录跑多个实例时,只有一个能拿租约,其余会每次连上都刷一行 lease 错误。

startScheduler(scheduler.ts:606)抢租约失败会重试 3 次、每次隔 30 秒,并明确告诉用户"在调度器起来前任务不会触发"。

一次 tick 的判断链(scheduler.ts:496-560):

每 60 秒 tick

├─ verifySchedulerLease 失败 ──► stopScheduler,退出

├─ 按 crons.json 的 mtime 决定要不要重读任务(refreshTaskCache)

└─ 对每个 active 任务:
handleTaskPreflight ──命中──► 记 failed / missed,跳过
(:434 = 无效 cron :232 + 错过的一次性 :396)
shouldFireTask(:202) ──否──► 跳过
firedThisMinute 去重(minuteKey :110)
└─► setTimeout(jitter) ──► fireCronTask(:254)

8.3 四个细节

触发判据分两路。 shouldFireTask(:202):一次性任务比 scheduled_for + jitter_offset_ms 是否已过;周期任务用 cronMatchesTime(task.cron, now, task.timezone)(parse-interval.ts:333)判这一分钟是否匹配。注释点明周期任务的抖动不在这里生效,而是在调用点用 setTimeout 延迟。

抖动上限是一个 tick。 computeJitter(cron-file.ts:441)对周期任务取 min(周期 × 10%, 59_999) ——因为调度器一分钟只看一次,跨分钟的抖动会被直接吃掉。对整点/半点的一次性任务反过来往前抖最多 90 秒(:459-467),避开所有人都定在整点的洪峰,同时保证不早于创建时间。

错过要留痕,不能静默丢。 handleMissedOneShot(:396)把超过 5 分钟没跑的一次性任务标成 missed,并写 run log,记录原因是 scheduler_inactive 还是上一次的失败原因。同理 handleInvalidRecurringTask(:232)对历史遗留的非法 cron 表达式保留任务但记一次可见失败,注释说明了理由:让用户能看见并替换,而不是悄悄丢掉或被 GC 清走。

"立即运行"不需要租约。 runCronTaskNow(:440)优先用完整的 schedulerState,拿不到就退回 listenerFireContext——注释解释得很清楚:"Send now 只需要活着的 listener,不需要 tick 循环和租约"(:459-461)。多实例场景下,没抢到租约的那个进程照样能手动触发。

8.4 提示词与包导出

触发时不是把用户的 prompt 原样发过去,而是包一层时间上下文:wrapCronPrompt(:114)→ formatCronPrompt(prompt.ts:36)→ formatScheduledTaskPrompt,带上 scheduledFor(本该触发的那一刻,由 getIntendedCronOccurrence prompt.ts:20 算)、currentTime(调度器实际入队的时刻)、时区、以及周期任务的第几次触发。

区分"本该几点"和"实际几点"很关键:抖动和错过补跑会让两者不同,agent 得知道自己是准点跑还是补跑。

src/schedules.ts 只有 14 行,是包导出面 @letta-ai/letta-code/schedules。文件头写死了约束:"这个入口必须不含 Node 和 backend 依赖"——它只导出纯契约(formatScheduledTaskPrompt / parseScheduledTaskPrompt / SCHEDULE_ORIGIN_TAG 与类型),让任何生产者和 transcript 消费者共享同一个信封定义。

8.5 触发到底做了什么

fireCronTask(scheduler.ts:254)的动作序列很能说明"定时只是另一个上游":

  1. 拿活跃 listener runtime,没有就记 runtime_unavailable 失败(:261-278);
  2. 解析目标会话——"new" 建新会话并打上 SCHEDULE_ORIGIN_TAG,"default" 用默认,其余用指定 id(:121-133);
  3. getOrCreateConversationRuntime 拿会话 runtime(:294);
  4. 把一条 kind: "cron_prompt" 塞进 queueRuntime(:326-334)。

到第 4 步,后续流程就和"用户从桌面端发一条消息"完全一样了。


9. 巧妙之处(可以拿走的)

一、路由函数返回 null 而不是抛错。 runSubcommandnumber | null 表达"这不归我管"(router.ts:62),让子命令表和主流程彻底解耦。

二、只读投影代替平行状态。 ConversationRuntime 上的 isProcessing/loopStatus/activeRunId 全是转发给 TurnLifecycle 的 getter(runtime.ts:265-292),配合 AGENTS.md 的"不准加 setter",从结构上杜绝了状态漂移。

三、单一写出口自动盖字段。 stream-json 的 timestampwriteWireMessage 统一盖(stream-json-writer.ts:31),新调用点不可能忘。

四、把"重复动作"变成显式错误。 MessageChannelDuplicateActionError 让模型知道消息没发出去,而不是收到一个假的成功(message-channel-idempotency.ts:9-20)。

五、缓存重查 dirty 而不是信任 in-flight。 ensureSecretsHydratedForAgentwhile(true) 循环(secrets-sync.ts:109-166)。

六、把 Origin 头当作"来自浏览器"的判据。 App Server 对带 Origin 的 HTTP 一律 403(app-server.ts:255-262),用一个浏览器无法伪造的信号挡掉网页对本机端口的探测。

七、对外 id 去歧义时,优先保证"不可劫持"。 agent 名字只在唯一且不与任何 id 撞车时才对外(app-server-openai-common.ts:196-217)。

八、把补丁写成完整文件而不是 diff。 vendor/ink/ + postinstall 复制(scripts/postinstall-patches.js:91-109),补丁内容可读、可 review,代价是升级要重新对齐。


10. 边界与局限

10.1 多数常驻能力依赖 Letta 服务端

BackendCapabilities(src/backend/backend.ts:151-158)是这一章最硬的边界。三个和本章直接相关的能力位:

能力APIBackend(backend.ts:306-309)LocalBackend(local-backend.ts:246-249)
remoteMemfstruefalse
serverSecretstruefalse
serverSideToolManagementtruefalse

后果很具体:本地后端下,secrets-sync.ts 那套"从服务端水合 secrets"无从谈起,远端 memfs 同步也不成立(本地后端走的是 localMemfs: true 的另一条路)。本地后端不是"Cloud 的等价物",是能力更窄的子集。

10.2 本地后端仍是实验开关

打开方式三选一:环境变量 LETTA_LOCAL_BACKEND_EXPERIMENTAL=1(src/backend/local/paths.ts:14)、--backend local 标志、或保存下来的 preferredBackendMode。解析在 resolveBackendMode(src/backend/backend-mode.ts:24):显式运行时覆盖优先,否则退回环境变量。

启动时如果本地后端的 transcript 需要迁移,会打警告并退回 api 模式,以免迁移问题把用户挡在账号之外(index.ts:864-883);迁移工具是 letta local-backend migrate-transcripts

10.3 其他边界

  • 自托管 server 不能当远程环境。 没带 --channelsresolveListenerStartupMode 返回 unsupported-self-hosted 并退出(listen.tsx:173:448-457),除非 IGNORE_SELF_HOSTED_LISTENER_ERROR=1
  • 一台机器一个逻辑 listener。 独立跑的 letta server 会抢一把实例锁(listen.tsx:487-505),已有同名环境在跑就报 pid 并退出;想并存得换 --env-name
  • 一台机器一个 cron 调度器。 租约是全机唯一的,其余进程只能手动 runCronTaskNow
  • CLI 改渠道配置不热更新。 letta channels route add/removepair 只改文件,不通知正在跑的 listener;要么用 ADE/桌面端的 /channels WS 命令,要么重启(channels.ts:85-87)。
  • 用户自定义渠道插件是 headless MVP。 没有桌面端专属界面,不支持 Slack/Discord 式的自动路由,只能走通用的配对 + 路由流程(channels/README.md:120-134:302-310)。
  • Slack 的 dmPolicy: "pairing" 名不副实。 它是从未被强制执行的历史默认值,实际按 open 处理;要限制 Slack 私信必须显式写 allowlist(access-control.ts:205-213)。
  • <Static> 历史条目的属性会过期。 见 §4.3,项目明确接受。

11. 代码地图

主题文件符号
主入口与分流src/index.tsmain
子命令路由表src/cli/subcommands/router.tsrunSubcommandsubcommandNeedsEarlyBackendMode
headless 判据src/cli/startup-mode.tsisHeadlessStartup
letta server 二级分发src/cli/subcommands/server.tsresolveServerCommandasLegacyAppServerCommand
远程环境启动src/cli/subcommands/listen.tsxrunListenSubcommandresolveListenerStartupMode
App Server 子命令src/cli/subcommands/app-server.tsrunAppServerSubcommand
渠道子进程入口src/cli/subcommands/channel-gateway.tsrunChannelGatewaySubcommand
环境列表 / 传送src/cli/subcommands/environments.tsteleport.tsrunEnvironmentsSubcommandrunTeleportSubcommand
只读查询src/cli/subcommands/agents.tsmessages.tsrunAgentsSubcommandrunMessagesSubcommand
本地后端维护src/cli/subcommands/local-backend.tsrunLocalBackendSubcommand
TUI 目录说明src/cli/app/README.md
TUI 状态与副作用src/cli/app/AppCoordinator.tsxApp
TUI 纯渲染src/cli/app/AppView.tsxAppView
斜杠命令路由src/cli/app/use-submit-handler.tsuseSubmitHandler
插队判定src/cli/app/command-routing.tsshouldSlashCommandBypassQueue
转录静态渲染src/cli/app/StaticTranscript.tsxStaticTranscript
Ink 内联补丁scripts/postinstall-patches.jsvendor/ink/copyToResolved
headless 主流程src/headless.tshandleHeadlessCommandrunBidirectionalMode
stream-json 出口src/stream-json-writer.tswriteWireMessagewriteWireMessageAsync
listener 契约文档src/websocket/listener/AGENTS.md
listener runtimesrc/websocket/listener/runtime.tsgetActiveRuntimecreateConversationRuntime
连接管理src/websocket/listener/connection.tsopenListenerConnectionsuspendListenerConnection
runtime 生命周期src/websocket/listener/lifecycle.tscreateRuntimeattachOpenListenerSocketstartConnectedListenerRuntime
回合编排src/websocket/listener/turn.tshandleIncomingMessage
回合状态机src/websocket/listener/turn-lifecycle.tsTurnLifecycle
排队门禁src/websocket/listener/queue.tsshouldProcessInboundMessageDirectly
批准分发src/websocket/listener/approval.tsrequestApprovalOverWS
批准恢复src/websocket/listener/recovery.tsresolveRecoveredApprovalResponse
记忆惰性同步src/websocket/listener/memfs-sync.tsensureMemfsSyncedForAgent
secrets 水合src/websocket/listener/secrets-sync.tsensureSecretsHydratedForAgent
会话传送src/websocket/listener/teleport.tshandleTeleportRequestclaimPendingTeleportAtBoundary
外部工具桥src/websocket/listener/external-tools.tsinstallExternalToolBridgeregisterRuntimeExternalTools
App Serversrc/websocket/app-server.tsstartAppServerparseAppServerListenUrl
App Server 认证src/websocket/app-server-auth.tsauthorizeUpgradeisUnauthenticatedNonLoopbackListener
OpenAI 兼容路由src/websocket/app-server-openai.tsisOpenAiCompatPathhandleOpenAiCompatRequest
OpenAI 模型映射src/websocket/app-server-openai-common.tshandleListModelsresolveAgentForModel
Responses 路由src/websocket/app-server-openai-responses.tshandleResponses
渠道插件契约src/channels/README.mdplugin-registry.tsloadChannelPlugin
渠道注册表src/channels/registry.tsChannelRegistryinitializeChannelscompletePairing
路由表src/channels/routing.tsgetRouteForInboundMessageaddRoute
网关核心src/channels/gateway-core.tsChannelGateway
网关监督src/channels/gateway-supervisor.tsstartChannelGatewaySupervisor
消息成形src/channels/processor.tsbuildChannelTurnSourceformatBatchedChannelMessagesForAgent
入站去抖src/channels/inbound-debounce.tscreateInboundDebouncer
发送者门禁src/channels/access-control.tsevaluateChannelSenderAccess
配对src/channels/pairing.tscreatePairingCodeconsumePairingCode
消息工具src/channels/message-channel-gateway-tool.tsmessage-channel-executor.tsmessage-channel-idempotency.tsbuildGatewayMessageChannelToolexecuteMessageChannelcreateMessageChannelIdempotencyScope
调度器src/cron/scheduler.tsstartSchedulershouldFireTaskrunCronTaskNowhandleMissedOneShot
任务文件与租约src/cron/cron-file.tsclaimSchedulerLeasecomputeJitteraddTask
时间表达式src/cron/parse-interval.tsparseEveryparseAtcronMatchesTime
定时提示词src/cron/prompt.tssrc/schedules.tsformatCronPromptformatScheduledTaskPrompt
后端能力位src/backend/backend.tssrc/backend/local/local-backend.tsBackendCapabilitiesAPIBackendLocalBackend
后端模式解析src/backend/backend-mode.tssrc/backend/local/paths.tsresolveBackendModeLOCAL_BACKEND_EXPERIMENTAL_ENV