package repo import ( "context" "errors" "github.com/agentmail/gateway/internal/db" "github.com/google/uuid" ) // ---------- 插件自动转发(免配额通道) ---------- // // **基本原则:配额约束的是模型的自主发信,不是 harness 的转发。** // // 配额存在的意义是防止 Agent 无限自我循环。而插件代劳搬运的两类消息不属于此列: // 1. 平台原生的权限询问(opencode 的 permission.updated)—— 不转给人,人就看不到, // Agent 卡在那里等一个永远不会来的回答 // 2. 本轮的最终总结(session.idle 时最后一条 assistant 消息)—— 模型已经把话说完了, // 插件只是把它搬到邮件里;对它收费会导致配额用尽时 Agent 连交代都做不了 // // 防滥用不靠计数,靠**幂等键**:relay_key 是上游那条消息的稳定标识 // (permission id / assistant message id)。唯一约束让同一条上游消息只能转一次, // 于是插件重试与 SSE 重放不会产生第二封,想多转就得拿出不同的上游消息 id —— // 而那些 id 由平台生成,模型伪造不出来。 // ErrRelayDuplicate 表示这条上游消息已经转发过了。 var ErrRelayDuplicate = errors.New("relay already recorded") // ClaimRelay 占用一次免配额转发名额。 // // 判断与占用在同一条 INSERT 里(靠主键唯一约束),并发重试下只有一个能成功 —— // 分成「先查有没有、再插入」两步的话,插件的两次重试会双双通过检查各插一条。 // // 返回 ErrRelayDuplicate 表示重复,调用方应当据此跳过发信而不是报错: // 重复转发是插件重试的正常结果,不是故障。 func ClaimRelay(ctx context.Context, agentName, relayKey, kind string) error { _, err := db.DB.ExecContext(ctx, `INSERT INTO relayed_mails (agent_name, relay_key, kind) VALUES ($1, $2, $3)`, agentName, relayKey, kind) if err != nil { if db.IsUniqueViolation(err) { return ErrRelayDuplicate } return err } return nil } // BindRelayMail 把已占用的名额关联到真正发出的邮件,便于事后审计 // 「这封免配额的信是从哪条上游消息来的」。 // // 关联失败不该让发信失败:邮件已经入库,缺一条审计关联不影响功能。 func BindRelayMail(ctx context.Context, agentName, relayKey string, mailID uuid.UUID) error { _, err := db.DB.ExecContext(ctx, `UPDATE relayed_mails SET mail_id = $1 WHERE agent_name = $2 AND relay_key = $3`, mailID, agentName, relayKey) return err } // ReleaseRelay 撤销名额占用。 // // 占用成功但发信失败时必须还回去,否则那条上游消息永远转不出来了 —— // 幂等键会一直认为它已经转过。 func ReleaseRelay(ctx context.Context, agentName, relayKey string) error { _, err := db.DB.ExecContext(ctx, `DELETE FROM relayed_mails WHERE agent_name = $1 AND relay_key = $2 AND mail_id IS NULL`, agentName, relayKey) return err } // RelayKeyForMail 反查某封邮件对应的上游消息 id。 // // 人类决策一条权限请求后,插件需要知道该回复 opencode 的哪个 permission —— // 光有 AgentMail 的 mail_id 是不够的,两边的 id 空间不同。 // 插件重启后内存映射会丢,所以这个映射必须在服务端持久化。 // // 无记录时返回空串(例如旧数据,或压根没走 relay 通道的请求)。 func RelayKeyForMail(ctx context.Context, mailID uuid.UUID) (string, string) { var key, kind string err := db.DB.QueryRowContext(ctx, `SELECT relay_key, kind FROM relayed_mails WHERE mail_id = $1 LIMIT 1`, mailID).Scan(&key, &kind) if err != nil { return "", "" } return key, kind }