v0.4.0: 工具与审批(业务工具骨架 + Approval Card)

This commit is contained in:
Rick Ye
2026-09-05 09:47:50 +08:00
parent fe70cb0f71
commit 807fab4f20
15 changed files with 472 additions and 16 deletions
+1
View File
@@ -13,6 +13,7 @@
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json"
},
"dependencies": {
"@earendil-works/pi-ai": "0.85.0",
"@earendil-works/pi-coding-agent": "0.85.0",
"@earendil-works/pi-server": "0.85.0",
"gsap": "3.15.0",
+66
View File
@@ -0,0 +1,66 @@
/**
* v0.4.0 冒烟:业务工具注册 / 手动直调(D2 双模式路径)/ tool_call 拦截契约
* 说明:审批拦截逻辑与 src/main/pi/approval.ts 保持一致(此处内联同构实现,
* 因冒烟脚本无法直接加载 TS 源文件;拦截契约由 pi 的 ExtensionHandler 类型保证)。
*/
import { createAgentSessionFromServices, createAgentSessionServices, defineTool, getAgentDir, SessionManager } from '@earendil-works/pi-coding-agent'
import { Type } from '@earendil-works/pi-ai'
import { homedir } from 'node:os'
const cwd = homedir()
const agentDir = getAgentDir()
// 与 src/main/tools/demo-tools.ts 同构的工具定义
const hotwordLookup = defineTool({
name: 'hotword_lookup',
label: '热点词查询(演示)',
description: 'demo',
parameters: Type.Object({ query: Type.String(), limit: Type.Optional(Type.Number()) }),
async execute(_id, params) {
return {
content: [{ type: 'text', text: `热点词查询结果(演示数据源,query="${params.query}"):ok` }],
details: { source: 'demo' }
}
}
})
// ---- 1) 工具注册检查(customTools 路径) ----
const services = await createAgentSessionServices({ cwd, agentDir })
const { session } = await createAgentSessionFromServices({
services,
sessionManager: SessionManager.inMemory(cwd),
customTools: [hotwordLookup]
})
const all = session.getAllTools()
console.log('[smoke4] registered tools:', all.map((t) => t.name).join(', '))
console.log('[smoke4] hotword_lookup registered:', all.some((t) => t.name === 'hotword_lookup'))
// ---- 2) 手动直调(双模式共用能力的证明,不经 LLM) ----
const result = await hotwordLookup.execute('smoke-call', { query: '宠物' }, undefined, undefined, {})
console.log('[smoke4] manual execute text:', JSON.stringify(result.content[0]?.text?.slice(0, 60)))
// ---- 3) tool_call 拦截契约(模拟 ExtensionAPI 捕获 handler) ----
const handlers = {}
const fakePi = { on: (event, handler) => { handlers[event] = handler } }
const needsApproval = new Set(['price_check', 'bash'])
let decision = false
fakePi.on('tool_call', async (event) => {
if (!needsApproval.has(event.toolName)) return
const approved = decision
if (!approved) return { block: true, reason: '用户拒绝了本次工具调用审批' }
return
})
const passthrough = await handlers['tool_call']({ type: 'tool_call', toolName: 'read', input: { path: 'x' } })
console.log('[smoke4] read(不在名单)放行:', passthrough === undefined)
decision = false
const denied = await handlers['tool_call']({ type: 'tool_call', toolName: 'price_check', input: { product_id: 'P1', target_price: 9.9 } })
console.log('[smoke4] price_check 拒绝 -> block:', JSON.stringify(denied))
decision = true
const approvedRes = await handlers['tool_call']({ type: 'tool_call', toolName: 'bash', input: { command: 'ls' } })
console.log('[smoke4] bash 批准 -> 放行:', approvedRes === undefined)
console.log('[smoke4] done')
process.exit(0)
+5
View File
@@ -34,6 +34,11 @@ async function createWindow(): Promise<void> {
})
})
// 窗口关闭时未决审批一律拒绝,避免 agent 永久挂起
win.on('closed', () => {
agentService.approvals.settleAll(false)
})
// 外部链接交给系统默认浏览器,不在应用内开新窗口
win.webContents.setWindowOpenHandler(({ url }) => {
void shell.openExternal(url)
+8
View File
@@ -105,6 +105,14 @@ export function registerIpc(): AgentService {
return pass(() => service.setSystemPrompt(text))
})
// ---- 工具审批 ----
ipcMain.handle('approval:resolve', (_event, id: unknown, approved: unknown) => {
if (typeof id !== 'string' || typeof approved !== 'boolean') throw new Error('invalid approval')
service.approvals.settle(id, approved)
service.broadcastEvent({ type: 'approval_settled', id, approved, timedOut: false })
return Promise.resolve()
})
ipcMain.handle('app:versions', () => ({
app: process.env.npm_package_version ?? '0.0.0',
electron: process.versions.electron,
+31 -8
View File
@@ -13,6 +13,8 @@ import {
type CreateAgentSessionRuntimeFactory,
type SessionInfo
} from '@earendil-works/pi-coding-agent'
import { ApprovalService, createApprovalExtension } from './approval'
import { demoTools, TOOLS_REQUIRING_APPROVAL } from '../tools/demo-tools'
import type {
AgentUiEvent,
AppSettings,
@@ -41,6 +43,7 @@ export class AgentService {
private attached: AgentSession | null = null
private unsubscribeAttached: (() => void) | null = null
private settings: AppSettings = { systemPrompt: '' }
readonly approvals = new ApprovalService()
private get cwd(): string {
return app.getPath('home')
@@ -74,6 +77,11 @@ export class AgentService {
return () => this.listeners.delete(fn)
}
/** 供 IPC 层主动广播事件(如审批结果同步到所有窗口) */
broadcastEvent(event: AgentUiEvent): void {
this.emit(event)
}
private emit(event: AgentUiEvent): void {
for (const fn of this.listeners) {
try {
@@ -110,13 +118,23 @@ export class AgentService {
const cwd = this.cwd
const agentDir = getAgentDir()
this.settings = this.loadSettings()
const initialServices = await createAgentSessionServices({
cwd,
agentDir,
resourceLoaderOptions: this.settings.systemPrompt.trim()
? { systemPrompt: this.settings.systemPrompt }
: undefined
// 审批拦截:TOOLS_REQUIRING_APPROVAL 名单内的工具调用需经渲染层 Approval Card 确认
this.approvals.onRequest((request) => {
this.emit({ type: 'approval_request', request })
})
this.approvals.onSettle((id, approved, timedOut) => {
this.emit({ type: 'approval_settled', id, approved, timedOut })
})
const buildServices = (targetCwd: string, targetAgentDir: string) =>
createAgentSessionServices({
cwd: targetCwd,
agentDir: targetAgentDir,
resourceLoaderOptions: {
...(this.settings.systemPrompt.trim() ? { systemPrompt: this.settings.systemPrompt } : {}),
extensionFactories: [createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL)]
}
})
const initialServices = await buildServices(cwd, agentDir)
// 每次切换/新建时由 runtime 调用;cwd 变化时重建服务,否则复用
const factory: CreateAgentSessionRuntimeFactory = async ({
@@ -128,8 +146,13 @@ export class AgentService {
const services =
nextCwd === initialServices.cwd && nextAgentDir === initialServices.agentDir
? initialServices
: await createAgentSessionServices({ cwd: nextCwd, agentDir: nextAgentDir })
const result = await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })
: await buildServices(nextCwd, nextAgentDir)
const result = await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
customTools: demoTools
})
return { ...result, services, diagnostics: services.diagnostics }
}
+89
View File
@@ -0,0 +1,89 @@
import { randomUUID } from 'node:crypto'
import type { ExtensionAPI, InlineExtension } from '@earendil-works/pi-coding-agent'
/**
* 工具调用审批桥:
* - extension 拦截 tool_call → 挂起等待渲染层审批
* - ApprovalService 维护 pending 请求,经 IPC 与 Approval Card 交互
* - 超时自动拒绝,避免 agent 永久阻塞
*/
const APPROVAL_TIMEOUT_MS = 120_000
export interface PendingApproval {
id: string
toolName: string
args: unknown
resolve: (approved: boolean) => void
timer: NodeJS.Timeout
}
export class ApprovalService {
private pending = new Map<string, PendingApproval>()
private listener: ((request: { id: string; toolName: string; args: unknown }) => void) | null = null
private settleListener: ((id: string, approved: boolean, timedOut: boolean) => void) | null = null
onRequest(fn: (request: { id: string; toolName: string; args: unknown }) => void): void {
this.listener = fn
}
onSettle(fn: (id: string, approved: boolean, timedOut: boolean) => void): void {
this.settleListener = fn
}
request(toolName: string, args: unknown): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const id = randomUUID()
const timer = setTimeout(() => {
this.settle(id, false, true)
}, APPROVAL_TIMEOUT_MS)
this.pending.set(id, { id, toolName, args, resolve, timer })
this.listener?.({ id, toolName, args: cloneable(args) })
})
}
settle(id: string, approved: boolean, timedOut = false): void {
const entry = this.pending.get(id)
if (!entry) return
clearTimeout(entry.timer)
this.pending.delete(id)
entry.resolve(approved && !timedOut)
this.settleListener?.(id, approved, timedOut)
}
settleAll(approved: boolean): void {
for (const id of [...this.pending.keys()]) {
this.settle(id, approved)
}
}
}
function cloneable(value: unknown): unknown {
try {
return JSON.parse(JSON.stringify(value ?? null))
} catch {
return null
}
}
/**
* 生产 approval 拦截 extension:
* 匹配 NEEDS_APPROVAL 名单的工具调用先经用户审批,拒绝则 block。
*/
export function createApprovalExtension(
service: ApprovalService,
needsApproval: ReadonlySet<string>
): InlineExtension {
return {
name: 'aso-approval',
factory: (pi: ExtensionAPI) => {
pi.on('tool_call', async (event) => {
if (!needsApproval.has(event.toolName)) return
const approved = await service.request(event.toolName, event.input)
if (!approved) {
return { block: true, reason: '用户拒绝了本次工具调用审批' }
}
return
})
}
}
}
+77
View File
@@ -0,0 +1,77 @@
import { defineTool } from '@earendil-works/pi-coding-agent'
import { Type } from '@earendil-works/pi-ai'
/**
* 业务工具骨架(v0.4 演示版)。
*
* 架构约定(docs/architecture.md D2):每个业务功能 = 独立模块,
* 同时供 Agent(defineTool)与手动模式(IPC 直调)使用。
* 当前后端未设计,演示工具只返回本地构造数据;
* 真实实现将替换为「服务器 API 薄封装」,接口形态保持不变。
*/
/** 演示热点词库(后端就绪后替换为真实数据源) */
const DEMO_HOTWORDS: Array<{ word: string; heat: number; trend: 'up' | 'down' | 'flat'; category: string }> = [
{ word: '保温杯车载', heat: 9200, trend: 'up', category: '家居日用' },
{ word: '宠物自动喂食器', heat: 8500, trend: 'up', category: '宠物用品' },
{ word: '桌面升降支架', heat: 7100, trend: 'flat', category: '数码配件' },
{ word: '硅胶折叠饭盒', heat: 6600, trend: 'down', category: '家居日用' },
{ word: 'LED 化妆镜带灯', heat: 6100, trend: 'up', category: '美妆个护' }
]
export const hotwordLookup = defineTool({
name: 'hotword_lookup',
label: '热点词查询(演示)',
description:
'查询店铺运营热点词。输入品类或关键词,返回相关热点词、热度与趋势。当前为演示数据源(后端未接入),返回结果仅用于验证链路。',
promptSnippet: 'hotword_lookup: 查询热点词(热度/趋势/品类)',
parameters: Type.Object({
query: Type.String({ description: '品类或关键词,如"家居"、"宠物"' }),
limit: Type.Optional(Type.Number({ description: '返回条数,默认 5', default: 5 }))
}),
async execute(_toolCallId, params) {
const q = params.query.trim()
const limit = Math.max(1, Math.min(params.limit ?? 5, 20))
const matched = DEMO_HOTWORDS.filter(
(h) => !q || h.word.includes(q) || h.category.includes(q) || q.includes(h.category.slice(0, 2))
).slice(0, limit)
const lines =
matched.length === 0
? ['(演示数据源中无匹配项;后端接入后将返回真实采集结果)']
: matched.map((h) => `${h.word}|热度 ${h.heat}|趋势 ${h.trend === 'up' ? '↑上升' : h.trend === 'down' ? '↓回落' : '→持平'}|品类 ${h.category}`)
return {
content: [{ type: 'text', text: `热点词查询结果(演示数据源,query=${q ? `"${q}"` : '全部'}):\n${lines.join('\n')}` }],
details: { source: 'demo', matched: matched.length }
}
}
})
export const priceCheck = defineTool({
name: 'price_check',
label: '核价(演示·需审批)',
description:
'对指定商品执行核价操作。核价属于涉及资金与平台规则的敏感操作,调用前必须获得用户审批。当前为演示实现,仅回显参数。',
promptSnippet: 'price_check: 商品核价(敏感操作,需用户审批)',
promptGuidelines: ['调用 price_check 前先用一句话向用户说明将要核价的商品与价格区间。'],
parameters: Type.Object({
product_id: Type.String({ description: '商品 ID(如 Temu 商品编码)' }),
target_price: Type.Number({ description: '目标价格' }),
currency: Type.Optional(Type.String({ description: '币种,默认 CNY', default: 'CNY' }))
}),
async execute(_toolCallId, params) {
return {
content: [
{
type: 'text',
text: `核价完成(演示):商品 ${params.product_id} 目标价 ${params.target_price} ${params.currency ?? 'CNY'}。真实实现将调用 CDP 操作店铺后台核价页并回读结果。`
}
],
details: { demo: true, params }
}
}
})
/** 需要人工审批的工具名单(含高危内置工具) */
export const TOOLS_REQUIRING_APPROVAL: ReadonlySet<string> = new Set(['price_check', 'bash', 'write', 'edit'])
export const demoTools = [hotwordLookup, priceCheck]
+2
View File
@@ -21,6 +21,8 @@ const api = {
logoutProvider: (providerId: string): Promise<void> => ipcRenderer.invoke('auth:logout', providerId),
getSettings: (): Promise<AppSettings> => ipcRenderer.invoke('settings:get'),
setSystemPrompt: (text: string): Promise<void> => ipcRenderer.invoke('settings:setPrompt', text),
resolveApproval: (id: string, approved: boolean): Promise<void> =>
ipcRenderer.invoke('approval:resolve', id, approved),
onEvent: (callback: (event: AgentUiEvent) => void): (() => void) => {
const listener = (_e: Electron.IpcRendererEvent, event: AgentUiEvent): void => callback(event)
ipcRenderer.on('agent:event', listener)
+16 -3
View File
@@ -5,9 +5,21 @@ import { Sidebar } from './components/Sidebar'
import { ChatView } from './components/ChatView'
import { Composer } from './components/Composer'
import { SettingsModal } from './components/SettingsModal'
import { ApprovalStack } from './components/ApprovalCard'
export default function App(): JSX.Element {
const { state, sessions, send, stop, clearError, newSession, openSession, renameSession, deleteSession } = useAgent()
const {
state,
sessions,
send,
stop,
clearError,
newSession,
openSession,
renameSession,
deleteSession,
resolveApproval
} = useAgent()
const prefillRef = useRef<string | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
@@ -18,7 +30,7 @@ export default function App(): JSX.Element {
const queuedCount = state.queue.followUp.length + state.queue.steering.length
return (
<div className="flex h-full w-full bg-zinc-950 text-zinc-100 antialiased">
<div className="relative flex h-full w-full bg-zinc-950 text-zinc-100 antialiased">
<Sidebar
sessionState={state.sessionState}
stats={state.stats}
@@ -30,7 +42,7 @@ export default function App(): JSX.Element {
onDeleteSession={deleteSession}
onOpenSettings={() => setSettingsOpen(true)}
/>
<main className="flex min-w-0 flex-1 flex-col">
<main className="relative flex min-w-0 flex-1 flex-col">
<ChatView
messages={state.messages}
tools={state.tools}
@@ -49,6 +61,7 @@ export default function App(): JSX.Element {
onStop={stop}
prefillRef={prefillRef}
/>
<ApprovalStack requests={state.approvals} onResolve={resolveApproval} />
</main>
<SettingsModal open={settingsOpen} sessionState={state.sessionState} onClose={() => setSettingsOpen(false)} />
</div>
@@ -0,0 +1,102 @@
import { useEffect, useRef } from 'react'
import type { JSX } from 'react'
import { Check, ShieldAlert, X } from 'lucide-react'
import type { ApprovalRequest } from '@shared/types'
/**
* 工具调用审批卡(beautifului.dev Approval Card 风格)。
* 敏感工具(核价、写操作等)执行前弹出,允许/拒绝后 1.2s 收起。
*/
function ApprovalRow({
request,
onResolve
}: {
request: ApprovalRequest
onResolve: (approved: boolean) => void
}): JSX.Element {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
let tween: { kill(): void } | null = null
let cancelled = false
void import('gsap').then((gsapModule) => {
if (cancelled) return
tween = gsapModule.gsap.fromTo(
el,
{ opacity: 0, y: 16, scale: 0.97 },
{ opacity: 1, y: 0, scale: 1, duration: 0.3, ease: 'back.out(1.6)' }
)
})
return () => {
cancelled = true
tween?.kill()
}
}, [])
const argsText = (() => {
try {
return JSON.stringify(request.args, null, 2)
} catch {
return String(request.args)
}
})()
return (
<div
ref={ref}
className="pointer-events-auto w-[440px] max-w-[90vw] rounded-2xl border border-amber-900/60 bg-zinc-900 p-4 shadow-2xl shadow-black/50"
>
<div className="mb-2.5 flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-amber-950/70 ring-1 ring-amber-900/60">
<ShieldAlert size={14} className="text-amber-400" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-medium text-zinc-100">敏感操作,需要你的确认</div>
<div className="font-mono text-[11px] text-amber-300/80">{request.toolName}</div>
</div>
</div>
{argsText && argsText !== 'null' && (
<pre className="mb-3 max-h-32 overflow-auto rounded-lg bg-zinc-950/70 p-2.5 font-mono text-[11px] leading-relaxed text-zinc-400">
{argsText.length > 600 ? `${argsText.slice(0, 600)}…` : argsText}
</pre>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => onResolve(false)}
className="flex items-center gap-1.5 rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-[12.5px] text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
>
<X size={13} />
拒绝
</button>
<button
type="button"
onClick={() => onResolve(true)}
className="flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3 py-1.5 text-[12.5px] font-medium text-white transition-colors hover:bg-emerald-500"
>
<Check size={13} />
允许执行
</button>
</div>
</div>
)
}
export function ApprovalStack({
requests,
onResolve
}: {
requests: ApprovalRequest[]
onResolve: (id: string, approved: boolean) => void
}): JSX.Element | null {
if (requests.length === 0) return null
return (
<div className="pointer-events-none absolute inset-x-0 bottom-4 z-30 flex flex-col items-center gap-2">
{requests.map((request) => (
<ApprovalRow key={request.id} request={request} onResolve={(approved) => onResolve(request.id, approved)} />
))}
</div>
)
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'
import type { AgentUiEvent, SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
import type { AgentUiEvent, ApprovalRequest, SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
export interface ToolState {
toolName: string
@@ -9,6 +9,13 @@ export interface ToolState {
status: 'running' | 'done' | 'error'
}
/** 已结算的审批(保留短暂时间用于展示结果反馈) */
export interface SettledApproval {
id: string
approved: boolean
timedOut: boolean
}
export interface UiMessage {
key: string
role: string
@@ -31,6 +38,8 @@ interface AgentUiState {
stats: SessionStatsInfo | null
retry: { attempt: number; maxAttempts: number; errorMessage: string } | null
ready: boolean
approvals: ApprovalRequest[]
settledApprovals: SettledApproval[]
}
type Action =
@@ -48,7 +57,9 @@ const initialState: AgentUiState = {
sessionState: null,
stats: null,
retry: null,
ready: false
ready: false,
approvals: [],
settledApprovals: []
}
let keyCounter = 0
@@ -240,6 +251,16 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
sessionState: { ...state.sessionState, sessionName: event.name }
}
}
case 'approval_request':
return { ...state, approvals: [...state.approvals, event.request] }
case 'approval_settled': {
const settled: SettledApproval = { id: event.id, approved: event.approved, timedOut: event.timedOut }
return {
...state,
approvals: state.approvals.filter((a) => a.id !== event.id),
settledApprovals: [...state.settledApprovals.slice(-2), settled]
}
}
case 'stats':
return { ...state, stats: event.stats }
default:
@@ -258,6 +279,7 @@ export interface UseAgent {
openSession: (path: string) => void
renameSession: (name: string) => void
deleteSession: (path: string) => void
resolveApproval: (id: string, approved: boolean) => void
}
export function useAgent(): UseAgent {
@@ -341,6 +363,13 @@ export function useAgent(): UseAgent {
[dispatchError]
)
const resolveApproval = useCallback(
(id: string, approved: boolean): void => {
window.api.resolveApproval(id, approved).catch(dispatchError)
},
[dispatchError]
)
return useMemo(
() => ({
state,
@@ -352,8 +381,21 @@ export function useAgent(): UseAgent {
newSession,
openSession,
renameSession,
deleteSession
deleteSession,
resolveApproval
}),
[state, sessions, send, stop, clearError, refreshSessions, newSession, openSession, renameSession, deleteSession]
[
state,
sessions,
send,
stop,
clearError,
refreshSessions,
newSession,
openSession,
renameSession,
deleteSession,
resolveApproval
]
)
}
+1
View File
@@ -29,6 +29,7 @@ declare global {
logoutProvider: (providerId: string) => Promise<void>
getSettings: () => Promise<AppSettings>
setSystemPrompt: (text: string) => Promise<void>
resolveApproval: (id: string, approved: boolean) => Promise<void>
onEvent: (callback: (event: AgentUiEvent) => void) => () => void
}
}
+9
View File
@@ -63,6 +63,13 @@ export interface AppSettings {
systemPrompt: string
}
/** 审批请求(主进程 → 渲染层 Approval Card) */
export interface ApprovalRequest {
id: string
toolName: string
args: unknown
}
/**
* 转发到渲染进程的 agent 事件。
* 已归一化为可结构化克隆的普通 JSON(message 原样透传,渲染层防御性读取)。
@@ -72,6 +79,8 @@ export type AgentUiEvent =
| { type: 'session_error'; message: string }
| { type: 'session_switched'; state: SessionState; messages: unknown[]; activeTools: Record<string, unknown> }
| { type: 'state'; state: SessionState }
| { type: 'approval_request'; request: ApprovalRequest }
| { type: 'approval_settled'; id: string; approved: boolean; timedOut: boolean }
| { type: 'agent_start' }
| { type: 'agent_end' }
| { type: 'agent_settled' }
+15
View File
@@ -48,6 +48,21 @@
4. **系统提示词重建成本**——保存提示词会整体重建 runtime(重新加载扩展/skills),秒级耗时且丢当前流式状态;保存前应检查是否有进行中的回复。当前实现未拦截,记录待修。
5. **运行时重建后模型选择**——重建走 continueRecent,会话内保存的模型跟随会话;但全局默认模型(settings 持久化)与 UI 状态的同步依赖 `state` 事件,尚未覆盖"重建后 model fallback"提示(modelFallbackMessage 丢弃未展示)。排 v0.5。
## v0.4.0 — 工具与审批
**交付**:
- 业务工具骨架(`src/main/tools/demo-tools.ts`):`hotword_lookup`(演示数据源)、`price_check`(核价演示,敏感操作);接口形态对齐架构 D2(后端就绪后换实现不动接口)
- 审批链路:`ApprovalService` + `tool_call` 拦截 extension(`TOOLS_REQUIRING_APPROVAL` 名单:price_check/bash/write/edit)→ IPC `approval_request` → 渲染层 Approval Card(beautifului 风格,GSAP back.out 入场)→ `approval:resolve` 回传;120s 超时自动拒绝;窗口关闭拒绝全部未决
- 工具经 `customTools` 注入(与内置 read/bash/… 并存注册),promptSnippet/promptGuidelines 进系统提示词
- 验证:typecheck/build;Node 冒烟(customTools 注册、**手动直调 execute 成功 = D2 双模式共用能力实证**、拦截契约三名单场景:放行/拒绝 block/批准放行);应用带扩展正常启动
**自省**(v0.4.0 → 指导 v0.5+):
1. **端到端审批未实拍**——LLM 生成 tool_call → 审批卡弹出 → 拒绝后 agent 收到 block 结果的完整链路仍缺真实凭据验证(本机无 key)。拦截契约已单元级验证,剩余风险在事件时序(extension 在 AgentSession 的 tool hooks 上触发,理论覆盖所有工具)。
2. **审批名单硬编码**——应做成设置项(每个工具可配置 always/ask/never);且业务工具多了以后要按"工具元数据声明敏感级别",而非集中名单。排 v0.5。
3. **手动模式 UI 缺失**——D2 的另一半(UI 按钮直调工具)后端就绪前只能停留在代码层(smoke 已证 execute 可直调)。等 packages/agent 建立后做"手动模式面板"。
4. **工具卡片在审批等待期间无"等待审批"状态**——ToolCard 显示 running,但用户看不出是在等他确认;应联动 approval 状态显示"等待审批中"。排 v0.5。
5. **审批卡无倒计时**——120s 超时静默拒绝,卡片上应显示剩余时间。排 v0.5。
## v0.1.0 — 骨架 + 聊天链路
**交付**:
+4 -1
View File
@@ -10,6 +10,9 @@ importers:
apps/desktop:
dependencies:
'@earendil-works/pi-ai':
specifier: 0.85.0
version: 0.85.0(ws@8.21.3)
'@earendil-works/pi-coding-agent':
specifier: 0.85.0
version: 0.85.0(ws@8.21.3)
@@ -1795,7 +1798,7 @@ snapshots:
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.7.3
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.18.0
tslib: 2.8.1