feat(v3): T28 操作审批流(dsh 式 allow-once/deny,fail-closed)

- ToolLoop 增 approval_hook:工具执行前挂起等待用户裁决,拒绝/异常折叠为
  失败结果回喂模型(可改道),fail-closed
- 策略 agent.approval_policy:off | dangerous(写/编辑/命令询问,只读放行,默认)| all;
  approval_timeout_s 超时自动拒绝(轮询实现,规避 portal 循环下 wait_for 定时器不可靠)
- POST /agent/{id}/approve 裁决端点;approval_request/decided 事件对进 SSE 与审计
- 前端:运行中审批卡(工具名+参数预览+拒绝/允许一次),composer 审批策略 chip
- 测试 +8(策略矩阵/拒绝回喂/允许执行/fail-closed/超时/端点分支),全量 289 passed
This commit is contained in:
tzt
2026-09-01 23:12:26 +08:00
parent 7d11ae2644
commit ca8add02e2
16 changed files with 487 additions and 21 deletions
+14
View File
@@ -312,6 +312,7 @@ export async function listPoolModels(id: string) {
export interface AgentEvent {
type: 'round' | 'tool_call' | 'tool_result' | 'usage' | 'final' | 'phase' | 'message'
| 'approval_request' | 'approval_decided' | 'delta'
ts?: number
round?: number
name?: string
@@ -328,6 +329,19 @@ export interface AgentEvent {
model?: string
role?: 'planner' | 'executor'
content?: string
// 审批(D9/ 流式(D10
id?: string
policy?: string
allowed?: boolean
note?: string
text?: string
}
/** POST /agent/{id}/approve:裁决待审批操作(dsh 式 allow-once / deny */
export async function approveAgent(requestId: string, approvalId: string, allowed: boolean) {
const { data } = await http.post<{ ok: boolean }>(`/agent/${requestId}/approve`,
{ approval_id: approvalId, allowed })
return data
}
export interface AgentStatus {
+123 -3
View File
@@ -62,6 +62,9 @@
:class="['mini-row', ev.ok ? 'ok' : 'err']">
{{ ev.ok ? '✓' : '✕' }} {{ ev.name }} · {{ clip(ev.preview, 120) }}
</div>
<div v-else-if="ev.type === 'delta'" class="mini-row delta-row">
{{ clip(ev.text, 200) }}
</div>
</template>
</div>
</details>
@@ -69,7 +72,22 @@
<!-- 历史轮次步数摘要 -->
<div v-else-if="m.toolCalls" class="tool-summary"> 执行了 {{ m.toolCalls }} 步工具调用</div>
<!-- 审批卡D9未裁决时显示操作按钮 -->
<div v-if="m.approval && !m.approval.decided" class="approval-card">
<div class="ap-head"> 等待审批<b>{{ m.approval.name }}</b> 请求执行</div>
<pre class="ap-args">{{ shortArgs(m.approval.arguments) }}</pre>
<div class="ap-actions">
<button class="ap-btn reject" @click="decideApproval(m, false)">拒绝</button>
<button class="ap-btn allow" @click="decideApproval(m, true)">允许一次</button>
</div>
</div>
<div v-else-if="m.approval && m.approval.decided" class="ap-done"
:class="m.approval.allowed ? 'ok' : 'no'">
{{ m.approval.allowed ? '✓ 已允许执行' : '✕ 已拒绝' }}
</div>
<!-- 正文 / 运行中指示 -->
<div v-if="m.streamText" class="ai-stream">{{ m.streamText }}<span class="cursor" /></div>
<div v-if="m.content" class="ai-text">{{ m.content }}</div>
<div v-else-if="m.error" class="ai-error"> {{ m.error }}</div>
<div v-else-if="running && i === messages.length - 1" class="ai-running">
@@ -98,6 +116,12 @@
<label class="chip toggle-chip" title="开启后智能体可执行 shell 命令">
<input type="checkbox" v-model="allowShell" @change="toggleShell" /> 命令
</label>
<select v-model="approvalPolicy" class="chip select-chip" title="审批策略:哪些操作需要你确认"
@change="savePolicy">
<option value="off">审批</option>
<option value="dangerous">审批写与命令</option>
<option value="all">审批全部操作</option>
</select>
<span v-if="errorMsg" class="composer-err">{{ errorMsg }}</span>
</div>
<div class="composer-main">
@@ -153,9 +177,9 @@
<script setup lang="ts">
import { ref, computed, nextTick, onMounted } from 'vue'
import {
startAgent, getAgentStatus, watchAgent,
startAgent, getAgentStatus, watchAgent, approveAgent, getConfig, updateConfig,
getPool,
browseFs, getAgentWorkspaces, openWorkspace, updateConfig,
browseFs, getAgentWorkspaces, openWorkspace,
listAgentSessions, createAgentSession, getAgentSession, deleteAgentSession, cancelAgent,
} from '@/api'
import type { AgentEvent, PoolEntry, FsBrowse } from '@/api'
@@ -167,6 +191,8 @@ interface ChatMsg {
error?: string | null
liveEvents?: AgentEvent[]
requestId?: string
streamText?: string
approval?: { id: string; name: string; arguments?: Record<string, unknown>; decided: boolean; allowed: boolean }
}
const SAMPLES = [
@@ -192,6 +218,7 @@ const errorMsg = ref('')
const selectedRoot = ref('')
const selectedExecutorId = ref('')
const allowShell = ref(false)
const approvalPolicy = ref('dangerous')
const recent = ref<string[]>([])
const poolEntries = ref<PoolEntry[]>([])
const pickerOpen = ref(false)
@@ -306,6 +333,10 @@ async function toggleShell() {
try { await updateConfig({ agent: { allow_shell: allowShell.value } } as any) }
catch { allowShell.value = !allowShell.value }
}
async function savePolicy() {
try { await updateConfig({ agent: { approval_policy: approvalPolicy.value } } as any) }
catch { /* 恢复由刷新处理 */ }
}
// ── 发送 / 停止 ─────────────────────────────────────────────────────────
async function send() {
@@ -342,6 +373,21 @@ async function send() {
_watch.subscribe({
onEvent: (ev) => {
if (ev.type === 'final') return
if (ev.type === 'approval_request') {
aiMsg.approval = { id: ev.id!, name: ev.name!, arguments: ev.arguments,
decided: false, allowed: false }
} else if (ev.type === 'approval_decided') {
if (aiMsg.approval && aiMsg.approval.id === ev.id) {
aiMsg.approval.decided = true
aiMsg.approval.allowed = !!ev.allowed
}
} else if (ev.type === 'delta') {
aiMsg.streamText = (aiMsg.streamText || '') + (ev.text || '')
scrollToBottom()
return
} else if (ev.type === 'tool_call' || ev.type === 'phase' || ev.type === 'message') {
aiMsg.streamText = '' // 工具/阶段开始,清空流式文本
}
aiMsg.liveEvents!.push(ev)
scrollToBottom()
},
@@ -371,13 +417,25 @@ async function stop() {
try { await cancelAgent(currentRequestId.value) } catch { /* ignore */ }
}
async function decideApproval(m: ChatMsg, allowed: boolean) {
if (!m.approval || !m.requestId) return
m.approval.decided = true
m.approval.allowed = allowed
try { await approveAgent(m.requestId, m.approval.id, allowed) }
catch (e: any) {
m.approval.decided = false
errorMsg.value = e?.response?.data?.detail || e?.message || String(e)
}
}
onMounted(async () => {
refreshSessions()
try {
const [pool, ws] = await Promise.all([getPool(), getAgentWorkspaces()])
const [pool, ws, cfg] = await Promise.all([getPool(), getAgentWorkspaces(), getConfig()])
poolEntries.value = pool.entries
selectedRoot.value = ws.current || ''
recent.value = ws.recent || []
if ((cfg as any).agent?.approval_policy) approvalPolicy.value = (cfg as any).agent.approval_policy
} catch { /* ignore */ }
})
</script>
@@ -563,6 +621,68 @@ onMounted(async () => {
.mini-args { color: var(--c-caption); margin-left: 6px; }
.tool-summary { font-size: 12px; color: var(--c-caption); margin-bottom: 8px; }
/* 审批卡(D9 */
.approval-card {
border: 1px solid var(--c-warn);
background: var(--c-warn-soft);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 10px;
}
.ap-head { font-size: 13px; font-weight: 600; color: var(--c-warn); margin-bottom: 6px; }
.ap-args {
margin: 0 0 8px;
font-size: 12px;
font-family: var(--font-mono);
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 6px;
padding: 6px 9px;
white-space: pre-wrap;
word-break: break-all;
max-height: 140px;
overflow-y: auto;
color: var(--c-text);
}
.ap-actions { display: flex; gap: 8px; }
.ap-btn {
border: none;
border-radius: 7px;
padding: 6px 16px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
color: #fff;
}
.ap-btn.reject { background: var(--c-text-2); }
.ap-btn.reject:hover { background: var(--c-text); }
.ap-btn.allow { background: var(--c-primary); }
.ap-btn.allow:hover { background: var(--c-primary-strong); }
.ap-done { font-size: 12px; font-weight: 600; margin-bottom: 8px; }
.ap-done.ok { color: var(--c-ok); }
.ap-done.no { color: var(--c-err); }
/* 流式实时文本(D10 */
.ai-stream {
font-size: 14px;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
color: var(--c-text);
margin-bottom: 6px;
}
.cursor {
display: inline-block;
width: 7px;
height: 15px;
background: var(--c-primary);
margin-left: 2px;
vertical-align: -2px;
animation: blink 0.9s steps(1) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
.delta-row { color: var(--c-caption); }
/* ---------- Composer ---------- */
.composer-wrap { padding: 10px 20px 16px; }
.composer {