feat(intercept): 增加审批记录详情与执行审计
This commit is contained in:
1 parent
8dae851b9b
commit
9ad323dc4c
22 files changed
+1687
-535
No files matched your search
@@ -7,6 +7,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"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"
|
||||
@@ -33,8 +34,13 @@ func captureRun(ctx context.Context, opts agentcore.Options, input string, emit
|
||||
// multiple prompts on the SAME conversation (e.g. a settlement round that reuses
|
||||
// the worker's accumulated context after the main run hit max_turns).
|
||||
func captureRunSession(ctx context.Context, s *agentcore.Session, input string, emit func(db.Activity)) (string, harness.TerminalReason, error) {
|
||||
ctx, auditTrace := intercept.WithTrace(ctx, input, approvalHistory(s.Messages()))
|
||||
defer auditTrace.Finish()
|
||||
var reason harness.TerminalReason
|
||||
rec := func(r db.Activity) {
|
||||
if r.Kind == "text" || r.Kind == "tool_result" {
|
||||
auditTrace.Append(db.InterceptContextEntry{Kind: r.Kind, Tool: r.Tool, ToolUseID: r.ToolUseID, Text: r.Detail, IsError: r.IsError})
|
||||
}
|
||||
if emit != nil {
|
||||
emit(r)
|
||||
}
|
||||
@@ -90,6 +96,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
|
||||
toolNames[ev.ToolUse.ID] = ev.ToolUse.Name
|
||||
in := string(ev.ToolUse.Input)
|
||||
lastTool.start(ev.ToolUse.ID, ev.ToolUse.Name, in)
|
||||
auditTrace.Start(ev.ToolUse.ID, ev.ToolUse.Name, ev.ToolUse.Input)
|
||||
rec(db.Activity{Kind: "tool_use", Tool: ev.ToolUse.Name, ToolUseID: ev.ToolUse.ID,
|
||||
Summary: ev.ToolUse.Name + " " + firstLine(in, 200), Detail: in})
|
||||
case harness.KindToolResult:
|
||||
@@ -99,6 +106,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
|
||||
flush()
|
||||
out := blocksText(ev.ToolResult.Content)
|
||||
lastTool.done(ev.ToolResult.ToolUseID)
|
||||
auditTrace.Complete(ev.ToolResult.ToolUseID, out, ev.ToolResult.IsError)
|
||||
rec(db.Activity{Kind: "tool_result", Tool: toolNames[ev.ToolResult.ToolUseID], ToolUseID: ev.ToolResult.ToolUseID,
|
||||
IsError: ev.ToolResult.IsError, Summary: firstLine(out, 200), Detail: out})
|
||||
case harness.KindText:
|
||||
@@ -184,3 +192,26 @@ func firstLine(s string, max int) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// 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.
|
||||
func approvalHistory(messages []llm.Message) []db.InterceptContextEntry {
|
||||
var entries []db.InterceptContextEntry
|
||||
for _, message := range messages {
|
||||
for _, block := range message.Content {
|
||||
entry := db.InterceptContextEntry{Kind: string(message.Role)}
|
||||
switch block.Type {
|
||||
case llm.BlockText:
|
||||
entry.Text = block.Text
|
||||
case llm.BlockToolUse:
|
||||
entry.Kind, entry.Tool, entry.ToolUseID, entry.Text = "tool_use", block.Name, block.ID, string(block.Input)
|
||||
case llm.BlockToolResult:
|
||||
entry.Kind, entry.ToolUseID, entry.Text, entry.IsError = "tool_result", block.ToolUseID, blocksText(block.Content), block.IsError
|
||||
default:
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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"
|
||||
)
|
||||
|
||||
// Exercise the actual SDK event -> hook -> execution -> result path. No real
|
||||
// model or command is used; the probe tool only returns a fixed string.
|
||||
func TestCaptureApprovalLifecycle(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() })
|
||||
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) })
|
||||
if err := ic.SetEnabledTools([]string{"ApprovalAuditProbe"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ic.SetJudgeConfig(intercept.JudgeConfig{Enabled: true, AskTimeoutSeconds: 1, AskTimeoutAction: "allow"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, action, status, execution string
|
||||
manual, approve, toolError bool
|
||||
}{
|
||||
{"model_fallback", "invalid", "allowed", "succeeded", false, false, false},
|
||||
{"model_allow", "allow", "allowed", "succeeded", false, false, false},
|
||||
{"model_deny", "deny", "denied", "not_executed", false, false, false},
|
||||
{"human_allow_tool_error", "ask", "allowed", "failed", true, true, true},
|
||||
{"human_deny", "ask", "denied", "not_executed", true, false, false},
|
||||
{"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) {
|
||||
return intercept.Decision{Action: tc.action, Message: "probe review", ProfileID: 7}, nil
|
||||
})
|
||||
g := guard.NewWithInterceptor(ic)
|
||||
taskID := "approval-lifecycle-" + tc.name
|
||||
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id=$1`, taskID) })
|
||||
ctx := intercept.WithTaskContext(t.Context(), taskID, "test-agent", func(a db.Activity) {
|
||||
if a.Kind != "intercept_request" || !tc.manual {
|
||||
return
|
||||
}
|
||||
var detail struct {
|
||||
ID int64 `json:"pending_id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(a.Detail), &detail); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if err := ic.Decide(detail.ID, tc.approve); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
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 == 1 {
|
||||
events = []llm.StreamEvent{
|
||||
{Type: llm.SEToolUseStart, ToolID: "probe-call", ToolName: "ApprovalAuditProbe"},
|
||||
{Type: llm.SEToolInputJSON, Text: `{}`}, {Type: llm.SEMessageDelta, StopReason: "tool_use"},
|
||||
}
|
||||
}
|
||||
for _, event := range events {
|
||||
if !yield(event, nil) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}}
|
||||
probe := tool.Build(tool.Spec{Name: "ApprovalAuditProbe", Schema: map[string]any{"type": "object"},
|
||||
Run: func(context.Context, json.RawMessage, *tool.ToolContext) (tool.Result, error) {
|
||||
executions++
|
||||
if tc.toolError {
|
||||
return tool.Errorf("probe failed"), nil
|
||||
}
|
||||
return tool.Text("probe succeeded"), nil
|
||||
},
|
||||
})
|
||||
_, _, err := captureRun(ctx, agentcore.Options{Provider: provider, Tools: []tool.CoreTool{probe}, Hooks: g.Hooks(),
|
||||
PermissionMode: permission.ModeBypass, WorkingDir: t.TempDir(), MaxTurns: 2}, "record this review", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := d.ListTaskIntercepts(taskID)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("rows=%d err=%v", len(rows), err)
|
||||
}
|
||||
detail, err := d.GetInterceptDetail(rows[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := detail.Audit
|
||||
initialAction := tc.action
|
||||
if tc.action == "invalid" {
|
||||
initialAction = "allow"
|
||||
}
|
||||
if detail.Status != tc.status || detail.DecisionSource != "model" || a == nil || a.ExecutionStatus != tc.execution || a.ToolUseID != "probe-call" || a.UserMessage != "record this review" || a.InitialAction != initialAction || a.ModelFallback != (tc.action == "invalid") || a.ProfileID != 7 {
|
||||
t.Fatalf("unexpected review: row=%+v audit=%+v", detail.InterceptApprovalRow, a)
|
||||
}
|
||||
wantCalls := 1
|
||||
if tc.execution == "not_executed" {
|
||||
wantCalls = 0
|
||||
}
|
||||
if executions != wantCalls {
|
||||
t.Fatalf("tool ran %d times, wanted %d", executions, wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user