feat(intercept): 模型审查补充任务上下文与裁决说明 (#124)
审查模型改为接收版本化 JSON:任务描述/目标/操作约束、工作目录、Worker 意图、当前轮输入,以及按 tool_use_id 配对的近期工具执行记录;末尾是完整的 tool_name 与 arguments。 - 任务约束每次审查从数据库重新读取;读取失败或约束超预算转人工。 - 历史只保留唯一配对的执行事实,排除 Agent 自述、未完成请求与平台拒绝反馈。 - 裁决改为 JSON decision(allow/ask/deny) + comment,三种结果均须说明实际操作、成功后果与命中规则。 - 所有模型裁决(含自动放行)保存实际模型输入与 SHA-256 指纹,详情页可展开。 - 对话审批卡片支持展开完整请求、模型输入、裁决与执行结果。 - 修复父目录 lockfile 导致的 Turbopack 根目录误判。 官方默认拦截规则、工具范围与优先级不变。
This commit is contained in:
1 parent
234bd8bf8e
commit
c0d1aa6f37
26 files changed
+1159
-199
No files matched your search
+2
-1
@@ -198,7 +198,8 @@ func firstLine(s string, max int) string {
|
||||
}
|
||||
|
||||
// Preserve the recorded session's visible messages, excluding thinking blocks.
|
||||
// This is audit context; it is not presented as input to the existing tool-only judge.
|
||||
// This is audit context; the judge receives only bounded, paired execution
|
||||
// evidence selected from it, never assistant prose or thinking blocks.
|
||||
func approvalHistory(messages []llm.Message) []db.InterceptContextEntry {
|
||||
var entries []db.InterceptContextEntry
|
||||
for _, message := range messages {
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestCaptureApprovalLifecycle(t *testing.T) {
|
||||
{"timeout_allow", "ask", "timeout", "succeeded", false, false, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ic.SetReviewer(func(context.Context, int64, string, string, string) (intercept.Decision, error) {
|
||||
ic.SetReviewer(func(context.Context, int64, string, intercept.ReviewInput) (intercept.Decision, error) {
|
||||
return intercept.Decision{Action: tc.action, Message: "probe review", ProfileID: 7}, nil
|
||||
})
|
||||
g := guard.NewWithInterceptor(ic)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/guard"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"github.com/Autumn-27/norma/agentcore"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
"github.com/Autumn-27/norma/permission"
|
||||
@@ -106,6 +107,7 @@ func (c *ChatAgent) Chat(ctx context.Context, agentKey, sessionID, message strin
|
||||
// Isolates file writes across conversations, mirroring how workers use i<intentID>/.
|
||||
sessionWorkDir := filepath.Join(c.workDir, "sessions", sessionID)
|
||||
_ = os.MkdirAll(sessionWorkDir, 0o755)
|
||||
ctx = intercept.WithReviewContext(ctx, sessionWorkDir, "", nil)
|
||||
|
||||
// Pure assistant: DefaultTools as the base; AugmentTools layers in the key's
|
||||
// visible skills/MCP and lets the DB tools table filter/override. DefaultTools
|
||||
|
||||
@@ -123,6 +123,7 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, mainSeg int, as *db.
|
||||
defer cleanup()
|
||||
// 本任务的工作目录 <workDir>/tasks/<taskID>,先建好。
|
||||
mainDir := ensureRunDir(m.workDir, taskID, 0)
|
||||
ctx = withTaskReviewContext(ctx, taskID, ts, mainDir, nil)
|
||||
system, boundary := deferredSystem(mainAgentSystem(goal, m.workDir, mainDir), def)
|
||||
opts := agentcore.Options{
|
||||
Provider: m.prov,
|
||||
|
||||
@@ -376,6 +376,7 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
|
||||
}
|
||||
// 本任务的工作目录 <workDir>/tasks/<taskID>,先建好。
|
||||
taskDir := ensureRunDir(p.workDir, taskID, 0)
|
||||
ctx = withTaskReviewContext(ctx, taskID, ts, taskDir, nil)
|
||||
sysBody := plannerSystem(goal, p.workDir, taskDir)
|
||||
if p.wantConstraints() {
|
||||
sysBody += constraintBlock(ts) // 操作约束(若有)注入系统提示,框定探索边界
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
)
|
||||
|
||||
// Keep operator constraints separate from worker plans and discovered assets.
|
||||
// The reader runs for each review so mid-run constraint edits are visible.
|
||||
func withTaskReviewContext(ctx context.Context, taskID int64, ts *db.ExplorationStore, workDir string, intent *db.Node) context.Context {
|
||||
intentText := ""
|
||||
if intent != nil {
|
||||
intentText = string(intent.Payload)
|
||||
}
|
||||
var load func(context.Context) (*intercept.ReviewTask, error)
|
||||
if ts != nil {
|
||||
load = func(ctx context.Context) (*intercept.ReviewTask, error) {
|
||||
description, goal, constraints, err := ts.OperationReviewContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &intercept.ReviewTask{TaskID: taskID, Description: description, Goal: goal, Constraints: constraints}, nil
|
||||
}
|
||||
}
|
||||
return intercept.WithReviewContext(ctx, workDir, intentText, load)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/guard"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"github.com/Autumn-27/norma/agentcore"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
"github.com/Autumn-27/norma/permission"
|
||||
"github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
// Real PostgreSQL + SDK hooks: the second call must see both the first result
|
||||
// and a newly registered task constraint. No model or shell command is executed.
|
||||
func TestTaskReviewContextAcrossToolCalls(t *testing.T) {
|
||||
dsn, _, err := db.DSN()
|
||||
if err != nil {
|
||||
t.Skip("no test database configured")
|
||||
}
|
||||
d, err := db.Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
expID, err := d.CreateExploration("只操作隔离测试目录", "验证创建和清理")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := d.Exploration(expID)
|
||||
taskID := fmt.Sprint(expID)
|
||||
t.Cleanup(func() {
|
||||
_, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id=$1`, taskID)
|
||||
_, _ = d.Exec(`DELETE FROM explorations WHERE id=$1`, expID)
|
||||
})
|
||||
ic := intercept.New(d)
|
||||
priorTools, err := ic.GetEnabledTools()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
priorConfig := ic.GetJudgeConfig()
|
||||
t.Cleanup(func() { _ = ic.SetEnabledTools(priorTools); _ = ic.SetJudgeConfig(priorConfig) })
|
||||
const probeName = "ContextEvidenceProbe"
|
||||
if err := ic.SetEnabledTools([]string{probeName}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ic.SetJudgeConfig(intercept.JudgeConfig{Enabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var inputs []intercept.ReviewInput
|
||||
ic.SetReviewer(func(_ context.Context, _ int64, _ string, in intercept.ReviewInput) (intercept.Decision, error) {
|
||||
if in.Task == nil {
|
||||
t.Error("reviewer did not receive task context")
|
||||
return intercept.Decision{Action: "deny"}, nil
|
||||
}
|
||||
inputs = append(inputs, in)
|
||||
action := "allow"
|
||||
if len(in.Task.Constraints) > 0 {
|
||||
action = "deny"
|
||||
}
|
||||
return intercept.Decision{Action: action, Message: "probe policy"}, nil
|
||||
})
|
||||
turn, executions := 0, 0
|
||||
provider := captureUsageProvider{stream: func(_ context.Context, yield func(llm.StreamEvent, error) bool) {
|
||||
turn++
|
||||
events := []llm.StreamEvent{{Type: llm.SETextDelta, Text: "done"}, {Type: llm.SEMessageDelta, StopReason: "end_turn"}}
|
||||
if turn <= 2 {
|
||||
events = []llm.StreamEvent{
|
||||
{Type: llm.SEToolUseStart, ToolID: fmt.Sprintf("call-%d", turn), ToolName: probeName},
|
||||
{Type: llm.SEToolInputJSON, Text: fmt.Sprintf(`{"step":%d}`, turn)},
|
||||
{Type: llm.SEMessageDelta, StopReason: "tool_use"},
|
||||
}
|
||||
}
|
||||
for _, event := range events {
|
||||
if !yield(event, nil) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}}
|
||||
probe := tool.Build(tool.Spec{Name: probeName, Schema: map[string]any{"type": "object"},
|
||||
Run: func(context.Context, json.RawMessage, *tool.ToolContext) (tool.Result, error) {
|
||||
executions++
|
||||
_, err := ts.AddConstraint("deny", "禁止后续清理", "human")
|
||||
return tool.Text("Created a new fixture; no existing file overwritten."), err
|
||||
},
|
||||
})
|
||||
workDir := t.TempDir()
|
||||
ctx := intercept.WithTaskContext(t.Context(), taskID, "test-agent", nil)
|
||||
ctx = withTaskReviewContext(ctx, expID, ts, workDir, &db.Node{Payload: json.RawMessage(`{"summary":"创建并清理"}`)})
|
||||
_, _, err = captureRun(ctx, agentcore.Options{Provider: provider, Tools: []tool.CoreTool{probe},
|
||||
Hooks: guard.NewWithInterceptor(ic).Hooks(), PermissionMode: permission.ModeBypass, WorkingDir: workDir, MaxTurns: 3},
|
||||
"请完成当前验证步骤", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(inputs) != 2 || executions != 1 {
|
||||
t.Fatalf("reviews=%d executions=%d", len(inputs), executions)
|
||||
}
|
||||
first, second := inputs[0], inputs[1]
|
||||
if first.Task == nil || first.Task.TaskID != expID || first.Task.Description != "只操作隔离测试目录" || len(first.Task.Constraints) != 0 || len(first.History) != 0 {
|
||||
t.Fatalf("wrong initial task snapshot: %+v", first)
|
||||
}
|
||||
if second.Task.Goal != "验证创建和清理" || len(second.Task.Constraints) != 1 || second.Task.Constraints[0].Text != "禁止后续清理" || second.WorkingDir != workDir || second.Intent == "" || second.TurnInput != "请完成当前验证步骤" {
|
||||
t.Fatalf("lost fresh task context: %+v", second)
|
||||
}
|
||||
if second.Correlation != "exact" || len(second.History) != 1 || second.History[0].ToolUseID != "call-1" || second.History[0].Status != "succeeded" || string(second.Arguments) != `{"step":2}` {
|
||||
t.Fatalf("lost paired execution evidence: %+v", second)
|
||||
}
|
||||
rows, err := d.ListTaskIntercepts(taskID)
|
||||
if err != nil || len(rows) != 2 {
|
||||
t.Fatalf("rows=%d err=%v", len(rows), err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
detail, err := d.GetInterceptDetail(row.ID)
|
||||
if err != nil || detail == nil || detail.Audit == nil || len(detail.Audit.ModelInput) == 0 {
|
||||
t.Fatalf("verdict lost model input: %+v err=%v", detail, err)
|
||||
}
|
||||
var saved intercept.ReviewInput
|
||||
if json.Unmarshal(detail.Audit.ModelInput, &saved) != nil || saved.Task == nil || saved.TurnInput != first.TurnInput {
|
||||
t.Fatal("stored review input cannot reconstruct the actual task context")
|
||||
}
|
||||
if row.Status == "allowed" && (detail.Audit.ExecutionStatus != "succeeded" || len(saved.Task.Constraints) != 0) {
|
||||
t.Fatal("automatic allow lost execution result or its original constraint snapshot")
|
||||
}
|
||||
if row.Status == "denied" && detail.Audit.ExecutionStatus != "not_executed" {
|
||||
t.Fatal("denial recorded an execution")
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("unavailable_constraints_do_not_fail_open", func(t *testing.T) {
|
||||
if err := ic.SetJudgeConfig(intercept.JudgeConfig{Enabled: true, FailAction: "allow"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ic.SetReviewer(func(context.Context, int64, string, intercept.ReviewInput) (intercept.Decision, error) {
|
||||
t.Error("reviewer called without required task constraints")
|
||||
return intercept.Decision{Action: "allow"}, nil
|
||||
})
|
||||
ctx := intercept.WithReviewContext(t.Context(), workDir, "", func(context.Context) (*intercept.ReviewTask, error) {
|
||||
return nil, fmt.Errorf("task database unavailable")
|
||||
})
|
||||
decision, judged := ic.Judge(ctx, probeName, json.RawMessage(`{}`))
|
||||
if !judged || decision.Action != "ask" || !decision.ModelFallback || len(decision.ModelInput) != 0 {
|
||||
t.Fatalf("missing policy was silently allowed: %+v", decision)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -379,6 +379,7 @@ func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db.
|
||||
// worker 有。仅【全局态势 overview】留在启动 user 消息里——它可降级、容忍 stale,压掉无碍。
|
||||
// 本次意图的专属工作目录 <workDir>/tasks/<taskID>/i<intentID>,引擎侧先建好。
|
||||
runDir := ensureRunDir(w.workDir, taskID, intent.ID)
|
||||
ctx = withTaskReviewContext(ctx, taskID, ts, runDir, intent)
|
||||
overview := renderWorkerGraphOverview(tsx.graphOverviewData())
|
||||
sysBody := workerSystem(w.proxyAddr, w.proxyCACert, w.workDir, runDir)
|
||||
if w.wantConstraints() {
|
||||
|
||||
Reference in new issue
Block a user