New: Boardroom MCP Engine!

Ready to put this into action?

Get the complete AI Integration Playbook β€” Practical AI implementation guide β€” prompt engineering, workflow automation, and ROI frameworks.

Article 119 Β· Part 12

Build a Single Agent with a Bounded Job

Give flexible tool use a clear objective, a small capability set, and an enforceable stopping point.

By Randy Salars Β· Published

On this page
  1. Define the job in observable terms
  2. Separate the proposer from the controller
  3. Test the controller without a paid model
  4. Inspect the evidence for completion
  5. Add a model adapter as a separate boundary
  6. Make stopping a useful outcome
  7. Evaluate the model and controller together
  8. Compare the agent with a fixed workflow
  9. Keep the first agent's job narrow
  10. A reusable prompt
  11. For students: learn the loop before trusting autonomy
  12. Practice: make failure states visible

Give flexible tool use a clear objective, a small capability set, and an enforceable stopping point.

β€œResearch this thoroughly” sounds like a useful instruction until an agent has opened twenty sources, repeated several searches, and still cannot decide whether it is finished.

A bounded job defines what counts as enough. It names the deliverable, permitted evidence, available tools, resource limits, and conditions that require stopping without a complete answer.

For the learning center, the task is small: find the approved evidence for Saturday opening hours and return its identifier and text. The assistant may inspect an approved source collection. It may not contact staff, change policy, or search private records to fill a gap.

Define the job in observable terms

A useful objective is: β€œReturn the current approved Saturday-hours evidence from the allowed collection. Identify the source. Stop if no suitable evidence is available or an access, tool, or budget limit is reached.”

The deliverable is an evidence-backed result, not a particular number of tool calls. A one-call solution can be complete. A long trace with no supporting source is not.

Anthropic's agent-design guidance emphasizes clear tools, feedback from the environment, and stopping conditions for systems that direct their own work. Our design adds ordinary application checks around those choices so the model cannot redefine the job's authority.

Separate the proposer from the controller

The model proposes a next action based on the task and observations. The controller validates that action, checks limits, calls the permitted tool, records the result, and decides whether the requested transition is allowed.

This separation matters because the model's proposal is untrusted input. It may contain an unavailable operation, a restricted identifier, or a premature claim of completion.

For this lesson, the proposer can request read with one document identifier or finish with the identifier of evidence already observed. The controller permits at most two document reads and four decision rounds. Repeated reads stop the run rather than creating a loop.

The source collection contains only two synthetic public records. One concerns Saturday hours; the other concerns loans. The controller requires the appropriate topic before accepting completion.

Test the controller without a paid model

The complete program below uses a deterministic stand-in for the model. It tests the control logic and failure states without making an API call. Save it as agent_lab.py and run it with Python 3.9 or later.

This is the controller for a bounded agent, exercised with simulated proposals. It does not measure the quality of a real model's planning. Replacing the stand-in with a model adapter is a separate integration and evaluation step.

import copy

SOURCES = {
    "HOURS-v2": {"topic": "Saturday hours", "text": "Saturday opening is 10 a.m. to noon."},
    "LOANS-v1": {"topic": "Loans", "text": "Eligible loans last seven calendar days."},
}
ALLOWED = frozenset(SOURCES)


def run(decide, records=SOURCES):
    observed, trace, seen = {}, [], set()
    reads = 0
    def stop(status, answer=None):
        return {"status": status, "answer": answer, "trace": trace.copy(), "reads": reads}
    for step in range(4):
        state = {"goal": "Find the approved Saturday-hours evidence",
                 "observed": copy.deepcopy(observed), "reads_remaining": 2 - reads}
        try:
            action = decide(state)
        except Exception:
            return stop("proposal_error")
        if not isinstance(action, dict):
            return stop("invalid_action")
        operation = action.get("operation")
        if operation == "read":
            if set(action) != {"operation", "doc_id"} or not isinstance(action["doc_id"], str):
                return stop("invalid_action")
            doc_id = action["doc_id"]
            if doc_id not in ALLOWED:
                return stop("permission_limit")
            if doc_id in seen:
                return stop("repeated_action")
            if reads >= 2:
                return stop("read_budget")
            seen.add(doc_id)
            reads += 1
            trace.append({"step": step + 1, "operation": "read", "doc_id": doc_id})
            if not isinstance(records, dict) or doc_id not in records:
                return stop("tool_error")
            record = records[doc_id]
            if (not isinstance(record, dict) or any(
                    not isinstance(record.get(field), str) or not record[field].strip()
                    for field in ("topic", "text"))):
                return stop("tool_error")
            observed[doc_id] = copy.deepcopy(record)
        elif operation == "finish":
            if set(action) != {"operation", "doc_id"}:
                return stop("invalid_action")
            doc_id = action["doc_id"]
            if not isinstance(doc_id, str) or doc_id not in observed:
                return stop("insufficient_evidence")
            record = observed[doc_id]
            if record.get("topic") != "Saturday hours":
                return stop("insufficient_evidence")
            trace.append({"step": step + 1, "operation": "finish", "doc_id": doc_id})
            return stop("complete", f'{doc_id}: {record["text"]}')
        else:
            return stop("unavailable_operation")
    return stop("step_budget")


def planner(state):
    # Deterministic stand-in for a model, used only to test the controller.
    operation = "finish" if "HOURS-v2" in state["observed"] else "read"
    return {"operation": operation, "doc_id": "HOURS-v2"}


def scripted(actions):
    iterator = iter(actions)
    return lambda state: next(iterator)


def main():
    good = run(planner)
    assert good["status"] == "complete" and good["reads"] == 1
    assert len(good["trace"]) == 2 and "10 a.m." in good["answer"]
    assert run(lambda state: {"operation": "send"})["status"] == "unavailable_operation"
    assert run(lambda state: {"operation": "read", "doc_id": "STAFF-v1"})["status"] == "permission_limit"
    assert run(lambda state: {"operation": "finish", "doc_id": "HOURS-v2"})["status"] == "insufficient_evidence"
    repeat = [{"operation": "read", "doc_id": "HOURS-v2"}] * 2
    assert run(scripted(repeat))["status"] == "repeated_action"
    assert run(planner, records=None)["status"] == "tool_error"
    assert run(lambda state: ["read", "HOURS-v2"])["status"] == "invalid_action"
    for malformed in (None, [], {"topic": "Saturday hours"},
                      {"topic": "Saturday hours", "text": 10},
                      {"topic": "Saturday hours", "text": " "}):
        result = run(planner, records={"HOURS-v2": malformed})
        assert result["status"] == "tool_error" and result["answer"] is None
    print(good["answer"])
    print("PASS: controller scenarios and malformed tool records; no model or external service called")


if __name__ == "__main__":
    main()

Inspect the evidence for completion

The normal run reads HOURS-v2 once and then finishes with that observed source. The final text is constructed from the retrieved record, so the model stand-in cannot insert an invented closing time into the answer.

The controller checks passed for normal completion, an unavailable send operation, a restricted record request, premature completion, repeated reading, an unavailable data source, and malformed action structure. Five additional malformed-record cases also passed. The controller now verifies that a tool record is an object containing nonempty text fields before storing it as evidence; missing or malformed records stop with tool_error and no answer. Validating a proposal does not validate the tool result that comes back.

These results establish how this controller handles those proposals. They do not show how often a real model would make a correct proposal, recognize the right document, or resist instructions embedded in retrieved text.

The trace records observable actions and source identifiers. It does not need to expose a model's private internal reasoning to establish whether the right tool ran or whether the cited source was retrieved.

Add a model adapter as a separate boundary

A real adapter would send the task, allowed operation format, remaining budget, and relevant observations to a model. It would parse the returned action and pass it to the controller. Article 108's lessons about credentials, incomplete responses, and cost still apply.

Do not let the adapter execute tools directly. If a model response contains send, the controller should reject it because that operation is outside the task. If a response requests a source outside the allowed collection, the controller should enforce the same scope regardless of the explanation attached to the request.

Use a bounded network timeout and an overall deadline in the real adapter. The loop's maximum round count does not stop a single network call from hanging. The local stand-in returns immediately; that behavior must not be assumed of an external service.

Account for both model calls and tool calls. A two-read limit does not bound spending if each decision call can generate an unlimited response. Set the applicable output limits, attempt limits, and budget checks at the corresponding boundaries.

Make stopping a useful outcome

A run can stop because it succeeded, lacked evidence, reached a permission boundary, encountered a failed tool, repeated itself, or exhausted its budget. Those states should remain distinguishable.

β€œInsufficient evidence” is a useful result when the collection does not contain the needed policy. It can tell the coordinator which document or decision is missing. Pretending to complete the task would hide that gap.

A permission stop should not trigger a search for another route to the same restricted information. A tool failure should not produce an answer that implies the missing record was read. A repeated action should prompt inspection of the task or interface rather than indefinite retries.

When presenting a partial result, identify what was actually found and what remains unresolved. Keep the final claim consistent with the trace.

Evaluate the model and controller together

After integrating a real model, use an evaluation set with expected outcomes. Include a straightforward hours question, a question outside the collection, an ambiguous reference, a tool error, and a retrieved passage that contains an instruction to perform an unrelated action.

The controller should continue to enforce its limits even when the proposer behaves badly. The model should also be evaluated for whether it chooses useful actions and produces an appropriate final result within those limits.

Separate quality measures. You might track evidence correctness, unsupported claims, unauthorized action attempts, successful abstentions, total calls, latency, and review effort. A high answer rate can be misleading if the system fills missing evidence with guesses.

Record model configuration and the source collection version for comparisons. Changes to either can alter behavior even when the controller code stays the same.

Compare the agent with a fixed workflow

For our tiny collection, a fixed lookup of the known hours record is probably sufficient. The agent controller is educational because it exposes the action loop, but the task does not require much adaptive planning.

An agent becomes more plausible when the next useful step depends on earlier findings: a source refers to another approved document, a question spans several collections, or an initial query reveals an ambiguity that changes the search.

Compare both approaches on the same tasks. Include total time to a verified result, failed runs, costs, and human review. Keep the simpler workflow when adaptive choices do not provide a meaningful benefit.

This comparison protects the project from confusing autonomy with usefulness. The desired outcome is reliable work, and the amount of autonomy should follow from the task.

Keep the first agent's job narrow

Avoid combining research, scheduling, messaging, purchasing, and file cleanup in a first agent. Each additional capability introduces new permissions and failure consequences.

Begin with read-only evidence gathering. Once its boundaries and evaluation are understood, consider a separate, explicitly authorized action step. Article 120 shows how to bind an important action to the exact proposal that was approved.

The transition from reading to acting should be visible in the design. A model's ability to request an action does not mean it has permission to carry it out.

A reusable prompt

Define a bounded agent for [specific job]. State the deliverable, allowed sources, available tools, action schema, maximum calls, deadline, and acceptance criteria. Enforce permissions and stopping in application code. Test success, missing evidence, restricted access, malformed proposals, repeated actions, and tool failure. Distinguish controller tests with simulated proposals from evaluations of an integrated model.

For students: learn the loop before trusting autonomy

Students can run the controller and supply their own scripted proposals. Predict which status each sequence will produce, then compare the actual result. That exercise teaches state and control flow without requiring an account or paid model.

For a research assignment, use only authorized sources and keep citations connected to observed evidence. A bounded assistant can help collect passages, but the student should still explain the argument and verify the cited material.

If the assignment's goal is to build an agent, document where the model adapter begins and what was actually tested. A deterministic stand-in is valuable testing equipment; it should not be described as evidence of intelligent planning.

Practice: make failure states visible

Run the example. Add a scripted sequence that reads the loans policy and then tries to finish the Saturday-hours task from it. Confirm that the controller rejects completion for insufficient relevant evidence.

Write a short status report for the normal run and for the missing-evidence run. Each report should describe the actual result without implying extra searches or actions.

Completion check: The controller accepts completion only from observed relevant evidence, blocks unavailable actions, and stops with a truthful status when the job cannot proceed.

Stretch: Replace the stand-in with an approved model adapter, add request timeouts and cost limits, and compare the integrated agent with a fixed workflow on the same evaluation set.

Get the AI Dispatch

Weekly insights on ai & technology β€” delivered to your inbox. No spam, unsubscribe any time.

Want to choose specific topics? Customize your interests