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.
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 internalToolCallobjects;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.
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.pycourses/ai-agents/labs/agent_lab/agent.pycourses/ai-agents/labs/agent_lab/tools.pycourses/ai-agents/labs/agent_lab/budget.pycourses/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
ToolParametertypes, lengths, ranges, and enums; - a task-specific completion validator—any non-null
final_outputbecomescompletedafter 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:
- Make the model call an unknown tool; assert no handler runs.
- Set
max_tool_calls=1; assert the run endsbudget_exhaustedbefore the read. - Insert one
TransientModelError; assert the bounded retry appears in the trace. - Return an overlong final answer; assert it is truncated.
- 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:
- send eligible tool definitions with a request;
- receive zero, one, or several
function_calloutput items; - execute application code for each validated call;
- send
function_call_outputitems using the matchingcall_id; - 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
- Which parts belong in the provider-neutral core?
- What does a model adapter translate?
- Why can provider-managed conversation state not replace run control state?
- What items does the Responses function-calling loop exchange?
- What does strict tool schema mode fail to prove?
- 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_iddo?
A: Correlates a returnedfunction_call_outputwith 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.