Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 8 additions & 0 deletions .changeset/step-budget.md
Original file line number Diff line number Diff line change
@@ -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`.
12 changes: 12 additions & 0 deletions packages/core/src/v1/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
31 changes: 21 additions & 10 deletions packages/redcode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
55 changes: 55 additions & 0 deletions packages/redcode/src/session/step-budget.ts
Original file line number Diff line number Diff line change
@@ -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"
34 changes: 34 additions & 0 deletions packages/redcode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down
34 changes: 34 additions & 0 deletions packages/redcode/test/session/step-budget.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
})
Loading