精简动作审查输入并支持审批来源定位 (#125)

* fix: 精简审查背景并区分人工消息与调度输入

* fix: 模型审查仅提交当前调用与选定背景

* feat: 审批来源定位原会话并移除 Worker 审查背景
This commit is contained in:
RuoJi6 authored and GitHub committed 2026-09-15 23:51:37 +08:00
1 parent 0d6fa880b8
commit 04d25eb133
30 files changed
+1143 -465

No files matched your search

+1 -1
View File
@@ -107,7 +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)
ctx = intercept.WithReviewWorkingDirectory(ctx, sessionWorkDir)
// Pure assistant: DefaultTools as the base; AugmentTools layers in the key's
// visible skills/MCP and lets the DB tools table filter/override. DefaultTools
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept"
"github.com/Autumn-27/norma/agentcore"
"github.com/Autumn-27/norma/llm"
"github.com/Autumn-27/norma/permission"
@@ -123,7 +124,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)
ctx = intercept.WithReviewWorkingDirectory(ctx, mainDir)
system, boundary := deferredSystem(mainAgentSystem(goal, m.workDir, mainDir), def)
opts := agentcore.Options{
Provider: m.prov,
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"sync"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept"
"github.com/Autumn-27/norma/agentcore"
"github.com/Autumn-27/norma/llm"
"github.com/Autumn-27/norma/permission"
@@ -376,7 +377,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)
ctx = intercept.WithReviewContext(ctx, taskDir, intercept.ReviewBackground{})
sysBody := plannerSystem(goal, p.workDir, taskDir)
if p.wantConstraints() {
sysBody += constraintBlock(ts) // 操作约束(若有)注入系统提示,框定探索边界
-28
View File
@@ -1,28 +0,0 @@
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)
}
+49 -40
View File
@@ -4,20 +4,19 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"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) {
// Real PostgreSQL + SDK hooks: Worker reviews receive the current call only.
// Intent summaries, inherited background and prior execution are excluded.
func TestWorkerReviewContextAcrossToolCalls(t *testing.T) {
dsn, _, err := db.DSN()
if err != nil {
t.Skip("no test database configured")
@@ -53,13 +52,9 @@ func TestTaskReviewContextAcrossToolCalls(t *testing.T) {
}
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 {
if string(in.Arguments) == `{"step":2}` {
action = "deny"
}
return intercept.Decision{Action: action, Message: "probe policy"}, nil
@@ -90,10 +85,16 @@ func TestTaskReviewContextAcrossToolCalls(t *testing.T) {
})
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)
ctx = intercept.WithReviewContext(ctx, "/parent", intercept.ReviewBackground{Source: intercept.BackgroundUserMessage, Text: "PARENT_BACKGROUND_SENTINEL"})
intentPayload := map[string]any{"summary": "创建并清理", "extra": "FULL_INTENT_SENTINEL"}
intentID, err := ts.AddNode("intent", intentPayload, 0, "running", "planner", nil)
if err != nil {
t.Fatal(err)
}
rawIntent, _ := json.Marshal(intentPayload)
worker := NewWorker(provider, "test-model", workDir, nil, 0, 3, probe)
_, _, err = worker.Execute(ctx, "test-agent", expID, nil, ts, &db.Node{ID: intentID, Payload: rawIntent}, guard.NewWithInterceptor(ic).Hooks(), nil, nil, nil)
runDir := ensureRunDir(workDir, expID, intentID)
if err != nil {
t.Fatal(err)
}
@@ -101,15 +102,25 @@ func TestTaskReviewContextAcrossToolCalls(t *testing.T) {
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)
for _, in := range inputs {
if in.Version != 4 || in.Background != nil || in.WorkingDir != runDir {
t.Fatalf("unexpected Worker background: %+v", in)
}
raw, _ := json.Marshal(in)
for _, forbidden := range []string{"创建并清理", "PARENT_BACKGROUND_SENTINEL", `"background"`, "只操作隔离测试目录", "验证创建和清理", "禁止后续清理", "FULL_INTENT_SENTINEL", "全局探索态势", `"task_id"`, `"task"`, `"turn_input"`, `"worker_intent"`, `"history"`, `"history_truncated"`, `"correlation"`, "Created a new fixture"} {
if strings.Contains(string(raw), forbidden) {
t.Fatalf("unexpected review data: %s", forbidden)
}
}
}
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)
constraints, err := ts.ListConstraints()
if err != nil || len(constraints) != 1 {
t.Fatal("Agent task constraints were unexpectedly changed")
}
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)
if string(first.Arguments) != `{"step":1}` || string(second.Arguments) != `{"step":2}` {
t.Fatal("review lost current parameters")
}
rows, err := d.ListTaskIntercepts(taskID)
if err != nil || len(rows) != 2 {
t.Fatalf("rows=%d err=%v", len(rows), err)
@@ -120,31 +131,29 @@ func TestTaskReviewContextAcrossToolCalls(t *testing.T) {
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 json.Unmarshal(detail.Audit.ModelInput, &saved) != nil || saved.Background != nil || saved.Version != 4 {
t.Fatal("stored Worker review input retained a background")
}
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 == "allowed" && (detail.Audit.ExecutionStatus != "succeeded" || saved.Version != 4) {
t.Fatal("automatic allow lost execution result or its original background snapshot")
}
if detail.Audit.Correlation != "exact" {
t.Fatal("audit lost call correlation")
}
if row.Status == "denied" {
found := false
for _, entry := range detail.Audit.Context {
if entry.Kind == "tool_result" && entry.ToolUseID == "call-1" && strings.Contains(entry.Text, "Created a new fixture") {
found = true
}
}
if !found {
t.Fatal("prior execution missing from separate audit")
}
}
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)
}
})
}
+4 -1
View File
@@ -11,6 +11,7 @@ import (
"time"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept"
"github.com/Autumn-27/norma/agentcore"
"github.com/Autumn-27/norma/harness"
"github.com/Autumn-27/norma/llm"
@@ -379,7 +380,9 @@ 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)
// The run-wide intent is not the current tool action. Do not forward it or
// inherit a parent run's background into the action reviewer.
ctx = intercept.WithReviewContext(ctx, runDir, intercept.ReviewBackground{})
overview := renderWorkerGraphOverview(tsx.graphOverviewData())
sysBody := workerSystem(w.proxyAddr, w.proxyCACert, w.workDir, runDir)
if w.wantConstraints() {