5 min read agentsaieffect

Error messages are prompt engineering now

In Efferent, tool failures correct the current turn and gate findings brief the next attempt. The placement, shape, and recovery verb matter more than the exception text.

On this page

At 110k tokens of context, an Efferent coding run called write_file five times with an empty body.

The model was not asking to blank the file. Its output had collapsed: it still knew a write was required, still produced the right path, and could no longer sustain the payload. A normal filesystem API would have obeyed and destroyed the file. A normal exception would have ended the turn with “invalid input.” Neither response tells the actual user — the model — what to do next.

The handler now refuses the write as data:

if (params.content.length === 0) {
  return yield* Effect.fail({
    error: "EmptyContent",
    message:
      `refusing to write an EMPTY ${params.path} — provide the full file body ` +
      `(use edit_file to blank a file intentionally)`, 
  })
}

That string does three jobs in one line: says what was observed, refuses the dangerous interpretation, and names a safe next move. On the next model step the coder either supplies the body or uses the explicit blanking route. The turn heals itself.

This post is about that feedback path and its larger sibling. Efferent has two audiences for failure: the model still inside a turn, and the coder starting another forge attempt. Both need failures as data, but they need different placement and density.

Tool failure is part of the transcript

Every Smith tool declares the same failure schema:

export const Failure = Schema.Struct({
  error: Schema.String,
  message: Schema.String,
})

And every tool opts into failureMode: "return":

export const EditFile = Tool.make("edit_file", {
  // parameters, success, …
  failure: Failure,
  failureMode: "return", 
})

That flag changes control flow. A handler failure becomes the result paired with the model’s tool call; it does not tear down generateText, leave an unanswered call id, or abort the user turn. The loop appends it and asks the model for the next move with the failure at the freshest end of context.

The important distinction is between a failed operation and a failed harness. “Your exact text occurs three times” is an operation failure the model can correct. “The conversation store cannot append” is infrastructure death; another prompt cannot repair it. Returning both through the same friendly channel would teach the model to retry conditions it cannot move.

Recovery verbs beat diagnoses

Smith’s edit handler is a small catalogue of useful failure writing:

if (count === 0) {
  return Effect.fail({
    error: "EditFailed",
    message:
      `oldText not found in ${params.path} — re-read the file; ` +
      `the text must match exactly (whitespace included)`,
  })
}
if (count > 1) {
  return Effect.fail({
    error: "EditFailed",
    message:
      `oldText matches ${count} times in ${params.path} — ` +
      `include more surrounding context to make it unique`, 
  })
}

“Not found” and “ambiguous” could both have been EINVAL. Instead, each message gives the one recovery that changes its condition. The first says re-read because the model’s cached copy is stale. The second says widen the match because repeating the same text is guaranteed to fail again.

The same pattern appears at policy boundaries. A path outside the workspace returns OutsideWorkspace with the resolved root. A request for an unknown skill returns the available names. A clipped read sets truncated: true and the tool description teaches offset/limit. The model should not have to infer the affordance from an operating-system symptom.

There is one caveat: helpful recovery can become compatibility creep. edit_file accepts both the documented edits: [...] form and a flat oldText/newText pair because models repeatedly imported the latter shape from other harnesses. That is pragmatic, but it deletes a signal. Once the wrong form is accepted, transcripts stop showing how often the schema was misunderstood.

A gate finding is a prompt for another attempt

Tool failures operate inside one turn. Foundry’s gate findings operate between attempts, after the coder has yielded control and the workspace has been snapshotted.

The domain value is deliberately richer:

export class Finding extends Schema.Class<Finding>("Finding")({
  rule: RuleId,
  severity: Severity,
  message: Schema.NonEmptyString,
  location: Schema.optionalWith(SourceLocation, { as: "Option" }),
  fixHint: Schema.optionalWith(Schema.NonEmptyString, { as: "Option" }), 
}) {}

message describes this observation; fixHint carries the rule author’s reusable way out. Keeping them separate matters when the same rule finds twelve sites: the location and instance change, the recovery principle does not.

The pipeline renders all rejected gates into one retry brief and feeds it to the same persisted coder conversation. The model keeps its working context, but the newest user message is now deterministic evidence:

export const renderRetryBrief = (feedback: string, qualityBar: Option.Option<string>) =>
  [
    `The quality gates REJECTED your previous attempt. Fix ALL findings.\n\n${feedback}`,
    ...Option.toArray(qualityBar),
    "The gates re-run over the WHOLE workspace — fix root causes, not symptoms.",
  ].join("\n\n")

The quality-bar reminder is not decoration. Without it, a model can fix the named failure by introducing a different prohibited construct. The brief carries both the local delta (“these findings failed”) and the standing contract (“these rules still apply”).

Classify what the model can move

One Zig run exposed the cost of a bad fix hint. Every acceptance command exited 127 because the gate environment could not see the toolchain. The generic finding said “make this command pass,” so the coder edited code against an environment failure for three attempts.

Command gates now classify 126/127 plus a not-found signature under an env/ rule and say that editing code cannot fix it; provision the tool in <workspace>/.local/bin, which both coder and gates share. Same non-zero exit, completely different next move.

That is the core review question for every model-facing error: can the recipient change this condition?

  • If yes, return it where the model is still able to act, with the action named.
  • If no, stop converting infrastructure failure into conversational advice.
  • If the work is partial, mark the artifact partial where its consumer reads it; a warning in a side log does not count.

The error channel is high-value prompt space

System prompts speak in advance and compete with thousands of tokens. A failure message arrives at the exact moment its instruction becomes relevant, attached to the exact operation that needs correction. It is some of the best prompt real estate in an agent system.

That makes wording part of implementation, not polish. A useful model-facing failure has a cause in the agent’s vocabulary, evidence specific enough to rule out guessing, and one recovery verb. The typed shape keeps the protocol alive; the sentence keeps the work alive. Error: EINVAL satisfies neither.