From e4052f8e84693fabfe59cca9846d02d6d32e4f7c Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 09:11:43 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20B-8=20=E5=9C=A8=20HomeAgent=20=E4=B8=8A?= =?UTF-8?q?=E6=98=AF=20N/A=EF=BC=88=E5=B9=B3=E5=8F=B0=E6=97=A0=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E7=8E=AF=E8=8A=82=EF=BC=89=EF=BC=8C=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E7=9F=A9=E9=98=B5=E7=AC=AC=E5=9B=9B=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B-8 的 homeagent 那格一直标着「❌ 要先摸清 homed approval API」。 查清了:**那个 API 不存在,而且不该存在。** 判据:SDK 与核心两处 grep `approval|consent|permission|confirm`, 命中数均为 0。 前置条件是「平台本来就要问人」。另三个平台各有一个现成的审批环节 (opencode `permission.ask` / DSH `approval/request` / pi `tool_call`), 桥做的只是把它从本地 TUI 改道到邮件通道 —— 没有发明审批协议。 HomeAgent 的核心是纯思维核:本身无对外交互能力(全部能力来自插件), 也没有会话这一层(单事件循环)。它不问人,工具调用直接执行。 它确实有 `StageBeforeToolcall` 可以拦下调用(`process.go:273`,插件给 `ctx.Response` 赋值即拒绝,核心把「工具 X 已被插件拒绝」喂回模型)。 但那是「插件可以否决」而非「平台在征求同意」:没有待批准的请求、 没有选项、也没有等人的语义。 所以 B-8 是 N/A 而不是待办。硬补等于给平台加它本来没有的能力 —— 要自己划高风险工具白名单、自己定义超时与 fail closed、自己决定人不在时 怎么办,那些是产品决策不是契约合规。 顺带记下一个需要知道的事实:homeagent 的工具全部无条件执行(含 cmd_run), 接入邮件之后任何能给它发信的人或 Agent 都能间接触发,中间没有人类确认。 另三条链至少有 409 兜底,这条没有 —— 因为它根本不发起询问。 这不是缺陷而是那个平台的信任模型(homed 跑在用户自己机器上,默认完整权限), 记下来是为了让「谁能给 homeagent 发信」被当作访问控制来对待。 平台差异对照表补齐 HomeAgent 一列(14 行)。它是四平台里唯一**不需要** 为每封邮件开平台侧会话的:没有会话概念,所有邮件注入同一事件循环, 靠 output_send__agentmail 输出通道送回复。 --- docs/PHASE7-REMAINING.md | 60 +++++++++++++++++++++++++++++++++++++ docs/PLUGIN-CONTRACT.md | 65 +++++++++++++++++++++++++++------------- 2 files changed, 105 insertions(+), 20 deletions(-) diff --git a/docs/PHASE7-REMAINING.md b/docs/PHASE7-REMAINING.md index 583d10f..dbf59fb 100644 --- a/docs/PHASE7-REMAINING.md +++ b/docs/PHASE7-REMAINING.md @@ -355,3 +355,63 @@ T 前的 `M`(月)与正号 trigger 一律忽略而不是乱换算)。 会被截断。常见客户端导出的短字段不受影响 - `RRULE` 只认 `FREQ=`,忽略 `INTERVAL`/`BYDAY`/`COUNT`/`UNTIL` - 周/日视图不按小时定位色块高度(事件都是等高行,不体现时长) + + +--- + +## B-8(权限询问转邮件)在 HomeAgent 上不适用 + +四平台能力矩阵里 homeagent 的 B-8 一直标着「❌ 要先摸清 homed approval API」。 +本轮查清了:**那个 API 不存在,而且不该存在。** + +判据:在 SDK(`third_party/homeagent-sdk/sdk/*.go`)与核心 +(`internal/**/*.go`)两处 grep `approval|consent|permission|confirm`, +命中数均为 **0**。 + +原因是设计取向不同。另三个平台各有一个现成的审批环节,桥做的只是把它从 +本地 TUI **改道**到邮件通道: + +| 平台 | 审批钩子 | +|---|---| +| opencode | `permission.ask` | +| DSH | `approval/request` | +| pi | `tool_call` | + +HomeAgent 的核心是一个**纯思维核** —— 本身没有任何对外交互能力, +全部能力来自插件,也没有会话这一层(单事件循环)。它不问人: +工具调用直接执行。 + +它确实有一个可以拦下调用的位置:`StageBeforeToolcall` +(`internal/agent/core/process.go:273`,插件给 `ctx.Response` 赋值即视为拒绝, +核心会把「工具 X 已被插件拒绝」当作 tool 结果喂回模型并发 `status: "denied"` +事件)。但那是「插件可以否决」而不是「平台在征求同意」:没有待批准的请求、 +没有选项、也没有等人的语义。 + +**因此 B-8 在这个平台上是 N/A,不是待实现项。** 硬要补等于给平台加一层 +它本来没有的能力:要自己划高风险工具白名单、自己定义超时与 fail closed +语义、自己决定人不在时怎么办 —— 那些都是产品决策而不是契约合规。 +(技术上可行:`RegisterTool` 的 handler 是我们的代码,能在执行前发权限邮件 +并阻塞等待;`InjectInputSync` 证明这套 SDK 里「同步等外部答复」是既有形态。 +但没有需求驱动就不做。) + +### 随之而来的一个事实,需要知道 + +homeagent 的工具**全部无条件执行**,其中包括 `cmd_run` 这类能力。 +它接入 AgentMail 之后,任何能给 `homeagent@…` 发信的人或 Agent 都能间接 +触发这些工具,中间没有人类确认环节。 + +另三条链上至少有一道兜底:桥收到 409(Gateway 判定整条会话树上没有人类 +可路由)时当场表态拒绝。homeagent 这条链没有这一环 —— 因为它根本不发起询问。 + +这不是缺陷,是那个平台的信任模型:homed 及其插件都跑在用户自己的机器上, +默认完整权限。记在这里是为了让「谁能给 homeagent 发信」这个问题被当作 +访问控制来对待,而不是当作邮件权限。 + +### 契约文档的改动 + +- `B-8` 标题从「若平台支持,MUST」改为「若平台有**审批环节**,MUST;没有则 N/A」, + 并补一段说明前置条件与 `StageBeforeToolcall` 的语义差异 +- 平台差异对照表补齐 **HomeAgent 一列**(14 行)。它是四个平台里 + 唯一不需要为每封邮件开平台侧会话的 —— 没有会话概念,所有邮件注入同一个 + 事件循环,靠 `output_send__agentmail` 输出通道送回复 +- 检查清单里「未决权限询问 fail closed」标注适用条件 diff --git a/docs/PLUGIN-CONTRACT.md b/docs/PLUGIN-CONTRACT.md index 5c9321f..c544179 100644 --- a/docs/PLUGIN-CONTRACT.md +++ b/docs/PLUGIN-CONTRACT.md @@ -313,11 +313,30 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一 > **B-7.5 为什么跳过 permission**:原来的工具调用早随进程一起没了, > 投过去模型没有可恢复的上下文。 -### B-8 平台权限询问 → 邮件(若平台支持,MUST) +### B-8 平台权限询问 → 邮件(若平台有审批环节,MUST;没有则 N/A) 挂平台的权限/审批钩子,把询问转成一封邮件问人。这是 `I-1` 最直接的体现: 被平台真正拦下的那一次才是事实,不依赖模型「记得」问人。 +> **前置条件是「平台本来就要问人」。** +> +> 三个已实现的平台各有一个现成的审批环节,桥做的只是把它从本地 TUI +> **改道**到邮件通道:opencode 的 `permission.ask`、DSH 的 `approval/request`、 +> pi 的 `tool_call`。桥没有发明审批协议。 +> +> HomeAgent 的设计不同:核心是一个纯思维核,本身没有任何对外交互能力 +> (能力全部来自插件),因此它**不问人** —— 工具调用直接执行。 +> 它确实有 `StageBeforeToolcall` 这个可以拦下调用的位置 +> (插件给 `ctx.Response` 赋值即视为拒绝),但那是「插件可以否决」而不是 +> 「平台在征求同意」:没有待批准的请求、没有选项、也没有等人的语义。 +> +> 在那种平台上,B-8 整条**不适用**(N/A),不是待实现项。硬要补的话等于 +> 给平台加一层它本来没有的能力:需要自己划定高风险工具白名单、自己定义 +> 超时与 fail closed 语义 —— 那是产品决策,不是契约合规。 +> +> 判据:在 SDK 与核心两处 grep `approval|consent|permission|confirm`, +> 命中数均为 0。 + ``` 平台权限钩子被调用 ├─ 1. POST /permission/request(带平台的权限 id 作 relay_key) @@ -582,7 +601,7 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一 ### D-5 别名来源阶梯(`C-11` 缺失或不可用时) -| 优先级 | 别名来源 | 何时用 | +| 优先级 | 别名来源 | 何时用 | 邮件主题派生(无平台标题) | |---|---|---| | 1 | 平台自带的 slug(如 `witty-planet`) | 平台创建会话时就给 | | 2 | 由平台标题派生(`slugFromTitle`) | 有标题且标题可用 | @@ -1018,7 +1037,7 @@ GET /api/v1/attachments/{id} [ ] sqlite3 "SELECT status, last_seen FROM agents WHERE agent_name=''" → status=online,last_seen 每 30 秒推进(B-2.1) [ ] 断网 60 秒再恢复:SSE 自动重连,期间的邮件通过 Last-Event-ID 补回(D-7.2) -[ ] kill 插件:未决权限询问全部 fail closed(B-8.2) +[ ] kill 插件:未决权限询问全部 fail closed(B-8.2;平台无审批环节时跳过) ``` ### 7.3 主链路 @@ -1144,24 +1163,30 @@ GET /api/v1/attachments/{id} 三次真实适配的对照表。接新平台时逐行回答「我这边是什么」。 -| 关注点 | opencode | DeepSeek Harness | pi | -|---|---|---|---| -| 插件形态 | `export default async function(input)` | Cordis:`export const inject` + `apply(ctx, config)` | **常驻守护进程**,不是插件(见下) | -| 建会话 | `client.session.create({ query: { directory } })` | `ctx.agents.create({ sessionId, meta: { cwd }, agentOptions })` | `createAgentSession({ cwd, sessionManager, … })` | -| 注入一轮 | `client.session.promptAsync({ parts })` | `agent.followup(UserMessage)` | `session.prompt(text)`;忙时 `prompt(text, {streamingBehavior:'followUp'})` | -| 工具定义 | zod schema | `defineTool()` + spec 格式参数 | `defineTool()` + **TypeBox** schema | -| 轮次结束 | `session.idle` 事件 | `agent/status` → `idle` | `prompt()` 的 promise resolve;事件是 `agent_end` | -| 模型失败信号 | `session.error` 事件 | `turn/end` 的 `reason.kind === 'error'` | `prompt()` reject **或** 末条 assistant 的 `stopReason==='error'` | -| 权限钩子 | `permission.ask`(**同步,不能等**) | `approval/request`(异步 waterfall,**能等**) | `tool_call` 扩展事件(**能 await**,实测) | -| 会话列表 | `client.session.list()` | `ctx.sessionQuery.listSessions()` | `SessionManager.listAll()`(**不传参**,传字符串会被当自定义目录) | -| 模型目录 | `client.config.providers()`(`models` 是**对象**) | `ctx.llm.listProviders()` + `listModels()` | `modelRuntime.getAvailable()`(**不是** `getModels()`:1221 条里只有 1 条能用) | -| 别名来源 | `session.slug`(创建时就有) | 模型标题派生 | **邮件主题派生**(SDK 会话没有平台标题,见下) | -| 日志可见性 | `console.error` | `console.error`(`ctx.logger` 不进 journalctl) | `console.error` | -| 工作区分组 | `session.create({directory})` 自带 | `ctx.get('workspaceRegistry')` → `create(cwd)` + `attachSession()` | 按 cwd 自动分目录(`~/.pi/agent/sessions/--tmp-x--/`),无需注册 | -| 工具参数命名 | `path` / `save_to` | `file_path` / `save_path` | `file_path` / `save_path` | -| 权限三态 | 原生 `once`/`always`/`reject` | **只有** `allowed-once`/`rejected` | 只有 block/放行 | -| 「一直同意」 | 给,平台自己记 | **不给**(表达不了,桥代劳会覆盖平台策略) | 给,桥用 `createGrantStore()` 记 | +| 关注点 | opencode | DeepSeek Harness | pi | HomeAgent | +|---|---|---|---|---| +| 插件形态 | `export default async function(input)` | Cordis:`export const inject` + `apply(ctx, config)` | **常驻守护进程**,不是插件(见下) | 独立子进程 `plugin.bin`,SDK 走 C ABI | +| 建会话 | `client.session.create({ query: { directory } })` | `ctx.agents.create({ sessionId, meta: { cwd }, agentOptions })` | `createAgentSession({ cwd, sessionManager, … })` | **没有会话概念** —— 单事件循环,不做会话映射 | +| 注入一轮 | `client.session.promptAsync({ parts })` | `agent.followup(UserMessage)` | `session.prompt(text)`;忙时 `prompt(text, {streamingBehavior:'followUp'})` | `s.InjectText(source, channel, text)` / `InjectInputSync`(同步等回复) | +| 工具定义 | zod schema | `defineTool()` + spec 格式参数 | `defineTool()` + **TypeBox** schema | `s.RegisterTool(name, ToolDef, handler)`,Parameters 是裸 JSON Schema map | +| 轮次结束 | `session.idle` 事件 | `agent/status` → `idle` | `prompt()` 的 promise resolve;事件是 `agent_end` | 无「轮次」事件;靠 `RegisterOutputChannel` 的 handler 被调用 | +| 模型失败信号 | `session.error` 事件 | `turn/end` 的 `reason.kind === 'error'` | `prompt()` reject **或** 末条 assistant 的 `stopReason==='error'` | 无(核心不把模型错误暴露给插件) | +| 权限钩子 | `permission.ask`(**同步,不能等**) | `approval/request`(异步 waterfall,**能等**) | `tool_call` 扩展事件(**能 await**,实测) | **无审批环节**(核心不问人)。有 `StageBeforeToolcall` 可否决,但语义不同 —— 见 `B-8` | +| 会话列表 | `client.session.list()` | `ctx.sessionQuery.listSessions()` | `SessionManager.listAll()`(**不传参**,传字符串会被当自定义目录) | N/A | +| 模型目录 | `client.config.providers()`(`models` 是**对象**) | `ctx.llm.listProviders()` + `listModels()` | `modelRuntime.getAvailable()`(**不是** `getModels()`:1221 条里只有 1 条能用) | N/A(模型由核心配置,插件不选) | +| 别名来源 | `session.slug`(创建时就有) | 模型标题派生 | **邮件主题派生**(SDK 会话没有平台标题,见下) | 邮件主题派生(无平台标题) | +| 日志可见性 | `console.error` | `console.error`(`ctx.logger` 不进 journalctl) | `console.error` | `log.Printf` 进 homed 的 journalctl(带 `[plugin]` 前缀) | +| 工作区分组 | `session.create({directory})` 自带 | `ctx.get('workspaceRegistry')` → `create(cwd)` + `attachSession()` | 按 cwd 自动分目录(`~/.pi/agent/sessions/--tmp-x--/`),无需注册 | N/A(无会话,无 cwd 概念) | +| 工具参数命名 | `path` / `save_to` | `file_path` / `save_path` | `file_path` / `save_path` | 自定(本桥用 `file_path` / `save_path` 对齐其他平台) | +| 权限三态 | 原生 `once`/`always`/`reject` | **只有** `allowed-once`/`rejected` | 只有 block/放行 | N/A | +| 「一直同意」 | 给,平台自己记 | **不给**(表达不了,桥代劳会覆盖平台策略) | 给,桥用 `createGrantStore()` 记 | N/A | +> **HomeAgent 为什么整列都是 N/A**:它的核心是一个纯思维核 —— +> 本身没有任何对外交互能力,全部能力来自插件,也没有「会话」这一层 +> (单事件循环)。因此桥在这个平台上不做会话映射:所有邮件注入同一个循环, +> 靠 `output_send__agentmail` 输出通道把回复送出去。 +> 它是四个平台里唯一**不需要**为每封邮件开一条平台侧会话的。 +> > **pi 为什么是守护进程而不是扩展**:pi 扩展被加载进**一条已经存在的**会话, > 那条会话的 cwd 由启动 pi 的人决定。而 `B-3.1` 要求每封邮件的 `to_workspace` > 成为会话 cwd —— 扩展做不到「按邮件新开一条 cwd 不同的会话」。