diff --git a/.changeset/step-budget.md b/.changeset/step-budget.md new file mode 100644 index 000000000000..9141b630198f --- /dev/null +++ b/.changeset/step-budget.md @@ -0,0 +1,8 @@ +--- +"@reddb-io/redcode": patch +"@reddb-io/redcode-core": patch +--- + +Ask for a report before the step ceiling instead of cutting the turn off at it + +The turn ceiling was a cliff: at step 200 the turn stopped and everything the model had worked out but not yet written down went with it, leaving the user told to "send another message to continue" with nothing to base it on. The last steps before the wall are now spent the way `agent.steps` already spends its own: tools off, a summary of what was done, what is left, and what to do next. The wall itself is unchanged, for a model that will not yield. Configurable via `experimental.turn_steps`. diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index cf084ede2e74..bd364b765197 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -182,6 +182,18 @@ export const Info = Schema.Struct({ mcp_timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in milliseconds for model context protocol (MCP) requests", }), + turn_steps: Schema.optional( + Schema.Union([ + Schema.Literal(false), + Schema.Struct({ + wrap_up_at: Schema.optional(PositiveInt), + stop_at: Schema.optional(PositiveInt), + }), + ]), + ).annotate({ + description: + "Steps one turn may run before the model is asked to stop and report what it did (wrap_up_at, default 198) and before the turn is stopped outright (stop_at, default 200). Set to false to remove the ceiling.", + }), loop_guard: Schema.optional( Schema.Union([ Schema.Literal(false), diff --git a/packages/redcode/src/session/prompt.ts b/packages/redcode/src/session/prompt.ts index cdd3212a3832..2f06badd9584 100644 --- a/packages/redcode/src/session/prompt.ts +++ b/packages/redcode/src/session/prompt.ts @@ -19,10 +19,6 @@ import { Instruction } from "./instruction" import { Plugin } from "../plugin" import { MAX_STEPS_PROMPT } from "@reddb-io/redcode-core/session/runner/max-steps" -// Deliberately far above any real turn: this is the wall that stops a runaway, not a budget. -// Agents that want a tighter bound set `agent.steps`. -const TURN_STEP_CEILING = 200 - /** How often the watchdog looks. Well below the thresholds it is checking against. */ const STALL_POLL_SECONDS = 15 @@ -44,6 +40,7 @@ import { ConfigMarkdown } from "@/config/markdown" import { SessionSummary } from "./summary" import { NamedError } from "@reddb-io/redcode-core/util/error" import { SessionProcessor } from "./processor" +import { StepBudget } from "./step-budget" import { SessionStall } from "./stall" import { Tool } from "@/tool/tool" import { Permission } from "@/permission" @@ -1199,20 +1196,32 @@ const layer = Layer.effect( // A ceiling the model cannot talk its way past. `agent.steps` only appends a prompt // asking it to stop, which a model that has stopped making progress ignores — and - // then the turn runs until someone notices the spend. - if (step >= TURN_STEP_CEILING) { + // then the turn runs until someone notices the spend. Cutting the turn off at the wall + // also throws away everything worked out but not yet written down, so the last steps + // before it are spent asking for that instead. + const budget = StepBudget.decide({ + // `step` counts steps already finished, so this is the one about to run. + step: step + 1, + limits: StepBudget.limits((yield* config.get()).experimental?.turn_steps), + }) + if (budget.type === "stop") { yield* Effect.logWarning("turn exceeded the step ceiling", { "session.id": sessionID, steps: step, }) yield* events.publish(Session.Event.Error, { sessionID, - error: new NamedError.Unknown({ - message: `This turn ran ${step} steps without finishing and was stopped. Send another message to continue it.`, - }).toObject(), + error: new NamedError.Unknown({ message: budget.message }).toObject(), }) break } + if (budget.type === "wrap-up") { + yield* Effect.logWarning("turn is near the step ceiling; asking for a final report", { + "session.id": sessionID, + steps: step, + remaining: budget.remaining, + }) + } let msgs = yield* MessageV2.filterCompactedEffect(sessionID).pipe( Effect.provideService(Database.Service, database), @@ -1334,7 +1343,9 @@ const layer = Layer.effect( throw error } const maxSteps = agent.steps ?? Infinity - const isLastStep = step >= maxSteps + // The agent's own bound and the turn's wall ask for the same thing at the end: stop + // using tools and say what happened. + const isLastStep = step >= maxSteps || budget.type === "wrap-up" msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe( Effect.provideService(RuntimeFlags.Service, flags), Effect.provideService(FSUtil.Service, fsys), diff --git a/packages/redcode/src/session/step-budget.ts b/packages/redcode/src/session/step-budget.ts new file mode 100644 index 000000000000..79f0fc6902ba --- /dev/null +++ b/packages/redcode/src/session/step-budget.ts @@ -0,0 +1,55 @@ +/** + * What to do as a turn approaches the wall. + * + * The ceiling used to be a cliff: at step 200 the turn was cut off and everything the model had + * worked out but not yet written down went with it. The user is told to "send another message to + * continue", but the reasoning that would have made that message useful is gone. + * + * A step before the wall is enough to ask for the thing that makes the loss recoverable: a report + * of what was done, what is left, and what to do next. The wall stays where it was, for a model + * that will not yield. + */ + +/** Deliberately far above any real turn: this is the wall that stops a runaway, not a budget. */ +export const CEILING = 200 + +/** + * Steps of grace between the request for a report and the wall. + * + * More than one because a model asked to summarise sometimes makes one last tool call first; + * few, because each is a full request against a turn already known to be over budget. + */ +export const GRACE = 2 + +export interface Limits { + readonly wrapUpAt: number + readonly stopAt: number +} + +export function limits(config?: false | { wrap_up_at?: number; stop_at?: number }): Limits | undefined { + if (config === false) return undefined + const stopAt = config?.stop_at ?? CEILING + const wrapUpAt = config?.wrap_up_at ?? Math.max(1, stopAt - GRACE) + if (stopAt <= 0) return undefined + return { stopAt, wrapUpAt: Math.min(wrapUpAt, stopAt) } +} + +export type Decision = + | { readonly type: "run" } + /** Tools stay available, but the model is told to finish and report. */ + | { readonly type: "wrap-up"; readonly remaining: number } + | { readonly type: "stop"; readonly message: string } + +export function decide(input: { step: number; limits?: Limits }): Decision { + if (!input.limits) return { type: "run" } + const { wrapUpAt, stopAt } = input.limits + if (input.step >= stopAt) return { type: "stop", message: stopped(input.step) } + if (input.step >= wrapUpAt) return { type: "wrap-up", remaining: stopAt - input.step } + return { type: "run" } +} + +export function stopped(step: number) { + return `This turn ran ${step} steps without finishing and was stopped. Send another message to continue it.` +} + +export * as StepBudget from "./step-budget" diff --git a/packages/redcode/test/session/prompt.test.ts b/packages/redcode/test/session/prompt.test.ts index abf4c5b21e34..a0eae437d2fb 100644 --- a/packages/redcode/test/session/prompt.test.ts +++ b/packages/redcode/test/session/prompt.test.ts @@ -467,6 +467,40 @@ noLLMServer.instance( { config: cfg }, ) +it.instance("asks for a report before the step ceiling instead of cutting the turn off at it", () => + Effect.gen(function* () { + // The ceiling used to be a cliff: at the wall the turn was cut off and everything worked out + // but not written down went with it, leaving the user told to "send another message" with + // nothing to send it about. + const { llm } = yield* useServerConfig((url) => ({ + ...providerCfg(url), + experimental: { turn_steps: { stop_at: 3, wrap_up_at: 2 } }, + })) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "keep going" }], + }) + // Never finishing on its own: only the budget ends this turn. + yield* llm.tool("todowrite", { todos: [{ content: "one", status: "in_progress", priority: "high" }] }) + yield* llm.tool("todowrite", { todos: [{ content: "two", status: "in_progress", priority: "high" }] }) + yield* llm.text("here is what I did and what is left") + + yield* awaitWithTimeout(prompt.loop({ sessionID: chat.id }), "the turn never finished", "30 seconds") + + const bodies = (yield* llm.hits).map((hit) => JSON.stringify(hit.body)) + // First step runs normally; the step before the wall carries the request for a final report. + expect(bodies[0]).not.toContain("MAXIMUM STEPS REACHED") + expect(bodies[1]).toContain("MAXIMUM STEPS REACHED") + }), + 60_000, +) + it.instance("loop continues a natural stop while persisted todos are unfinished", () => Effect.gen(function* () { const { llm } = yield* useServerConfig((url) => ({ diff --git a/packages/redcode/test/session/step-budget.test.ts b/packages/redcode/test/session/step-budget.test.ts new file mode 100644 index 000000000000..c8e0a925ebe9 --- /dev/null +++ b/packages/redcode/test/session/step-budget.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test" +import { CEILING, decide, limits } from "@/session/step-budget" + +describe("step budget", () => { + const bounds = limits() + + test("stays out of the way for the length of any real turn", () => { + expect(decide({ step: 1, limits: bounds }).type).toBe("run") + expect(decide({ step: 100, limits: bounds }).type).toBe("run") + }) + + test("asks for a report before the wall, not at it", () => { + // The step before the cliff is the last chance to keep the work that was done. + const decision = decide({ step: CEILING - 1, limits: bounds }) + expect(decision.type).toBe("wrap-up") + if (decision.type !== "wrap-up") return + expect(decision.remaining).toBe(1) + }) + + test("still stops a model that will not yield", () => { + const decision = decide({ step: CEILING, limits: bounds }) + expect(decision.type).toBe("stop") + if (decision.type !== "stop") return + expect(decision.message).toContain("Send another message") + }) + + test("a custom ceiling keeps its grace steps, and a tiny one does not invert", () => { + expect(limits({ stop_at: 10 })).toEqual({ stopAt: 10, wrapUpAt: 8 }) + expect(limits({ stop_at: 1 })).toEqual({ stopAt: 1, wrapUpAt: 1 }) + expect(limits(false)).toBeUndefined() + // A wrap-up asked for after the wall would never be asked for at all. + expect(limits({ stop_at: 5, wrap_up_at: 9 })).toEqual({ stopAt: 5, wrapUpAt: 5 }) + }) +})