Skip to content
Open
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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,39 @@ For the complete first-session walkthrough, see the
Backboard SSO into a separate application, see the
[Backboard SSO integration guide](https://docs.backboard.io/concepts/sso).

## Per-turn time budgets

```sh
backboard --timer 900
backboard --timer 900 --print "Implement the fix and run the tests"
backboard --no-timer
```

`--timer` sets an advisory wall-clock budget in positive whole seconds. It
restarts for each turn and never cancels work. Use an external harness if you
need a hard deadline. It is separate from Execute timeouts and sub-agent limits.
The default is off.

In an interactive session, `/timer 900` sets and saves the budget for subsequent
turns. Bare `/timer` clears it. `--timer off`, `--timer 0`, and `--no-timer`
override a saved preference for the current run without changing it on disk.

The model receives the budget before it starts working, then remaining-time
reminders at 50%, 25%, and 10%. Reminders are appended to the last tool response
of the next completed round, not sent as asynchronous interruptions. Background
Execute launches also receive a current-time reminder.

When the agent includes `timeBudgetSeconds` on its TodoWrite steps, step
transitions report planned versus actual duration, completed-step count, and
remaining time versus unfinished allocations. A materially overcommitted plan
is flagged once per turn. These are factual reports, not instructions to abandon
work or automatic changes to the plan.

Timing state lives in `src/core/agent/timing/`; wording lives in
`src/prompts/timerPrompt.ts`. The harness decorates outbound tool-result copies
after recording the ordinary results, keeping injected notices out of the
local transcript. No supervisor-model calls are made.

## Use your own model providers

A Backboard login is not required when you want to call a model provider
Expand Down
2 changes: 2 additions & 0 deletions src/config/BackboardConfigTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface BackboardConfigFile {
memoryProfile?: MemoryProfile;
notify?: boolean;
verbose?: boolean;
/** Advisory per-turn wall-clock budget, in seconds. */
timerSeconds?: number;
/** User-defined HTTP model providers. Secrets remain in keys.json. */
providers?: CustomProviderDefinition[];
/** Expert mode: implementation runs on `model`, planning stays on `/model`. */
Expand Down
29 changes: 29 additions & 0 deletions src/config/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
qUserMcpConfigPath,
} from "./paths.ts";
import { getProfile, type Profile } from "./profiles/index.ts";
import { isTimerSeconds, parseTimerSeconds } from "./timer.ts";

export interface ConfigOptions {
env?: BackboardEnv;
Expand Down Expand Up @@ -85,6 +86,7 @@ export class Config {
private skillDiscoveryEnabled = false;
private notifyEnabled = false;
private verboseEnabled = true;
private currentTimerSeconds: number | undefined;
private readonly currentMemoryProfile: MemoryProfile;
private currentThinking: ThinkingIntent | null | undefined;
private expertEnabled = false;
Expand Down Expand Up @@ -177,6 +179,10 @@ export class Config {
}
this.notifyEnabled = persistedConfig.notify ?? false;
this.verboseEnabled = persistedConfig.verbose ?? true;
this.currentTimerSeconds = parseTimerSeconds(
this.flags.timer,
persistedConfig.timerSeconds,
);
this.excludedToolNames = parseExcludedTools(this.flags.excludedTools).map(
canonicalToolName,
);
Expand Down Expand Up @@ -315,6 +321,29 @@ export class Config {
this.verboseEnabled = enabled;
}

get timerSeconds(): number | undefined {
return this.currentTimerSeconds;
}

setTimerSeconds(seconds: number | undefined): void {
if (seconds !== undefined && !isTimerSeconds(seconds)) {
throw new Error(
"Time budget must be a positive whole number of seconds.",
);
}
this.currentTimerSeconds = seconds;
}

async saveTimerPreference(): Promise<void> {
await this.enqueueSave(async () => {
const existing = readBackboardConfig(this.persistedConfigHomeDir);
await saveBackboardConfig(
{ ...existing, timerSeconds: this.currentTimerSeconds },
this.persistedConfigHomeDir,
);
});
}

get memoryProfile(): MemoryProfile {
return this.currentMemoryProfile;
}
Expand Down
4 changes: 4 additions & 0 deletions src/config/backboardConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type ThinkingLevel,
} from "./defaults.ts";
import { parseCustomProviders } from "./providers.ts";
import { isTimerSeconds } from "./timer.ts";

export type { BackboardConfigFile } from "./BackboardConfigTypes.ts";

Expand Down Expand Up @@ -51,6 +52,9 @@ export function readBackboardConfig(
memoryProfile: readMemoryProfileConfig(config),
notify: typeof config.notify === "boolean" ? config.notify : undefined,
verbose: typeof config.verbose === "boolean" ? config.verbose : undefined,
timerSeconds: isTimerSeconds(config.timerSeconds)
? config.timerSeconds
: undefined,
providers: parseCustomProviders(config.providers),
expert: readExpertConfig(config),
};
Expand Down
7 changes: 7 additions & 0 deletions src/config/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export interface CliFlags {
model?: string;
format?: string;
thinking?: string;
timer?: string;
memory?: string;
memoryProfile?: string;
excludedTools: string[];
Expand Down Expand Up @@ -77,6 +78,12 @@ export function parseFlags(argv: string[]): CliFlags {
case "thinking":
flags.thinking = readValue();
break;
case "timer":
flags.timer = readValue() ?? "";
break;
case "no-timer":
flags.timer = "off";
break;
case "memory":
flags.memory = readValue();
break;
Expand Down
25 changes: 25 additions & 0 deletions src/config/timer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/** Shared validation for CLI flags, saved preferences, and todo allocations. */
export function isTimerSeconds(value: unknown): value is number {
return (
typeof value === "number" &&
Number.isSafeInteger(value) &&
value > 0 &&
value <= Math.floor(Number.MAX_SAFE_INTEGER / 1000)
);
}

export function parseTimerSeconds(
flag: string | undefined,
persisted?: number,
): number | undefined {
if (flag === undefined) return persisted;
const value = flag.trim().toLowerCase();
if (value === "off" || value === "0") return undefined;
const seconds = Number(value);
if (!isTimerSeconds(seconds)) {
throw new Error(
'--timer must be a positive whole number of seconds (or "off").',
);
}
return seconds;
}
1 change: 1 addition & 0 deletions src/core/agent/AgentController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,7 @@ export class AgentController {
thinkingResolver,
requestKind: "user",
finalVerificationNudge: config.finalVerificationNudge,
timerSeconds: config.timerSeconds,
turnId: turn.id,
turnStartedAt: turn.startedAt,
turnAlreadyStarted: true,
Expand Down
28 changes: 26 additions & 2 deletions src/core/agent/AgentLoop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
ThinkingRequestKind,
} from "../../config/defaults.ts";
import type { RuntimeThinkingResolver } from "../../config/thinkingRuntime.ts";
import { timerBudgetPrompt } from "../../prompts/timerPrompt.ts";
import type {
AgentClient,
RunMessageOptions,
Expand All @@ -25,6 +26,7 @@ import { ProviderStreamConsumer } from "./ProviderStreamConsumer.ts";
import { buildRunMessageRequest } from "./RunMessageRequestBuilder.ts";
import { ToolRoundProcessor } from "./ToolRoundProcessor.ts";
import { Turn } from "./Turn.ts";
import { TurnTiming } from "./timing/TurnTiming.ts";

export interface AgentLoopDeps {
client: AgentClient;
Expand All @@ -44,6 +46,8 @@ export interface AgentLoopDeps {
thinkingResolver?: RuntimeThinkingResolver;
requestKind?: ThinkingRequestKind;
finalVerificationNudge?: boolean;
/** Advisory only; independent of sub-agent execution limits. */
timerSeconds?: number;
turnId?: string;
turnStartedAt?: number;
turnAlreadyStarted?: boolean;
Expand Down Expand Up @@ -78,6 +82,14 @@ export class AgentLoop {
}

const consumer = new ProviderStreamConsumer(bus, session);
const timing =
this.deps.timerSeconds === undefined
? undefined
: new TurnTiming(this.deps.timerSeconds, turn.startedAt);
const timedContent =
this.deps.timerSeconds === undefined
? content
: `${timerBudgetPrompt(this.deps.timerSeconds)}\n\n${content}`;
const processor = new ToolRoundProcessor({
client: this.deps.client,
scheduler: this.deps.scheduler,
Expand All @@ -86,6 +98,7 @@ export class AgentLoop {
consumer,
tools: this.deps.tools,
maxToolRounds: this.deps.maxToolRounds,
timing,
});
this.executedToolRounds = 0;

Expand Down Expand Up @@ -128,16 +141,25 @@ export class AgentLoop {
notifications.activeNotificationHidesResponse(),
});

let detachTiming: (() => void) | undefined;
try {
if (timing) {
detachTiming = bus.on("todos:updated", (event) => {
if (event.toolCallId && !ctx.signal.aborted) {
timing.recordTodoUpdate(event.toolCallId, event.todos);
}
});
}
const initialThinking = this.resolveThinking();
const messageOptions = {
signal: ctx.signal,
attachmentFilePaths: this.deps.attachmentFilePaths,
displayContent: this.deps.displayContent,
displayContent:
this.deps.displayContent ?? (timing ? content : undefined),
durableSession: this.deps.durableSession,
};
const buildMessageRequest = () =>
buildRunMessageRequest(content, this.deps, initialThinking);
buildRunMessageRequest(timedContent, this.deps, initialThinking);
const createMessageStream = () =>
this.deps.client.runMessage(buildMessageRequest(), messageOptions);

Expand Down Expand Up @@ -188,6 +210,8 @@ export class AgentLoop {
durationMs: turn.durationMs(),
});
return "failed";
} finally {
detachTiming?.();
}

bus.emit({
Expand Down
2 changes: 2 additions & 0 deletions src/core/agent/AgentLoopFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export interface CreateLoopOptions {
thinkingResolver?: RuntimeThinkingResolver;
requestKind: ThinkingRequestKind;
finalVerificationNudge?: boolean;
timerSeconds?: number;
turnId?: string;
turnStartedAt?: number;
turnAlreadyStarted?: boolean;
Expand Down Expand Up @@ -106,6 +107,7 @@ export class AgentLoopFactory {
thinkingResolver: options.thinkingResolver,
requestKind: options.requestKind,
finalVerificationNudge: options.finalVerificationNudge,
timerSeconds: options.timerSeconds,
turnId: options.turnId,
turnStartedAt: options.turnStartedAt,
turnAlreadyStarted: options.turnAlreadyStarted,
Expand Down
8 changes: 7 additions & 1 deletion src/core/agent/ToolRoundProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
ProviderStreamConsumer,
} from "./ProviderStreamConsumer.ts";
import { preserveProviderContext } from "./ProviderStreamConsumer.ts";
import type { TurnTiming } from "./timing/TurnTiming.ts";

export interface ToolRoundProcessorDeps {
client: AgentClient;
Expand All @@ -26,6 +27,7 @@ export interface ToolRoundProcessorDeps {
consumer: ProviderStreamConsumer;
tools: SubmitToolOutputsRequest["tools"];
maxToolRounds?: number;
timing?: TurnTiming;
}

export class ToolRoundProcessor {
Expand Down Expand Up @@ -123,10 +125,14 @@ export class ToolRoundProcessor {
}
this.recordToolMessage(newCalls, outputs);

// Decorate only the outbound copies, after recording ordinary results.
// Interrupted rounds above never consume timer thresholds.
const request: SubmitToolOutputsRequest = {
thread_id: this.deps.session.threadId ?? "",
...(pending.runId ? { run_id: pending.runId } : {}),
tool_outputs: outputs.map(toBackboardToolOutput),
tool_outputs:
this.deps.timing?.append(outputs, this.deps.session.todos) ??
outputs.map(toBackboardToolOutput),
tools: this.deps.tools,
};
round = this.createEarlyRound(ctx, turnId);
Expand Down
75 changes: 75 additions & 0 deletions src/core/agent/timing/TodoSchedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {
todoOvercommitNotice,
todoStepNotice,
} from "../../../prompts/timerPrompt.ts";
import type { TodoItem } from "../../bus/events.ts";
import { MIN_PLAN_SLACK_MS, PLAN_SLACK_FRACTION } from "./constants.ts";
import type { TurnTimer } from "./TurnTimer.ts";

/** Factual progress against the agent's own allocations, not scheduling policy. */
export class TodoSchedule {
private activeId?: string;
private activeStartedAt?: number;
private activeBudgetMs?: number;
private overcommitReported = false;

private forgetActive(): void {
this.activeId = undefined;
this.activeStartedAt = undefined;
this.activeBudgetMs = undefined;
}

recordUpdate(
todos: readonly TodoItem[],
timer: TurnTimer | undefined,
now = Date.now(),
): string[] {
if (!timer || todos.length === 0) {
this.forgetActive();
return [];
}
const active = todos.find((todo) => todo.status === "in_progress");
const changed = this.activeId !== undefined && active?.id !== this.activeId;
const previous = changed
? todos.find((todo) => todo.id === this.activeId)
: undefined;
const tookMs =
changed && this.activeStartedAt !== undefined
? now - this.activeStartedAt
: undefined;
const plannedMs = changed ? this.activeBudgetMs : undefined;

// Re-arm even for unbudgeted replans, so a later report cannot span
// abandoned steps. Preserve the original allowance while a step runs.
if (changed) this.forgetActive();
if (active && this.activeId === undefined) {
this.activeId = active.id;
this.activeStartedAt = now;
this.activeBudgetMs =
active.timeBudgetSeconds === undefined
? undefined
: active.timeBudgetSeconds * 1000;
}
if (!todos.some((todo) => todo.timeBudgetSeconds !== undefined)) return [];

const remainingMs = timer.remainingMs(now);
const aheadMs = todos
.filter((todo) => todo.status !== "completed")
.reduce((sum, todo) => sum + (todo.timeBudgetSeconds ?? 0) * 1000, 0);
const lines: string[] = [];
const slackMs = Math.max(
MIN_PLAN_SLACK_MS,
timer.totalBudgetMs * PLAN_SLACK_FRACTION,
);
if (!this.overcommitReported && aheadMs > remainingMs + slackMs) {
this.overcommitReported = true;
lines.push(todoOvercommitNotice(aheadMs, remainingMs));
}
if (tookMs !== undefined) {
lines.push(
todoStepNotice(previous?.status === "completed", tookMs, plannedMs),
);
}
return lines;
}
}
Loading