Timeouts and nudges
An agent can work for an hour, stop without answering, or never finish. Two things keep a run in hand: timeouts, which bound how long work may take, and nudges, which ask an agent that went quiet for its answer. Either way the turn still ends with an outcome, so the workflow decides what happens next, and when there's no point going on, it stops the run on purpose.
Here two reviews run within a budget. wf.parallel is how a group of turns gets a timeout, so the reviews go through it, one at a time (concurrency: 1): each turn has ten minutes, the pair has twenty, a quiet reviewer is nudged for what it has so far, and the run stops, saying why, when nothing came back.
import { typed } from "agentswf/typebox";
import {
DeadlineExceededError,
defineWorkflow,
type TurnOutcome,
} from "agentswf/workflow";
import Type from "typebox";
export const FINDINGS = typed(
Type.Object(
{ findings: Type.Array(Type.String()) },
{ additionalProperties: false },
),
);
export type Findings = Type.Static<typeof FINDINGS>;
export const LENSES = ["correctness", "tests"] as const;
export default defineWorkflow({
meta: {
name: "timed-review",
description:
"Two reviews, one after the other, within a budget in minutes; 20 by default.",
},
prepare: ({ argv }) => ({ minutes: Number(argv[0] ?? 20) }),
async run(wf, { minutes }) {
let reviews: TurnOutcome<Findings>[];
try {
reviews = await wf.parallel(
LENSES,
async (lens) => {
const reviewer = await wf.agents.open({
key: `review-${lens}`,
runtime: "claude",
});
const { outcome } = await reviewer.run({
prompt: `Review the diff on this branch against main, for ${lens} only.`,
schema: FINDINGS,
timeout: "10m",
nudge: {
prompt:
"Send the findings you have so far, even if the review isn't done.",
},
});
return outcome;
},
{
label: "review",
concurrency: 1,
timeout: `${minutes}m`,
},
);
} catch (error) {
if (error instanceof DeadlineExceededError) {
return wf.stop(`the reviews took over ${minutes} minutes`);
}
throw error;
}
const answered = reviews.filter((o) => o.answered);
if (answered.length === 0) {
return wf.stop(
`no review answered: ${reviews.map((o) => o.kind).join(", ")}`,
);
}
return { findings: answered.flatMap((o) => o.value.findings) };
},
});The sample is checked and tested: examples/site/guide/timeouts.ts and its test.
Timeouts
A timeout is set at three levels, and each one sits inside the one around it: the run's --timeout, a wf.parallel's timeout, and a turn's timeout. Each timeout is a duration from the call, "10m", or a Date to end by. One set later than the one around it doesn't extend anything: the earlier one wins (Deadlines). When it passes, every turn under it ends timed-out.
The run
awf run --timeout 2h review.tsA run has no deadline unless --timeout gives it one, as a duration, such as 2h or 1d. It bounds one attempt: a --continue has no deadline unless you give --timeout again. When it passes, the turns running end timed-out, the workflow is cut off wherever it is, and the attempt ends timed-out. Runs has every ending, its exit code, and how to go on from it.
Inside the workflow, wf.deadline is the run's deadline as a Date, and absent without --timeout.
A group of agents
wf.parallel takes a timeout for the whole group, as in the sample. When it passes, the turns still running end timed-out, items not yet started never start, and parallel rejects with DeadlineExceededError, imported from agentswf/workflow, and the results of the branches that had finished are dropped with it: parallel returns all of its results or none. Catch it where running out of time has an answer of its own:
if (error instanceof DeadlineExceededError) {
return wf.stop(`the reviews took over ${minutes} minutes`);
}
throw error;A parallel with no timeout of its own rejects the same way when the run's deadline passes, and wf.decisions.decide rejects with it too.
To keep what finished, bound each turn instead of the group, with timeout: a turn that runs out ends timed-out, which its branch returns as data like any other outcome. The sample does both, ten minutes a turn inside twenty for the pair.
One turn
const { outcome } = await reviewer.run({
prompt: `Review the diff on this branch against main, for ${lens} only.`,
schema: FINDINGS,
timeout: "10m",
nudge: {
prompt:
"Send the findings you have so far, even if the review isn't done.",
},
});timeout bounds the whole turn, nudges included, and the outcome is timed-out when it passes. A duration, "10m", counts from the call; a Date is the time it ends by. Without one, or with false, the turn inherits the deadline around it, and with none at all it runs as long as the agent keeps working. A turn that times out closes its agent: Agents has what each ending leaves.
A turn that waits for an answer from a person outside the run, a participant, isn't working: its clock stops while it waits, but never past the deadlines around it.
Nudges
Every turn is nudged by default. When the agent stops without sending an accepted answer, the engine sends a nudge in the same session:
You finished without reporting the requested result. Report it now.followed by how to answer, as the turn's first prompt had. An agent that answers then ends the turn answered. One that stops again without answering ends it unanswered. The exception is the calling session, where awf can't always tell its operator's interrupt from the agent stopping: there it isn't nudged unless you pass nudge (The calling session).
In a claude TUI
A claude TUI agent gets more room, because awf can see its prompts arrive. The engine waits 30 seconds of quiet before nudging, and the nudge says:
Report the result now, or use wfa waiting with a reason if you still need time.The agent has two minutes from receiving it to send the result or a wait. An agent in the middle of a long build answers with wfa waiting --reason …, which holds off the next nudge, by default for two minutes and at least 30 seconds, and is then nudged again. A wait never moves the turn's deadline. How it works lists wfa waiting with the agent's other commands.
Your own words
nudge: { prompt: "Send the findings you have so far, even if the review isn't done." }nudge: { prompt } replaces the nudge's opening line; how to answer still follows it. Use it to say what a partial answer should be, as the sample does. The turn's timeout bounds its nudges too.
No nudge
nudge: falseA turn with nudge: false that ends without an answer is unanswered at once. Turn it off when a nudge is worth less than the time it takes: a quick question you'd rather ask a fresh agent again, or a turn whose silence you handle anyway.
Stopping on purpose
const answered = reviews.filter((o) => o.answered);
if (answered.length === 0) {
return wf.stop(
`no review answered: ${reviews.map((o) => o.kind).join(", ")}`,
);
}
return { findings: answered.flatMap((o) => o.value.findings) };When going on is pointless, here because no reviewer answered or the budget ran out, return wf.stop(reason) ends the attempt stopped with your reason rather than failed. Workflows covers what a stop means for the run. From outside, Ctrl-C cancels the run, and a second Ctrl-C stops awf at once.
Catches
- A duration counts from the call. A turn queued behind another on the same agent spends its time waiting too.
- No deadline means no limit. Nudges end a turn whose agent stops without answering, but an agent that keeps working is never cut off. Set
timeouton turns you can't wait on, or run with--timeout. - Deadlines are real time in a test.
noAnswer.timedOut()ends a scripted turntimed-outat once, and a silent script is nudged as in a run, withturn.nudgetrue: Testing. - These nudges are for an agent in a turn. Reminding a person who hasn't answered is a workflow of its own, waiting on time: Timers.
See also
- Deadlines and Giving it work in the workflow API.
- Runs: how an attempt ends, and going on from one that stopped or timed out.
- Parallel and loops: the group a deadline bounds, and branching on each outcome,
timed-outandunansweredincluded.