Compare commits

...

1 Commits

Author SHA1 Message Date
Shoubhit Dash 5f6059c8e2 fix(acp): report model request budget exhaustion 2026-09-16 17:58:00 +05:30
9 changed files with 65 additions and 8 deletions
+13 -4
View File
@@ -94,6 +94,7 @@ export async function streamTurn(input: {
let started = false
let assistantMessageID: string | undefined
let finish: SessionMessageAssistant["finish"]
let stepLimit = false
let executionError: { readonly type: string; readonly message: string } | undefined
const tools = new Map<string, ToolState>()
const children = new Map<string, ChildSession>()
@@ -185,7 +186,10 @@ export async function streamTurn(input: {
}
if (event.type === "session.step.started") {
if (!child) assistantMessageID = event.data.assistantMessageID
if (!child) {
assistantMessageID = event.data.assistantMessageID
stepLimit = event.data.stepLimit === true
}
continue
}
if (event.type === "session.text.delta") {
@@ -349,14 +353,14 @@ export async function streamTurn(input: {
if (input.action) {
streamController.abort()
await completed.catch(() => {})
return response(undefined, undefined, "succeeded", control.cancelled, undefined)
return response(undefined, undefined, "succeeded", control.cancelled, undefined, false)
}
if (control.cancelled) {
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
if (!started) {
streamController.abort()
await completed.catch(() => {})
return response(undefined, undefined, "interrupted", true, undefined)
return response(undefined, undefined, "interrupted", true, undefined, false)
}
}
const terminal = await completed
@@ -378,6 +382,7 @@ export async function streamTurn(input: {
terminal,
control.cancelled,
finish,
stepLimit,
)
} catch (error) {
streamController.abort()
@@ -557,6 +562,7 @@ function response(
terminal: "succeeded" | "failed" | "interrupted",
cancelled: boolean,
finish: SessionMessageAssistant["finish"],
stepLimit: boolean,
): PromptResponse {
const error = assistant?.error ?? executionError
if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError()
@@ -578,7 +584,7 @@ function response(
...(tokens.cache.write > 0 ? { cachedWriteTokens: tokens.cache.write } : {}),
}
: undefined
const stopReason = resolveStopReason({ terminal, cancelled, finish, error: error?.type })
const stopReason = resolveStopReason({ terminal, cancelled, finish, stepLimit, error: error?.type })
return { stopReason, ...(usage ? { usage } : {}), _meta: {} }
}
@@ -586,11 +592,14 @@ function resolveStopReason(input: {
readonly terminal: "succeeded" | "failed" | "interrupted"
readonly cancelled: boolean
readonly finish: SessionMessageAssistant["finish"]
readonly stepLimit: boolean
readonly error?: string
}): PromptResponse["stopReason"] {
if (input.cancelled || input.terminal === "interrupted" || input.error === "aborted") return "cancelled"
if (input.finish === "length") return "max_tokens"
if (input.finish === "content-filter" || input.error === "provider.content-filter") return "refusal"
// The runner disabled tools because the agent's step allowance ran out, so the model could not continue.
if (input.stepLimit) return "max_turn_requests"
return "end_turn"
}
@@ -0,0 +1,21 @@
import type { PromptResponse } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
describe("acp stop reason subprocess", () => {
// Natural completion already reports end_turn throughout the other subprocess tests.
test("reports max_turn_requests when the agent's step allowance forces the final step", async () => {
await using fixture = await createAcpFixture({ config: { agents: { build: { steps: 1 } } } })
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const response = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "hello" }],
}),
)
expect(response.stopReason).toBe("max_turn_requests")
}, 60_000)
})
+8 -2
View File
@@ -4,6 +4,7 @@ import type {
SessionConfigOption,
SessionConfigSelectOption,
} from "@agentclientprotocol/sdk"
import { Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
@@ -64,7 +65,9 @@ description: Verifier compatibility skill.
# Verifier Skill
`
export async function createAcpFixture(options: { readonly skill?: string } = {}) {
export async function createAcpFixture(
options: { readonly skill?: string; readonly config?: Record<string, Schema.Json> } = {},
) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cli-acp-"))
const home = path.join(root, "workspace")
const config = path.join(root, "config")
@@ -92,7 +95,10 @@ export async function createAcpFixture(options: { readonly skill?: string } = {}
})
await Bun.write(
path.join(config, "opencode.json"),
JSON.stringify(verifierConfig(`http://127.0.0.1:${llm.port}/v1`, options.skill ? skills : undefined)),
JSON.stringify({
...verifierConfig(`http://127.0.0.1:${llm.port}/v1`, options.skill ? skills : undefined),
...options.config,
}),
)
await Bun.write(models, "{}")
+1
View File
@@ -812,6 +812,7 @@ export type SessionLogOutput =
readonly agent: Agent.ID
readonly model: Model.Ref
readonly snapshot?: (string & Brand.Brand<"Snapshot.ID">) | undefined
readonly stepLimit?: boolean | undefined
}
}
| {
@@ -710,7 +710,14 @@ export type SessionStepStarted = {
type: "session.step.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; agent: string; model: ModelRef; snapshot?: string }
data: {
sessionID: string
assistantMessageID: string
agent: string
model: ModelRef
snapshot?: string
stepLimit?: boolean
}
}
export type SessionStepStreamed = {
@@ -21,6 +21,7 @@ type Input = {
readonly providerMetadataKey: string
readonly snapshot?: Snapshot.ID
readonly assistantMessageID: SessionMessage.ID
readonly stepLimit?: boolean
}
const asRecord = (value: unknown): Record<string, unknown> =>
@@ -104,6 +105,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
model: input.model,
assistantMessageID,
snapshot: input.snapshot,
...(input.stepLimit ? { stepLimit: true } : {}),
})
return assistantMessageID
})
+1
View File
@@ -77,6 +77,7 @@ export const make = Effect.gen(function* () {
model: input.model.ref,
providerMetadataKey: input.model.model.route.providerMetadataKey ?? input.model.model.provider,
snapshot: startSnapshot,
stepLimit: input.prepared.request.toolChoice?.type === "none",
})
const toolRuns: Array<{
readonly call: ToolCall
+8 -1
View File
@@ -4840,11 +4840,18 @@ describe("SessionRunnerLLM", () => {
content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }],
})
expect(s.executions).toEqual(["done"])
expect(yield* s.context).toMatchObject([
const context = yield* s.context
expect(context).toMatchObject([
Expected.user("Finish at the limit"),
Expected.assistant({}, [Expected.completedTool({ id: "call-terminal" }, {})]),
Expected.assistant({}, [Expected.failedTool({ id: "call-forbidden" }, {})]),
])
// Only the forced final step records the limit, so clients can report it as the stop cause.
const started = yield* Effect.forEach(
context.filter((message) => message.type === "assistant"),
(message) => recordedStepSettlementEvents(sessionID, message.id),
).pipe(Effect.map((events) => events.flat().filter((event) => event.type === "session.step.started.1")))
expect(started.map((event) => event.data.stepLimit)).toEqual([undefined, true])
})
scenario("resets the configured step allowance when steering input promotes", function* (s) {
+3
View File
@@ -328,6 +328,9 @@ export namespace Step {
agent: Agent.ID,
model: Model.Ref,
snapshot: Snapshot.ID.pipe(optional),
stepLimit: Schema.Boolean.pipe(optional).annotate({
description: "Set when the agent's step allowance forced this tools-disabled final step.",
}),
},
})
export type Started = typeof Started.Type