diff --git a/.github/releases/v1.0.64.md b/.github/releases/v1.0.64.md index 8e8f9402b..25581895c 100644 --- a/.github/releases/v1.0.64.md +++ b/.github/releases/v1.0.64.md @@ -1,6 +1,12 @@ ## opencode 1.0.64 -Stable release from `main` branch. Repair independently confirmed defects in the Claude-compatible hooks API, project memory, the autonomous goal loop and the DAG orchestrator. +Stable release from `main` branch. Repair independently confirmed defects in the Claude-compatible hooks API, project memory, the autonomous goal loop, the DAG orchestrator and opt-in reasoning distillation. + +--- + +### 🎯 Features + +- **Reasoning distillation**: `reasoningDistillation.language` selects the organizer language, `zh` (default; shorter for the same content) or `en`. Paths, commands, code, configuration keys, versions and numbers stay verbatim in either language. --- @@ -13,6 +19,11 @@ Stable release from `main` branch. Repair independently confirmed defects in the - **Goal**: An unanswered user message pauses the goal visibly instead of stalling while spending judge calls. The judge prompt receives the agent response verbatim. An undecodable goal record can be cleared and no longer blocks the session's automation lease. A stray brace in judge prose no longer hides a valid verdict. - **DAG**: Parents are woken for terminal workflows after a paused reminder, for nodes settling while paused, and after a timed-out or failed wake delivery. Recovery reruns work skipped or aborted by workflow termination instead of completing without it. Replan/extend keeps a failed required upstream blocking its dependents. Moving a session hands its running workflow to the new owner. The supervision sweep honours attempt identity, and summaries refresh on queue, step and deadline-extension events. - **DAG**: Conditions using `===`, `!==`, `&&` or `||` are rejected at authoring time instead of silently mis-routing; existing workflows keep their stored conditions. HTTP replan/extend apply the same authoring checks as the workflow tool. A missing prompt template fails its node instead of hanging the workflow. A cycle among required nodes is reported as a diagnostic. Review gates honour `node_defaults.required` and reject an inverted PASS gate. An empty model tier no longer blocks single-tier fallback. The workflow tool asks `read` permission before reading spec files. +- **DAG**: A node that needs clarification asks its owning parent through the `agent` tool instead of the user. The parent answers with a conclusion grounded in the user's instructions or checked evidence; when it cannot reach one it states what remains uncertain and what the node may safely do, or asks the user first. +- **Hooks**: A hook-forced `ask` shows the input the tool will actually run, including `updatedInput`, together with the hook's reason, in the TUI and web app; PermissionRequest hooks receive the same input. "Allow always" is hidden when nothing would be remembered. Once confirmed, the same call is not asked again; deny rules still apply. Tool parts keep the model's own input. An ignored PermissionRequest `allow` is logged, and the semantics are documented. +- **Reasoning distillation**: Long sessions keep distilling. Each organizer call's worst-case reservation is reconciled with reported usage, so the session budget no longer ran out after about ten turns. A transport failure or timeout is refunded; only three consecutive failures pause a session. +- **Reasoning distillation**: Reasoning too short to repay the organizer call is left as is, and a replacement that is not smaller than its source is discarded. In a multi-part batch, only the explicit no-useful-reasoning marker clears a part; an empty body keeps the original. +- **Reasoning distillation**: The turn-end snapshot covers only the completed turn instead of hashing the whole session on the foreground path. The core runner honours `OPENCODE_REASONING_DISTILLATION_AUX_TIMEOUT_MS` and a small model's declared `none` variant. The `compatibility` setting is documented as applying only to the legacy wire projector. --- @@ -26,10 +37,11 @@ Stable release from `main` branch. Repair independently confirmed defects in the ### 🧪 Test Summary ```text -workspace test tasks: 26/26 passed workspace typecheck tasks: 31/31 passed -opencode tests: 5322 passed, 0 failed -core tests: 1559 passed, 0 failed +opencode tests: 5332 passed; 9 prompt.test.ts cases hit the 30s timeout under host load, 158/158 on an isolated rerun +core tests: 1568 passed, 0 failed +tui tests: 282 passed, 0 failed +app unit tests: 456 passed, 0 failed HTTP exerciser scenarios: 239 passed (coverage, auth, effect) DAG behavior/coverage: passed curated DAG templates: 16/16 validated @@ -41,7 +53,7 @@ dual client generation: unchanged ### 🔍 Verification -Each repaired finding was reproduced by a failing test, independently re-confirmed before repair, and kept as a regression test. Tests use isolated sessions, temporary data and synthetic model fixtures; no live model calls or interactive TUI sessions were used. Native CI verifies the final PR head before merge, and the release workflow validates reference templates with the releasing runtime and verifies platform archives and checksums. +Each repaired finding was reproduced by a failing test, independently re-confirmed before repair, and kept as a regression test. Tests use isolated sessions, temporary data and synthetic model fixtures. The distillation and forced-ask repairs were also exercised against the configured DeepSeek and GLM models with a temporary config and database: a 3,309-character reasoning part was organized by one small-model call into 909 Chinese characters and adopted, with the reservation reconciled to reported usage; a forced ask with a rewritten command was shown with its reason and effective input in the TUI and web app, ran the rewritten command once and was not asked again; a forced file write showed its path and content in the TUI and was rejected without writing. Native CI verifies the final PR head before merge, and the release workflow validates reference templates with the releasing runtime and verifies platform archives and checksums. --- diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index e66cf8150..aaea3d930 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -46,36 +46,41 @@ test("shows a pending question dock", async ({ page }) => { expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] }) }) -test("shows a pending permission dock", async ({ page }) => { - await mockServer(page, { - permissions: [ - { - id: "permission-request", - sessionID, - permission: "bash", - patterns: ["git status", "git diff"], - metadata: {}, - always: [], - }, - ], - }) +for (const scenario of [ + { name: "with a persistable pattern", always: ["git *"], buttons: ["Deny", "Allow always", "Allow once"] }, + { name: "without a persistable pattern", always: [], buttons: ["Deny", "Allow once"] }, +]) { + test(`shows a pending permission dock ${scenario.name}`, async ({ page }) => { + await mockServer(page, { + permissions: [ + { + id: "permission-request", + sessionID, + permission: "bash", + patterns: ["git status", "git diff"], + metadata: {}, + always: scenario.always, + }, + ], + }) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - await expectSessionTitle(page, title) + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) - const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]') - await expect(permission).toBeVisible() - await expect(permission.getByText("git status")).toBeVisible() - await expect(permission.getByText("git diff")).toBeVisible() - await expect(permission.locator('[data-slot="permission-footer-actions"] button')).toHaveCount(3) - await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0) + const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]') + await expect(permission).toBeVisible() + await expect(permission.getByText("git status")).toBeVisible() + await expect(permission.getByText("git diff")).toBeVisible() + await expect(permission.locator('[data-slot="permission-footer-actions"] button')).toHaveText(scenario.buttons) + await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0) - const reply = page.waitForRequest((request) => request.method() === "POST") - await permission.getByRole("button", { name: "Allow once" }).click() - const request = await reply - expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`) - expect(request.postDataJSON()).toEqual({ response: "once" }) -}) + const reply = page.waitForRequest((request) => request.method() === "POST") + await permission.getByRole("button", { name: "Allow once" }).click() + const request = await reply + expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`) + expect(request.postDataJSON()).toEqual({ response: "once" }) + }) +} async function mockServer( page: Page, diff --git a/packages/app/src/pages/session/composer/hook-ask-input.test.ts b/packages/app/src/pages/session/composer/hook-ask-input.test.ts new file mode 100644 index 000000000..1e31bdf6e --- /dev/null +++ b/packages/app/src/pages/session/composer/hook-ask-input.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test" +import { hookAskInput } from "./hook-ask-input" + +describe("hook-forced ask input", () => { + test("shows a command as is and other tools' full arguments, bounded", () => { + expect(hookAskInput({ metadata: { hookAsk: true, input: { command: "echo rewritten" } } })).toBe("echo rewritten") + const edit = hookAskInput({ + metadata: { hookAsk: true, input: { filePath: "src/a.ts", oldString: "a", newString: "b" } }, + }) + expect(edit).toContain('"filePath": "src/a.ts"') + expect(edit).toContain('"newString": "b"') + const large = hookAskInput({ metadata: { hookAsk: true, input: { filePath: "a", content: "x".repeat(5_000) } } }) + expect(large.length).toBeLessThan(2_100) + expect(large).toEndWith("… (truncated)") + }) + + test("ordinary requests keep their patterns", () => { + expect(hookAskInput({ metadata: { input: { command: "ls" } } })).toBe("") + expect(hookAskInput({ metadata: { hookAsk: true } })).toBe("") + }) +}) diff --git a/packages/app/src/pages/session/composer/hook-ask-input.ts b/packages/app/src/pages/session/composer/hook-ask-input.ts new file mode 100644 index 000000000..8e43a1ef8 --- /dev/null +++ b/packages/app/src/pages/session/composer/hook-ask-input.ts @@ -0,0 +1,17 @@ +import type { PermissionRequest } from "@opencode-ai/sdk/v2" + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const MAX_CHARS = 2_000 + +/** + * The input a hook-forced ask will run with: a shell command as is, otherwise all arguments as JSON (so a path is + * shown together with the content or edit it applies), bounded for the dock. + */ +export const hookAskInput = (request: Pick) => { + const input = request.metadata?.hookAsk === true ? request.metadata.input : undefined + if (!isRecord(input)) return "" + const text = typeof input.command === "string" && input.command ? input.command : JSON.stringify(input, null, 2) + return text.length > MAX_CHARS ? `${text.slice(0, MAX_CHARS)}\n… (truncated)` : text +} diff --git a/packages/app/src/pages/session/composer/session-permission-dock.tsx b/packages/app/src/pages/session/composer/session-permission-dock.tsx index 35a62f75c..d76047409 100644 --- a/packages/app/src/pages/session/composer/session-permission-dock.tsx +++ b/packages/app/src/pages/session/composer/session-permission-dock.tsx @@ -4,6 +4,7 @@ import { Button } from "@opencode-ai/ui/button" import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt" import { Icon } from "@opencode-ai/ui/icon" import { useLanguage } from "@/context/language" +import { hookAskInput } from "./hook-ask-input" export function SessionPermissionDock(props: { request: PermissionRequest @@ -12,6 +13,11 @@ export function SessionPermissionDock(props: { }) { const language = useLanguage() + const hookReason = () => { + const reason = props.request.metadata?.reason + return props.request.metadata?.hookAsk === true && typeof reason === "string" ? reason : "" + } + const toolDescription = () => { const key = `settings.permissions.tool.${props.request.permission}.description` const value = language.t(key as Parameters[0]) @@ -19,6 +25,12 @@ export function SessionPermissionDock(props: { return value } + // A hook-forced ask confirms the effective input; its pattern is only the tool name. + const shown = () => { + const input = hookAskInput(props.request) + return input ? [input] : props.request.patterns + } + return ( props.onDecide("reject")} disabled={props.responding}> {language.t("ui.permission.deny")} - + 0}> + + @@ -52,6 +66,13 @@ export function SessionPermissionDock(props: { } > + +
+
+
+
- 0}> + 0}>
diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index c0716d461..a5320d242 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -98,7 +98,7 @@ export class Info extends Schema.Class("Config.Info")({ }), reasoningDistillation: ConfigReasoningDistillation.Info.pipe(Schema.optional).annotate({ description: - "Reasoning distillation of returned model thoughts (default-off; enable in opencode.json; any rewrite is gated by per-provider compatibility evidence)", + "Reasoning distillation of returned model thoughts (default-off; enable in opencode.json; signed, encrypted or unknown provider reasoning is never rewritten; `language` selects zh (default) or en)", }), skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({ description: "Additional paths or URLs to discover skills from", diff --git a/packages/core/src/config/reasoning-distillation.ts b/packages/core/src/config/reasoning-distillation.ts index ca09353da..f5bf9479a 100644 --- a/packages/core/src/config/reasoning-distillation.ts +++ b/packages/core/src/config/reasoning-distillation.ts @@ -13,16 +13,24 @@ export class Compatibility extends Schema.Class("ConfigV2.Reasoni upstreamVerified: Schema.Boolean, }) {} +export const Language = Schema.Literals(["zh", "en"]) +export type Language = typeof Language.Type + /** - * Reasoning-distillation switch (default-off; explicit opt-in still requires compatibility authorization). - * The feature only ever rewrites a reasoning slot that carries a §2.1 dual-evidence compatibility record; absent that - * record every slot is P5-protected, so enabling the switch is safe and never rewrites an unproven provider. + * Reasoning-distillation switch (default-off). When enabled, settled canonical reasoning without signed, encrypted + * or unknown provider carriers is organized once per completed turn and the result replaces the stored text. + * `compatibility` records only gate the legacy native-wire projector; canonical adoption does not consult them. */ export class Info extends Schema.Class("ConfigV2.ReasoningDistillation")({ enabled: Schema.Boolean.pipe(Schema.optional), + /** Language of the organized prose; literals such as paths and commands stay verbatim. Defaults to `zh`. */ + language: Language.pipe(Schema.optional), compatibility: Compatibility.pipe(Schema.Array, Schema.optional), }) {} +/** Organizer language; Chinese by default because it is shorter for the same content. */ +export const resolveLanguage = (info: Pick | undefined): Language => info?.language ?? "zh" + export type EnableSource = "environment" | "config" | "default" export type EnableResolution = Readonly<{ diff --git a/packages/core/src/plugin/command/orchestration-policy.md b/packages/core/src/plugin/command/orchestration-policy.md index b77b16837..abd885b8b 100644 --- a/packages/core/src/plugin/command/orchestration-policy.md +++ b/packages/core/src/plugin/command/orchestration-policy.md @@ -59,6 +59,13 @@ cancellable limit. A read or cancelled wait does not consume messages; delivery means inclusion in a model input snapshot. An ordinary message does not require interrupting the active turn or polling merely to wait. +A node that needs clarification or a decision asks its owning parent through +`agent`; nodes do not ask the user. When a node asks you, reply to its message +with a conclusion you can stand behind, grounded in the user's instructions or +evidence you checked. If you cannot reach one, state what remains uncertain and +what the node may safely do, or ask the user before replying. Never present a +guess as settled. + Agent messages are supplied context with their recorded source, not human user authorization or a workflow-control operation. Preserve permissions, budgets, checkpoint decisions, output schemas, and review fingerprints. If a message diff --git a/packages/core/src/plugin/skill/configure-hooks.md b/packages/core/src/plugin/skill/configure-hooks.md index 057dc819b..b8a61c66a 100644 --- a/packages/core/src/plugin/skill/configure-hooks.md +++ b/packages/core/src/plugin/skill/configure-hooks.md @@ -191,7 +191,18 @@ project-controlled PATH are unavailable. Use the three file tools on Windows. `continue: false` stops prompt admission and wins over a Stop hook's request to continue. Permission hooks can reject using `permissionDecision: "deny"`, a block -decision, or exit code 2. Post-tool block reasons and contexts are appended to +decision, or exit code 2. + +A PreToolUse `permissionDecision: "ask"` is a forced ask: the user always +confirms it, even after an earlier "always" approval, and the dialog shows the +input the tool will run with, including any `updatedInput`, plus the hook's +`permissionDecisionReason`. A PermissionRequest hook can deny a forced ask, but +its `allow` is ignored (logged). Once confirmed, the tool's own ask-level +permission checks for that call do not prompt again; deny rules still apply. +`opencode run --dangerously-skip-permissions` is an automatic mode and approves +forced asks once; without it, `run` rejects them. + +Post-tool block reasons and contexts are appended to model-facing tool feedback after execution; they cannot undo a completed write. `FileChanged` carries an absolute path and `add`, `change` or `delete`; patch renames report both the removed path and the added destination. diff --git a/packages/core/src/session/reasoning-distillation/index.ts b/packages/core/src/session/reasoning-distillation/index.ts index f8feff212..5b7b35bfe 100644 --- a/packages/core/src/session/reasoning-distillation/index.ts +++ b/packages/core/src/session/reasoning-distillation/index.ts @@ -38,8 +38,8 @@ export { } from "./plan" export { ReasoningDistillationPolicy } from "./policy" export { compactCandidateForReview } from "./review" -export { organizeReasoning, NO_USEFUL_REASONING, ORGANIZE_INSTRUCTION } from "./organize" -export type { OrganizeCall, OrganizeResult, OrganizeSlot } from "./organize" +export { organizeReasoning, NO_USEFUL_REASONING, ORGANIZE_INSTRUCTION, ORGANIZE_INSTRUCTIONS } from "./organize" +export type { OrganizeCall, OrganizeLanguage, OrganizeResult, OrganizeSlot } from "./organize" export { projectDistillationRequest } from "./projection" export { capabilityFingerprint, classifySlotEligibility } from "./slot" export type { CompatibilityRecord, SlotAssessment, SlotCapability } from "./slot" diff --git a/packages/core/src/session/reasoning-distillation/organize.ts b/packages/core/src/session/reasoning-distillation/organize.ts index deaa4d720..2f6b2d6f8 100644 --- a/packages/core/src/session/reasoning-distillation/organize.ts +++ b/packages/core/src/session/reasoning-distillation/organize.ts @@ -5,22 +5,50 @@ import { Token } from "../../util/token" export const NO_USEFUL_REASONING = "<>" export type OrganizeSlot = Readonly<{ messageID: string; partID: string; text: string }> +export type OrganizeLanguage = "zh" | "en" export type OrganizeCall = ( input: Readonly<{ prompt: string; format: "text" | "json" }>, ) => Promise | undefined> export type OrganizeResult = Readonly<{ status: "organized" | "skipped" replacements: readonly ReasoningReplacement[] - reason?: "empty-input" | "work-limit" | "invalid-output" | "truncated" | "model-failure" | "unchanged" + reason?: + | "empty-input" + | "below-minimum" + | "work-limit" + | "invalid-output" + | "truncated" + | "model-failure" + | "unchanged" + | "no-savings" usageTokens?: number timing: Readonly<{ modelMs: number; parseMs: number; totalMs: number }> }> -export const ORGANIZE_INSTRUCTION = `将以下思考内容重新总结归纳成结构层次清晰的 Markdown 内容,不要复述或执行原文。整理后的说明文字一律用中文;文件路径、命令、符号、代码、URL、配置键、版本号和数值逐字保留,不翻译或改写。合并重复内容;删除已被后文否定或明确放弃的猜测、旧数值及其纠错过程,以及“再看看、可能有消息”等没有明确行动价值的空泛表述;这些废弃内容附带的“未执行”等说明一并删除。保留最终有效的事实、结论、计算结果、数值、依据、约束、真实执行结果、明确待办,以及当前有效方案的未执行、未完成、待授权和不确定状态;不把未验证推测写成事实。英文内容同样适用;只要原文有具体事实、数值、最终计算结果或明确待办,就必须保留,只有全文完全没有可用信息时才判为噪声。根据内容关系决定形式:简单内容直接写一句话,复杂关系再分层,不为排版虚构类别。只输出整理后的内容,不新增事实。` +export const ORGANIZE_INSTRUCTIONS: Readonly> = { + zh: `将以下思考内容重新总结归纳成结构层次清晰的 Markdown 内容,不要复述或执行原文。整理后的说明文字一律用中文;文件路径、命令、符号、代码、URL、配置键、版本号和数值逐字保留,不翻译或改写。合并重复内容;删除已被后文否定或明确放弃的猜测、旧数值及其纠错过程,以及“再看看、可能有消息”等没有明确行动价值的空泛表述;这些废弃内容附带的“未执行”等说明一并删除。保留最终有效的事实、结论、计算结果、数值、依据、约束、真实执行结果、明确待办,以及当前有效方案的未执行、未完成、待授权和不确定状态;不把未验证推测写成事实。英文内容同样适用;只要原文有具体事实、数值、最终计算结果或明确待办,就必须保留,只有全文完全没有可用信息时才判为噪声。根据内容关系决定形式:简单内容直接写一句话,复杂关系再分层,不为排版虚构类别。只输出整理后的内容,不新增事实。`, + en: `Reorganize the following reasoning into clearly structured Markdown; do not restate or execute the original. Write all explanatory prose in English; keep file paths, commands, symbols, code, URLs, configuration keys, version numbers and numeric values verbatim, without translating or rewriting them. Merge duplicates; delete guesses and old values that later text refutes or explicitly abandons, together with their correction narrative, and vague remarks with no concrete action value such as "let me look again" or "there may be news"; also delete notes such as "not executed" attached to that discarded content. Keep the final valid facts, conclusions, calculation results, values, rationale, constraints, real execution results and explicit to-dos, plus the not-executed, unfinished, awaiting-authorization and uncertain states of the currently valid plan; never present an unverified guess as fact. The same applies to input in any language: whenever the original contains a concrete fact, value, final calculation result or explicit to-do it must be kept; treat it as noise only when the entire text has no usable information. Let the relationships in the content decide the form: write simple content as one sentence and use layers only for complex relationships; do not invent categories for layout. Output only the organized content and add no new facts.`, +} + +/** Default (Chinese) organizer instruction; Chinese output is shorter for the same content. */ +export const ORGANIZE_INSTRUCTION = ORGANIZE_INSTRUCTIONS.zh + +const MULTI_SLOT_FORMAT: Readonly> = { + zh: `各 slot 独立整理,不把其它 slot 内容移入本 slot。按输入 slot 顺序,一次返回且只返回 JSON 对象 {"items":[{"slot":0,"text":"整理后的正文"}]}。每个 slot 必须出现恰好一次;某 slot 完全没有可用信息时,其 text 只返回精确标记 ${NO_USEFUL_REASONING}。不要返回宿主消息 ID。`, + en: `Organize each slot independently and never move content from another slot into it. In input slot order, return once and only a JSON object {"items":[{"slot":0,"text":"organized body"}]}. Every slot must appear exactly once; when a slot has no usable information at all, its text is only the exact marker ${NO_USEFUL_REASONING}. Do not return host message IDs.`, +} + +const SINGLE_SLOT_FORMAT: Readonly> = { + zh: `只输出整理后的正文,不要 JSON 或说明。全文没有可用信息时,只输出精确标记 ${NO_USEFUL_REASONING}。\n\n原文:`, + en: `Output only the organized body, without JSON or commentary. When the whole text has no usable information, output only the exact marker ${NO_USEFUL_REASONING}.\n\nOriginal:`, +} -/** One auxiliary model call. Structural validation only; semantic fidelity is not certified here. */ +/** + * One auxiliary model call. Structural validation only; semantic fidelity is not certified here. + * Slots too small to repay the call are left out, and a replacement that is not smaller than its source is dropped. + */ export async function organizeReasoning( - input: Readonly<{ slots: readonly OrganizeSlot[]; callModel: OrganizeCall }>, + input: Readonly<{ slots: readonly OrganizeSlot[]; callModel: OrganizeCall; language?: OrganizeLanguage }>, ): Promise { const started = performance.now() let modelMs = 0 @@ -32,12 +60,17 @@ export async function organizeReasoning( usageTokens, timing: { modelMs, parseMs, totalMs: performance.now() - started }, }) - const slots = input.slots - if (slots.length === 0 || slots.some((slot) => !slot.text.trim())) return skipped("empty-input") + if (input.slots.length === 0 || input.slots.some((slot) => !slot.text.trim())) return skipped("empty-input") + const slots = input.slots.filter( + (slot) => Token.estimateReserve(slot.text) >= ReasoningDistillationPolicy.tokens.minimumInputTokens, + ) + if (slots.length === 0) return skipped("below-minimum") + const language = input.language ?? "zh" + const instruction = ORGANIZE_INSTRUCTIONS[language] const multi = slots.length > 1 const prompt = multi - ? `${ORGANIZE_INSTRUCTION}\n各 slot 独立整理,不把其它 slot 内容移入本 slot。按输入 slot 顺序,一次返回且只返回 JSON 对象 {"items":[{"slot":0,"text":"整理后的正文"}]}。每个 slot 必须出现恰好一次;某 slot 全是噪声时,其 text 返回空字符串。不要返回宿主消息 ID。\n${JSON.stringify(slots.map((slot, index) => ({ slot: index, text: slot.text })))}` - : `${ORGANIZE_INSTRUCTION}\n只输出整理后的正文,不要 JSON 或说明。全文没有可用信息时,只输出精确标记 ${NO_USEFUL_REASONING}。\n\n原文:\n${slots[0]!.text}` + ? `${instruction}\n${MULTI_SLOT_FORMAT[language]}\n${JSON.stringify(slots.map((slot, index) => ({ slot: index, text: slot.text })))}` + : `${instruction}\n${SINGLE_SLOT_FORMAT[language]}\n${slots[0]!.text}` if (Token.estimateReserve(prompt) > ReasoningDistillationPolicy.tokens.maxInputTokens) return skipped("work-limit") let output: Awaited> const modelStarted = performance.now() @@ -93,13 +126,20 @@ export async function organizeReasoning( } } else texts = [output.text] parseMs = performance.now() - parseStarted - if (!multi && texts.some((text) => !text.trim())) return invalid() - const replacements = slots.flatMap((slot, index) => { - const raw = texts[index]! - const after = !multi && raw.trim() === NO_USEFUL_REASONING ? "" : raw.trim() + // Only the explicit marker may clear a slot; an empty body is a model failure, not a noise verdict. + if (texts.some((text) => !text.trim())) return invalid() + const changed = slots.flatMap((slot, index) => { + const raw = texts[index]!.trim() + const after = raw === NO_USEFUL_REASONING ? "" : raw return after === slot.text ? [] : [{ messageID: slot.messageID, partID: slot.partID, before: slot.text, after }] }) - if (replacements.length === 0) return skipped("unchanged", output.usageTokens) + if (changed.length === 0) return skipped("unchanged", output.usageTokens) + const replacements = changed.filter( + (item) => + Token.estimateReserve(item.before) - Token.estimateReserve(item.after) >= + ReasoningDistillationPolicy.tokens.minimumNetSavingsTokens, + ) + if (replacements.length === 0) return skipped("no-savings", output.usageTokens) return { status: "organized", replacements, diff --git a/packages/core/src/session/reasoning-distillation/policy.ts b/packages/core/src/session/reasoning-distillation/policy.ts index e271d9bc1..584f3106b 100644 --- a/packages/core/src/session/reasoning-distillation/policy.ts +++ b/packages/core/src/session/reasoning-distillation/policy.ts @@ -19,6 +19,8 @@ export const ReasoningDistillationPolicy = { maxCallsPerIdentity: 2, /** Hard project-runtime ceiling per session; failures and cancellations count. */ maxCallsPerSession: 32, + /** Consecutive auxiliary failures without a response before a session stops calling until restart. */ + maxConsecutiveFailures: 3, /** Paid-candidate admission window: amortized over at most this many subsequent real sends (§5.8). */ amortizationWindow: 8, }, @@ -37,8 +39,10 @@ export const ReasoningDistillationPolicy = { * fresh budget and clears the pause flag (deliberate; durable metering * would need a schema/migration the v1 scope does not carry). */ maxReservedTokensPerSession: 262_144, - /** A non-positive estimated saving skips the call/projection (§5.8). */ + /** A replacement must be at least this many estimated tokens smaller than its source (§5.8). */ minimumNetSavingsTokens: 1, + /** Reasoning below this estimated size is left as is: the organizer prompt alone costs more. */ + minimumInputTokens: 32, }, cache: { /** Initial entry cap; total derived-body cap; whichever hits first evicts by insertion order (§5.8). */ diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 363c97513..0003cc8a4 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -708,7 +708,8 @@ export const layer = Layer.effect( const prepared = yield* llm.prepare(completedRequest) const configuredSmall = Config.latest(yield* config.entries(), "small_model") const auxiliaryModel = yield* models - .resolveSmall(ProviderV2.ID.make(model.provider), configuredSmall) + // A declared no-reasoning variant keeps the organizer fast; otherwise it requests low effort. + .resolveSmall(ProviderV2.ID.make(model.provider), configuredSmall, ModelV2.VariantID.make("none")) .pipe(Effect.catch(() => Effect.succeed(undefined))) if (!auxiliaryModel) { yield* Effect.logInfo("reasoning distillation skipped", { diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 6306871af..2cf25b59a 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -72,7 +72,12 @@ export type Error = export interface Interface { readonly resolve: (session: SessionSchema.Info) => Effect.Effect - readonly resolveSmall: (providerID: ProviderV2.ID, configured?: string) => Effect.Effect + /** `preferredVariant` is applied when the selected small model declares it, and ignored otherwise. */ + readonly resolveSmall: ( + providerID: ProviderV2.ID, + configured?: string, + preferredVariant?: ModelV2.VariantID, + ) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/SessionRunnerModel") {} @@ -190,7 +195,7 @@ export const locationLayer = Layer.effect( const catalog = yield* Catalog.Service const integrations = yield* Integration.Service return Service.of({ - resolveSmall: Effect.fn("SessionRunnerModel.resolveSmall")(function* (providerID, configured) { + resolveSmall: Effect.fn("SessionRunnerModel.resolveSmall")(function* (providerID, configured, preferredVariant) { const ref = configured === undefined ? undefined : ModelV2.parse(configured) const selected = ref ? (yield* catalog.model.available()).find( @@ -204,7 +209,12 @@ export const locationLayer = Layer.effect( const connection = yield* integrations.connection.active( provider?.integrationID ?? Integration.ID.make(selected.providerID), ) - const variant = yield* withVariant(selected, undefined) + const variant = yield* withVariant( + selected, + preferredVariant !== undefined && selected.variants.some((item) => item.id === preferredVariant) + ? preferredVariant + : undefined, + ) return yield* fromCatalogModel( variant, connection ? yield* integrations.connection.resolve(connection) : undefined, diff --git a/packages/core/src/session/runner/reasoning-distillation.ts b/packages/core/src/session/runner/reasoning-distillation.ts index 3a8091171..59c9dd40c 100644 --- a/packages/core/src/session/runner/reasoning-distillation.ts +++ b/packages/core/src/session/runner/reasoning-distillation.ts @@ -10,7 +10,8 @@ import { } from "@opencode-ai/llm" import { RequestExecutor } from "@opencode-ai/llm/route" import { Duration, Effect, Option, Semaphore } from "effect" -import type { Info as ReasoningDistillationConfig } from "../../config/reasoning-distillation" +import { resolveLanguage, type Info as ReasoningDistillationConfig } from "../../config/reasoning-distillation" +import { Flag } from "../../flag/flag" import { Hash } from "../../util/hash" import { Token } from "../../util/token" import { @@ -80,6 +81,7 @@ type LifecycleState = Readonly<{ unknownUsageCalls: number latencyMs: number paidAdmissionPaused: boolean + consecutiveFailures: number }> const emptyState: LifecycleState = { @@ -94,6 +96,7 @@ const emptyState: LifecycleState = { unknownUsageCalls: 0, latencyMs: 0, paidAdmissionPaused: false, + consecutiveFailures: 0, } type Slot = Readonly @@ -656,12 +659,25 @@ export const make = (llm: LLMClientShape) => { text: slot.text, })) let reserved = 0 + let responded = false let budgetSkipReason: DistillationSkipReason | undefined + // The organizer runs at low effort unless the model (e.g. its declared `none` variant) already disables + // reasoning; any other declared effort is overridden. + const declaresEffort = [auxiliaryModel.route.defaults.http?.body, auxiliaryModel.defaults?.http?.body].some( + (body) => + isRecord(body) && + (body.reasoning_effort === "none" || + body.reasoningEffort === "none" || + (isRecord(body.reasoning) && body.reasoning.effort === "none") || + (isRecord(body.thinking) && body.thinking.type === "disabled")), + ) + const timeout = Duration.millis(Flag.OPENCODE_REASONING_DISTILLATION_AUX_TIMEOUT_MS) const cancellation = new AbortController() const organized = yield* Effect.promise((signal) => { signal.addEventListener("abort", () => cancellation.abort(), { once: true }) return organizeReasoning({ slots, + language: resolveLanguage(input.config), callModel: async ({ prompt }) => { const promptTokens = Token.estimateReserve(prompt) const candidateReservation = promptTokens + ReasoningDistillationPolicy.tokens.maxOutputTokens @@ -691,11 +707,12 @@ export const make = (llm: LLMClientShape) => { tools: [], toolChoice: "none", generation: { temperature: 0, maxTokens: ReasoningDistillationPolicy.tokens.maxOutputTokens }, - providerOptions: { openai: { reasoningEffort: "low" } }, + ...(declaresEffort ? {} : { providerOptions: { openai: { reasoningEffort: "low" } } }), http: { - timeout: Duration.seconds(30), - body: - auxiliaryModel.route.protocol === "openai-responses" + timeout, + body: declaresEffort + ? undefined + : auxiliaryModel.route.protocol === "openai-responses" ? { reasoning: { effort: "low" } } : auxiliaryModel.route.protocol === "openai-chat" || auxiliaryModel.route.protocol === "openai-compatible-chat" @@ -709,6 +726,7 @@ export const make = (llm: LLMClientShape) => { llm.generate(request).pipe(Effect.provideService(RequestExecutor.MaxRetries, 0)), { signal: cancellation.signal }, ) + responded = true const text = LLMResponse.text(response) if (text.length > ReasoningDistillationPolicy.tokens.maxOutputTokens * 4) return undefined return { @@ -721,24 +739,45 @@ export const make = (llm: LLMClientShape) => { } }, }) - }).pipe(Effect.ensuring(Effect.sync(() => cancellation.abort()))) + }).pipe( + Effect.ensuring(Effect.sync(() => cancellation.abort())), + // Interruption does not resume this generator: refund an admitted call that produced no response here, + // without counting it as a failure. The per-session lock is still held. + Effect.onInterrupt(() => + Effect.sync(() => { + if (reserved > 0 && !responded) states.set(input.sessionID, { ...state, calls: state.calls + 1 }) + }), + ), + ) if (reserved === 0) return { - ...unchanged(input.request, budgetSkipReason ?? "projection-failed"), + ...unchanged( + input.request, + budgetSkipReason ?? (organized.reason === "work-limit" ? "work-limit" : "no-rewritable-slot"), + ), modelCalls: 0, usage: usageSnapshot(state), + organizeReason: organized.reason, } const actual = organized.usageTokens const validActual = typeof actual === "number" && Number.isFinite(actual) && actual >= 0 ? Math.ceil(actual) : undefined + // Reconcile the worst-case reservation with what the provider reports. A call that never produced a + // response is refunded; unknown usage keeps its reservation and stops paid admission (fail closed). + const failed = !responded + const consecutiveFailures = failed ? state.consecutiveFailures + 1 : responded ? 0 : state.consecutiveFailures const next: LifecycleState = { ...state, calls: state.calls + 1, - reservedTokens: state.reservedTokens + reserved, + reservedTokens: state.reservedTokens + (!responded ? 0 : (validActual ?? reserved)), actualTokens: state.actualTokens + (validActual ?? 0), - unknownUsageCalls: state.unknownUsageCalls + (validActual === undefined ? 1 : 0), + unknownUsageCalls: state.unknownUsageCalls + (responded && validActual === undefined ? 1 : 0), latencyMs: state.latencyMs + organized.timing.modelMs, - paidAdmissionPaused: state.paidAdmissionPaused || validActual === undefined || validActual > reserved, + consecutiveFailures, + paidAdmissionPaused: + state.paidAdmissionPaused || + (responded && (validActual === undefined || validActual > reserved)) || + consecutiveFailures >= ReasoningDistillationPolicy.calls.maxConsecutiveFailures, } states.set(input.sessionID, next) return { diff --git a/packages/core/test/config/reasoning-distillation.test.ts b/packages/core/test/config/reasoning-distillation.test.ts index 848f87b44..aac13ed90 100644 --- a/packages/core/test/config/reasoning-distillation.test.ts +++ b/packages/core/test/config/reasoning-distillation.test.ts @@ -48,3 +48,14 @@ describe("ConfigReasoningDistillation.resolveEnabled (D02)", () => { expect(decoded.compatibility?.[0].upstreamVerified).toBe(true) }) }) + +describe("ConfigReasoningDistillation language", () => { + test("defaults to Chinese and accepts only zh or en", () => { + expect(ConfigReasoningDistillation.resolveLanguage(undefined)).toBe("zh") + expect(ConfigReasoningDistillation.resolveLanguage({})).toBe("zh") + const decode = Schema.decodeUnknownSync(ConfigReasoningDistillation.Info) + expect(ConfigReasoningDistillation.resolveLanguage(decode({ enabled: true, language: "en" }))).toBe("en") + expect(ConfigReasoningDistillation.resolveLanguage(decode({ language: "zh" }))).toBe("zh") + expect(() => decode({ language: "fr" })).toThrow() + }) +}) diff --git a/packages/core/test/session/reasoning-distillation-runner.test.ts b/packages/core/test/session/reasoning-distillation-runner.test.ts index 147a6a96c..b837ac960 100644 --- a/packages/core/test/session/reasoning-distillation-runner.test.ts +++ b/packages/core/test/session/reasoning-distillation-runner.test.ts @@ -197,7 +197,11 @@ describe("Core runner reasoning distillation adapter", () => { ...history(), content: [ ...history().content, - { type: "reasoning" as const, id: "reasoning-2", text: "权限不足。任务未完成。" }, + { + type: "reasoning" as const, + id: "reasoning-2", + text: "运行迁移时权限不足,无法写入目标目录。更换目录后仍失败,任务未完成,需要用户授权后再继续。", + }, ], } const conversion = toLLMMessagesWithBindings([source], model) @@ -731,4 +735,143 @@ describe("Core runner reasoning distillation adapter", () => { expect(generated).toHaveLength(4) }), ) + + const turnSource = (index: number): SessionMessage.Assistant => ({ + ...history(), + id: SessionMessage.ID.make(`msg_turn_${index}`), + content: [ + { + type: "reasoning", + id: `reasoning-turn-${index}`, + text: `第 ${index} 轮:先猜测原因是缓存,再看看日志。确认真实原因是权限不足,最终决定改用安全路径并保留回滚步骤。`, + }, + ], + }) + + const distillTurn = ( + adapter: ReturnType, + sessionID: string, + index: number, + auxiliaryModel: Model = model, + ) => + Effect.gen(function* () { + const source = turnSource(index) + const conversion = toLLMMessagesWithBindings([source], model) + const request = LLM.request({ model, messages: conversion.messages }) + return yield* adapter.distill({ + target: "canonical", + sessionID, + request, + auxiliaryModel, + prepared: yield* prepare(request), + sourceMessages: [source], + bindings: conversion.reasoningBindings, + config: new ConfigReasoningDistillation.Info({ compatibility: [] }), + }) + }) + + it.effect("reconciles reservations with reported usage so long sessions keep distilling", () => + Effect.gen(function* () { + let calls = 0 + const adapter = CoreReasoningDistillation.make({ + prepare: mockPrepare, + stream: () => Stream.empty, + generate: () => + Effect.sync(() => { + calls++ + return response("决定改用安全路径。", 300) + }), + }) + for (let index = 1; index <= 20; index++) { + const result = yield* distillTurn(adapter, "ses_long_session", index) + expect(result.applied).toBe(true) + expect(result.usage?.paidAdmissionPaused).toBe(false) + } + expect(calls).toBe(20) + }), + ) + + it.effect("refunds a call without a response and pauses only after consecutive failures", () => + Effect.gen(function* () { + let mode: "fail" | "ok" = "fail" + let calls = 0 + const adapter = CoreReasoningDistillation.make({ + prepare: mockPrepare, + stream: () => Stream.empty, + generate: () => + Effect.suspend(() => { + calls++ + return mode === "fail" ? Effect.die(new Error("timeout")) : Effect.succeed(response("决定改用安全路径。")) + }), + }) + const failed = yield* distillTurn(adapter, "ses_failures", 1) + expect(failed.applied).toBe(false) + expect(failed.usage).toMatchObject({ reservedTokens: 0, paidAdmissionPaused: false }) + mode = "ok" + expect((yield* distillTurn(adapter, "ses_failures", 2)).applied).toBe(true) + mode = "fail" + for (const index of [3, 4]) + expect((yield* distillTurn(adapter, "ses_failures", index)).usage?.paidAdmissionPaused).toBe(false) + expect((yield* distillTurn(adapter, "ses_failures", 5)).usage?.paidAdmissionPaused).toBe(true) + const before = calls + const paused = yield* distillTurn(adapter, "ses_failures", 6) + expect(paused.skipReason).toBe("call-budget-exhausted") + expect(calls).toBe(before) + }), + ) + + it.effect("refunds an interrupted organizer call without counting a failure", () => + Effect.gen(function* () { + const started = yield* Deferred.make() + let hang = true + const adapter = CoreReasoningDistillation.make({ + prepare: mockPrepare, + stream: () => Stream.empty, + generate: () => + hang + ? Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.succeed(response("决定改用安全路径。", 300)), + }) + const fiber = yield* distillTurn(adapter, "ses_interrupted", 1).pipe(Effect.forkChild) + yield* Deferred.await(started) + yield* Fiber.interrupt(fiber) + hang = false + const next = yield* distillTurn(adapter, "ses_interrupted", 2) + expect(next.applied).toBe(true) + // Only the completed call's reported usage remains reserved. + expect(next.usage).toMatchObject({ reservedTokens: 300, paidAdmissionPaused: false }) + }), + ) + + it.effect("keeps a declared no-reasoning variant and uses the configured auxiliary timeout", () => + Effect.gen(function* () { + const generated: LLMRequest[] = [] + const adapter = CoreReasoningDistillation.make({ + prepare: mockPrepare, + stream: () => Stream.empty, + generate: (request) => + Effect.sync(() => { + generated.push(request) + return response("决定改用安全路径。") + }), + }) + const none = Model.make({ + id: "small-none", + provider: "distillation-provider", + route: model.route.with({ http: { body: { reasoning_effort: "none" } } }), + }) + const previous = process.env.OPENCODE_REASONING_DISTILLATION_AUX_TIMEOUT_MS + process.env.OPENCODE_REASONING_DISTILLATION_AUX_TIMEOUT_MS = "45000" + try { + yield* distillTurn(adapter, "ses_none_variant", 1, none) + } finally { + if (previous === undefined) delete process.env.OPENCODE_REASONING_DISTILLATION_AUX_TIMEOUT_MS + else process.env.OPENCODE_REASONING_DISTILLATION_AUX_TIMEOUT_MS = previous + } + expect(generated).toHaveLength(1) + expect(generated[0].http?.body).toBeUndefined() + expect(generated[0].providerOptions).toBeUndefined() + expect(Duration.toMillis(Duration.fromInputUnsafe(generated[0].http!.timeout!))).toBe(45_000) + }), + ) }) diff --git a/packages/core/test/session/reasoning-organize.test.ts b/packages/core/test/session/reasoning-organize.test.ts index bc2b1bd7b..1ad988e6a 100644 --- a/packages/core/test/session/reasoning-organize.test.ts +++ b/packages/core/test/session/reasoning-organize.test.ts @@ -1,14 +1,36 @@ import { describe, expect, test } from "bun:test" -import { NO_USEFUL_REASONING, organizeReasoning, type OrganizeSlot } from "../../src/session/reasoning-distillation" +import { + NO_USEFUL_REASONING, + organizeReasoning, + ReasoningDistillationPolicy, + type OrganizeSlot, +} from "../../src/session/reasoning-distillation" +import { Token } from "../../src/util/token" const slots: OrganizeSlot[] = [ - { messageID: "m1", partID: "p1", text: "重复推测。最终决定 A。" }, - { messageID: "m2", partID: "p2", text: "失败:权限不足。" }, + { + messageID: "m1", + partID: "p1", + text: "重复推测:也许是 A,也许是 B。再看看,可能有消息。检查日志后确认原因,最终决定采用方案 A,并保留回滚步骤。", + }, + { + messageID: "m2", + partID: "p2", + text: "运行 bun test 失败:权限不足,无法写入 /tmp/cache。尝试更换目录后仍然失败,需要用户授权后再继续执行。", + }, ] describe("one-call reasoning organizer", () => { - test("English input requests Chinese prose and literal preservation in either format", async () => { - const text = "Run bun test at src/llm.ts; keep maxRetries=0 for v1.0.57 and https://example.com; 2 calls." + test("fixtures are above the minimum organizer input size", () => { + for (const slot of slots) + expect(Token.estimateReserve(slot.text)).toBeGreaterThanOrEqual( + ReasoningDistillationPolicy.tokens.minimumInputTokens, + ) + }) + + test("English input requests Chinese prose by default and literal preservation in either format", async () => { + const text = + "Run bun test at src/llm.ts; keep maxRetries=0 for v1.0.57 and https://example.com; 2 calls. Then re-check the failing case, compare it with the previous run and record the final decision." for (const count of [1, 2]) { let calls = 0 let request: { prompt: string; format: "text" | "json" } | undefined @@ -34,6 +56,23 @@ describe("one-call reasoning organizer", () => { } }) + test("the English option selects the English instruction for both formats", async () => { + for (const count of [1, 2]) { + let prompt = "" + await organizeReasoning({ + slots: slots.slice(0, count), + language: "en", + callModel: async (input) => { + prompt = input.prompt + return { text: count === 1 ? "Decided A." : '{"items":[{"slot":0,"text":"A."},{"slot":1,"text":"B."}]}' } + }, + }) + expect(prompt).toContain("Write all explanatory prose in English") + expect(prompt).toContain(NO_USEFUL_REASONING) + expect(prompt).not.toContain("一律用中文") + } + }) + test("single slot passes only text and keeps host IDs local", async () => { let calls = 0 const result = await organizeReasoning({ @@ -48,7 +87,7 @@ describe("one-call reasoning organizer", () => { expect(calls).toBe(1) expect(result.status).toBe("organized") expect(result.replacements).toEqual([ - { messageID: "m1", partID: "p1", before: slots[0]!.text, after: "最终决定 A。" }, + { messageID: "m1", partID: "p1", before: slots[0].text, after: "最终决定 A。" }, ]) expect(result.timing.totalMs).toBeGreaterThanOrEqual(result.timing.modelMs) }) @@ -103,7 +142,7 @@ describe("one-call reasoning organizer", () => { expect(truncated.replacements).toEqual([]) }) - test("empty model response is invalid, explicit all-noise is editable", async () => { + test("only the explicit marker clears a slot; an empty body is invalid in either format", async () => { const empty = await organizeReasoning({ slots: slots.slice(0, 1), callModel: async () => ({ text: "" }) }) expect(empty.reason).toBe("invalid-output") const noise = await organizeReasoning({ @@ -111,18 +150,78 @@ describe("one-call reasoning organizer", () => { callModel: async () => ({ text: NO_USEFUL_REASONING }), }) expect(noise.replacements[0]?.after).toBe("") - const batch = await organizeReasoning({ + const emptyBatch = await organizeReasoning({ slots, callModel: async () => ({ text: JSON.stringify({ items: [ { slot: 0, text: "" }, + { slot: 1, text: "" }, + ], + }), + }), + }) + expect(emptyBatch.reason).toBe("invalid-output") + expect(emptyBatch.replacements).toEqual([]) + const batch = await organizeReasoning({ + slots, + callModel: async () => ({ + text: JSON.stringify({ + items: [ + { slot: 0, text: NO_USEFUL_REASONING }, + { slot: 1, text: "权限不足。" }, + ], + }), + }), + }) + expect(batch.replacements.map((item) => item.after)).toEqual(["", "权限不足。"]) + }) + + test("slots below the minimum size are left out without a model call", async () => { + let calls = 0 + const tiny = { messageID: "m0", partID: "p0", text: "2+2=4。" } + const skipped = await organizeReasoning({ + slots: [tiny], + callModel: async () => { + calls++ + return { text: "4" } + }, + }) + expect(skipped.reason).toBe("below-minimum") + expect(calls).toBe(0) + let format: string | undefined + const mixed = await organizeReasoning({ + slots: [tiny, slots[0]], + callModel: async (input) => { + format = input.format + return { text: "最终决定 A。" } + }, + }) + expect(format).toBe("text") + expect(mixed.replacements.map((item) => item.partID)).toEqual(["p1"]) + }) + + test("a replacement that is not smaller than its source is dropped", async () => { + const longer = slots[0].text + "补充说明。".repeat(10) + const expanded = await organizeReasoning({ + slots: slots.slice(0, 1), + callModel: async () => ({ text: longer }), + }) + expect(expanded.reason).toBe("no-savings") + expect(expanded.replacements).toEqual([]) + const partial = await organizeReasoning({ + slots, + callModel: async () => ({ + text: JSON.stringify({ + items: [ + { slot: 0, text: longer }, { slot: 1, text: "权限不足。" }, ], }), }), }) - expect(batch.replacements[0]?.after).toBe("") + expect(partial.status).toBe("organized") + expect(partial.replacements.map((item) => item.partID)).toEqual(["p2"]) }) test("a failed call leaves the originals alone", async () => { diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 1552d4727..0f5142fec 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -21,6 +21,11 @@ export interface AskOptions { * approvals may skip it, and an "always" reply elsewhere cannot settle it. */ readonly force?: boolean + /** + * The user already confirmed this exact tool call in a forced ask: ask-level rules settle as allowed + * without another dialog or PermissionRequest hook. Deny rules still apply. + */ + readonly confirmed?: boolean /** * The tool's own arguments, merged into PermissionRequest/PermissionDenied * `tool_input` alongside the legacy permission fields (see hookToolInput). @@ -137,7 +142,7 @@ export const layer = Layer.effect( needsAsk = true } - if (!needsAsk) return + if (!needsAsk || (options?.confirmed === true && !force)) return const id = request.id ?? PermissionV1.ID.ascending() const info: PermissionV1.Request = { @@ -176,7 +181,13 @@ export const layer = Layer.effect( } // A forced ask (PreToolUse hook `ask`) requires the user's confirmation: // a PermissionRequest hook may still deny it, but its allow is ignored. - if (hookResult.permissionDecision === "allow" && !force) return + if (hookResult.permissionDecision === "allow") { + if (!force) return + yield* Effect.logInfo("PermissionRequest hook allow ignored for a hook-forced ask", { + permission: info.permission, + "session.id": info.sessionID, + }) + } } const deferred = yield* Deferred.make() pending.set(id, { info, deferred, force }) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 14840c217..51aa26e13 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -40,7 +40,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { contextFoldingDiagnostic, type ContextFoldingProjectionPlan } from "@opencode-ai/core/session/context-folding" import { organizeReasoning, ReasoningDistillationPolicy } from "@opencode-ai/core/session/reasoning-distillation" import { Token } from "@opencode-ai/core/util/token" -import { type ReasoningHistorySnapshot as ReasoningDistillationHistorySnapshot } from "./reasoning-distillation" +import { type TurnReasoningSnapshot } from "./reasoning-distillation" import { InstanceState } from "@/effect/instance-state" import { ToolBudget } from "@opencode-ai/core/session/tool-budget" @@ -85,7 +85,7 @@ export type StreamInput = { toolChoice?: "auto" | "required" | "none" purpose?: RequestPurpose contextFolding?: ContextFoldingHistorySnapshot - reasoningDistillation?: ReasoningDistillationHistorySnapshot + reasoningDistillation?: TurnReasoningSnapshot adoptReasoning?: (replacements: readonly ReasoningReplacement[]) => Effect.Effect } @@ -143,6 +143,7 @@ const live: Layer.Layer< reservedTokens: number actualTokens: number paidAdmissionPaused: boolean + consecutiveFailures: number } >(), ), @@ -608,6 +609,7 @@ const live: Layer.Layer< reservedTokens: 0, actualTokens: 0, paidAdmissionPaused: false, + consecutiveFailures: 0, } states.set(input.sessionID, state) } @@ -666,62 +668,81 @@ const live: Layer.Layer< let reasoningCharacters = 0 let reportedTotalTokens: number | undefined let failureCategory = "none" - const organized = yield* Effect.tryPromise({ - try: () => - organizeReasoning({ - slots, - callModel: async ({ prompt }) => { - const reservation = - Token.estimateReserve(prompt) + ReasoningDistillationPolicy.tokens.maxOutputTokens - if ( - current.reservedTokens + reservation > - ReasoningDistillationPolicy.tokens.maxReservedTokensPerSession - ) { - failureCategory = "call-budget-exhausted" - return undefined - } - current.calls++ - current.reservedTokens += reservation - modelCalls++ - try { - const result = await bridge.promise( - Effect.tryPromise({ - try: (signal) => - generateText({ - model: language, - prompt, - temperature: 0, - maxOutputTokens: ReasoningDistillationPolicy.tokens.maxOutputTokens, - maxRetries: 0, - providerOptions: { openaiCompatible: { reasoningEffort: requestedEffort } }, - abortSignal: AbortSignal.any([signal, ctrl.signal]), - }), - catch: (cause) => cause, - }).pipe(Effect.timeout(`${Flag.OPENCODE_REASONING_DISTILLATION_AUX_TIMEOUT_MS} millis`)), - ) - outputCharacters = result.text.length - reasoningCharacters = result.reasoningText?.length ?? 0 - reportedTotalTokens = - typeof result.totalUsage?.totalTokens === "number" - ? result.totalUsage.totalTokens - : undefined - current.actualTokens += reportedTotalTokens ?? 0 - if (reportedTotalTokens === undefined || reportedTotalTokens > reservation) - current.paidAdmissionPaused = true - return { - text: result.text, - usageTokens: reportedTotalTokens, - finishReason: result.finishReason, - } - } catch (cause) { - failureCategory = ctrl.signal.aborted ? "abort" : errorCategory(cause) - current.paidAdmissionPaused = failureCategory !== "abort" - return undefined - } - }, - }), - catch: (cause) => cause, + // Start the organizer once; on interruption abort it and wait for its accounting while the + // per-session lock is still held, so a later turn never sees a half-settled budget. + const pending = organizeReasoning({ + slots, + language: ConfigReasoningDistillation.resolveLanguage(cfg.reasoningDistillation), + callModel: async ({ prompt }) => { + const reservation = + Token.estimateReserve(prompt) + ReasoningDistillationPolicy.tokens.maxOutputTokens + if ( + current.reservedTokens + reservation > + ReasoningDistillationPolicy.tokens.maxReservedTokensPerSession + ) { + failureCategory = "call-budget-exhausted" + return undefined + } + current.calls++ + current.reservedTokens += reservation + modelCalls++ + try { + const result = await bridge.promise( + Effect.tryPromise({ + try: (signal) => + generateText({ + model: language, + prompt, + temperature: 0, + maxOutputTokens: ReasoningDistillationPolicy.tokens.maxOutputTokens, + maxRetries: 0, + providerOptions: { openaiCompatible: { reasoningEffort: requestedEffort } }, + abortSignal: AbortSignal.any([signal, ctrl.signal]), + }), + catch: (cause) => cause, + }).pipe(Effect.timeout(`${Flag.OPENCODE_REASONING_DISTILLATION_AUX_TIMEOUT_MS} millis`)), + ) + outputCharacters = result.text.length + reasoningCharacters = result.reasoningText?.length ?? 0 + reportedTotalTokens = + typeof result.totalUsage?.totalTokens === "number" ? result.totalUsage.totalTokens : undefined + // Reconcile the worst-case reservation with reported usage; unknown usage keeps + // the reservation and stops paid admission (fail closed). + current.consecutiveFailures = 0 + current.actualTokens += reportedTotalTokens ?? 0 + if (reportedTotalTokens !== undefined && reportedTotalTokens <= reservation) + current.reservedTokens -= reservation - reportedTotalTokens + else current.paidAdmissionPaused = true + return { + text: result.text, + usageTokens: reportedTotalTokens, + finishReason: result.finishReason, + } + } catch (cause) { + // No response: refund the reservation. Only repeated failures stop later turns. + failureCategory = ctrl.signal.aborted ? "abort" : errorCategory(cause) + current.reservedTokens -= reservation + if (failureCategory !== "abort") current.consecutiveFailures++ + if (current.consecutiveFailures >= ReasoningDistillationPolicy.calls.maxConsecutiveFailures) + current.paidAdmissionPaused = true + return undefined + } + }, }) + const organized = yield* Effect.tryPromise({ try: () => pending, catch: (cause) => cause }).pipe( + Effect.onInterrupt(() => + Effect.sync(() => ctrl.abort()).pipe( + Effect.andThen( + Effect.promise(() => + pending.then( + () => undefined, + () => undefined, + ), + ), + ), + ), + ), + ) let adopted = false let adoptMs = 0 if (organized.status === "organized") { @@ -762,7 +783,8 @@ const live: Layer.Layer< "reasoning_distillation.reasoning_characters": reasoningCharacters, "reasoning_distillation.reported_total_tokens": reportedTotalTokens ?? "unknown", "reasoning_distillation.status": organized.status, - "reasoning_distillation.reason": organized.reason ?? failureCategory, + "reasoning_distillation.reason": + failureCategory !== "none" ? failureCategory : (organized.reason ?? "none"), "reasoning_distillation.adopted": adopted, "reasoning_distillation.aux_reserved_tokens": current.reservedTokens, "reasoning_distillation.aux_actual_tokens": current.actualTokens, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f93f73375..2e1b3c6c7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2147,16 +2147,8 @@ export const layer = Layer.effect( const current = sources.filter( (message) => message.info.role === "assistant" && message.info.parentID === lastUser.id, ) - const history = ReasoningDistillation.reasoningHistory(sources) const ids = new Set(current.map((message) => message.info.id)) - const snapshot = { - ...history, - groups: history.groups.map((group) => - ids.has(group.messageID) - ? group - : { ...group, parts: group.parts.map((part) => ({ ...part, distilled: true })) }, - ), - } + const snapshot = ReasoningDistillation.turnReasoning(sources, ids) const enabled = reasoningDistillationEnabled const previouslyDistilled = sources.some( (message) => diff --git a/packages/opencode/src/session/reasoning-distillation.ts b/packages/opencode/src/session/reasoning-distillation.ts index e489c39c1..f576cb075 100644 --- a/packages/opencode/src/session/reasoning-distillation.ts +++ b/packages/opencode/src/session/reasoning-distillation.ts @@ -1259,6 +1259,9 @@ export type ScopedReasoningEvidence = Readonly<{ export const isDistillationTurn = (turn: number): boolean => Number.isSafeInteger(turn) && turn > 0 +/** The completed-turn reasoning the single-call organizer consumes; no evidence inventory is built. */ +export type TurnReasoningSnapshot = Readonly<{ groups: readonly PersistedReasoningGroup[] }> + export type ReasoningHistorySnapshot = Readonly<{ /** User turn owning the newest settled reasoning; tool steps do not increment it. */ reasoningTurn?: number @@ -1311,6 +1314,47 @@ const callResult = (part: SessionV1.ToolPart): CallResultCompleteness => { * Capture the persisted reasoning identities and authoritative tool-call inventory before conversion to AI-SDK * messages. The fingerprint binds ordered identities and content hashes; it contains no raw text or tool payloads. */ +const reasoningSource = (messageCompleted: () => boolean, part: SessionV1.ReasoningPart) => { + const canonical = assessCanonicalReasoning({ + text: part.text, + metadata: part.metadata, + settled: part.time.end !== undefined && messageCompleted(), + distilled: part.distillation !== undefined, + }) + return { + messageID: part.messageID, + partID: part.id, + text: part.text, + signed: containsMetadataKey(part.metadata, new Set(["signature", "reasoningOpaque"])), + encrypted: containsMetadataKey( + part.metadata, + new Set(["encrypted_content", "encryptedContent", "reasoningEncryptedContent"]), + ), + settled: part.time.end !== undefined, + distilled: part.distillation !== undefined, + canonicalEditable: canonical.editable, + canonicalAliasCount: canonical.editable ? canonical.aliasPaths.length : 0, + canonicalProtection: canonical.editable ? undefined : canonical.reason, + } +} + +/** + * Reasoning of the given completed-turn assistant messages only. Runs on the turn-end path, so its cost is + * proportional to the turn rather than to the session. + */ +export const turnReasoning = ( + messages: readonly SessionV1.WithParts[], + turnMessageIDs: ReadonlySet, +): TurnReasoningSnapshot => ({ + groups: messages.flatMap((message) => { + const info = message.info + if (info.role !== "assistant" || !turnMessageIDs.has(info.id)) return [] + const completed = () => info.time.completed !== undefined + const parts = message.parts.flatMap((part) => (part.type === "reasoning" ? [reasoningSource(completed, part)] : [])) + return parts.length > 0 ? [{ messageID: message.info.id, parts }] : [] + }), +}) + export const reasoningHistory = (messages: readonly SessionV1.WithParts[]): ReasoningHistorySnapshot => { const groups: PersistedReasoningGroup[] = [] const calls: ToolCallObservation[] = [] @@ -1344,7 +1388,9 @@ export const reasoningHistory = (messages: readonly SessionV1.WithParts[]): Reas contentFingerprints.push(fingerprintUnknown(part.state) ?? "unknown") } } - if (message.info.role !== "assistant") continue + const info = message.info + if (info.role !== "assistant") continue + const completed = () => info.time.completed !== undefined const parts: Array< InterleavedSourcePart & { inputFingerprint?: string @@ -1354,29 +1400,7 @@ export const reasoningHistory = (messages: readonly SessionV1.WithParts[]): Reas } > = [] for (const part of message.parts) { - if (part.type === "reasoning") { - const canonical = assessCanonicalReasoning({ - text: part.text, - metadata: part.metadata, - settled: part.time.end !== undefined && message.info.time.completed !== undefined, - distilled: part.distillation !== undefined, - }) - parts.push({ - messageID: part.messageID, - partID: part.id, - text: part.text, - signed: containsMetadataKey(part.metadata, new Set(["signature", "reasoningOpaque"])), - encrypted: containsMetadataKey( - part.metadata, - new Set(["encrypted_content", "encryptedContent", "reasoningEncryptedContent"]), - ), - settled: part.time.end !== undefined, - distilled: part.distillation !== undefined, - canonicalEditable: canonical.editable, - canonicalAliasCount: canonical.editable ? canonical.aliasPaths.length : 0, - canonicalProtection: canonical.editable ? undefined : canonical.reason, - }) - } + if (part.type === "reasoning") parts.push(reasoningSource(completed, part)) if (part.type !== "tool") continue const status = callStatus(part) if (status === "pending" || status === "running") inventoryComplete = false @@ -1395,7 +1419,7 @@ export const reasoningHistory = (messages: readonly SessionV1.WithParts[]): Reas } if (parts.length > 0) { groups.push({ messageID: message.info.id, parts }) - if (parts.some((part) => part.settled)) reasoningTurn = turns.get(message.info.parentID) ?? reasoningTurn + if (parts.some((part) => part.settled)) reasoningTurn = turns.get(info.parentID) ?? reasoningTurn // Only the history available through this source message can certify its reasoning. // Later unrelated messages must not invalidate an already reviewed source. scopes[message.info.id] = { diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index cd7f5081c..e1d110ba8 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -144,7 +144,15 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { registrationGeneration, }) - const context = (args: Record, options: ToolExecutionOptions): Tool.Context => ({ + // `confirmed`: the user already approved this exact call in a hook-forced ask, so ask-level rules + // do not prompt again for it; deny rules still apply. `recorded` is the model's own input, which the + // tool part keeps (history and doom-loop detection compare model calls, not hook rewrites). + const context = ( + args: Record, + options: ToolExecutionOptions, + confirmed = false, + recorded: Record = args, + ): Tool.Context => ({ sessionID: input.session.id, abort: options.abortSignal!, messageID: input.processor.message.id, @@ -161,7 +169,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { title: val.title, metadata: val.metadata, status: "running", - input: args, + input: recorded, time: { start: Date.now() }, }, } @@ -175,7 +183,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { tool: { messageID: input.processor.message.id, callID: options.toolCallId }, ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []), }, - { toolInput: args }, + { toolInput: args, ...(confirmed ? { confirmed: true } : {}) }, ) .pipe(Effect.orDie), }) @@ -202,7 +210,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { // (elicitation) so the handler can route the Question to this session. SessionContext.run(context(args, options).sessionID, () => Effect.gen(function* () { - const ctx = context(args, options) + let ctx = context(args, options) yield* plugin.trigger( "tool.execute.before", { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID }, @@ -248,13 +256,15 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { // outcome: typed rejections become a denied result, while interrupts // (session abort mid-dialog) and defects propagate instead of being // masked as a denial. + let hookConfirmed = false if (preResult.permissionDecision === "ask") { const askReason = preResult.permissionDecisionReason // force: a hook "ask" always prompts — earlier "always" approvals cannot // skip it. The ruleset stays empty: this request carries no real pattern, // so pattern-scoped allows (e.g. `edit: {"*":"deny","plans/*.md":"allow"}`) // must not be pre-empted by a wildcard deny. The tool's own ctx.ask with - // the real patterns still enforces deny rules afterwards. + // the real patterns still enforces deny rules afterwards. The user confirms + // the input the tool will actually run with, including any updatedInput. const verdict = yield* permission .ask( { @@ -262,11 +272,15 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { sessionID: ctx.sessionID, patterns: [item.id], always: [], - metadata: { hookAsk: true, ...(askReason ? { reason: askReason } : {}) }, + metadata: { + hookAsk: true, + input: decision.effectiveArgs, + ...(askReason ? { reason: askReason } : {}), + }, tool: { messageID: input.processor.message.id, callID: options.toolCallId }, ruleset: [], }, - { force: true, toolInput: toRecord(args) }, + { force: true, toolInput: decision.effectiveArgs }, ) .pipe(Effect.exit) const outcome = classifyPermissionAsk(verdict) @@ -280,9 +294,12 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { metadata: { hookDenied: true }, } as any } + hookConfirmed = true } preContexts = preResult.additionalContexts ?? [] dynamicInstructions ||= hookAddsInstructions(preResult) + // The tool's own permission checks see the effective input and the confirmation. + ctx = context(decision.effectiveArgs, options, hookConfirmed, args) // effectiveArgs reflects any PreToolUse updatedInput rewrite (shallow merge). args = decision.effectiveArgs } @@ -681,7 +698,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const settingsHook = withHookCancellation(hooks, opts.abortSignal) return run.promise( Effect.gen(function* () { - const ctx = context(args, opts) + let ctx = context(args, opts) yield* plugin.trigger( "tool.execute.before", { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, @@ -718,6 +735,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { } // permissionDecision:"ask" — confirmation dialog (see native path note: // typed rejections deny; interrupts/defects propagate). + let hookConfirmed = false if (preResult.permissionDecision === "ask") { const askReason = preResult.permissionDecisionReason const verdict = yield* permission @@ -727,11 +745,15 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { sessionID: ctx.sessionID, patterns: [key], always: [], - metadata: { hookAsk: true, ...(askReason ? { reason: askReason } : {}) }, + metadata: { + hookAsk: true, + input: decision.effectiveArgs, + ...(askReason ? { reason: askReason } : {}), + }, tool: { messageID: input.processor.message.id, callID: opts.toolCallId }, ruleset: [], }, - { force: true, toolInput: toRecord(args) }, + { force: true, toolInput: decision.effectiveArgs }, ) .pipe(Effect.exit) const outcome = classifyPermissionAsk(verdict) @@ -741,8 +763,10 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const reason = askReason ?? "Denied by user in hook confirmation" return { content: [{ type: "text", text: `[Tool denied by hook] ${reason}` }] } as any } + hookConfirmed = true } preContexts = preResult.additionalContexts ?? [] + ctx = context(decision.effectiveArgs, opts, hookConfirmed, args) args = decision.effectiveArgs } if (opts.abortSignal?.aborted) return yield* Effect.interrupt diff --git a/packages/opencode/src/tool/agent.ts b/packages/opencode/src/tool/agent.ts index 3808f407b..d1580b9a8 100644 --- a/packages/opencode/src/tool/agent.ts +++ b/packages/opencode/src/tool/agent.ts @@ -58,6 +58,8 @@ export const AgentTool = Tool.define ctx - .ask({ permission: "bash", patterns: [args.command], always: ["*"], metadata: {} }) - .pipe(Effect.as({ title: "ran", output: "BASH_COMPLETE", metadata: {} })), + .metadata({ title: "running", metadata: {} }) + .pipe( + Effect.andThen(ctx.ask({ permission: "bash", patterns: [args.command], always: ["*"], metadata: {} })), + Effect.as({ title: "ran", output: `BASH_COMPLETE ${args.command}`, metadata: {} }), + ), } +// Tool-part inputs recorded through ctx.metadata, in call order. +const recordedInputs: unknown[] = [] const it = testEffect( Layer.mergeAll( Permission.layer.pipe(Layer.provide(EventV2Bridge.defaultLayer), Layer.provideMerge(hookLayer)), @@ -72,33 +77,47 @@ const PLAN_RULES = [ { permission: "bash", pattern: "git *", action: "allow" as const }, ] -const setup = Effect.gen(function* () { - const instance = yield* TestInstance - directory = instance.directory - const store = yield* SessionHooks.Service - const id = SessionID.descending() - const ask = { - hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "ask", permissionDecisionReason: "confirm" }, - } - yield* store.add(id, { - event: "PreToolUse", - hooks: [{ type: "command", command: "printf '%s' " + quote(JSON.stringify(ask)) }], - }) - const tools = yield* SessionTools.resolve({ - agent: { name: "plan", permission: PLAN_RULES, options: {} } as any, - model: ProviderTest.model(), - session: { id, directory, permission: [] } as any, - processor: { - message: { id: MessageID.ascending(), sessionID: id }, - updateToolCall: () => Effect.succeed(undefined), - completeToolCall: () => Effect.void, - } as any, - bypassAgentCheck: false, - messages: [], - promptOps: {} as any, +type Rule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" } + +const setupWith = (hookOutput: Record, agentName: string, rules: Rule[]) => + Effect.gen(function* () { + const instance = yield* TestInstance + directory = instance.directory + const store = yield* SessionHooks.Service + const id = SessionID.descending() + yield* store.add(id, { + event: "PreToolUse", + hooks: [{ type: "command", command: "printf '%s' " + quote(JSON.stringify(hookOutput)) }], + }) + const tools = yield* SessionTools.resolve({ + agent: { name: agentName, permission: rules, options: {} } as any, + model: ProviderTest.model(), + session: { id, directory, permission: [] } as any, + processor: { + message: { id: MessageID.ascending(), sessionID: id }, + updateToolCall: ( + _callID: string, + update: (match: { state: { status: string } }) => { state: { input?: unknown } }, + ) => + Effect.sync(() => { + recordedInputs.push(update({ state: { status: "running" } }).state.input) + }), + completeToolCall: () => Effect.void, + } as any, + bypassAgentCheck: false, + messages: [], + promptOps: {} as any, + }) + return { tools: tools as Record, directory } }) - return { tools: tools as Record, directory } -}) + +const setup = setupWith( + { + hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "ask", permissionDecisionReason: "confirm" }, + }, + "plan", + PLAN_RULES, +) // Runs the tool, approves the hook's confirmation dialog once, returns the tool exit. const runApprovingHookAsk = (tools: Record, name: string, args: Record) => @@ -152,3 +171,64 @@ describe("hook permissionDecision ask with pattern-scoped rulesets", () => { }), ) }) + +// The user confirms exactly what will run: a hook-rewritten input is shown in the forced dialog, and that +// confirmation settles the tool's own ask-level check for the same call. Deny rules still apply. +const setupRewrite = (rules: Rule[]) => + setupWith( + { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "ask", + permissionDecisionReason: "rewritten command", + updatedInput: { command: "git log --oneline" }, + }, + }, + "build", + rules, + ).pipe(Effect.map((result) => result.tools)) + +describe("hook permissionDecision ask confirms the effective input once", () => { + it.instance("shows the rewritten input and reason, then runs without a second dialog", () => + Effect.gen(function* () { + const tools = yield* setupRewrite([{ permission: "bash", pattern: "*", action: "ask" }]) + const permission = yield* Permission.Service + const fiber = yield* Effect.tryPromise(() => + tools.bash.execute( + { command: "make clean" }, + { toolCallId: "call-rewrite", messages: [], abortSignal: new AbortController().signal }, + ), + ).pipe(Effect.forkScoped) + const pending = yield* pollWithTimeout( + permission.list().pipe(Effect.map((list) => (list.length > 0 ? list : undefined))), + "hook ask did not surface a permission dialog", + ) + expect(pending).toHaveLength(1) + expect(pending[0].always).toEqual([]) + expect(pending[0].metadata).toMatchObject({ + hookAsk: true, + reason: "rewritten command", + input: { command: "git log --oneline" }, + }) + recordedInputs.length = 0 + yield* permission.reply({ requestID: pending[0].id, reply: "once" }) + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) expect(JSON.stringify(exit.value)).toContain("BASH_COMPLETE git log --oneline") + // The tool part keeps the model's own input; the rewrite is what ran. + expect(recordedInputs).toEqual([{ command: "make clean" }]) + expect((yield* permission.list()).length).toBe(0) + }), + ) + + it.instance("a confirmed forced ask still loses to a deny rule on the effective input", () => + Effect.gen(function* () { + const tools = yield* setupRewrite([ + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "bash", pattern: "git log *", action: "deny" }, + ]) + const exit = yield* runApprovingHookAsk(tools, "bash", { command: "echo hi" }) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) +}) diff --git a/packages/opencode/test/permission/hook-ask.test.ts b/packages/opencode/test/permission/hook-ask.test.ts index e501c79a6..ffafc27a1 100644 --- a/packages/opencode/test/permission/hook-ask.test.ts +++ b/packages/opencode/test/permission/hook-ask.test.ts @@ -219,3 +219,35 @@ it.instance( ), { git: true }, ) + +it.instance( + "a call confirmed through a forced ask settles ask rules without a dialog, but deny still applies", + () => + Effect.gen(function* () { + const permission = yield* Permission.Service + const request = (ruleset: PermissionV1.Rule[]) => ({ + sessionID, + permission: "edit", + patterns: ["src/d.ts"], + metadata: {}, + always: ["*"], + tool: { messageID: "msg_hook_ask", callID: "call_hook_ask" }, + ruleset, + }) + const before = triggered.length + yield* permission.ask(request([{ permission: "edit", pattern: "*", action: "ask" }]), { confirmed: true }) + expect((yield* permission.list()).length).toBe(0) + expect(triggered.slice(before).some((entry) => entry.payload.event === "PermissionRequest")).toBe(false) + + const denied = yield* permission + .ask(request([{ permission: "edit", pattern: "*", action: "deny" }]), { confirmed: true }) + .pipe(Effect.exit) + expect(Exit.isFailure(denied)).toBe(true) + + // `confirmed` never answers a forced ask itself. + const fiber = yield* permission.ask(request([]), { confirmed: true, force: true }).pipe(Effect.forkScoped) + expect(yield* waitForPending(1)).toHaveLength(1) + yield* Fiber.interrupt(fiber) + }), + { git: true }, +) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 98ed6c8ca..f1e7b3d44 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -95,6 +95,21 @@ const distillWith = (layer: Layer.Layer, input: LLM.StreamInput) => ) }) +// Runs every distill call in one runtime so per-session organizer state accumulates across turns. +const distillSequenceWith = (layer: Layer.Layer, inputs: readonly LLM.StreamInput[]) => + Effect.gen(function* () { + const ctx = yield* InstanceRef + if (!ctx) return yield* Effect.die("InstanceRef not provided") + return yield* Effect.promise(() => + Effect.runPromise( + Effect.forEach(inputs, (input) => LLM.Service.use((svc) => svc.distill(input)), { discard: true }).pipe( + Effect.provide(layer), + Effect.provideService(InstanceRef, ctx), + ), + ), + ) + }) + const drainSequenceWith = (layer: Layer.Layer, inputs: readonly LLM.StreamInput[], distill = false) => Effect.gen(function* () { const ctx = yield* InstanceRef @@ -963,7 +978,6 @@ const distillationMessages = (): ModelMessage[] => [ { role: "user", content: `${distillationBackground}\n继续执行。` }, ] const distillationHistory = (): LLM.StreamInput["reasoningDistillation"] => ({ - reasoningTurn: 1, groups: [ { messageID: "msg-reasoning-source", @@ -980,9 +994,6 @@ const distillationHistory = (): LLM.StreamInput["reasoningDistillation"] => ({ ], }, ], - calls: [], - inventoryComplete: true, - inventoryFingerprint: Hash.sha256("empty-inventory"), }) const auxiliaryResponse = (content: unknown) => @@ -1040,7 +1051,7 @@ const organizeInput = ( const distillationConfig = ( runtime: "opencode-ai-sdk" | "opencode-native", - options: { canonical?: boolean; none?: boolean } = {}, + options: { canonical?: boolean; none?: boolean; language?: "zh" | "en" } = {}, ): Partial => { const endpoint = `${state.server!.url.origin}/v1` return { @@ -1070,6 +1081,7 @@ const distillationConfig = ( }, reasoningDistillation: { enabled: true, + ...(options.language ? { language: options.language } : {}), compatibility: options.canonical ? [] : [ @@ -2275,7 +2287,7 @@ describe("session.llm.stream", () => { input("msg_user-distillation-first"), { ...input("msg_user-distillation-second"), - reasoningDistillation: { ...distillationHistory()!, reasoningTurn: 2 }, + reasoningDistillation: distillationHistory(), }, ], true, @@ -2417,7 +2429,7 @@ describe("session.llm.stream", () => { { ...group.parts[0], partID: "prt-reasoning-second", - text: "反复分析方案B与风险。", + text: "反复分析方案B与风险。".repeat(8), }, ], })), @@ -2488,6 +2500,91 @@ describe("session.llm.stream", () => { ) } + const turnInput = (model: Provider.Model, sessionID: SessionID, turn: number, adopted: unknown[][]) => { + const input = organizeInput(model, sessionID, distillationHistory()!, (replacements) => + Effect.sync(() => { + adopted.push([...replacements]) + return true + }), + ) + return { ...input, user: { ...input.user, id: MessageID.make(`msg_user-organize-${turn}`) } } + } + + it.instance( + "reconciles reservations with reported usage so a long session keeps organizing", + () => + Effect.gen(function* () { + const model = yield* Provider.use.getModel( + ProviderV2.ID.make("custom-provider"), + ModelV2.ID.make("deepseek-test-r1"), + ) + const sessionID = SessionID.make("session-test-organize-long") + const adopted: unknown[][] = [] + // Each prompt reserves ~45k tokens; without reconciliation the 262k ceiling stopped the sixth turn. + const turns = Array.from({ length: 12 }, (_, index) => index + 1) + for (const _ of turns) void waitRequest("/chat/completions", auxiliaryTextResponse("已确认方案A。")) + yield* distillSequenceWith( + llmLayerWithExecutor(RequestExecutor.defaultLayer), + turns.map((turn) => turnInput(model, sessionID, turn, adopted)), + ) + expect(state.queue).toHaveLength(0) + expect(adopted).toHaveLength(12) + }), + { config: () => distillationConfig("opencode-ai-sdk", { canonical: true }) }, + ) + + it.instance( + "refunds failed organizer calls and pauses only after consecutive failures", + () => + Effect.gen(function* () { + const model = yield* Provider.use.getModel( + ProviderV2.ID.make("custom-provider"), + ModelV2.ID.make("deepseek-test-r1"), + ) + const sessionID = SessionID.make("session-test-organize-failures") + const adopted: unknown[][] = [] + const unavailable = () => new Response("unavailable", { status: 503 }) + // fail, succeed, then three consecutive failures; the sixth turn must not call the model. + for (const response of [ + unavailable(), + auxiliaryTextResponse("已确认方案A。"), + unavailable(), + unavailable(), + unavailable(), + auxiliaryTextResponse("unexpected"), + ]) + void waitRequest("/chat/completions", response) + yield* distillSequenceWith( + llmLayerWithExecutor(RequestExecutor.defaultLayer), + [1, 2, 3, 4, 5, 6].map((turn) => turnInput(model, sessionID, turn, adopted)), + ) + expect(adopted).toHaveLength(1) + expect(state.queue).toHaveLength(1) + }), + { config: () => distillationConfig("opencode-ai-sdk", { canonical: true }) }, + ) + + it.instance( + "uses the configured English organizer instruction", + () => + Effect.gen(function* () { + const model = yield* Provider.use.getModel( + ProviderV2.ID.make("custom-provider"), + ModelV2.ID.make("deepseek-test-r1"), + ) + const organize = waitRequest("/chat/completions", auxiliaryTextResponse("Plan A confirmed.")) + const adopted: unknown[][] = [] + yield* distillWith( + llmLayerWithExecutor(RequestExecutor.defaultLayer), + turnInput(model, SessionID.make("session-test-organize-english"), 1, adopted), + ) + const capture = yield* Effect.promise(() => organize) + expect(JSON.stringify(capture.body.messages)).toContain("Write all explanatory prose in English") + expect(adopted).toHaveLength(1) + }), + { config: () => distillationConfig("opencode-ai-sdk", { canonical: true, language: "en" }) }, + ) + it.instance( "skips organization when the configured small model is unavailable", () => diff --git a/packages/opencode/test/session/reasoning-distillation.test.ts b/packages/opencode/test/session/reasoning-distillation.test.ts index 0761804cc..4c32cb26c 100644 --- a/packages/opencode/test/session/reasoning-distillation.test.ts +++ b/packages/opencode/test/session/reasoning-distillation.test.ts @@ -37,6 +37,7 @@ import { parseSupport, projectDistillationAISDK, reasoningHistory, + turnReasoning, resolveOrganizerTier, runJudge, runDistillationCycle, @@ -1551,3 +1552,40 @@ test("reasoning preparation cadence includes every completed user turn", () => { expect(isDistillationTurn(NaN)).toBe(false) expect(isDistillationTurn(1.5)).toBe(false) }) + +describe("completed-turn reasoning snapshot", () => { + test("includes only the turn's assistant reasoning, with editability and settlement", () => { + const assistant = (id: string, text: string, metadata?: Record) => ({ + info: { id, role: "assistant", time: { created: 1, completed: 2 } }, + parts: [ + { + id: `${id}-r`, + messageID: id, + sessionID: "s1", + type: "reasoning", + text, + metadata, + time: { start: 1, end: 2 }, + }, + { id: `${id}-t`, messageID: id, sessionID: "s1", type: "text", text: "answer", time: { start: 1, end: 2 } }, + ], + }) + const messages = [ + { info: { id: "u1", role: "user", time: { created: 1 } }, parts: [] }, + assistant("old", "旧轮次思考"), + { info: { id: "u2", role: "user", time: { created: 1 } }, parts: [] }, + assistant("new-1", "本轮思考一"), + assistant("new-2", "本轮签名思考", { anthropic: { signature: "sig" } }), + ] as unknown as SessionV1.WithParts[] + const snapshot = turnReasoning(messages, new Set(["new-1", "new-2"])) + expect(snapshot.groups.map((group) => group.messageID)).toEqual(["new-1", "new-2"]) + expect(snapshot.groups[0].parts[0]).toMatchObject({ + partID: "new-1-r", + text: "本轮思考一", + settled: true, + canonicalEditable: true, + }) + expect(snapshot.groups[1].parts[0]).toMatchObject({ signed: true, canonicalEditable: false }) + expect(turnReasoning(messages, new Set()).groups).toEqual([]) + }) +}) diff --git a/packages/opencode/test/tool/agent-tool.test.ts b/packages/opencode/test/tool/agent-tool.test.ts index 371eca7b1..92a663fb3 100644 --- a/packages/opencode/test/tool/agent-tool.test.ts +++ b/packages/opencode/test/tool/agent-tool.test.ts @@ -106,6 +106,16 @@ describe("agent tool contract", () => { expect(Result.isSuccess(decode({ params }))).toBe(false) }) + it.effect("routes node questions to the parent and asks the parent for grounded answers", () => + Effect.gen(function* () { + const tool = yield* AgentTool.pipe(Effect.provide(Layer.mock(DagAgentMessages.Service, {}))) + const { description } = yield* tool.init() + expect(description).toContain("sends the question to its owning parent; nodes do not ask the user") + expect(description).toContain("reply_to its message with a conclusion you can stand behind") + expect(description).toContain("never present a guess as settled") + }), + ) + it.effect("passes trusted session and exact destination through to durable acceptance", () => Effect.gen(function* () { let received: Parameters[0] | undefined diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index ca6d3b9ef..a23b64b82 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3929,6 +3929,7 @@ export type ConfigV2ReasoningDistillationCompatibility = { export type ConfigV2ReasoningDistillation = { enabled?: boolean + language?: "zh" | "en" compatibility?: Array } diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 766ed3c11..d95c1989c 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -108,6 +108,20 @@ function TextBody(props: { title: string; description?: string; icon?: string }) ) } +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +/** Permissions whose dialog view renders the tool input directly (rather than tool-provided metadata). */ +const INPUT_VIEWS = new Set(["read", "glob", "grep", "list", "bash", "task", "webfetch", "websearch"]) + +/** Bounded, readable preview of a hook-forced ask's effective input. */ +export const inputPreview = (input: Record, maxLines = 24, maxChars = 2_000) => { + const lines = JSON.stringify(input, null, 2).split("\n") + const shown = lines.slice(0, maxLines).join("\n") + const clipped = shown.length > maxChars ? shown.slice(0, maxChars) : shown + return clipped.length < JSON.stringify(input, null, 2).length ? `${clipped}\n… (truncated)` : clipped +} + export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) { const sdk = useSDK() const project = useProject() @@ -119,7 +133,15 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID)) + // A hook-forced ask carries the input the tool will actually run with (including hook rewrites). + const hookAsk = () => props.request.metadata?.hookAsk === true + const hookReason = () => { + const reason = props.request.metadata?.reason + return hookAsk() && typeof reason === "string" ? reason : "" + } const input = createMemo(() => { + const effective = props.request.metadata?.input + if (hookAsk() && isRecord(effective)) return effective const tool = props.request.tool if (!tool) return {} const parts = sync.data.part[tool.messageID] ?? [] @@ -196,6 +218,21 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? const permission = props.request.permission const data = input() + // A hook-forced ask carries no tool metadata (diff, path); show the effective input itself for tools + // without a view built on that input. + if (hookAsk() && !INPUT_VIEWS.has(permission)) { + const path = [data.filePath, data.path].find((value): value is string => typeof value === "string") + return { + icon: "⚙", + title: path ? `Call tool ${permission} · ${pathFormatter.format(path)}` : `Call tool ${permission}`, + body: ( + + {inputPreview(data)} + + ), + } + } + if (permission === "edit") { const raw = props.request.metadata?.filepath const filepath = typeof raw === "string" ? raw : "" @@ -397,12 +434,26 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? ) + // "Allow always" is offered only when the request carries patterns it would persist. + const options: Record = + props.request.always.length > 0 + ? { once: "Allow once", always: "Allow always", reject: "Reject" } + : { once: "Allow once", reject: "Reject" } const body = ( + + + {"Hook: " + hookReason()} + + + {current.body} + + } + options={options} escapeKey="reject" fullscreen onSelect={(option) => {