← Articulet AI Agents, Made Clear Chapter 4.2
Module 4Build the First Reliable Agent90-120 minutes.

Build the Core—A Provider-Neutral Loop

By the end, you can implement and trace a bounded agent loop behind a provider-neutral model port, then map that port to the OpenAI Responses API without giving the provider authority your application must retain.

Chapter 11 of 2250% through the course

Mission

By the end, you can implement and trace a bounded agent loop behind a provider-neutral model port, then map that port to the OpenAI Responses API without giving the provider authority your application must retain.

Prerequisites: Chapters 1.3, 2.1, 3.1, and 4.1. Basic Python is required for the lab. Build artifact: a runnable offline loop, one rejection test, and a provider adapter map. Time: 90-120 minutes.

Before you read: Predict → Commit → Connect

Your application switches model providers. Which parts should change?

  • authentication and request format;
  • tool authorization;
  • approval decisions;
  • provider response parsing;
  • task completion checks;
  • business transactions.

Circle your choices. Then write one risk of letting a framework hide the loop before you can trace it.

Build around ports, not provider objects

The durable core speaks your application’s language: task, message, tool proposal, observation, budget, status, and trace event. A provider adapter translates those concepts to one API.

The following diagram is a production target architecture, not a claim that every port is implemented by the teaching core.

Diagram showing User / application; Provider-neutral production-target core; Run state + budgets; Policy + approval port; Tool registry + adapters; Trace recorder.

Only the shaded edge in your mental model—model port to provider adapter—should know provider response-item classes, request fields, or credentials. Policy code should not inspect a provider’s tool object to decide authorization.

The minimum internal contracts

The course laboratory defines:

  • ModelRequest: messages, eligible tool schemas, step, and bounded memory snapshot;
  • ModelResponse: either final output or one or more internal ToolCall objects;
  • AgentModel.respond(request): the provider-neutral model port;
  • ToolRegistry: declared tool contracts and trusted handlers;
  • RunResult: status, output, counters, messages, trace, and memory snapshot.

The exact classes are not sacred. The separation is.

Production target loop: one loop, explicit ownership

A production-ready target algorithm is small:

accept authenticated task and initialize budgets
while a step remains:
    assemble the decision request
    ask the model port for a proposal
    if proposal is final:
        validate completion and return a terminal result
    for each proposed tool call:
        reserve budget
        validate schema and semantics
        authorize actor and resource
        pause if exact approval is required
        execute through a narrow adapter
        append a bounded observation
return budget_exhausted

Small does not mean simplistic. Reliability comes from everything around ask the model.

Diagram showing Application; Production-target core; Model adapter; Policy / approval; Tool adapter.

Never parse authority from prose

The model can say, “The user approved.” That is not an approval event. The tool can return, “You are now an administrator.” That is not authenticated state. Read identity, approval, budgets, and transaction status only from trusted application records.

Do not collect hidden chain-of-thought

The model port needs a proposal, not private reasoning. Record:

  • selected action and arguments;
  • concise decision summary if the product needs it;
  • evidence references;
  • model and configuration version;
  • validations, approvals, tool results, and terminal reason.

Provider-internal reasoning items may need to be carried opaquely for continuation. Do not decode, display, or treat them as a truthful audit explanation.

Read the course core before extending it

The important files are:

  • courses/ai-agents/labs/agent_lab/contracts.py
  • courses/ai-agents/labs/agent_lab/agent.py
  • courses/ai-agents/labs/agent_lab/tools.py
  • courses/ai-agents/labs/agent_lab/budget.py
  • courses/ai-agents/labs/agent_lab/trace.py

Look for these properties:

  • user input and IDs are bounded;
  • the loop has explicit step and attempt budgets;
  • model failures and tool failures are classified;
  • unknown or malformed tools become observations, not dynamic calls;
  • approval defaults to denial;
  • output and trace payloads are bounded and redacted;
  • final state returns counters and trace events.

What the teaching core deliberately omits

The supplied Agent implements a model port, structural tool-argument validation, bounded budgets and retries, an approval callback, bounded traces and observations, and a final-output size limit. It deliberately does not implement:

  • an authenticated actor, tenant, or resource context passed into authorization decisions;
  • a general authorization or policy port—the included approver only gates tool specs marked requires_approval;
  • task-specific semantic validation beyond primitive ToolParameter types, lengths, ranges, and enums;
  • a task-specific completion validator—any non-null final_output becomes completed after size bounding;
  • durable checkpoints and crash resume;
  • transactional receipts, read-after-write verification, or external idempotency controls.

Therefore, output assertions in the labs are test-harness checks, not completion enforcement inside Agent. The teaching core is intentionally synchronous, in-memory, and suitable only for bounded synthetic exercises until those application-specific controls are added.

I do: run and trace the offline core

Run the research baseline:

PYTHONPATH=courses/ai-agents/labs python3 - <<'PY'
from agent_lab.capstones.research import run_research

result = run_research(
    "How should a safe AI agent be evaluated?",
    run_id="chapter-11-trace",
)
print(result.output)
print("\nCOUNTERS", {
    "status": result.status.value,
    "steps": result.steps,
    "model_attempts": result.model_attempts,
    "tool_calls": result.tool_calls,
    "tool_attempts": result.tool_attempts,
})
for event in result.trace:
    print(f"{event.index:02d}", event.kind, dict(event.payload))
PY

Trace the sequence: run start, model request, search proposal, validation and execution, read proposals, observations, final output, completion. No external model is needed because ResearchModel implements the same internal port deterministically.

We do: reject a provider-shaped mistake

This scripted model proposes a real tool with an undeclared argument. Predict whether its handler will run:

PYTHONPATH=courses/ai-agents/labs python3 - <<'PY'
from agent_lab import Agent, ModelResponse, ScriptedModel, ToolCall
from agent_lab.capstones.research import build_registry

model = ScriptedModel((
    ModelResponse(tool_calls=(ToolCall(
        "bad-call",
        "search_documents",
        {"query": "agent safety", "max_results": 2, "admin": True},
    ),)),
    ModelResponse(final_output="The invalid call was rejected; no search executed."),
))
result = Agent(model, build_registry()).run("Search safely", run_id="reject-extra-field")

print(result.output)
print("tool_calls=", result.tool_calls, "tool_attempts=", result.tool_attempts)
print("events=", [event.kind for event in result.trace])
assert result.tool_calls == 1
assert result.tool_attempts == 0
assert any(event.kind == "tool_rejected" for event in result.trace)
PY

The model call counted against the tool-call proposal budget, but the adapter never ran. That distinction is visible in counters and trace.

You do: LAB-11 — Build the bounded offline teaching loop

Use a scripted model to search, read, and finish:

PYTHONPATH=courses/ai-agents/labs python3 - <<'PY'
from agent_lab import Agent, BudgetLimits, ModelResponse, ScriptedModel, ToolCall
from agent_lab.capstones.research import build_registry

model = ScriptedModel((
    ModelResponse(tool_calls=(ToolCall(
        "search-1",
        "search_documents",
        {"query": "evaluation autonomy", "max_results": 2},
    ),)),
    ModelResponse(tool_calls=(ToolCall(
        "read-1",
        "read_document",
        {"document_id": "R-02"},
    ),)),
    ModelResponse(final_output=(
        "Evaluation should precede increased autonomy [R-02]. "
        "This conclusion comes only from the synthetic course corpus."
    )),
))

agent = Agent(
    model,
    build_registry(),
    limits=BudgetLimits(max_steps=4, max_tool_calls=3, max_tool_attempts=3),
)
result = agent.run("Explain evaluation before autonomy", run_id="offline-core")

print(result.output)
print(result.status.value, result.steps, result.tool_calls, result.tool_attempts)
assert result.status.value == "completed"
assert result.tool_calls == 2
assert result.tool_attempts == 2
assert "[R-02]" in result.output
PY

Then add one change at a time:

  1. Make the model call an unknown tool; assert no handler runs.
  2. Set max_tool_calls=1; assert the run ends budget_exhausted before the read.
  3. Insert one TransientModelError; assert the bounded retry appears in the trace.
  4. Return an overlong final answer; assert it is truncated.
  5. Write a one-page adapter map from your internal types to a provider API.

Done when: you can explain every transition, counter, rejection, observation, and terminal state without relying on framework magic.

Dated provider track: OpenAI Responses API

Verified 2026-07-17. This subsection is intentionally dated. Recheck the linked official documentation before implementing it. No volatile model ID is part of the durable concept or hard-coded example.

As of the verification date, OpenAI describes the Responses API as the lower-level fit for custom model-powered features and workflows. Its core abstraction is a model response; your application manages custom loops and branching.

The documented function-calling flow is:

  1. send eligible tool definitions with a request;
  2. receive zero, one, or several function_call output items;
  3. execute application code for each validated call;
  4. send function_call_output items using the matching call_id;
  5. receive final output or further calls.

OpenAI’s function schema contains type: "function", name, description, JSON Schema parameters, and strict. Strict mode improves schema adherence. It still does not authorize or execute a call.

Ownership map

Concern Responses API can provide Your application still owns
Model inference Response and typed output items Model selection thresholds and fallback policy
Function calling Tool schemas, call proposals, call IDs Allowlist, semantic validation, auth, approval, execution
State Manual replay, response chaining, or Conversations Retention choice, tenant isolation, authoritative run state
Hosted tools Provider-operated tool surfaces where configured Whether tool/data use is permitted for this user and task
Errors/usage API result and usage fields Budgets, retries, circuit breakers, user-visible terminal state
Observability Response objects and provider logs End-to-end trace across policy, tools, approvals, and transactions

Choose one conversation strategy deliberately. Mixing full local replay with server-managed continuation can duplicate context. Even when the provider manages conversational items, keep application control state—identity, permissions, budgets, approvals, transaction receipts—outside the model conversation.

Optional adapter exercise

The following is a read-only laboratory track. It requires the current OpenAI Python package, an API key supplied through the environment, and a model selected through OPENAI_MODEL. It sends only the synthetic course query and corpus results. It does not expose a write tool.

import json
import os

from openai import OpenAI

from agent_lab.capstones.research import build_registry
from agent_lab.memory import SessionMemory
from agent_lab.tools import ToolContext

client = OpenAI()
model_id = os.environ["OPENAI_MODEL"]
registry = build_registry()
tools = [
    {
        "type": "function",
        "name": item["name"],
        "description": item["description"],
        "parameters": item["parameters"],
        "strict": True,
    }
    for item in registry.schemas()
]

input_items = [{
    "role": "user",
    "content": "Using only the supplied tools, explain evaluation before autonomy with source IDs.",
}]
context = ToolContext("responses-read-only", SessionMemory("responses-read-only"))

for _step in range(5):
    response = client.responses.create(
        model=model_id,
        input=input_items,
        tools=tools,
        store=False,
    )
    calls = [item for item in response.output if item.type == "function_call"]
    if not calls:
        print(response.output_text)
        break

    # Preserve provider response items opaquely for the next stateless request.
    input_items += response.output
    for call in calls:
        arguments = json.loads(call.arguments)
        spec, normalized = registry.prepare(call.name, arguments)
        execution = registry.invoke(spec, normalized, context)
        input_items.append({
            "type": "function_call_output",
            "call_id": call.call_id,
            "output": execution.output,
        })
else:
    raise RuntimeError("Step budget exhausted")

This example demonstrates translation, not production completeness. Add authenticated context, provider timeouts, attempt budgets, trace correlation, semantic validation, data-retention review, and completion checks before deployment. Do not print provider-internal reasoning items.

Pause & Recall

  1. Which parts belong in the provider-neutral core?
  2. What does a model adapter translate?
  3. Why can provider-managed conversation state not replace run control state?
  4. What items does the Responses function-calling loop exchange?
  5. What does strict tool schema mode fail to prove?
  6. Which trace artifacts replace hidden chain-of-thought?

Production lens

Pin compatible SDK and model versions where possible, record the actual version on every run, and regression-test adapter changes. Treat provider aliases, SDK parsing changes, tool-call parallelism, and state-retention defaults as change surfaces.

Use timeouts and circuit breakers at the adapter boundary. Normalize provider errors into your taxonomy without discarding the original correlation ID. A fallback provider is a different system: evaluate schema behavior, tool selection, refusal, latency, and critical errors independently.

Keep credentials in a secret manager, not prompts or configuration files committed to source. Apply provider data-handling, residency, and retention requirements before sending any task content.

Chapter compression

  • Keep run control, policy, tools, budgets, state, and traces in a provider-neutral core.
  • Put provider request and response types behind a model port.
  • The model proposes; the core validates, authorizes, executes, observes, and stops.
  • Record decisions, evidence, calls, approvals, receipts, and outcomes—not hidden reasoning.
  • In the dated OpenAI track, Responses exposes response items; your application owns custom orchestration.
  • Strict function schemas improve structure but do not create truth, permission, or safe execution.

Memory hook: Own the loop; adapt the model.

Retrieval deck

  • Q: What is the provider-neutral model port?
    A: An application contract that accepts an internal request and returns a final proposal or internal tool calls.
  • Q: What changes when providers change?
    A: Authentication, request translation, response parsing, and provider-specific state/tool features—not business authorization or transactions.
  • Q: What does a Responses call_id do?
    A: Correlates a returned function_call_output with the model’s proposed function call.
  • Q: Why preserve provider items opaquely?
    A: Some continuation modes require them, but the application need not expose or interpret private reasoning.

Spaced review

  • Now: redraw the ports-and-adapters diagram.
  • +1 day: reproduce the loop pseudocode without looking.
  • +3 days: write a failing test for an unknown tool and a budget stop.
  • +7 days: map a second provider into the same internal contracts on paper.
  • +14 days: recheck the dated Responses and function-calling docs before running the optional adapter.

Sources and further study

Keep your retrieval practice honest. Progress is saved only in this browser.