Skip to main content
ANVISoftware Solutions
Lesson 16 of 22Advanced19 min

Agent Workflows

By the end of this lesson

Design flows with branching and loops, and know when a chain suffices.

Between a fixed chain and a fully autonomous loop sits most of the work worth building. A router that picks one of three chains. A retrieval step that retries once with a rephrased query when nothing clears the relevance floor. A drafting step that stops and waits for a person before anything is sent.

These are workflows: branching and repetition that you designed, with the model making bounded decisions inside a structure you control. The distinction from an agent is not how clever the flow is. It is who owns the graph. In a workflow you drew the edges, so you can read them, test them and cost them. In an agent the edges are chosen at runtime.

Five shapes, in increasing order of what you give up. Pick the least autonomous one that solves the problem:

Single call
One prompt, one response. Classification, extraction, rewriting. Cheapest and easiest to evaluate, and it handles more than teams expect once the prompt is specific.
Fixed chain
A known sequence: retrieve, then answer. Each step's output feeds the next. Testable step by step, with a cost you can state as a number.
Branch, or router
One cheap model call classifies the request, then your code runs one of a few fixed chains. The model chooses among options you enumerated, which is a much smaller grant of autonomy than choosing what to do.
Bounded loop
A step repeats under a condition you wrote, with a hard iteration cap. Retry retrieval once with a rephrased query; refine a draft until a validator passes, up to three attempts. The condition is yours, not the model's.
Agent loop
The model picks the next step each turn. Everything from the previous lesson applies. Reach for this when the branching genuinely cannot be enumerated at design time, not when enumerating it would be tedious.
A routed workflow with explicit state, an iteration cap and a budget
Python
from dataclasses import dataclass, field

@dataclass
class FlowState:
    """Everything the flow knows. Not a transcript — fields you can assert on."""
    question: str
    caller: User
    route: str | None = None
    passages: list[Passage] = field(default_factory=list)
    retrieval_attempts: int = 0
    draft: str | None = None
    tokens_used: int = 0
    needs_approval: bool = False
    outcome: str | None = None

MAX_RETRIEVAL_ATTEMPTS = 2
TOKEN_BUDGET = 8_000

def handle(question: str, caller: User) -> FlowState:
    state = FlowState(question=question, caller=caller)
    state.route = classify(question, state)          # "policy" | "data" | "unsupported"

    if state.route == "unsupported":
        state.outcome = "out of scope"
        return state

    if state.route == "data":
        return answer_from_api(state)                # tool calling, validated per call

    # Policy route: retrieve, and allow one rephrased retry
    while not state.passages and state.retrieval_attempts < MAX_RETRIEVAL_ATTEMPTS:
        query = state.question if state.retrieval_attempts == 0 else rephrase(state)
        state.passages = search(query, caller=caller, top_k=5)
        state.retrieval_attempts += 1
        if state.tokens_used > TOKEN_BUDGET:
            state.outcome = "budget exhausted"
            return state

    if not state.passages:
        state.outcome = "no relevant documents"
        return state

    state.draft = generate_grounded_answer(state)
    state.outcome = "answered"
    log.info("flow complete", extra=state.audit())
    return state
  • FlowState is the whole point of this sample. Every decision the flow made is a named field you can read, log and write a test against. Keeping state only in a growing message transcript means the same information exists as prose, where asserting on it requires parsing text the model wrote.
  • classify is one cheap call that returns one of three fixed labels, validated against that list. The model is choosing among options you enumerated, which is a narrow and testable grant of autonomy. A fourth label coming back is a validation failure, not a new route.
  • The unsupported branch exists so the flow can decline. Without an explicit out-of-scope route, every question gets pushed down whichever path is closest, and questions your system was not built for get answered anyway.
  • The retrieval loop is bounded by a counter in the condition, not by the model deciding it has tried enough. One rephrased retry is worth having because a question worded unlike your documents is a common miss. A second retry rarely helps, and the cap says so.
  • The budget check sits inside the loop, so a flow cannot spend past it between iterations. Checking only at the end tells you what it cost after you have paid.
  • state.audit() emits one structured line holding the route, attempt count, chunk ids, tokens and outcome. When somebody asks why a question was declined, that line is the answer, and it does not require rerunning anything.
The audit record one run emits, and what each field is for
JSON
{
  "run_id": "flow-2f7a91c4",
  "caller_id": "EMP04417",
  "question_chars": 63,
  "route": "policy",
  "retrieval_attempts": 2,
  "rephrased": true,
  "chunk_ids": ["handbook-expenses#c0041", "handbook-expenses#c0042"],
  "top_similarity": 0.61,
  "citations_returned": [1],
  "model": "gpt-4.1-mini",
  "tokens_prompt": 3140,
  "tokens_completion": 212,
  "tokens_used_total": 3352,
  "duration_ms": 2870,
  "needs_approval": false,
  "outcome": "answered"
}
  • route and retrieval_attempts together tell you which path ran and whether it struggled. A rising share of runs needing the rephrased retry is an early signal that your chunking or your corpus has drifted away from how people ask.
  • chunk_ids and top_similarity let you reconstruct what the model was given without storing the documents again. A wrong answer with a top similarity of 0.61 is a retrieval problem; the same answer at 0.9 is a generation problem.
  • citations_returned crossed against chunk_ids is how you spot an answer with no grounding. Log it per run and the rate becomes something you can watch rather than something you discover.
  • Token fields belong here rather than only in an aggregate. Averages hide the runs that cost ten times the norm, and those are the ones worth reading.
  • outcome is a small closed set — answered, no relevant documents, out of scope, budget exhausted, awaiting approval. Counting outcomes weekly is the cheapest health check this system has.
  • Note what is absent: the question text and the answer text. Both may contain personal or commercial detail, so store them only where your data policy allows and keep the operational log safe to query widely.

Where the flow's state lives is the decision that most affects whether you can test and debug it:

 State implicit in the transcriptState as explicit fields
Where a decision is recordedIn text the model producedIn a named field your code set
Asserting on it in a testParse prose and hope the wording holdsassert state.route == "policy"
Resuming after an approval pauseReplay the transcript and hope the path repeatsLoad the state record and continue
Cost of each extra stepGrows — the whole transcript is resentFlat — you send only what the step needs
Audit trailA conversation someone has to readOne structured record per run
Where it fitsShort conversational exchangesAnything multi-step, resumable or reviewed

Consequential actions need a person in the path. The shape that works, using claim approval as the example:

  1. The flow produces a proposal, not an effect

    A record saying what should happen, why, and which passages or figures support it. Nothing has changed in any system yet. This is the step people skip, by letting the tool that drafts the decision also apply it.

  2. Persist the proposal with the state that produced it

    Store the FlowState alongside it. The reviewer needs to see the retrieved passages and the figures, not just the conclusion, and the flow needs to resume from a known point rather than be re-run.

  3. Show a reviewer what they need to judge it

    The proposed action, the evidence with links to sources, and what will happen on approval stated plainly. A summary with no sources is a request to rubber-stamp, and reviewers who cannot check anything stop checking.

  4. Execute under the approver's identity

    Your code performs the action, with the approving person's permissions and their id on the audit record. The service account behind the flow should not be able to approve a claim on its own.

  5. Make execution idempotent

    One proposal, one effect, however many times the approval is submitted. A retry after a timeout must not approve twice. A unique key on the proposal id is the usual mechanism, and it is cheaper to add now than to reconcile later.

Summary

  • Between a single call and an agent sit chains, routers and bounded loops — pick the least autonomous shape that works
  • Hold flow state in named fields rather than a transcript, so decisions can be asserted on, logged and resumed
  • Every loop needs an iteration cap in its condition and a budget checked before each step that spends
  • Consequential actions go through a proposal, a reviewer who can see the evidence, execution under the approver's identity, and an idempotent write
  • A fixed chain is predictable, testable and cheaper; an agent is for when the steps genuinely cannot be written down in advance

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Write the state, then the flow

Take one multi-step feature you would want from the assistant: a manager asks whether a submitted claim complies with policy, and gets a recommendation they can approve or reject.

Before writing any flow logic, write the state class. List every field, including the ones that record decisions and the ones that record spend. Then draw the edges between steps and mark where each limit and each pause sits.

Show solution

A workable state holds the claim id and the caller, the retrieved policy passages with scores, the retrieval attempt count, the recommendation and its reasoning, the citations, tokens used, an awaiting-approval flag, the approver id once set, and the final outcome.

Writing the state first tends to expose the design problems immediately. If you cannot name the field that records why a claim was flagged, the flow does not know either, and the reviewer will get a conclusion with nothing behind it.

The edges are: classify the claim type, retrieve the applicable policy, generate a recommendation, then pause. Limits sit on the retrieval loop, on total tokens, and on wall-clock time. The pause sits between the recommendation and any effect on the claim record.

The field most people miss is the approver id, and it matters more than it looks. Without it the action is recorded as performed by the service account, and the audit trail says the assistant approved the claim. Recording the human makes the accountability real rather than notional.

One more worth adding: a field for the policy version or effective date the recommendation was based on. Policies change, and a decision defensible in March needs to show which March policy it applied.

Think about it

Justify the loop, or drop it

A colleague proposes replacing the routed workflow with an agent that has four tools: document search, expenses lookup, employee lookup and a calculator. Their argument is that it will handle questions nobody anticipated.

Write the case against, and then write the smallest change that would genuinely need the loop.

Show solution

The case against starts with what is lost: predictable cost per question, a repeatable path, tests that mean something next week, and a clear answer when somebody asks why the assistant said what it said. For a user-facing assistant answering policy and spend questions, those four are close to requirements.

It continues with what is gained, examined honestly. "Questions nobody anticipated" is worth having only if such questions are common and currently fail badly. Look at the logs. If 90 percent of traffic is a policy question or a spend lookup, the loop is being added for the last few percent while making the common case more expensive and less consistent.

There is also a smaller intervention available. If the gap is questions needing a document lookup and a data lookup together, add that as a fourth route: retrieve, look up, answer, with the steps fixed. That is an afternoon's work and keeps every property above.

The change that would genuinely need a loop is a question whose next step depends on an intermediate result in a way you cannot enumerate. "This claim looks anomalous, work out why" is one: it might be a rate change, a new client site, a duplicate submission, a coding error, or a team that grew, and the second lookup depends entirely on the first.

Notice the shape of that example. It is investigative, a human reads the output and judges it, it runs rarely, and nothing is written without approval. Those four conditions together are roughly when the trade is worth making.

Saved in this browser only.