Files
my-pi/pi-permission-system/src/authority/permission-dialog.ts
T
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7e6a8faae1 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>
2026-10-19 01:54:17 -07:00

184 lines
5.7 KiB
TypeScript

import type { DecisionSource } from "#src/authority/decision-source";
import type { PromptDecisionNote } from "#src/presentation/prompt-payload";
export type PermissionDecisionState =
| "approved"
| "approved_for_session"
| "approved_for_serving_session"
| "denied"
| "denied_with_reason";
export type PermissionPromptDecision = {
approved: boolean;
state: PermissionDecisionState;
denialReason?: string;
/**
* True when the decision was made automatically by yolo mode rather than
* by an interactive user prompt. Used by handlers to emit "auto_approved"
* rather than "user_approved" in the permissions:decision broadcast.
*/
autoApproved?: true;
/**
* True when no human ever ruled on this ask: either no live authority was
* reachable at all (`DenyingAuthorizer`, a no-UI non-subagent session) or the
* forwarding path gave up before reaching one (`ParentAuthorizer` — target
* unresolvable, request undeliverable, target not serving, or no answer
* within the timeout). Consumed by deriveResolution (the decision-event
* resolution), the gate (block reason), and PermissionPrompter (review-entry
* resolution) to emit "confirmation_unavailable" rather than a plain user
* denial — a user who was never asked denied nothing (#719).
*/
confirmationUnavailable?: true;
/**
* What decided this request, stamped by the site that decided it.
*
* Required: every decision names its decider, and the type is what
* guarantees it rather than a convention each producer has to remember — the
* same discipline `PromptPermissionDetails.payload` carries (#726).
*/
decidedBy: DecisionSource;
};
/**
* A decision before its decider is known.
*
* The inner producers — the dialog's decision model, the `select`/`input`
* fallback, the verdict mapper — state the outcome; which decider to attribute
* it to is settled one layer up, at the site that chose the producer. The same
* shape `GateBypass.decision` uses for the request id: a producer emits only
* what it knows.
*/
export type UnattributedDecision = Omit<PermissionPromptDecision, "decidedBy">;
export interface PermissionDecisionUi {
select(title: string, options: string[]): Promise<string | undefined>;
input(title: string, placeholder?: string): Promise<string | undefined>;
}
const APPROVE_OPTION = "Yes";
const APPROVE_FOR_SESSION_OPTION = "Yes, for this session";
const DENY_OPTION = "No";
const DENY_WITH_REASON_OPTION = "No, provide reason";
export function normalizePermissionDenialReason(
value: unknown,
): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
export function createDeniedPermissionDecision(
denialReason?: string,
): UnattributedDecision {
const normalizedReason = normalizePermissionDenialReason(denialReason);
return normalizedReason
? {
approved: false,
state: "denied_with_reason",
denialReason: normalizedReason,
}
: {
approved: false,
state: "denied",
};
}
export function isPermissionDecisionState(
value: unknown,
): value is PermissionDecisionState {
return (
value === "approved" ||
value === "approved_for_session" ||
value === "approved_for_serving_session" ||
value === "denied" ||
value === "denied_with_reason"
);
}
export interface RequestPermissionOptions {
/** Override the "for this session" option label (e.g. to show the suggested pattern). */
sessionLabel?: string;
/**
* Forwarded asks only: when set, choosing the "for this session" option opens
* a second select asking whether the grant applies to the requesting subagent
* only (the least-privilege default) or the whole serving session.
*/
sessionScope?: {
subagentLabel: string;
servingSessionLabel: string;
};
/**
* Why automatic review declined to approve, shown ahead of the ask facts.
*
* `origin` marks who wrote each note: a `model` reason is the reviewer's own
* verdict text and renders marked as model-generated; a `system` note is a
* deterministic, code-authored sentence.
*/
deferNotes?: readonly PromptDecisionNote[];
}
export async function requestPermissionDecisionFromUi(
ui: PermissionDecisionUi,
title: string,
message: string,
options?: RequestPermissionOptions,
): Promise<UnattributedDecision> {
const sessionOption = options?.sessionLabel ?? APPROVE_FOR_SESSION_OPTION;
const decisionOptions = [
APPROVE_OPTION,
sessionOption,
DENY_OPTION,
DENY_WITH_REASON_OPTION,
] as const;
const selected = await ui.select(`${title}\n${message}`, [
...decisionOptions,
]);
if (selected === APPROVE_OPTION) {
return {
approved: true,
state: "approved",
};
}
if (selected === sessionOption) {
if (options?.sessionScope) {
const scope = await ui.select(`${title}\nApply this session grant to:`, [
options.sessionScope.subagentLabel,
options.sessionScope.servingSessionLabel,
]);
return {
approved: true,
// A cancelled scope select (undefined) falls back to the
// least-privilege subagent scope.
state:
scope === options.sessionScope.servingSessionLabel
? "approved_for_serving_session"
: "approved_for_session",
};
}
return {
approved: true,
state: "approved_for_session",
};
}
if (selected === DENY_WITH_REASON_OPTION) {
const denialReason = normalizePermissionDenialReason(
await ui.input(
`${title}\nShare why this request was denied (optional).`,
"Reason shown back to the agent",
),
);
return createDeniedPermissionDecision(denialReason);
}
return createDeniedPermissionDecision();
}