精简动作审查输入并支持审批来源定位 (#125)
* fix: 精简审查背景并区分人工消息与调度输入 * fix: 模型审查仅提交当前调用与选定背景 * feat: 审批来源定位原会话并移除 Worker 审查背景
This commit is contained in:
1 parent
0d6fa880b8
commit
04d25eb133
30 files changed
+1143
-465
No files matched your search
@@ -1,28 +1,10 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OperationReviewContext takes one database snapshot of the task's original
|
||||
// description/goal and registered constraints. Discovered task_scope assets are
|
||||
// deliberately excluded: discovery does not grant authorization.
|
||||
func (s *ExplorationStore) OperationReviewContext(ctx context.Context) (description, goal string, constraints []Constraint, err error) {
|
||||
var raw []byte
|
||||
err = s.db.QueryRowContext(ctx, `SELECT COALESCE(e.description,''), e.goal,
|
||||
COALESCE((SELECT jsonb_agg(jsonb_build_object(
|
||||
'id', c.id, 'kind', c.kind, 'text', c.text, 'origin', COALESCE(c.origin,''), 'created_at', c.created_at
|
||||
) ORDER BY c.id) FROM task_constraints c WHERE c.exploration_id=e.id), '[]'::jsonb)
|
||||
FROM explorations e WHERE e.id=$1`, s.expID).Scan(&description, &goal, &raw)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(raw, &constraints)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Constraint is one operator-authored operation constraint for a task: kind=allow
|
||||
// (permitted operations) or kind=deny (forbidden operations), free-text. Stored in
|
||||
// task_constraints, keyed by exploration_id (cascades with the exploration).
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var ErrInterceptTaskDeleted = errors.New("任务已被删除或归档")
|
||||
var ErrInterceptSessionDeleted = errors.New("对应会话或执行记录已被删除或不存在")
|
||||
|
||||
var ErrInterceptExecutionUnavailable = errors.New("未找到可唯一关联的原始工具调用;记录可能已删除,或旧审批没有保存关联 ID")
|
||||
|
||||
// InterceptExecution is a navigation target read from original activity rows.
|
||||
// It is not model context and never falls back to matching command text.
|
||||
type InterceptExecution struct {
|
||||
ConversationID *int64 `json:"conversation_id,omitempty"`
|
||||
TaskID *string `json:"task_id,omitempty"`
|
||||
Session string `json:"session"`
|
||||
Seq int64 `json:"seq"`
|
||||
Items []Activity `json:"-"`
|
||||
}
|
||||
|
||||
func (d *DB) GetInterceptExecution(id int64) (*InterceptExecution, error) {
|
||||
approval, err := d.GetInterceptDetail(id)
|
||||
if err != nil || approval == nil {
|
||||
return nil, err
|
||||
}
|
||||
audit := approval.Audit
|
||||
if audit == nil || audit.Correlation != "exact" || audit.ToolUseID == "" {
|
||||
return nil, ErrInterceptExecutionUnavailable
|
||||
}
|
||||
var query string
|
||||
var scope any
|
||||
if approval.ConversationID != nil {
|
||||
scope = *approval.ConversationID
|
||||
query = `SELECT id, NULL::bigint, COALESCE(worker,''), kind, COALESCE(tool,''), tool_use_id, is_error, COALESCE(summary,''), created_at, NULL::integer FROM conversation_activities WHERE conversation_id=$1`
|
||||
} else if approval.TaskID != nil {
|
||||
taskID, parseErr := strconv.ParseInt(*approval.TaskID, 10, 64)
|
||||
if parseErr != nil || taskID <= 0 {
|
||||
return nil, ErrInterceptExecutionUnavailable
|
||||
}
|
||||
var exists bool
|
||||
if err := d.QueryRow(`SELECT EXISTS(SELECT 1 FROM tasks WHERE id=$1 AND archived_at IS NULL AND deleted_at IS NULL)`, taskID).Scan(&exists); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, ErrInterceptTaskDeleted
|
||||
}
|
||||
scope = taskID
|
||||
query = `SELECT id, node_id, COALESCE(worker,''), kind, COALESCE(tool,''), tool_use_id, is_error, COALESCE(summary,''), created_at, main_seg FROM activity WHERE exploration_id=(SELECT exploration_id FROM tasks WHERE id=$1 AND archived_at IS NULL AND deleted_at IS NULL)`
|
||||
} else {
|
||||
return nil, ErrInterceptExecutionUnavailable
|
||||
}
|
||||
// Three matches suffice to detect duplicate IDs without loading a transcript.
|
||||
rows, err := d.Query(query+` AND tool_use_id=$2 AND kind IN ('tool_use','tool_result') ORDER BY id LIMIT 3`, scope, audit.ToolUseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := &InterceptExecution{ConversationID: approval.ConversationID, TaskID: approval.TaskID}
|
||||
for rows.Next() {
|
||||
var a Activity
|
||||
if err := rows.Scan(&a.ID, &a.NodeID, &a.Worker, &a.Kind, &a.Tool, &a.ToolUseID, &a.IsError, &a.Summary, &a.CreatedAt, &a.MainSeg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Items = append(out.Items, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out.Items) == 0 {
|
||||
return nil, ErrInterceptSessionDeleted
|
||||
}
|
||||
if len(out.Items) > 2 {
|
||||
return nil, ErrInterceptExecutionUnavailable
|
||||
}
|
||||
call := out.Items[0]
|
||||
if call.Kind != "tool_use" || call.Tool != approval.ToolName {
|
||||
return nil, ErrInterceptExecutionUnavailable
|
||||
}
|
||||
if len(out.Items) == 2 {
|
||||
result := out.Items[1]
|
||||
if result.Kind != "tool_result" || result.Worker != call.Worker || (result.Tool != "" && result.Tool != call.Tool) || !sameOptionalInt64(call.NodeID, result.NodeID) || !sameMainSegment(call.MainSeg, result.MainSeg) {
|
||||
return nil, ErrInterceptExecutionUnavailable
|
||||
}
|
||||
}
|
||||
out.Seq = call.ID
|
||||
if approval.ConversationID == nil {
|
||||
switch {
|
||||
case call.Worker == "mainagent":
|
||||
seg := 0
|
||||
if call.MainSeg != nil {
|
||||
seg = *call.MainSeg
|
||||
}
|
||||
out.Session = fmt.Sprintf("main:%d", seg)
|
||||
case call.Worker == "planner":
|
||||
out.Session = "plan"
|
||||
case call.NodeID != nil:
|
||||
out.Session = fmt.Sprintf("intent:%d", *call.NodeID)
|
||||
default:
|
||||
return nil, ErrInterceptExecutionUnavailable
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func sameOptionalInt64(a, b *int64) bool {
|
||||
return (a == nil && b == nil) || (a != nil && b != nil && *a == *b)
|
||||
}
|
||||
func sameMainSegment(a, b *int) bool {
|
||||
av, bv := 0, 0
|
||||
if a != nil {
|
||||
av = *a
|
||||
}
|
||||
if b != nil {
|
||||
bv = *b
|
||||
}
|
||||
return av == bv
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInterceptExecutionNavigation(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
approval := func(conv int64, task, id string, exact bool) int64 {
|
||||
t.Helper()
|
||||
audit := &InterceptAudit{ToolUseID: id, Correlation: "exact"}
|
||||
if !exact {
|
||||
audit.Correlation = "ambiguous"
|
||||
}
|
||||
n, err := d.CreateInterceptPending(0, conv, task, "display-name-is-not-a-session-id", "Bash", []byte(`{"command":"pwd"}`), "review", audit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_pending WHERE id=$1`, n) })
|
||||
return n
|
||||
}
|
||||
conv := func() int64 {
|
||||
t.Helper()
|
||||
c, err := d.CreateConversation("test", "navigation", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM conversations WHERE id=$1`, c.ID) })
|
||||
return c.ID
|
||||
}
|
||||
addConv := func(c int64, kind, id string) int64 {
|
||||
t.Helper()
|
||||
seq, err := d.AppendConvActivity(c, Activity{Worker: "test", Kind: kind, Tool: "Bash", ToolUseID: id, Summary: "pwd"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return seq
|
||||
}
|
||||
t.Run("conversation scope and old paginated call", func(t *testing.T) {
|
||||
c1, c2 := conv(), conv()
|
||||
seq := addConv(c1, "tool_use", "same-id")
|
||||
addConv(c1, "tool_result", "same-id")
|
||||
addConv(c2, "tool_use", "same-id")
|
||||
addConv(c2, "tool_result", "same-id")
|
||||
for range 210 {
|
||||
addConv(c1, "text", "")
|
||||
}
|
||||
id := approval(c1, "", "same-id", true)
|
||||
got, err := d.GetInterceptExecution(id)
|
||||
if err != nil || got.Seq != seq || len(got.Items) != 2 || *got.ConversationID != c1 {
|
||||
t.Fatalf("wrong conversation target: %+v %v", got, err)
|
||||
}
|
||||
addConv(c1, "tool_use", "same-id")
|
||||
if _, err := d.GetInterceptExecution(id); !errors.Is(err, ErrInterceptExecutionUnavailable) {
|
||||
t.Fatal("duplicate call ID selected an arbitrary execution")
|
||||
}
|
||||
})
|
||||
t.Run("task sessions and result pairing", func(t *testing.T) {
|
||||
task, err := d.CreateTask("navigation", "fixture", nil, 0, 600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp := task.ExplorationID
|
||||
t.Cleanup(func() {
|
||||
_, _ = d.Exec(`DELETE FROM tasks WHERE id=$1`, task.ID)
|
||||
_, _ = d.Exec(`DELETE FROM explorations WHERE id=$1`, exp)
|
||||
})
|
||||
es := d.Exploration(exp)
|
||||
intent, err := es.AddIntent(map[string]any{"summary": "do not parse agent label"}, 1, nil, "planner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seg := 3
|
||||
for _, tc := range []struct {
|
||||
worker, key string
|
||||
node *int64
|
||||
segment *int
|
||||
}{{"work#7", fmt.Sprintf("intent:%d", intent), &intent, nil}, {"planner", "plan", nil, nil}, {"mainagent", "main:3", nil, &seg}, {"mainagent", "main:0", nil, nil}} {
|
||||
callID := "call-" + tc.key
|
||||
a := Activity{Worker: tc.worker, NodeID: tc.node, MainSeg: tc.segment, Kind: "tool_use", Tool: "Bash", ToolUseID: callID, Summary: "pwd"}
|
||||
seq, err := es.AppendActivity(a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Kind = "tool_result"
|
||||
if _, err := es.AppendActivity(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := d.GetInterceptExecution(approval(0, fmt.Sprint(task.ID), callID, true))
|
||||
if err != nil || got.Seq != seq || got.Session != tc.key || len(got.Items) != 2 {
|
||||
t.Fatalf("wrong task session: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
deletedCall := "deleted-session-call"
|
||||
deletedSeq, err := es.AppendActivity(Activity{Worker: "work#9", NodeID: &intent, Kind: "tool_use", Tool: "Bash", ToolUseID: deletedCall})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deletedApproval := approval(0, fmt.Sprint(task.ID), deletedCall, true)
|
||||
if _, err = d.Exec(`DELETE FROM activity WHERE id=$1`, deletedSeq); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = d.GetInterceptExecution(deletedApproval); !errors.Is(err, ErrInterceptSessionDeleted) {
|
||||
t.Fatalf("deleted session: %v", err)
|
||||
}
|
||||
if _, err = d.Exec(`UPDATE tasks SET archived_at=NOW() WHERE id=$1`, task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = d.GetInterceptExecution(deletedApproval); !errors.Is(err, ErrInterceptTaskDeleted) {
|
||||
t.Fatalf("archived task: %v", err)
|
||||
}
|
||||
if _, err = d.Exec(`UPDATE tasks SET archived_at=NULL WHERE id=$1`, task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := Activity{Worker: "work#7", NodeID: &intent, Kind: "tool_use", Tool: "Bash", ToolUseID: "unpaired", Summary: "pwd"}
|
||||
if _, err := es.AppendActivity(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := approval(0, fmt.Sprint(task.ID), "unpaired", true)
|
||||
got, err := d.GetInterceptExecution(id)
|
||||
if err != nil || len(got.Items) != 1 {
|
||||
t.Fatal("pending call is not navigable")
|
||||
}
|
||||
a.Kind = "tool_result"
|
||||
a.Worker = "different-worker"
|
||||
if _, err := es.AppendActivity(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := d.GetInterceptExecution(id); !errors.Is(err, ErrInterceptExecutionUnavailable) {
|
||||
t.Fatal("cross-worker result was paired")
|
||||
}
|
||||
})
|
||||
if _, err := d.GetInterceptExecution(approval(conv(), "", "missing", true)); !errors.Is(err, ErrInterceptSessionDeleted) {
|
||||
t.Fatalf("missing call: %v", err)
|
||||
}
|
||||
for _, id := range []int64{approval(conv(), "", "", true), approval(conv(), "", "duplicate", false)} {
|
||||
if _, err := d.GetInterceptExecution(id); !errors.Is(err, ErrInterceptExecutionUnavailable) {
|
||||
t.Fatalf("invented execution for %d", id)
|
||||
}
|
||||
}
|
||||
if got, err := d.GetInterceptExecution(-1); err != nil || got != nil {
|
||||
t.Fatal("missing approval")
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,8 @@ ALTER TABLE activity ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{
|
||||
ALTER TABLE activity ADD COLUMN IF NOT EXISTS main_seg INTEGER;
|
||||
CREATE INDEX IF NOT EXISTS idx_act_node ON activity(exploration_id, node_id, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_act_since ON activity(exploration_id, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_act_tool_call ON activity(exploration_id, tool_use_id, id)
|
||||
WHERE kind IN ('tool_use', 'tool_result');
|
||||
-- Main/Plan history pages filter by worker (both carry NULL node_id, so idx_act_node
|
||||
-- can't distinguish them); this covers reverse pagination of those sessions.
|
||||
CREATE INDEX IF NOT EXISTS idx_act_worker ON activity(exploration_id, worker, id);
|
||||
@@ -970,6 +972,8 @@ CREATE TABLE IF NOT EXISTS conversation_activities (
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_conv_act ON conversation_activities(conversation_id, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conv_act_tool_call ON conversation_activities(conversation_id, tool_use_id, id)
|
||||
WHERE kind IN ('tool_use', 'tool_result');
|
||||
|
||||
-- =====================================================================
|
||||
-- J. Agent 触发器
|
||||
|
||||
Reference in new issue
Block a user