Skip to content
Français

Check work and retry

Use test output or a review to accept an agent’s work or request another attempt.

Define an attempt and a check with defineLoopTask(). The check accepts the result or returns feedback for the next attempt. Set maxRounds so a result that never passes ends with a bounded failure.

import { reportValue } from "./reporter.ts";
import { defineLoopTask, defineWorkflow } from "@elie-laloum/outpost";

const fix = defineLoopTask({
  key: "fix",
  maxRounds: 3,
  attempt: (context, feedback) => ({
    round: context.round,
    feedback: feedback ?? "",
  }),
  check: (_, candidate) =>
    candidate.round === 2
      ? { done: true }
      : { done: false, feedback: "Cover the missing edge case." },
});

const result = await defineWorkflow("verified", [fix]).start();
result.unwrap();
reportValue(result.value(fix));
// Example output: { round: 2, feedback: 'Cover the missing edge case.' }
Drag to move · Ctrl + scroll to zoom
100 %
  • AttemptYour attempt(context, feedback) returns a candidate.
    1. First roundThe callback receives undefined as feedback.
    2. Later roundsThe callback receives the text of the last rejection.
    (Steps)
    • → Check : then
  • CheckYour check(context, candidate) returns a verdict.
    1. AcceptReturn { done: true }.
    2. RejectReturn { done: false, feedback } with a text.
    (Steps)
    • → Next : then
  • NextThe verdict decides.
    1. Next roundA rejection feeds the next attempt.
    2. DoneThe accepted candidate becomes the task’s value.
    3. ExhaustedA rejection in the last round fails the task. LoopTaskExhausted
    (Steps)

Round 1 is rejected; round 2 receives the feedback and is accepted. after, condition and cache work as on other tasks.

The agent works in attempt, the tests run in check, both in one warm sandbox.

Keep the sandbox, attempt and check separate so each step is easy to follow.

import { createSandbox } from "@elie-laloum/outpost";
import { repository, sandboxProvider, coder } from "./outpost.config.ts";

export function openLoopSandbox() {
  return createSandbox({
    repository,
    sandboxProvider,
    agent: coder,
    branch: { mode: "named", name: "outpost/fix-tests" },
  });
}
import type { Sandbox } from "@elie-laloum/outpost";
import { defineAgentTask } from "@elie-laloum/outpost";

export function codingTask(sandbox: Sandbox, feedback?: string) {
  return defineAgentTask({
    key: "coder",
    sandbox,
    request: () => ({
      brief: { text: `Fix the failing tests and commit.\n${feedback ?? ""}` },
    }),
  });
}
import type { Sandbox, LoopTaskContext } from "@elie-laloum/outpost";
import { codingTask } from "./coding-task.ts";

export function createAttempt(sandbox: Sandbox) {
  return async (context: LoopTaskContext, feedback: string | undefined) => {
    const result = await codingTask(sandbox, feedback).perform(context);
    return { summary: result.text, commits: result.commits.length };
  };
}
import type {
  Sandbox,
  LoopTaskContext,
  LoopCheckResult,
} from "@elie-laloum/outpost";

export function createCheck(sandbox: Sandbox) {
  return async (context: LoopTaskContext): Promise<LoopCheckResult> => {
    const tests = await sandbox.command({
      executable: "npm",
      arguments: ["test"],
      signal: context.signal,
    });
    return tests.status === 0
      ? { done: true }
      : { done: false, feedback: `${tests.stdout}\n${tests.stderr}` };
  };
}
import type { Sandbox } from "@elie-laloum/outpost";
import { defineLoopTask } from "@elie-laloum/outpost";
import { createAttempt } from "./loop-attempt.ts";
import { createCheck } from "./loop-check.ts";

export function defineFix(sandbox: Sandbox) {
  return defineLoopTask({
    key: "fix-tests",
    maxRounds: 4,
    attempt: createAttempt(sandbox),
    check: createCheck(sandbox),
  });
}

Run run-loop.ts; it keeps the sandbox open until the workflow finishes.

import { reportValue } from "./reporter.ts";
import { openLoopSandbox } from "./loop-sandbox.ts";
import { defineFix } from "./fix-loop.ts";
import { defineWorkflow } from "@elie-laloum/outpost";

await using sandbox = await openLoopSandbox();
export const fix = defineFix(sandbox);
export const result = await defineWorkflow("fix-tests", [fix]).start();
result.unwrap();
reportValue(result.value(fix).summary);
// Example output: Fixed the parser and verified the tests.

coding.perform(context) ties the agent’s tokens and cancellation to the loop and its budget. coder is a helper: only fix goes in the workflow. attempt returns JSON because a checkpoint saves every candidate, even rejected ones.

A failing npm test returns a nonzero status, and its output becomes the feedback. You decide where it goes: the next brief, as here, or a continued conversation.

check can dispatch a reviewer and turn its typed response into a verdict. Both agents count against the same budget.

import {
  createAgent,
  createClaudeHarness,
  defineJsonResponse,
} from "@elie-laloum/outpost";
import { z } from "zod";

export const reviewer = createAgent({
  harness: createClaudeHarness({ authentication: "account" }),
});
export const review = defineJsonResponse({
  tag: "review",
  schema: z.object({ approved: z.boolean(), feedback: z.string() }),
});
export const brief = {
  text: 'Review the last commit. End with <review>{"approved": false, "feedback": "What to change"}</review>.',
};
import type { Sandbox } from "@elie-laloum/outpost";
import { defineAgentTask } from "@elie-laloum/outpost";
import { reviewer, review, brief } from "./review-settings.ts";

export declare const sandbox: Sandbox;
export function reviewingTask() {
  return defineAgentTask({
    key: "reviewer",
    sandbox,
    request: () => ({ agent: reviewer, response: review, brief }),
  });
}
import type { LoopTaskContext, LoopCheckResult } from "@elie-laloum/outpost";
import { reviewingTask } from "./reviewing-task.ts";

export async function reviewChanges(
  context: LoopTaskContext,
): Promise<LoopCheckResult> {
  const { value } = await reviewingTask().perform(context);
  return value.approved
    ? { done: true }
    : { done: false, feedback: value.feedback };
}

Pass it as check: reviewChanges. The request’s agent replaces the sandbox’s agent for this dispatch only.

Limit or eventWhat happens
maxRoundsCaps the rounds, resumes included.
Workflow attemptsEach round, and each replay of an interrupted round, counts against budget.attempts.
timeoutMsBounds each round. It stops callbacks that honor context.signal.
An exceptionFails the task at once. A loop task has no retry option.
The last check rejectsThe task fails with LoopTaskExhausted, which holds the last feedback. Dependents do not run.

Fix a failing CI build handles each outcome in a complete script.

With a checkpoint, each candidate is saved before check. On resume, a saved candidate goes straight to check, and rejected rounds are not rerun.

An interrupted round replays only with resume: "retry-incomplete", since it may already have changed files. Changing maxRounds makes the checkpoint incompatible.

Each task record lists its rounds, and each phase emits a loop workflow event. context.idempotencyKey differs per round and phase, to deduplicate effects as in Job queues and workers.

API: defineLoopTask · LoopTaskOptions · LoopTaskContext · LoopCheckResult · LoopTaskExhausted · defineAgentTask.