fix(permission): 权限决策者必须是人类,顺会话树查找
死锁现场:pi 的权限邮件 to_name = "pi"(决策者被解析成 Agent 自己), SendToUser 找不到收件人 —— 请求发出去后无人可答,会话无声挂死。 新增 repo.NearestHumanInThread 三级查找:会话 owner → 最近人类发件人 → 最近人类收件/抄送方。发件人优先于收件人:主动说话的人更可能在关注这条线索。 不用递归 CTE 沿 parent_mail_id 上溯而按会话扫:权限请求常常不挂父邮件, 上溯会在第一跳就断。 没有人类可路由时**自动拒绝并给 Agent 可执行的替代建议**,而不是转给管理员 —— 管理员对这条 Agent 链的上下文一无所知,收到也无法判断。 409 返回结构化的 error/detail/suggestion/decider_was。 nearesthuman_test.go 4 子测试。
This commit is contained in:
@ -116,6 +116,38 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) {
|
||||
decider = admin
|
||||
}
|
||||
|
||||
// 关键防线:decider 必须是人类用户。
|
||||
//
|
||||
// Agent 无法通过 Web UI 决策权限 —— SendToUser 投递到不存在的用户通道,
|
||||
// 而桥的 await Promise 永不 resolve,会话永久阻塞。这在 Agent 给自己发信时
|
||||
// 必然发生:pi 分配任务给自己的另一个会话 → 该会话触发权限询问 → 邮件发给 pi
|
||||
// → pi 不是人类用户 → 整条会话卡死。
|
||||
//
|
||||
// 修复:沿会话树上溯找最近的人类节点 —— 权限应追溯到最初分配任务的人。
|
||||
if isHuman, _ := repo.IsHumanUser(r.Context(), decider); !isHuman {
|
||||
human, err := repo.NearestHumanInThread(r.Context(), sessionID, decider)
|
||||
if err == nil && human != "" {
|
||||
decider = human
|
||||
} else {
|
||||
// 整条任务链上没有人类:Agent → Agent → Agent,中间没有任何人介入。
|
||||
// 此时把权限请求转给管理员毫无意义 —— 管理员对这条 Agent 链的上下文一无所知,
|
||||
// 既不知道这个 bash 命令在做什么,也不知道拒绝后 Agent 该怎么绕过去。
|
||||
//
|
||||
// 正确做法:直接拒绝,让 Agent 收到明确的错误信息,由它自己决定下一步:
|
||||
// 换用不需要权限的方式(subprocess、文件操作等),或在邮件里说明情况让上游转给人类。
|
||||
if relayKey != "" {
|
||||
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
|
||||
}
|
||||
JSON(w, http.StatusConflict, map[string]interface{}{
|
||||
"error": "权限询问无法送达:该任务链上没有人类用户",
|
||||
"detail": "整条任务都是 Agent 之间的邮件往来,没有人类参与决策。请换用不需要权限的方式完成此操作,或在回复中说明情况让上游转达给人类。",
|
||||
"suggestion": "考虑用 subprocess/file 工具替代需要权限的工具,或通过邮件向上游请求人类协助。",
|
||||
"decider_was": decider,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
body := req.Context
|
||||
if body == "" {
|
||||
body = req.Question
|
||||
|
||||
119
gateway/internal/repo/nearesthuman_test.go
Normal file
119
gateway/internal/repo/nearesthuman_test.go
Normal file
@ -0,0 +1,119 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
)
|
||||
|
||||
// 权限询问的决策者必须是人:Agent 收不到 SendToUser,桥的 await 永不 resolve。
|
||||
// 生产事故:pi 把任务派给自己的另一条会话 → 那条会话要跑 bash → 权限邮件发给 "pi"
|
||||
// → pi 不是人类用户 → 整条会话永久阻塞。
|
||||
//
|
||||
// 修法是顺着会话的邮件链上溯找最近的人类 —— 派活的人才是该点头的人。
|
||||
func TestNearestHumanInThread(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mustExec(t, ctx, `INSERT INTO users (username, display_name, password_hash, role)
|
||||
VALUES ('alice','Alice','x','user')`)
|
||||
for _, a := range []string{"opencode", "dsh", "pi"} {
|
||||
if err := CreateOrUpdateAgent(ctx, a, "s", "test", nil); err != nil {
|
||||
t.Fatalf("注册 %s: %v", a, err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("沿链上溯找到派活的人", func(t *testing.T) {
|
||||
sid, err := CreateSession(ctx, nil, "opencode", "任务链", "/home")
|
||||
if err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
// alice → opencode → dsh,dsh 触发权限询问
|
||||
m1, err := CreateMail(ctx, sid, nil, "alice", "", "opencode", "", "任务", "请帮忙", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("封1: %v", err)
|
||||
}
|
||||
m2, err := CreateMail(ctx, sid, &m1, "opencode", "", "dsh", "", "转派", "你来看", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("封2: %v", err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, sid, &m2, "dsh", "", "opencode", "", "进展", "做了一半", nil); err != nil {
|
||||
t.Fatalf("封3: %v", err)
|
||||
}
|
||||
|
||||
for _, agent := range []string{"dsh", "opencode"} {
|
||||
got, err := NearestHumanInThread(ctx, sid, agent)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", agent, err)
|
||||
}
|
||||
if got != "alice" {
|
||||
t.Errorf("%s 触发权限时应路由到 alice,得到 %q", agent, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("全 Agent 链返回空串", func(t *testing.T) {
|
||||
// 没有人类参与的链条:调用方据此拒绝请求,而不是转给一个
|
||||
// 对上下文一无所知的管理员。
|
||||
sid, err := CreateSession(ctx, nil, "opencode", "纯 Agent", "/tmp")
|
||||
if err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
m1, err := CreateMail(ctx, sid, nil, "opencode", "", "dsh", "", "干活", "go", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("封1: %v", err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, sid, &m1, "dsh", "", "opencode", "", "好", "ok", nil); err != nil {
|
||||
t.Fatalf("封2: %v", err)
|
||||
}
|
||||
|
||||
got, err := NearestHumanInThread(ctx, sid, "dsh")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("链上没有人类时应返回空串,得到 %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skipAgent 是自己时不会把自己当人", func(t *testing.T) {
|
||||
// pi 给自己的另一条会话派活正是事故场景:链上只有 pi 一个名字。
|
||||
sid, err := CreateSession(ctx, nil, "pi", "自派", "/home")
|
||||
if err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, sid, nil, "pi", "", "pi", "", "拆任务", "自己干", nil); err != nil {
|
||||
t.Fatalf("封1: %v", err)
|
||||
}
|
||||
|
||||
got, err := NearestHumanInThread(ctx, sid, "pi")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("自派链上没有人类,应返回空串,得到 %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空会话不报错", func(t *testing.T) {
|
||||
sid, err := CreateSession(ctx, nil, "opencode", "空", "/tmp")
|
||||
if err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
got, err := NearestHumanInThread(ctx, sid, "opencode")
|
||||
if err != nil {
|
||||
t.Fatalf("空会话应返回空串而非报错,得到 err=%v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("空会话应返回空串,得到 %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func mustExec(t *testing.T, ctx context.Context, q string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.DB.ExecContext(ctx, q, args...); err != nil {
|
||||
t.Fatalf("exec %s: %v", q, err)
|
||||
}
|
||||
}
|
||||
@ -510,3 +510,132 @@ func IsHumanUser(ctx context.Context, name string) (bool, error) {
|
||||
`SELECT COUNT(*) FROM users WHERE username = $1`, name).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// NearestHumanInThread 找出该会话上「最该为它点头的人」。
|
||||
//
|
||||
// 用途:权限询问的决策者是 Agent 时的救援路径。Agent 收不到 SendToUser
|
||||
// (那是人类的 SSE 通道),桥的 await 也就永不 resolve —— 会话永久阻塞。
|
||||
// 生产事故:pi 把任务派给自己的另一条会话,那条会话要跑 bash,
|
||||
// 权限邮件发给了 "pi" 自己,整条线索卡死,只能改数据库救回来。
|
||||
//
|
||||
// 三级查找,按「谁最了解这件事」排序:
|
||||
//
|
||||
// 1. 会话 owner —— 人在界面上开的会话,归属明确
|
||||
// 2. 最近一个往这条线索里**发过信**的人类 —— 派活的人
|
||||
// 3. 最近一个作为**收件人或抄送**出现的人类 —— 至少他知道这件事在进行
|
||||
//
|
||||
// 找不到时返回空串(不是错误):调用方据此拒绝请求。这比转给一个对上下文
|
||||
// 一无所知的管理员好 —— 他既不知道这个 bash 命令在做什么,
|
||||
// 也不知道拒绝之后 Agent 该怎么绕过去。
|
||||
//
|
||||
// skipSelf 是发起询问的 Agent 名,永不作为决策者返回:它正是被卡住的那一方。
|
||||
// 名字与人类用户名共用命名空间,所以这里也顺手挡住「Agent 名恰好等于某人类名」
|
||||
// 这种配置错误。
|
||||
func NearestHumanInThread(ctx context.Context, sessionID uuid.UUID, skipSelf string) (string, error) {
|
||||
// 1. 会话 owner
|
||||
if owner, err := SessionOwnerUsername(ctx, sessionID); err == nil && owner != "" && owner != skipSelf {
|
||||
return owner, nil
|
||||
}
|
||||
|
||||
// 2/3. 扫这条会话的邮件。发件人优先于收件人/抄送方:
|
||||
// 发过信的人是主动参与者,被抄送的人可能只是旁观。
|
||||
//
|
||||
// 不用递归 CTE 沿 parent_mail_id 上溯:调用方只持有 session_id,
|
||||
// 没有触发询问的那封锚点邮件,所谓「链」的起点本来就得靠猜。
|
||||
// 而按会话扫还能覆盖分叉分支与断链(父邮件被删)的情形。
|
||||
//
|
||||
// **必须先把行读完再判定人类身份**:SQLite 连接池在测试与单文件库下
|
||||
// 常常只有一条连接,rows 未关闭时再发一条查询会自我死锁(实测挂死 60s)。
|
||||
type participants struct {
|
||||
from string
|
||||
others []string
|
||||
}
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT m.from_name, m.to_name, m.cc_list
|
||||
FROM mails m
|
||||
WHERE m.session_id = $1
|
||||
ORDER BY m.created_at DESC, m.mail_id DESC
|
||||
`, sessionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var scanned []participants
|
||||
for rows.Next() {
|
||||
var from, to string
|
||||
var ccRaw []byte
|
||||
if err := rows.Scan(&from, &to, &ccRaw); err != nil {
|
||||
rows.Close()
|
||||
return "", err
|
||||
}
|
||||
scanned = append(scanned, participants{
|
||||
from: from,
|
||||
others: append([]string{to}, ccNames(ccRaw)...),
|
||||
})
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 人类身份查询结果缓存:一条会话里同一个名字会出现很多次
|
||||
human := map[string]bool{}
|
||||
isHuman := func(name string) (bool, error) {
|
||||
if v, ok := human[name]; ok {
|
||||
return v, nil
|
||||
}
|
||||
v, err := IsHumanUser(ctx, name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
human[name] = v
|
||||
return v, nil
|
||||
}
|
||||
|
||||
var fallback string // 收件人/抄送里的人类,仅在没有人类发件人时使用
|
||||
for _, p := range scanned {
|
||||
if p.from != skipSelf {
|
||||
if ok, err := isHuman(p.from); err != nil {
|
||||
return "", err
|
||||
} else if ok {
|
||||
return p.from, nil // 最近的人类发件人,直接定案
|
||||
}
|
||||
}
|
||||
if fallback != "" {
|
||||
continue
|
||||
}
|
||||
for _, cand := range p.others {
|
||||
if cand == "" || cand == skipSelf {
|
||||
continue
|
||||
}
|
||||
if ok, err := isHuman(cand); err != nil {
|
||||
return "", err
|
||||
} else if ok {
|
||||
fallback = cand
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
// ccNames 从 cc_list 的 JSON 里取出 name 位。
|
||||
//
|
||||
// 解析失败返回空切片而不是报错:抄送列表读不出来只该让这一封少几个候选,
|
||||
// 不该让整个决策者查找失败 —— 那会把「会话卡死」换成「权限请求 500」。
|
||||
func ccNames(raw []byte) []string {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var list []models.Address
|
||||
if err := json.Unmarshal(raw, &list); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for _, a := range list {
|
||||
if a.Name != "" {
|
||||
out = append(out, a.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user