Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 2 additions & 39 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,43 +166,6 @@ function retryTimeoutPolicyFor(
})
}

function sideEffectBoundarySnapshot(tools: LLM.StreamInput["tools"]): RunObservability.SideEffectBoundarySnapshot {
const entries = Object.entries(tools ?? {})
const names = entries.map(([name]) => name)
const effects = names.map((name) => RunObservability.toolEffect(name))
const unknownCount = effects.filter((effect) => effect.kind === "unknown").length
const unclassifiedCount = effects.filter((effect) => !effect.complete).length
const providerExecutedCapabilityPresent = entries.some(([, item]) => isRecord(item) && item.type === "provider")
const externalBoundaryPresent = entries.some(
([, item]) => isRecord(item) && (item as { externalResult?: unknown }).externalResult === true,
)
const unknownBoundaryPresent = entries.some(([, item]) => !isRecord(item))
const incomplete = unknownCount > 0 || unclassifiedCount > 0
const proofReason = providerExecutedCapabilityPresent
? "provider_executed_capability"
: externalBoundaryPresent
? "external_boundary"
: unknownBoundaryPresent
? "unknown"
: incomplete
? unknownCount > 0
? "unknown_tool_boundary"
: "unclassified_effect"
: "all_boundaries_classified"
return {
exposed_tool_count: names.length,
unknown_tool_count: unknownCount,
unclassified_effect_count: unclassifiedCount,
provider_executed_capability_present: providerExecutedCapabilityPresent,
external_boundary_present: externalBoundaryPresent,
proof_result:
incomplete || providerExecutedCapabilityPresent || externalBoundaryPresent || unknownBoundaryPresent
? "incomplete"
: "complete",
proof_reason: proofReason,
}
}

function recoveryInterruptionMessage(recovery: NonNullable<RunObservability.Summary["incident"]>["recovery"] | undefined) {
switch (recovery?.reason) {
case "visible_output_without_tool_execution":
Expand Down Expand Up @@ -1343,7 +1306,7 @@ export const layer: Layer.Layer<
ctx.reasoningMap = {}
ctx.attemptCount++
const activeTools = LLM.resolveTools(streamInput)
const boundarySnapshot = sideEffectBoundarySnapshot(activeTools)
const boundarySnapshot = RunObservability.sideEffectBoundarySnapshot(activeTools)
const sessionTimeouts = attemptStreamTimeouts(
streamInput.model,
automaticStreamRetriesUsed,
Expand Down Expand Up @@ -1426,7 +1389,7 @@ export const layer: Layer.Layer<
streamInput.model,
automaticStreamRetriesUsed,
streamInput,
sideEffectBoundarySnapshot(LLM.resolveTools(streamInput)),
RunObservability.sideEffectBoundarySnapshot(LLM.resolveTools(streamInput)),
),
})

Expand Down
58 changes: 14 additions & 44 deletions packages/opencode/src/session/retry-decision.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { RunIncident } from "./run-incident"
import { RunIncident as RunIncidentPolicy } from "./run-incident"
Comment thread
Astro-Han marked this conversation as resolved.
Resolved
import type { RetryClassification } from "./retry-classification"

export type TechnicalRetryability =
Expand Down Expand Up @@ -72,56 +73,25 @@ export function buildModelRetryDecision(input: {
}
}

const safety = input.safetyGateDecision
if (safety.recommendation === "auto_retry_once") {
const maxAttempts = safety.auto_retry?.max_attempts ?? 1
if (input.safeRecoveryAttempt < maxAttempts) {
return {
...input,
canRetry: true,
recoveryMode: "replay",
attemptKind: "safe_recovery_replay",
presentation: "safe_recovery",
}
}
const safety = RunIncidentPolicy.evaluateReplaySafety({
recovery: input.safetyGateDecision,
safeRecoveryAttempt: input.safeRecoveryAttempt,
})
Comment thread
Astro-Han marked this conversation as resolved.
Resolved
if (safety.canReplay) {
return {
...input,
canRetry: false,
recoveryMode: "auto_replay_blocked",
blockedReason: "safe_recovery_budget_exhausted",
attemptKind: "safe_recovery_replay",
presentation: "safe_recovery_failed",
}
}

if (safety.recommendation === "offer_continue") {
return {
...input,
canRetry: false,
recoveryMode: "offer_continue",
blockedReason: safety.reason,
presentation: "default",
canRetry: true,
recoveryMode: safety.recoveryMode,
attemptKind: safety.attemptKind,
presentation: safety.presentation,
}
}

if (
safety.recommendation === "ask_user_before_retry" ||
safety.recommendation === "offer_resume_with_confirmation"
) {
return {
...input,
canRetry: false,
recoveryMode: "ask_user",
blockedReason: safety.reason,
presentation: "default",
}
}

return {
...input,
canRetry: false,
recoveryMode: "stop",
blockedReason: safety.reason,
presentation: "default",
recoveryMode: safety.recoveryMode,
blockedReason: safety.blockedReason,
attemptKind: safety.attemptKind,
presentation: safety.presentation,
}
}
2 changes: 2 additions & 0 deletions packages/opencode/src/session/run-incident/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { deriveIncident as deriveRunIncident, transportCause as providerTranspor
import { recoveryFor as deriveRecovery } from "./policy"
import { plainSummary as derivePlainSummary, userSummary as deriveUserSummary } from "./presentation"
import { sanitizeIncident as sanitizeRunIncident, sanitizeLifecycleRequest } from "./sanitize"
import { evaluateReplaySafety as deriveReplaySafety } from "./safety-gate"
import { RUN_INCIDENT_SCHEMA_VERSION as VERSION } from "./types"
import * as Types from "./types"

Expand Down Expand Up @@ -31,6 +32,7 @@ export namespace RunIncident {
export const derive = deriveRunIncident
export const transportCause = providerTransportCause
export const recoveryFor = deriveRecovery
export const evaluateReplaySafety = deriveReplaySafety
export const userSummary = deriveUserSummary
export const plainSummary = derivePlainSummary
export const sanitize = sanitizeRunIncident
Expand Down
63 changes: 63 additions & 0 deletions packages/opencode/src/session/run-incident/safety-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { RecoveryDecision } from "./types"

export type ReplaySafetyDecision = {
canReplay: boolean
recoveryMode: "replay" | "auto_replay_blocked" | "ask_user" | "offer_continue" | "stop"
blockedReason?: "safe_recovery_budget_exhausted" | RecoveryDecision["reason"]
attemptKind?: "safe_recovery_replay"
presentation: "default" | "safe_recovery" | "safe_recovery_failed"
}

export function evaluateReplaySafety(input: {
recovery: RecoveryDecision
safeRecoveryAttempt: number
}): ReplaySafetyDecision {
const safety = input.recovery

if (safety.recommendation === "auto_retry_once") {
const maxAttempts = safety.auto_retry?.max_attempts ?? 1
if (input.safeRecoveryAttempt < maxAttempts) {
return {
canReplay: true,
recoveryMode: "replay",
attemptKind: "safe_recovery_replay",
presentation: "safe_recovery",
}
}
return {
canReplay: false,
recoveryMode: "auto_replay_blocked",
blockedReason: "safe_recovery_budget_exhausted",
attemptKind: "safe_recovery_replay",
presentation: "safe_recovery_failed",
}
}

if (safety.recommendation === "offer_continue") {
return {
canReplay: false,
recoveryMode: "offer_continue",
blockedReason: safety.reason,
presentation: "default",
}
}

if (
safety.recommendation === "ask_user_before_retry" ||
safety.recommendation === "offer_resume_with_confirmation"
) {
return {
canReplay: false,
recoveryMode: "ask_user",
blockedReason: safety.reason,
presentation: "default",
}
}

return {
canReplay: false,
recoveryMode: "stop",
blockedReason: safety.reason,
presentation: "default",
}
}
39 changes: 39 additions & 0 deletions packages/opencode/src/session/run-observability/boundary.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,43 @@
import type { SideEffectBoundarySnapshot } from "./types"
import { toolEffect } from "./sanitize"
import { isRecord } from "@/util/record"

export function sideEffectBoundarySnapshot(tools: Record<string, unknown> | undefined): SideEffectBoundarySnapshot {
const entries = Object.entries(tools ?? {})
const names = entries.map(([name]) => name)
const effects = names.map((name) => toolEffect(name))
const unknownCount = effects.filter((effect) => effect.kind === "unknown").length
const unclassifiedCount = effects.filter((effect) => !effect.complete).length
const providerExecutedCapabilityPresent = entries.some(([, item]) => isRecord(item) && item.type === "provider")
const externalBoundaryPresent = entries.some(
([, item]) => isRecord(item) && (item as { externalResult?: unknown }).externalResult === true,
)
const unknownBoundaryPresent = entries.some(([, item]) => !isRecord(item))
const incomplete = unknownCount > 0 || unclassifiedCount > 0
const proofReason = providerExecutedCapabilityPresent
? "provider_executed_capability"
: externalBoundaryPresent
? "external_boundary"
: unknownBoundaryPresent
? "unknown"
: incomplete
? unknownCount > 0
? "unknown_tool_boundary"
: "unclassified_effect"
: "all_boundaries_classified"
return {
exposed_tool_count: names.length,
unknown_tool_count: unknownCount,
unclassified_effect_count: unclassifiedCount,
provider_executed_capability_present: providerExecutedCapabilityPresent,
external_boundary_present: externalBoundaryPresent,
proof_result:
incomplete || providerExecutedCapabilityPresent || externalBoundaryPresent || unknownBoundaryPresent
? "incomplete"
: "complete",
proof_reason: proofReason,
}
}

export function allowsBeforeProgressRetry(snapshot: SideEffectBoundarySnapshot | undefined) {
if (!snapshot) return false
Expand Down
6 changes: 5 additions & 1 deletion packages/opencode/src/session/run-observability/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import {
makeRunID as makeRunIdentifier,
summaryKeyFor as makeSummaryKey,
} from "./recorder"
import { allowsBeforeProgressRetry as allowsBeforeProgressBoundaryRetry } from "./boundary"
import {
allowsBeforeProgressRetry as allowsBeforeProgressBoundaryRetry,
sideEffectBoundarySnapshot as deriveSideEffectBoundarySnapshot,
} from "./boundary"
import { safeToolName as makeSafeToolName, toolEffect as classifyToolEffect } from "./sanitize"
import { SCHEMA_VERSION as VERSION, RunID as RunIDSchema, AttemptID as AttemptIDSchema } from "./types"
import * as Types from "./types"
Expand All @@ -20,6 +23,7 @@ export namespace RunObservability {
export const safeToolName = makeSafeToolName
export const toolEffect = classifyToolEffect
export const boundaryAllowsBeforeProgressRetry = allowsBeforeProgressBoundaryRetry
export const sideEffectBoundarySnapshot = deriveSideEffectBoundarySnapshot

export type RunID = Types.RunID
export type AttemptID = Types.AttemptID
Expand Down
68 changes: 68 additions & 0 deletions packages/opencode/test/session/run-incident-safety-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test"
import { RunIncident } from "../../src/session/run-incident"

const base = {
confidence: "high",
safety_scope: "visible_output_and_tool_side_effects",
} as const

describe("run incident safety gate", () => {
test("allows one automatic replay for safe recovery decisions", () => {
const decision = RunIncident.evaluateReplaySafety({
recovery: {
...base,
recommendation: "auto_retry_once",
reason: "reasoning_only_without_final_text_or_tool_activity",
auto_retry: { max_attempts: 1, backoff_ms: 1_000 },
},
safeRecoveryAttempt: 0,
})

expect(decision).toMatchObject({
canReplay: true,
recoveryMode: "replay",
attemptKind: "safe_recovery_replay",
presentation: "safe_recovery",
})
expect(decision.blockedReason).toBeUndefined()
})

test("blocks automatic replay after the safe recovery budget is exhausted", () => {
const decision = RunIncident.evaluateReplaySafety({
recovery: {
...base,
recommendation: "auto_retry_once",
reason: "no_visible_output_or_tool_execution",
auto_retry: { max_attempts: 1, backoff_ms: 1_000 },
},
safeRecoveryAttempt: 1,
})

expect(decision).toMatchObject({
canReplay: false,
recoveryMode: "auto_replay_blocked",
blockedReason: "safe_recovery_budget_exhausted",
attemptKind: "safe_recovery_replay",
presentation: "safe_recovery_failed",
})
})

test("keeps visible-output recovery as continuation instead of replay", () => {
const decision = RunIncident.evaluateReplaySafety({
recovery: {
...base,
recommendation: "offer_continue",
reason: "visible_output_without_tool_execution",
},
safeRecoveryAttempt: 0,
})

expect(decision).toMatchObject({
canReplay: false,
recoveryMode: "offer_continue",
blockedReason: "visible_output_without_tool_execution",
presentation: "default",
})
expect(decision.attemptKind).toBeUndefined()
})
})
Comment thread
Astro-Han marked this conversation as resolved.
Resolved
20 changes: 20 additions & 0 deletions packages/opencode/test/session/run-observability-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test"
import { RunObservability } from "../../src/session/run-observability"

describe("run observability side-effect boundary", () => {
test("summarizes provider and external tool boundaries as incomplete proof", () => {
expect(
RunObservability.sideEffectBoundarySnapshot({
providerSearch: { type: "provider" },
externalLookup: { externalResult: true },
unknownShape: "tool",
}),
).toMatchObject({
exposed_tool_count: 3,
provider_executed_capability_present: true,
external_boundary_present: true,
proof_result: "incomplete",
proof_reason: "provider_executed_capability",
})
})
})
Loading