88 lines
3.7 KiB
Go
88 lines
3.7 KiB
Go
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
|
||
}
|