Contents11 sections
Create a repository that is easy to read
The research-brief agent now has defined inputs, outputs, a tool allowlist, and explicit prohibitions. Map that boundary into one brief-agent repository, beginning with the model adapter and domain types.
brief-agent/
├── src/brief_agent/
│ ├── config.py
│ ├── model.py
│ ├── domain.py
│ ├── runtime.py
│ └── cli.py
├── tests/
├── fixtures/
├── artifacts/
└── pyproject.tomlConfiguration, model adapter, and command-line entry point stay separate. When tools and state arrive later, the entry point will not become a container for every behavior.
Read configuration from the environment once
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
model: str
offline: bool
def load_settings() -> Settings:
return Settings(
model=os.getenv("OPENAI_MODEL", ""),
offline=os.getenv("BRIEF_AGENT_OFFLINE", "0") == "1",
)The SDK reads the API key from its standard environment variable. The key never enters Settings string output or logs.
Define a minimal model protocol
from typing import Protocol
class TextModel(Protocol):
def complete(self, prompt: str) -> str: ...
class EchoModel:
def complete(self, prompt: str) -> str:
return f"OFFLINE: {prompt}"EchoModel has no intelligence. It keeps the CLI, configuration, and tests runnable without a network.
Connect the live Responses API
from openai import OpenAI
class OpenAITextModel:
def __init__(self, model: str) -> None:
self.client = OpenAI()
self.model = model
def complete(self, prompt: str) -> str:
response = self.client.responses.create(
model=self.model,
input=prompt,
)
return response.output_textCLI ──▶ TextModel.complete ──▶ OpenAI Responses API
│
└────▶ EchoModel (offline)The CLI selects an adapter from Settings, asks “explain an agent loop,” and prints the result. The program is still one question and one answer.
Model-adapter acceptance
- Offline mode makes no network request.
- Online mode reports a clear error before requesting when no model is configured.
- Logs contain no API key or complete private prompt.
- The CLI depends only on the TextModel protocol.
With the model adapter connected, the next section defines the main agent structure and turns goals, state, actions, observations, tools, and stopping conditions into Python types.
Draw the main structure before writing the loop
┌──────── Goal ────────┐
│ Topic, audience, tests│
└──────────┬───────────┘
▼
┌──────── Runtime ───────────────────────────┐
│ State ─▶ Model ─▶ Action ─▶ Tool ─▶ Observation │
│ ▲ │ │
│ └──────────────────────────────────────┘ │
│ Stop Policy / Budget │
└────────────────────────────────────────────┘The goal describes the task. State stores progress. The model chooses an action from state. A tool executes an action and returns an observation. The runtime connects every component. A stop policy decides success, failure, or budget exhaustion.
Fix the agent language with types
from dataclasses import dataclass, field
from typing import Any, Literal
@dataclass(frozen=True)
class Goal:
topic: str
audience: Literal["engineer", "manager"]
@dataclass(frozen=True)
class Action:
kind: Literal["tool", "finish"]
name: str = ""
arguments: dict[str, Any] = field(default_factory=dict)
answer: str = ""
@dataclass(frozen=True)
class Observation:
ok: bool
source: str
data: dict[str, Any]
@dataclass
class AgentState:
goal: Goal
turn: int = 0
observations: list[Observation] = field(default_factory=list)
status: str = "running"These types have no OpenAI SDK dependency. A model adapter converts current state into a request and converts model output into an action.
Give control to the runtime
class DecisionModel:
def decide(self, state: AgentState) -> Action:
raise NotImplementedError
class ToolRunner:
def execute(self, action: Action) -> Observation:
raise NotImplementedError
class StopPolicy:
def check(self, state: AgentState) -> str | None:
if state.turn >= 6:
return "budget_exhausted"
return NoneThe model cannot mutate state or call a tool directly. The runtime receives an action, checks allowlists and budgets, executes it, and appends the observation to state.
Follow one runtime turn
state.turn += 1
│
▼
model.decide(state)
│
├── finish ─▶ validate answer ─▶ succeeded
│
└── tool ───▶ runner.execute ─▶ observation ─▶ stateActions use structured fields instead of natural-language conventions such as “please call a tool.” Observations store both success and error so the model can see a failed tool on the next turn.
Leave advanced components out of the simple agent
Version one has no long-term memory, planner, multiple agents, MCP, or vector database. It needs only one goal, state, decision model, tool runner, and loop.
Omitting those capabilities preserves the minimum agent structure and makes every state change printable and testable.
Accept the main structure
- State is serializable and stores no client connection or function object.
- Actions have only
toolandfinishvariants. - ToolRunner rejects unregistered names.
- StopPolicy is independent of model output.
- Every runtime state change emits an event.
Chapter 4 reuses these domain types, connects the first function-calling tool, and places model requests, tool execution, and result return inside the agent loop.
REFERENCES
References
Series
AI AGENT Beginner's Guide