fix(core): preserve logical step across retries (#37316)

This commit is contained in:
Kit Langton
2026-07-16 10:54:51 -04:00
committed by GitHub
parent eec6cd5234
commit 4829308f2d
2 changed files with 33 additions and 11 deletions
+2 -3
View File
@@ -339,8 +339,7 @@ const layer = Layer.effect(
const error = toSessionError(llmFailure) const error = toSessionError(llmFailure)
if ( if (
SessionRunnerRetry.isRetryable(llmFailure) && SessionRunnerRetry.isRetryable(llmFailure) &&
!publisher.hasRetryEvidence() && !publisher.hasRetryEvidence()
(agentInfo.steps === undefined || currentStep < agentInfo.steps)
) { ) {
return yield* new SessionRunnerRetry.RetryableFailure({ return yield* new SessionRunnerRetry.RetryableFailure({
cause: llmFailure, cause: llmFailure,
@@ -457,7 +456,7 @@ const layer = Layer.effect(
Effect.tapError((error) => Effect.tapError((error) =>
error instanceof SessionRunnerRetry.RetryableFailure error instanceof SessionRunnerRetry.RetryableFailure
? Effect.sync(() => { ? Effect.sync(() => {
currentStep = error.step + 1 currentStep = error.step
assistantMessageID = error.assistantMessageID assistantMessageID = error.assistantMessageID
currentPromotion = undefined currentPromotion = undefined
}) })
+31 -8
View File
@@ -4028,11 +4028,19 @@ describe("SessionRunnerLLM", () => {
{ attempt: 5, at: 30_000 }, { attempt: 5, at: 30_000 },
]) ])
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(5) expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(5)
expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1) const assistant = requireAssistant(yield* session.context(sessionID))
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
{ type: "session.step.started.1" },
{ type: "session.step.started.1" },
{ type: "session.step.started.1" },
{ type: "session.step.started.1" },
{ type: "session.step.started.1" },
{ type: "session.step.failed.1" },
])
}), }),
) )
it.effect("counts retry attempts against the agent step allowance", () => it.effect("retries a physical attempt without consuming the logical agent step", () =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* setup const session = yield* setup
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service
@@ -4041,21 +4049,36 @@ describe("SessionRunnerLLM", () => {
agent.steps = 2 agent.steps = 2
}), }),
) )
yield* admit(session, "Bound retries by steps") yield* admit(session, "Retry without consuming a step")
const failure = providerUnavailable() const failure = providerUnavailable()
responseStream = Stream.fail(failure) responseStream = Stream.fail(failure)
streamFailure = failure responses = [reply.tool("call-after-retry", "echo", { text: "recovered" }), reply.stop()]
const run = yield* session.resume(sessionID).pipe(Effect.forkChild) const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
while (requests.length < 1) yield* Effect.yieldNow while (requests.length < 1) yield* Effect.yieldNow
yield* TestClock.adjust("2 seconds") yield* TestClock.adjust("2 seconds")
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure) yield* Fiber.join(run)
expect(requests).toHaveLength(2) expect(requests).toHaveLength(3)
expect(requests[0]?.toolChoice).toBeUndefined()
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("echo")
expect(requests[1]?.toolChoice).toBeUndefined()
expect(requests[1]?.tools.map((tool) => tool.name)).toContain("echo")
expect(requests[1]?.messages.at(-1)).not.toMatchObject({
role: "assistant",
content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }],
})
expect(requests[2]?.toolChoice).toMatchObject({ type: "none" })
expect(requests[2]?.tools).toEqual([])
expect(requests[2]?.messages.at(-1)).toMatchObject({
role: "assistant",
content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }],
})
expect(executions).toEqual(["recovered"])
const eventTypes = yield* recordedEventTypes(sessionID) const eventTypes = yield* recordedEventTypes(sessionID)
expect(eventTypes.filter((type) => type === "session.step.started.1")).toHaveLength(2) expect(eventTypes.filter((type) => type === "session.step.started.1")).toHaveLength(3)
expect(eventTypes.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(1) expect(eventTypes.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(1)
expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1) expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(2)
}), }),
) )