Skip to content

Stop Guard ​

A run normally ends when the model replies with plain text and calls no tool. A stop guard is your code that runs at that moment and decides what happens next. It can let the run stop, send the model back for another step with a correction, or fail the run. Before it decides, it can do whatever your app needs: read and change the run's state, add context rows, or call your own services and database.

Use it for "the answer must cite a source", "the order must be confirmed before the turn ends", or "if the model stopped early, nudge it once".

typescript
import { defineAgent } from '@helix-agents/core';

const agent = defineAgent({
  name: 'sourced-answers',
  systemPrompt: 'You write short, sourced answers.',
  llmConfig: { model },
  stopGuard: {
    run: async (ctx) => {
      if (/\[source:/.test(ctx.reply.text)) return { action: 'stop' };
      return { action: 'continue', feedback: 'Cite one source as [source: …] before you finish.' };
    },
  },
});

Runs on runtime-js and the Cloudflare Durable Object. CF Workflows, Temporal and DBOS refuse an agent that declares stopGuard at every entry, with a typed framework_not_supported, until they run it.

When it runs ​

The guard runs only where the run would otherwise end on a natural text reply: the model's stop reason is end_turn or stop_sequence. It never runs:

StepWhy
A step that calls __finish__ or a finishWith toolAn explicit finish. A finishing tool's own error already continues
A forced-completion callThat phase has its own bounded attempts (maxCompletionRetries)
A reply cut at its token limit, a content filter, a refusal, an errorThese already fail or enter forced completion; a correction would hide the cause
The step that reaches maxStepsContinuing would turn a completion into a max-steps failure
A run that was stopped or supersededThe stop wins

On an outputSchema agent the guard runs before forced completion. A continue sends your correction instead of the generic forced-completion message; a stop enters forced completion as it would without a guard. When the guard's step enters forced completion, the run that forced completion ends (with the answer, or failing) still reports the guard: AgentResult.stopGuard, the onAgentComplete / onAgentFail payload, and for a spent budget or a guard error the run's completionReason. The report is saved with the forced-completion phase, so a crash and a retry() keep it.

What the guard receives ​

typescript
interface StopGuardContext<TState> {
  sessionId: string;
  runId: string;
  stepCount: number; // the step that just replied
  guardKey: string; // `${runId}:${stepCount}:${attemptId}`, for your own idempotency
  reply: { text: string; stopReason: 'end_turn' | 'stop_sequence' };
  messages: readonly Message[]; // the session's log, including this reply
  getState(): Readonly<TState>;
  updateState(updater: (draft: TState) => void): void; // like a tool's
  addContext(items: ContextItem[]): void; // instruction / notice / tools items
  continuesUsed: number; // in this turn
  continuesLeft: number;
  abortSignal: AbortSignal; // its time limit, or the run's stop
}

The guard is ordinary async code. Your services and database come from your own closure, as a tool's do. It does not call the agent's tools: if it needs what a tool does, call your own function, and record what changed through updateState and addContext, so the model sees it on its next call.

What it decides ​

ReturnWhat happens
nothing, or { action: 'stop' }The run ends as it would have without a guard
{ action: 'continue', feedback?: string }The run takes another step. It needs feedback or at least one addContext item
{ action: 'fail', message: string }The run fails with framework_stop_guard_rejected (non-retryable), carrying your message

Your writes and rows commit with the reply, in the step's own commit, whatever you decide. A crash cannot leave the reply committed without them. So "settle and stop" is just a stop after updateState / addContext.

feedback becomes one instruction row behind a fixed label, Automated feedback from the application (this is not from the user): . The model must never read your correction as the user's words. A guard's rows are shown to the model as a user turn (with that label), like a tool's rows, never as a system message. Its tools items may switch a tool back on, as your app's and hooks' rows may (a tool's rows may only switch tools off).

The budget ​

maxContinues (default 3) bounds the continues per turn. The count comes from the log (the guard's own rows), so a client-tool suspension, an approval or a crash in between keeps it, a rewind refunds what it removes, and a new turn starts from zero. Each continue also costs a step of maxSteps. maxContinues: 0 never continues.

When the budget is spent, the guard is still called, so it can choose fail. A continue then ends the run normally: its rows are dropped (a correction nobody acts on would otherwise reach the next turn's model), its updateState writes are kept, and the run records the completion reason stop_guard_exhausted (below).

Counting continues in state

If your guard keeps its own count of continues in state, derive it from continuesUsed instead of adding one on every call. The guard is also called once the budget is spent, and its writes from that call are kept, so a += 1 there counts a continue that never happened.

typescript
if (ctx.continuesLeft > 0) {
  ctx.updateState((draft) => {
    draft.guardContinues = ctx.continuesUsed + 1;
  });
  return { action: 'continue', feedback: 'Cite one source as [source: …] before you finish.' };
}

What a client sees on a fail ​

The run fails with framework_stop_guard_rejected and your message. The chat stream ends failed: the failed stream's info carries the error detail (as for any framework failure, such as running out of maxSteps), and no error chunk is written, since that chunk is reserved for a model failure. The client reads the error from the snapshot and the run's status (AgentResult.errorDetail and the session's persisted errorDetail, on a reconnect too), and onAgentFail fires with the same typed error.

When the guard itself fails ​

A guard that throws, exceeds timeoutMs (default 30 s) or returns an invalid verdict commits nothing of itself: no rows, no writes. Then:

  • onError: 'stop' (default): the run ends as a stop would, and the error is reported: the log event core.stop_guard.error (error level), the span, and the completion reason stop_guard_error.
  • onError: 'fail': the run fails with framework_stop_guard_failed (non-retryable).

Seeing what the guard did ​

WhereLive resultReconnect, getHandle().result(), the DO's /status
completionReason: 'stop_guard_exhausted' / 'stop_guard_error'yesyes (the run record's reason)
errorDetail of a failyesyes (the session's persisted detail)
stopGuard: { continuesUsed, exhausted?, error? }yesyes (the run record's report)

AgentResult.stopGuard (and the onAgentComplete / onAgentFail payload's stopGuard) is also written on the run record with the run's terminal status (RunMetadata.stopGuard), so a reconnect's result and every caller of a Durable Object (which reads results through /status) see the guard's own error detail and continuesUsed as the live result does. The log events (core.stop_guard.continued / .exhausted / .error / .rejected) and the span record every decision. A completed session never carries an errorDetail.

One case keeps the report off the record: a run whose terminal status write failed after its terminal commit is settled later from durable data, and a plain completion's report is not in that data (as a stop_when stop's reason is not). A run that ended in forced completion keeps it, since the forced phase carries the report.

The chat stream needs no change: a continue is one more step (finish-step, start-step), the guard's rows are hidden context rows, and its writes are a state_patch. The reply ends with the ordinary finish.

Hook order ​

For the replying step: beforeLLMCall → the model → afterLLMCall → the guard → then:

  • continue: the step commit → the next step (its context pass, beforeLLMCall, …);
  • stop: the terminal commit → onAgentComplete → the run ends;
  • fail: the step commit → onAgentFail → the run fails.

onAgentComplete fires once, after the last decline; a continue fires no lifecycle hook of its own.

A guard can run more than once for one reply ​

If the replying step is lost before its commit (a crash, a refused commit followed by retry()), the step runs again, and so does the guard. Its rows and writes land once (they ride the commit), but anything your guard does outside the framework (a database write, an email) may run twice. Key such effects by guardKey or by the reply.

Released under the MIT License.