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:
RuoJi6 authored and GitHub committed 2026-09-15 20:08:12 +08:00
1 parent 234bd8bf8e
commit c0d1aa6f37
26 files changed
+1159 -199

No files matched your search

+18 -7
View File
@@ -2,6 +2,7 @@ package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -24,9 +25,9 @@ func (s *Server) chatGuard() *guard.Guard {
// wireInterceptReviewer installs the LLM fallback judge into the interceptor. The
// judge runs only on tool calls that matched no rule (see intercept.Judge). It
// resolves the configured judge profile (0 → active/default), builds a provider,
// runs a one-shot single-line classification, and parses ALLOW/ASK/DENY.
// runs a one-shot JSON classification with an explanation for every verdict.
func (s *Server) wireInterceptReviewer() {
s.m.interceptor.SetReviewer(func(ctx context.Context, profileID int64, prompt, tool, command string) (intercept.Decision, error) {
s.m.interceptor.SetReviewer(func(ctx context.Context, profileID int64, prompt string, input intercept.ReviewInput) (intercept.Decision, error) {
if profileID == 0 {
if p, err := s.m.pg.ActiveProfile(); err == nil && p != nil {
profileID = p.ID
@@ -39,25 +40,35 @@ func (s *Server) wireInterceptReviewer() {
if !ok {
return intercept.Decision{ProfileID: profileID}, fmt.Errorf("裁判模型 profile %d 不可用", profileID)
}
user := fmt.Sprintf("Tool: %s\nArguments:\n%s", tool, command)
text, err := streamCollectText(ctx, prov, prompt, user)
text, err := reviewCompletion(ctx, prov, prompt, input)
if err != nil {
return intercept.Decision{ProfileID: profileID}, err
}
v := intercept.ParseVerdict(text)
if v.Action == "" {
return intercept.Decision{ProfileID: profileID}, fmt.Errorf("模型裁决格式无效,必须包含裁决、实际操作、成功后的后果和命中规则")
}
return intercept.Decision{Action: v.Action, Message: v.Reason, ProfileID: profileID}, nil
})
}
func reviewCompletion(ctx context.Context, prov llm.Provider, prompt string, input intercept.ReviewInput) (string, error) {
user, err := json.Marshal(input)
if err != nil {
return "", err
}
return streamCollectText(ctx, prov, prompt, string(user))
}
// streamCollectText runs a single non-streaming-style completion (thinking off,
// low temperature, tiny output) and returns the concatenated text. Used by the
// LLM fallback judge, whose reply is one short line (ALLOW/ASK:.../DENY:...).
// low temperature, bounded output) and returns the concatenated text. The
// budget includes the explanation and complete closing JSON delimiters.
func streamCollectText(ctx context.Context, prov llm.Provider, system, user string) (string, error) {
temp := 0.0
req := llm.CompletionRequest{
System: []string{system},
Messages: []llm.Message{llm.UserText(user)},
MaxTokens: 128,
MaxTokens: 1024,
Temperature: &temp,
Thinking: "disabled",
}