feat(pi-permission-system): AutoReview 改为跟随主模型的对话式工具调用审查

AutoReview:

- 审查器回到 Codex Guardian 风格的对话式审查,默认跟随会话当前主模型
  (每次审查实时读取 ctx.model);provider/model 必须同时设置才固定审查
  模型,只设其一视为配置错误并转人工。
- 结论只通过 approve / reject 两个工具给出,参数仅 reason;不强制
  toolChoice,正文文本(即使是合法 JSON)一律忽略。
- approve 自动放行;reject 转人工并在弹窗显示理由(标注模型生成)。
  超时、Provider 错误、截断、未调用工具、多次调用、参数无效等异常同样
  转人工,并附固定的系统说明;用户按 Esc 中止时直接拒绝。
- 模型调用改为 modelRegistry.streamSimple(...).result():Pi 1.3.0 面向
  扩展的 ModelRegistry 没有 completeSimple,原写法在真实环境中每次都会
  转人工。
- 对话记录保留可信度标注;工具结果在有更短的 pi-compaction 摘要时替换
  为摘要;token 估算改为非 ASCII 字符按 1 token 计;预算可配置。
- 同一条用户消息内完全相同的请求只缓存 approve 结论。
- 默认 reasoning low、总超时 30 秒、最多 2 次尝试、maxOutputTokens 4000。

删除:

- 未发布过的 TypeSafe Jev 分类器实现、DenialCircuitBreaker。
- 整个 annotator 机制(model-explanation、注册表、registerAnnotator、
  provider-timeout);annotators 配置键保留为已弃用并忽略,避免旧配置
  被拒绝。

测试:

- 新增 reviewer / transcript / 弹窗说明 / 默认配置测试;清理重复、恒真
  或只测实现细节的旧测试。
- 使用 deepseek-v4.1-flash 实测 5 个场景各 5 次,25 次结论全部符合预期。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
云服务部-叶林立
2026-10-19 01:54:17 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent dec48dab6d
commit 7e6a8faae1
87 changed files with 2831 additions and 2658 deletions
@@ -1,32 +1,74 @@
import type { SessionEntry } from '@earendil-works/pi-coding-agent'
const MAX_RECENT_UNTRUSTED_ENTRIES = 40
const MAX_MESSAGE_TRANSCRIPT_TOKENS = 10_000
const MAX_TOOL_TRANSCRIPT_TOKENS = 10_000
const MAX_MESSAGE_ENTRY_TOKENS = 2_000
const MAX_TOOL_ENTRY_TOKENS = 1_000
const TRUSTED_USER_INTERACTION_TOOLS = new Set(['ask_user_question', 'plan_mode_question'])
/** Transcript budgets, as configured on `autoReview.transcript`. */
export interface TranscriptBudgets {
readonly messageTokens: number
readonly toolTokens: number
readonly maxUntrustedEntries: number
}
export const DEFAULT_TRANSCRIPT_BUDGETS: TranscriptBudgets = {
messageTokens: 10_000,
toolTokens: 10_000,
maxUntrustedEntries: 40,
}
/**
* One tool-result summary record, as returned by the injected lookup.
*
* Read structurally: the fields mirror pi-compaction's `ToolHistoryRecord`,
* but the transcript must not import pi-compaction code — the record reaches
* it through a lookup function the caller resolves (or leaves absent).
*/
export interface ToolResultSummaryRecord {
readonly toolCallId: string
readonly resultTimestamp?: unknown
readonly semanticSummary?: unknown
readonly deterministicSummary?: unknown
}
/**
* Resolve summaries for a batch of tool-call ids, in one call per render.
*
* The caller binds the session id; the transcript asks for every toolResult
* entry's id at once so the lookup performs a single service query.
*/
export type ToolResultSummaryQuery = (
toolCallIds: readonly string[],
) => readonly ToolResultSummaryRecord[]
/** Options accepted by {@link renderTranscript}. */
export interface TranscriptRenderOptions {
readonly budgets?: TranscriptBudgets
/** Optional summary lookup; absent means no substitution. */
readonly querySummaries?: ToolResultSummaryQuery
}
type TranscriptKind = 'user' | 'user_interaction' | 'assistant' | 'tool'
export interface TranscriptEntry {
index: number
/** The SessionEntry `id` this transcript entry was rendered from. */
entryId: string
kind: TranscriptKind
label: string
text: string
/** Set on plain toolResult entries, so a summary lookup can match them. */
toolCallId?: string
/** The message's own timestamp, read structurally; may be absent. */
timestamp?: unknown
truncated?: boolean
}
export interface TranscriptStats {
transcriptEntriesRetained: number
transcriptEntriesOmitted: number
transcriptEntriesTruncated: number
directUserEntriesRetained: number
directUserEntriesOmitted: number
directUserEntriesTruncated: number
userInteractionEntriesRetained: number
userInteractionEntriesOmitted: number
userInteractionEntriesTruncated: number
entriesRetained: number
entriesOmitted: number
entriesTruncated: number
toolResultsSummarized: number
latestTrustedEntryRetained: boolean
}
@@ -34,6 +76,12 @@ export interface RenderedTranscript {
entries: string[]
omittedCount: number
stats: TranscriptStats
/**
* The SessionEntry `id` of the newest trusted (`user` or `user_interaction`)
* entry in this render, or `null` when the branch has none. Lets a caller
* key per-request caches to the conversation point that authorized the ask.
*/
latestTrustedEntryId: string | null
}
interface ContentBlock {
@@ -56,6 +104,7 @@ interface MessageLike {
toolName?: unknown
isError?: unknown
details?: unknown
timestamp?: unknown
}
interface UserInteractionDetails {
@@ -70,8 +119,24 @@ interface UserInteractionAnswer {
notes?: unknown
}
function approximateTokens(text: string): number {
return Math.ceil(text.length / 4)
/**
* A conservative token estimate: ASCII characters count as a quarter token
* each, every non-ASCII code point counts as a full token.
*
* The plain `length / 4` heuristic under-counts CJK by roughly 2–4x, which
* would silently overflow every budget this module enforces.
*/
export function estimateTokens(text: string): number {
let ascii = 0
let nonAscii = 0
for (const character of text) {
if ((character.codePointAt(0) ?? 0) > 127) {
nonAscii += 1
} else {
ascii += 1
}
}
return Math.ceil(ascii / 4) + nonAscii
}
function truncateToCharacters(text: string, maxCharacters: number): string {
@@ -85,8 +150,31 @@ function truncateToCharacters(text: string, maxCharacters: number): string {
return `${text.slice(0, headLength)}${tag}${text.slice(-tailLength)}`
}
/**
* Largest head/tail truncation whose estimate fits `maxTokens`.
*
* Character budgets and token budgets diverge on non-ASCII text, so the bound
* is searched: binary search the character count while the estimate — not the
* length — decides whether a candidate fits.
*/
export function truncateToApproximateTokens(text: string, maxTokens: number): string {
return truncateToCharacters(text, maxTokens * 4)
if (estimateTokens(text) <= maxTokens) {
return text
}
let lower = 0
let upper = text.length
let result = truncateToCharacters(text, 0)
while (lower <= upper) {
const middle = Math.floor((lower + upper) / 2)
const candidate = truncateToCharacters(text, middle)
if (estimateTokens(candidate) <= maxTokens) {
result = candidate
lower = middle + 1
} else {
upper = middle - 1
}
}
return result
}
function serializeUnknown(value: unknown): string {
@@ -194,13 +282,14 @@ function normalizedUserInteraction(
function assistantEntries(
message: MessageLike,
index: number,
entryId: string,
interactionToolCalls: Map<string, string>,
): TranscriptEntry[] {
const content = Array.isArray(message.content) ? message.content : []
const text = textFromContent(message.content)
const entries: TranscriptEntry[] = []
if (text) {
entries.push({ index, kind: 'assistant', label: 'assistant', text })
entries.push({ index, entryId, kind: 'assistant', label: 'assistant', text })
}
for (const rawBlock of content) {
const block = rawBlock as ContentBlock
@@ -214,6 +303,7 @@ function assistantEntries(
}
entries.push({
index,
entryId,
kind: 'tool',
label: `tool:${name}`,
text: serializeUnknown(block.arguments),
@@ -225,15 +315,16 @@ function assistantEntries(
function entriesFromMessage(
message: MessageLike,
index: number,
entryId: string,
interactionToolCalls: Map<string, string>,
): TranscriptEntry[] {
switch (message.role) {
case 'user': {
const text = textFromContent(message.content)
return text ? [{ index, kind: 'user', label: 'user', text }] : []
return text ? [{ index, entryId, kind: 'user', label: 'user', text }] : []
}
case 'assistant':
return assistantEntries(message, index, interactionToolCalls)
return assistantEntries(message, index, entryId, interactionToolCalls)
case 'toolResult': {
const name = typeof message.toolName === 'string' ? message.toolName : 'unknown'
const userInteraction = normalizedUserInteraction(message, interactionToolCalls)
@@ -241,6 +332,7 @@ function entriesFromMessage(
return [
{
index,
entryId,
kind: 'user_interaction',
label: `user_interaction:${name}`,
text: userInteraction,
@@ -249,7 +341,19 @@ function entriesFromMessage(
}
const suffix = message.isError === true ? ' (error)' : ''
const text = textFromContent(message.content)
return text ? [{ index, kind: 'tool', label: `tool:${name}${suffix}`, text }] : []
return text
? [
{
index,
entryId,
kind: 'tool',
label: `tool:${name}${suffix}`,
text,
...(typeof message.toolCallId === 'string' ? { toolCallId: message.toolCallId } : {}),
timestamp: message.timestamp,
},
]
: []
}
case 'bashExecution': {
const command = serializeUnknown(message.command)
@@ -257,6 +361,7 @@ function entriesFromMessage(
return [
{
index,
entryId,
kind: 'tool',
label: 'tool:user-bash',
text: `${command}\n${output}`,
@@ -266,11 +371,11 @@ function entriesFromMessage(
case 'branchSummary':
case 'compactionSummary': {
const text = serializeUnknown(message.summary)
return text ? [{ index, kind: 'assistant', label: String(message.role), text }] : []
return text ? [{ index, entryId, kind: 'assistant', label: String(message.role), text }] : []
}
case 'custom': {
const text = textFromContent(message.content)
return text ? [{ index, kind: 'assistant', label: 'custom', text }] : []
return text ? [{ index, entryId, kind: 'assistant', label: 'custom', text }] : []
}
default:
return []
@@ -281,12 +386,18 @@ export function collectTranscriptEntries(sessionEntries: SessionEntry[]): Transc
const interactionToolCalls = new Map<string, string>()
return sessionEntries.flatMap((entry, index) => {
if (entry.type === 'message') {
return entriesFromMessage(entry.message as MessageLike, index, interactionToolCalls)
return entriesFromMessage(
entry.message as MessageLike,
index,
entry.id,
interactionToolCalls,
)
}
if (entry.type === 'compaction' || entry.type === 'branch_summary') {
return [
{
index,
entryId: entry.id,
kind: 'assistant' as const,
label: entry.type,
text: entry.summary,
@@ -299,6 +410,7 @@ export function collectTranscriptEntries(sessionEntries: SessionEntry[]): Transc
? [
{
index,
entryId: entry.id,
kind: 'assistant' as const,
label: 'custom',
text,
@@ -320,23 +432,22 @@ function renderTranscriptEntry(entry: TranscriptEntry): string {
}
function transcriptEntryTokens(entry: TranscriptEntry): number {
return approximateTokens(renderTranscriptEntry(entry))
return estimateTokens(renderTranscriptEntry(entry))
}
function pretruncate(entry: TranscriptEntry): TranscriptEntry {
const maxTokens = entry.kind === 'tool' ? MAX_TOOL_ENTRY_TOKENS : MAX_MESSAGE_ENTRY_TOKENS
const maxCharacters = maxTokens * 4
if (renderTranscriptEntry(entry).length <= maxCharacters) {
if (transcriptEntryTokens(entry) <= maxTokens) {
return entry
}
let lower = 0
let upper = Math.min(entry.text.length, maxCharacters)
let upper = entry.text.length
let text = truncateToCharacters(entry.text, 0)
while (lower <= upper) {
const middle = Math.floor((lower + upper) / 2)
const candidate = truncateToCharacters(entry.text, middle)
if (renderTranscriptEntry({ ...entry, text: candidate }).length <= maxCharacters) {
if (estimateTokens(renderTranscriptEntry({ ...entry, text: candidate })) <= maxTokens) {
text = candidate
lower = middle + 1
} else {
@@ -363,8 +474,86 @@ function addWithinBudget(selected: Set<TranscriptEntry>, entries: TranscriptEntr
return used
}
export function renderTranscript(sessionEntries: SessionEntry[]): RenderedTranscript {
const allEntries = collectTranscriptEntries(sessionEntries).map(pretruncate)
function summaryText(record: ToolResultSummaryRecord): string | undefined {
const summary = record.semanticSummary ?? record.deterministicSummary
return typeof summary === 'string' && summary.length > 0 ? summary : undefined
}
/**
* Substitute pi-compaction summaries for plain tool results.
*
* One lookup call per render, over every toolResult's call id at once. A
* record replaces the entry text only when it is strictly shorter — a summary
* longer than its source compresses nothing — and the label gains
* ` (summarized)` so the reviewer reads it as a digest, not the raw output.
* Entries normalized to `user_interaction` and assistant toolCall argument
* entries carry no `toolCallId`, so they are never touched.
*
* Returns the entries (substituted in place on copies) and how many were
* summarized. Any lookup failure keeps the originals.
*/
function substituteSummaries(
entries: TranscriptEntry[],
query: ToolResultSummaryQuery | undefined,
): number {
if (query === undefined) return 0
const candidates = entries.filter(
entry => entry.kind === 'tool' && entry.toolCallId !== undefined,
)
if (candidates.length === 0) return 0
let records: readonly ToolResultSummaryRecord[]
try {
records = query(candidates.map(entry => entry.toolCallId as string))
} catch {
return 0
}
if (!Array.isArray(records) || records.length === 0) return 0
const byCallId = new Map<string, ToolResultSummaryRecord[]>()
for (const record of records) {
if (record === null || typeof record !== 'object' || typeof record.toolCallId !== 'string') {
continue
}
const list = byCallId.get(record.toolCallId)
if (list === undefined) {
byCallId.set(record.toolCallId, [record])
} else {
list.push(record)
}
}
let summarized = 0
for (const entry of candidates) {
const list = byCallId.get(entry.toolCallId as string)
if (list === undefined) continue
const record =
list.find(
candidate =>
entry.timestamp !== undefined &&
candidate.resultTimestamp !== undefined &&
candidate.resultTimestamp === entry.timestamp,
) ?? list[0]
if (record === undefined) continue
const summary = summaryText(record)
if (summary === undefined) continue
const replacement = `${summary}\n[summarized]`
if (replacement.length >= entry.text.length) continue
entry.text = replacement
entry.label = `${entry.label} (summarized)`
summarized += 1
}
return summarized
}
export function renderTranscript(
sessionEntries: SessionEntry[],
options?: TranscriptRenderOptions,
): RenderedTranscript {
const budgets = options?.budgets ?? DEFAULT_TRANSCRIPT_BUDGETS
const collected = collectTranscriptEntries(sessionEntries)
const toolResultsSummarized = substituteSummaries(collected, options?.querySummaries)
const allEntries = collected.map(pretruncate)
const selected = new Set<TranscriptEntry>()
const trustedEntries = allEntries.filter(isTrusted)
@@ -383,22 +572,22 @@ export function renderTranscript(sessionEntries: SessionEntry[]): RenderedTransc
}
const remainingTrusted = trustedEntries.filter(entry => !selected.has(entry)).toReversed()
messageTokens += addWithinBudget(selected, remainingTrusted, MAX_MESSAGE_TRANSCRIPT_TOKENS - messageTokens)
messageTokens += addWithinBudget(selected, remainingTrusted, budgets.messageTokens - messageTokens)
let toolTokens = 0
let untrustedEntriesRetained = 0
for (const entry of allEntries.toReversed()) {
if (isTrusted(entry) || untrustedEntriesRetained >= MAX_RECENT_UNTRUSTED_ENTRIES) {
if (isTrusted(entry) || untrustedEntriesRetained >= budgets.maxUntrustedEntries) {
continue
}
const tokens = transcriptEntryTokens(entry)
if (entry.kind === 'tool') {
if (toolTokens + tokens > MAX_TOOL_TRANSCRIPT_TOKENS) {
if (toolTokens + tokens > budgets.toolTokens) {
continue
}
toolTokens += tokens
} else {
if (messageTokens + tokens > MAX_MESSAGE_TRANSCRIPT_TOKENS) {
if (messageTokens + tokens > budgets.messageTokens) {
continue
}
messageTokens += tokens
@@ -409,28 +598,50 @@ export function renderTranscript(sessionEntries: SessionEntry[]): RenderedTransc
const retained = [...selected].sort((left, right) => left.index - right.index)
const latestTrusted = trustedEntries.at(-1)
const directUsers = allEntries.filter(entry => entry.kind === 'user')
const userInteractions = allEntries.filter(entry => entry.kind === 'user_interaction')
const directUserEntriesRetained = retained.filter(entry => entry.kind === 'user').length
const userInteractionEntriesRetained = retained.filter(entry => entry.kind === 'user_interaction').length
const stats: TranscriptStats = {
transcriptEntriesRetained: retained.length,
transcriptEntriesOmitted: allEntries.length - retained.length,
transcriptEntriesTruncated: retained.filter(entry => entry.truncated === true).length,
directUserEntriesRetained,
directUserEntriesOmitted: directUsers.length - directUserEntriesRetained,
directUserEntriesTruncated: retained.filter(entry => entry.kind === 'user' && entry.truncated === true).length,
userInteractionEntriesRetained,
userInteractionEntriesOmitted: userInteractions.length - userInteractionEntriesRetained,
userInteractionEntriesTruncated: retained.filter(
entry => entry.kind === 'user_interaction' && entry.truncated === true,
).length,
entriesRetained: retained.length,
entriesOmitted: allEntries.length - retained.length,
entriesTruncated: retained.filter(entry => entry.truncated === true).length,
toolResultsSummarized,
latestTrustedEntryRetained: latestTrusted !== undefined && selected.has(latestTrusted),
}
return {
entries: retained.map(renderTranscriptEntry),
omittedCount: stats.transcriptEntriesOmitted,
omittedCount: stats.entriesOmitted,
stats,
latestTrustedEntryId: latestTrusted?.entryId ?? null,
}
}
/**
* A summary lookup bound to the session's pi-compaction service, or
* `undefined` when no service is published.
*
* The service is a `Symbol.for()` global — read structurally so this module
* never imports pi-compaction code, and a missing, throwing, or differently
* shaped service simply yields no lookup rather than a render failure.
*/
export function compactionSummaryQuery(
sessionId: string,
): ToolResultSummaryQuery | undefined {
const service = (globalThis as Record<symbol, unknown>)[
Symbol.for('my-pi.pi-compaction.service.v1')
]
if (service === null || typeof service !== 'object') {
return undefined
}
const query = (service as Record<string, unknown>).query
if (typeof query !== 'function') {
return undefined
}
const queryFn = query as (
this: unknown,
session: string,
ids: readonly string[],
) => unknown
return toolCallIds => {
const records = queryFn.call(service, sessionId, toolCallIds)
return Array.isArray(records) ? (records as ToolResultSummaryRecord[]) : []
}
}