Files
gori-agent/README.md
T
zenord 5229059fe3 feat: share finish stop and cancel across the board
Proposal visibility is already global; this change makes finish, stop,
and cancel shared queue-management actions so any user can unblock the
single worker and shared queue. Confirm, adjust, follow_up, and
start_next remain owner-only. Assistant/bootstrap copy, /help, /list,
and runtime checks now align with that split.
2026-08-22 00:02:26 +08:00

289 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# gori-agent
`gori-agent` 是一个 Node.js 20+ / TypeScript 网关:从一个 IM 平台接收消息,通过官方 Agent Client Protocol(ACP)驱动一个 Coding Agent。
```text
用户 → 单个平台 Adapter → Gateway → AssistantManager(Assistant / Proposal / Worker)→ 单个 ACP Agent
```
## Config v3 实例模型
Config v3 只支持以下边界:
- 一份运行配置对应一个固定 Bot 身份。
- 一个 Bot 只有一个 workspace、persona、ACP agent、skill 列表和 permission policy。
- 每个 chat 一个无工具 Assistant 会话;它只对话、创建 Proposal 并解释 Worker 反馈,自己从不执行。
- 同一时刻全实例只有一个 Worker 在 `bot.workspace` 执行一个已确认 Proposal;Worker 每轮只交回 result(pending),由用户决定 finish 结束还是继续说要求继续,系统不会自动开始下一个 Proposal。
- 一个实例只绑定一个平台;多平台使用多个实例。
- 不再有 `roles[]`、`defaultRole`、`backends[]`、动态 `/role` 切换、单主任务/近似 BTW 或 Config v1/v2 迁移。
- `configVersion` 非 `3` 会明确失败,旧 state v1/v2 也不会自动改写。
一台机器上的实例统一位于:
```text
${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances/<bot-id>/
├── config.json
├── logs/gori-agent.log
└── state/
├── acp-sessions.json # state v3:Assistant binding
├── proposals.json # Proposal 记录
├── assistant-workspaces/ # 每 chat 私有 Assistant cwd
├── kimi/assistant/ # Assistant 私有 KIMI_CODE_HOME
└── gori-agent.pid
```
实例目录就是该进程的 `GORI_AGENT_HOME`。实例目录、`state/`、`logs/` 使用 `0700`,配置与状态文件使用 `0600`。
## 构建与安装
```bash
npm install
npm run build
./install.sh
```
安装器只安装一个 launcher;不会为每个 Bot 复制源码或 `dist/`。默认 launcher 位于 `$HOME/.gori-agent/bin/gori-agent`。
确认 ACP agent 可用:
```bash
kimi --version
kimi doctor
kimi acp --help
```
模型和 provider 由 ACP agent 自身配置,例如 Kimi Code 使用 `~/.kimi-code/config.toml`。gori-agent 不复制模型凭据。
## 实例管理
```bash
gori-agent init <bot-id>
gori-agent setup <bot-id>
gori-agent doctor <bot-id>
gori-agent start <bot-id>
gori-agent status <bot-id>
gori-agent logs <bot-id>
gori-agent restart <bot-id>
gori-agent stop <bot-id>
gori-agent list
```
- `init` 交互生成 Config v3,本身不启动 Bot;它会询问并校验 `gateway.server.host/port`,扫描统一实例目录中其他 Config v3 的声明端口,并在默认端口冲突时建议下一个未占用端口。
- `setup` 只重配已有实例。它校验目录、配置身份、配置/PID 权限,并拒绝运行中实例或指向其他活进程的 PID;固定 `bot.id`,保留既有 agent(含 args/env)、skills、permissions、gateway policy、runtime、平台 secrets 与 publicBaseUrl,确认写入后自动运行 doctor。
- 平台 secret/token 使用不回显输入,空 generic webhook/weixin secret 会随机生成。
- `start` 会检查配置存在、目录名匹配 `bot.id`、目录/配置权限、模板占位符、workspace、agent executable、PID identity 和端口,并等待 `/health` 返回匹配的 Bot/platform identity;任一不满足即 fail closed。
- 实例进程由内部 `dist/cli/instance-runner.js` 承载;`status` 和 lifecycle 命令精确核对 Node executable、runner 路径及唯一 config 参数,再核对 `/health` 的 Bot/platform identity。
- 每个实例必须使用不同的 `gateway.server.port` 和平台凭据;setup 的声明冲突提示不替代 `start` 的实际端口检查。
- `stop` 发送 `SIGTERM`,runner 会优雅关闭 server,并等待最多 10 秒;不会自动 `SIGKILL`。
公开 CLI 不兼容旧 `instance` 前缀,也不提供 debug 命令、`--config` 或 `--json`。
可用 `GORI_AGENT_ROOT` 改变统一根目录:
```bash
GORI_AGENT_ROOT=/srv/gori-agent gori-agent list
```
## `config.example.json` 的定位
`config.example.json` 是仓库内唯一可提交、无密钥的配置说明,不是运行配置。它可被 Config v3 schema 解析,但保留 `BOT_ID`、`QQ_APP_ID` 等明显占位符。只有 `init` 会读取 example 作为初始化种子;其他命令只按 `<bot-id>` 加载统一实例目录中的 `config.json`,缺失即失败。
`writeConfigFile()` 使用同目录临时文件、`fsync` 和原子 rename,并强制最终文件为 `0600`。
## Config v3
完整说明见 `config.example.json`。顶层只有:
```text
configVersion 固定为 3
bot Bot、workspace、persona、assistantPersona、agent、skills、permissions
gateway HTTP server、入站 policy、唯一 platform
runtime.acp ACP state、timeout 和 worker pool
```
### Bot 与 ACP agent
```json
{
"bot": {
"id": "my-bot",
"workspace": "/absolute/workspace",
"persona": "Describe responsibilities, boundaries, and confirmation points.",
"assistantPersona": "Optional speaking personality for the Assistant; falls back to persona when empty.",
"agent": {
"id": "kimi",
"command": "/home/USER/.kimi-code/bin/kimi",
"args": ["acp"],
"env": {}
},
"skills": [],
"permissions": {
"mode": "deny",
"allowedTools": [],
"allowedCommandPatterns": []
}
}
}
```
- `bot.id` 只允许小写字母、数字和连字符,最长 63 字符。
- `workspace` 与每个 skill `file` 必须是绝对路径。
- Worker bootstrap 始终使用 `bot.persona`;Assistant 使用 `bot.assistantPersona`,为空时回退到 `bot.persona`。`assistantPersona` 只决定 Assistant 的讲话人格,可让运维 Bot 的 Assistant 说人话而 Worker 保持严格。
- `bot.skills[]` 直接声明 `{ id, file, maxBytes }`;ID 必须唯一。
- agent 使用 `shell: false` 在 `bot.workspace` 启动。
- agent env 可能包含 secret,不能进入可提交模板、日志或 diff。
### Permission policy
ACP permission request 由 `bot.permissions` 决策,persona 不是安全边界:
- `deny`:拒绝所有 permission request,默认值。
- `allowlist`:`toolCall.name` 必须与 `allowedTools` 精确匹配;bash/terminal 还必须由至少一个 `allowedCommandPatterns` 正则完整覆盖整条 raw command。名称或 command 缺失时拒绝。
- `auto`:自动允许,风险等价于 yolo;`doctor` 会告警。
`allowedTools` 只控制 ACP 审批,不会注册或创造工具。skills、agent 与 permission policy 是三个独立概念。
### 单平台配置
`gateway.platform` 是按 `type` 区分的 union,只能选择一个:
- `qq`:WebSocket 或 webhook。
- `feishu`:`/webhook/feishu`。
- `wecom`:`/webhook/wecom`;入站仍是 501 scaffold。
- `webhook`:`/webhook/generic`,同步 JSON + HMAC-SHA256。
- `weixin`:`/webhook/weixin`,外部 bridge scaffold。
对象存在即启用,不再使用 `enabled`。Server 只构造所选 adapter,也只挂载需要的 webhook route。QQ WebSocket 仅在 `type=qq` 且 `connectionMode=websocket` 时启动,此模式不挂载 `/webhook/qq`。
QQ websocket 示例:
```json
{
"type": "qq",
"connectionMode": "websocket",
"appId": "QQ_APP_ID",
"clientSecret": "QQ_CLIENT_SECRET",
"botSecret": "",
"verifySignature": true,
"botNames": ["QQ_BOT_NAME"],
"intents": 33554432,
"shard": [0, 1]
}
```
正式 Bot 应通过 `gateway.policy.allowedUsers` / `allowedChats` 限制入口。QQ 群 chat ID 为 `group:<group_openid>`;用户 ID 按 QQ 官方语义取值:群聊作者用 `member_openid`,C2C 私聊作者用 `user_openid`。
QQ 入站附件:`image/*` 附件会被下载(每条消息最多 3 张、单张超过 5MB 跳过、10 秒下载超时、缺 scheme 的 URL 自动补 `https:`)并转成 base64 随消息传给 Agent;agent 声明 `promptCapabilities.image` 时作为 ACP image content block 发送,否则降级为文本说明。视频/文件等非图片附件不下载,仅以 `[视频] <url>` / `[文件] <url>` 文本拼进消息,交给模型自由使用;纯图片消息使用占位文本「(发来一张图片)」。日志只记录附件类型/大小/数量,不记录 URL 全文或 base64。
QQ 出站图片:Worker/Assistant 报告的 workspace 内图片(png/jpg、≤10MB、最多 3 张)经 `POST /v2/{groups|users}/{id}/files` 上传(`file_type: 1`、`file_data` base64、`srv_send_msg: false`)后以 `msg_type: 7` + `media.file_info` 发送;群上传的 file_info 只能发群、私聊上传的只能发私聊,按目标分别上传。无主动消息权限(40034105),图片与文本一样只能走被动回复窗口,且与文本共用同一 `msg_id` + 递增 `msg_seq` 计数器;Worker 落定事件先发文案再发图,窗口过期时按现有规则记录并下次入站补发,补发时按路径重读文件,文件不存在则降级为文本说明。图片上传/发送失败不阻断文本,降级为文本说明 + 安全日志。其他平台 adapter 不支持图片时把图片降级为 `[图片] <文件名>` 文本行。
### ACP 生命周期
默认 `runtime.acp.promptTimeoutMs` 是 `14400000`(4 小时),适合长构建/部署任务。其他默认值:
- initialize:10 秒
- cancel grace:5 秒
- idle assistant:30 分钟
- assistant session reset idle:1 小时(`assistantSessionResetIdleMs`,`0` 禁用;Assistant 会话空闲超过该阈值且 owner 没有未完成 Proposal 时,下一条消息开新 session 而不是 resume)
- sweep:60 秒
- max processes:8(Assistant + Worker 总和)
- max assistant sessions:4
`stateFile` 为空时使用当前实例的 `$GORI_AGENT_HOME/state/acp-sessions.json`;Proposal 记录固定保存在同目录的 `proposals.json`。Kimi Code agent 的 `args` 必须严格为 `["acp"]`,避免额外启动参数绕过 Assistant 的 no-tool profile。
## Assistant / Proposal / Worker 与 state v3
运行链路是三层:
- **Assistant**:每个 conversation(chat + user)一个无工具 ACP 会话,只与用户对话;同群不同用户的会话互相隔离。它把用户意图整理成 Proposal(title、goal、steps),并解释 Worker 的反馈。Assistant 每条回复必须以隐藏 `GORI_ASSISTANT_ACTION_V2` envelope 结尾(`reply` + `actions`),action 只有 `create_proposal`、`confirm`、`adjust_proposal`、`follow_up`、`finish`、`send_image`、`start_next`、`cancel`、`stop`;格式错误只修复一次(envelope 缺闭合标签时先按花括号配平 salvage,失败才修复)。`send_image { path }` 把 Worker 报告过的 workspace 内图片发给用户,与 Worker 附件同样的路径/类型/大小校验。
- **Proposal**:一份工作单,owner 是 chat + 发起用户;`confirm` / `adjust` / `follow_up` / `start_next` 仍只有发起人本人可操作(runtime 强校验),但为了避免单人把单 worker 队列卡死,`finish` / `stop` / `cancel` 是全板共享动作:任何用户都可对可见 proposal 执行它们。Proposal 板全局共享可见:Assistant prompt 的全板列表包含所有 chat/user 的未完成条目(自己的标 `scope=own`,他人的标 `scope=other` 并附 chat 类型,不暴露 openid 明文),Assistant 可如实向任何用户描述全板状态。状态流为 `proposed → queued → working → pending → finished`。`proposed` 只有用户确认后才进入 `queued`;`pending` 就是「等用户决定」,不再区分 success/failure;只有 `finish` 把 pending 落定为 `finished(done)`,`cancel` 落定为 `finished(cancelled)`。
- **Worker**:同一时刻全实例只有一个,在 `bot.workspace` 用 `bot.permissions` policy 执行一个已确认 Proposal。每轮必须以隐藏 `GORI_WORKER_RESULT_V2` envelope 收尾:`PENDING`(`summary` 必填,可带 `question`、`workspaceDirty`),不区分成功/失败,只把结果交给用户。Worker 给用户看的图片(png/jpg)必须保存在 workspace 内(建议 `.gori-outbox/`),并通过 `attachments: [{ path, mimeType? }]`(最多 3 个)上报;runtime 校验路径必须在 workspace 内、magic bytes 为 png/jpg、单张 ≤10MB,违规的丢弃并在事件文本里说明。
确认语义是刻意的:
- Worker 落定后 Proposal 进入 `pending`,记录 `summary`、可选 `question` 和 `workspaceDirty`;用户说 `finish`(可带 note)落定为 `finished(done)`,或直接继续说要求:Assistant 发 `follow_up`,runtime 优先 resume 原 worker native session 继续(resume 失败则带 Proposal 上下文新起 session),用户输入的图片附件也随 prompt 给 Worker。
- 系统**不会**在一个 Proposal 落定后自动开始下一个;只有用户确认新 Proposal 或 Assistant 发出 `start_next` 才会推进队列。
- 阻塞规则:任何 `working` 全局拒绝 `start_next`/`follow_up`;owner 自己有 `pending` 时拒绝该 owner 的 `start_next`(提示先 finish 或 follow_up);任何 `workspaceDirty` 的 `pending` 全局拒绝 `start_next`(提示先处理 dirty 工作区)。非 dirty 的 pending 不挡其他用户。
- `stop` 只对 `working` 生效:停掉 Worker(含进程组清理)并把 Proposal 标记为 `pending`(summary 为「被用户中止」、`workspaceDirty: true`)。
- Assistant 有 pending 时每轮 prompt 注入 pending card(id/title/summary/question);Assistant 回复没提到 pending 时,runtime 在回复末尾追加一条人话兜底提醒。
- Worker working 期间,runtime 把 `session/update` 的工具活动按类别(read/search/write/execute/delegate/other)聚合成实时快照(不含工具参数、输出或正文);下一轮 Assistant prompt 注入紧凑状态卡(title、运行时长、最后活动类别、各类别计数),Assistant 据此用人话描述进度。调试期状态卡对所有人可见(他人任务会标注 `another user's proposal`)。
- Gateway 不再有 15/60/180/480 秒的固定时间提醒;Worker 落定结果时通过 Assistant 生成一条事件说明,由平台 adapter 作为**新消息**发给 owner chat(QQ 同样是新消息,不引用原消息)。
Assistant 隔离与旧 side Session 一致且更严格:
- cwd 位于实例私有 `state/assistant-workspaces/`,目录名由进程随机 salt 和 conversation key(chat + user)计算 HMAC,不直接编码 chat 标识,并且不能与项目 workspace 重叠;重启后按 binding 恢复同一目录。
- 项目级 `agent.md` override 显式设置 `tools: []` 与 `subagents: []`;ACP 传入空 `mcpServers`;Assistant 只接受 Kimi Code ACP,并强制 permission deny 作为附加层。
- 除非 `bot.agent.env` 已显式设置,Assistant 进程的 `KIMI_CODE_HOME` 指向实例私有 `state/kimi/assistant/`(`0700`),与真实用户配置隔离。
- 一旦出现 tool update 或 permission request,即视为隔离违约:当前 turn fail closed、终止整个 ACP 进程组并删除 binding。Worker 的 cancel、timeout、crash 同样按进程组清理工具后代;bootstrap 禁止 `setsid`、`nohup`、detached/daemon/background 遗留进程,主动脱离进程组仍属于无 Bubblewrap/cgroup 方案的信任边界。
- 每个 ACP worker 以独立进程组运行并携带随机 `GORI_AGENT_WORKER_TOKEN`;runner 重启时会把仍处于 `working` 的 Proposal 校验 token 后清理旧进程组,并标记为 `pending`(`worker_lost`、`workspaceDirty: true`),交给用户 finish 或 follow_up。
state v3(`acp-sessions.json`)只保存 header(version 3、`botId`、platform)和 Assistant binding:conversation key(chat + user)、agent ID、native session ID、assistant workspace、Bot fingerprint 与时间戳。`proposals.json`(version 2)保存 Proposal 记录:title/goal/steps、owner chat、发起用户、状态(`proposed | queued | working | pending | finished`)、pending(`summary`/`question?`/`workspaceDirty?`/`receivedAt`)、finished 的 `finishKind`(`done | cancelled`)/`finishNote?`、worker native session ID 与进程组 PGID/token、时间戳;不保存消息正文。两个 store 都做单 writer lock、串行持久化、临时文件 + fsync + 原子 rename;version 或 identity 不匹配(含旧 state v1/v2)一律拒绝启动并保留原文件,不清空。唯一例外是 proposals v1:打开时先在同目录写 `proposals.json.v1-<timestamp>.bak`(0600)备份,再按固定映射迁移为 v2(SUCCESS/FAILED/NEEDS_CONFIRMATION → pending,completed/failed → finished(done),cancelled → finished(cancelled))。
Bot fingerprint 包含 bootstrap schema version、Bot ID、workspace、persona、assistantPersona、agent 定义、permission policy 和 skill 路径/内容 hash;fingerprint 或 agent 不匹配的 binding 会被丢弃并重建 Assistant 会话。失败 prompt 不会自动重放,因为工具操作可能已有副作用。
## Workspace 独占
同一 `GORI_AGENT_ROOT` 下,旧的 `/usr/bin/flock` workspace lease 与 `owner.json` 已退役。`doctor`、外层 `start` 与内部 runner 都对其他实例目录和完整 Config v3 扫描 fail closed,canonical workspace 相同或互为父子都拒绝;不再做配置级 workspace 共享。`setup/start` 还通过实例 lifecycle flock 互斥,父 CLI 把已验证配置摘要传给 runner,配置发生换挡时 runner 拒绝启动。
## IM 命令
```text
/help
/status
/list
/confirm
/finish
/stop
/cancel
```
- `/help`:显示自然语言使用说明和兜底命令列表。
- `/status`:显示固定 Bot、agent、workspace、Assistant 会话数、各状态 Proposal 计数、Worker 是否在执行及当前用户是否 owner;另含当前用户维度(`myQueuedProposals`、`myPendingProposals`、`schedulerState`、`blockedReason`、`nextAction`,他人 Proposal 只给脱敏原因,不暴露 title/id)与当前 chat 的事件投递状态(`lastEventDelivery`、`lastEventError`、`lastEventAt`)。
- `/list`:全局 Proposal 板面板,按 待确认(pending 优先)→ 进行中 → 排队中 → 未确认(proposed)→ 最近结束 排列;自己的条目标注「你的」,他人的标注「其他成员」(不暴露 chat/user ID);最近结束也全局显示。
- `/confirm`:确认当前用户最近待确认的 `proposed` Proposal(进入队列)。
- `/finish`:把当前用户最近的 `pending` Proposal 落定为 `finished(done)`。
- `/stop`:停止当前用户正在执行的 Worker,Proposal 转为 `pending`(被用户中止、dirty);只有 Proposal 发起人可用。
- `/cancel`:取消当前用户最近的 `proposed` / `queued` / `pending` Proposal,落定为 `finished(cancelled)`。
日常操作以自然语言为主,Assistant 会自己生成 confirm/follow_up/finish/cancel/stop 等 action;这些命令是旁路 Assistant 的兜底入口。未识别的 `/` 命令按普通消息交给 Assistant。
Gateway 不维护普通消息队列或 per-chat task lock,也不再有固定时间(15/60/180/480 秒)的处理中提醒;每条 allowlist 普通消息按 conversation(chat + user)串行交给 AssistantManager。Worker 落定结果(成功、失败或提问)时,AssistantManager 生成事件文案并经 `Gateway.sendEvent` 发出。QQ 无主动消息权限(HTTP 400 / code 40034105)时事件走被动回复窗口:`sendEvent` 引用该 chat 最近一次入站消息(`msg_id` + 递增 `msg_seq`),群聊窗口 5 分钟(按 4.5 分钟保守判定)、C2C 窗口 60 分钟(按 55 分钟保守判定);超过窗口或没有 messageId 时不发主动消息,记录为 skipped 并在该 chat 下次入站时补发。发送失败只记录不含消息正文或 provider 错误详情的安全日志(仅 HTTP status / QQ code),不影响任务或后续发送。
每条异步入站使用独立、串行的回复流,`replySequence` 从 1 动态递增;同步 webhook 直接返回 JSON 结果。
## HTTP 端点
- `GET /health`:`configVersion`、`botId`、platform 和 aggregate ACP counters,不暴露凭据/native session ID。
- `GET /platforms`:当前单一 Bot/platform 概览。
- `POST /webhook/<selected-platform>`:只存在所选平台路由;generic 使用 `/webhook/generic`。
## Doctor 与安全纪律
`gori-agent doctor <bot-id>` 检查:
- Config v3 与 example placeholder。
- 配置权限 `0600`。
- Bot ID、workspace、skills、permissions,以及 agent `args` 严格为 `["acp"]`。
- 同一实例根下相同或 parent/child workspace 重叠。
- ACP initialize 与 session restore capability。
- state 目录可读写。
- 单平台必需字段,但不打印 credential 值。
自动测试不会启动真实 Bot、发送平台消息、pull、部署或访问远程机器。真实 `config.json`、备份、state 和日志不得提交。
## 开发验收
项目级部署/配置 skill 位于 `.kimi-code/skills/gori-agent-deploy/SKILL.md`,覆盖代码发布、实例模型/人格配置、state 备份和 QQ 事件投递边界。
```bash
npm run typecheck
npm test
npm run build
git diff --check
bash -n install.sh gori-agent.sh bin/gori-agent
```
测试使用 Node `node:test` + `tsx` 和本地 fake ACP 子进程,覆盖 Config v3、permission、Assistant/Worker envelope 协议(V2)、Proposal 状态流与 pending 语义、proposals v1→v2 迁移、timeout/cancel、worker_lost 恢复、state v3 identity、workspace 重叠扫描、Gateway 无队列语义、QQ normalization 与图片附件、ACP prompt content blocks 和单平台 route。真实 Kimi ACP 的执行层兼容性可用 `node scripts/side-session-spike.mjs` 复验(assistant no-tool spike:私有 cwd 在项目外、`mcpServers: []`、toolUpdates/permissionRequests/fsRequests 全为 0、`GORI_ASSISTANT_ACTION_V2` envelope 可解析);图片输入链路可用 `node scripts/acp-image-capability-spike.mjs` 与 `node scripts/acp-image-e2e-spike.mjs` 复验;这些脚本使用临时 cwd/profile,不读取或输出真实配置、凭据、日志正文。
`src/agents/*` 与 `src/core/session-store.ts` 仅为 legacy compatibility/reference,不在当前运行链路。运行时没有单轮 CLI fallback。