2.1. First Agent
In one glance
- You will: Run one grounded agent turn, read the evidence underneath its answer, and then make it answer with no evidence at all.
- You need:
mise run doctorandmise run doctor:modelpassing, withqwen3:4b-instructpulled in Ollama. - Time: about 30 minutes, hands-on.
Why an answer is worth nothing without evidence under it
A language model answers from what it read while training. Ask one about your own systems — a ticket filed last week, a service failing right now — and it has one safe move and one dangerous one: call something that knows, or write a fluent sentence with nothing behind it. Grounding is the property that separates them. An answer is grounded when every claim in it traces back to data the agent read during the turn, and you can point at that data.
The reason this matters more than it sounds: an ungrounded answer is not visibly broken. Same confident register, same plausible identifiers, same shape as the correct one. You cannot tell them apart by reading the reply, which is why the first skill this course teaches is not running an agent — it is checking whether an answer is attached to anything.
The reference agent’s seed data makes that check easy. agents/data/incidents.db holds ten incidents, three of them open, and the model finished training long before that file existed. So an identifier like INC-002 in a reply either came from a tool call or came from nowhere, with no third option. This page follows one turn through the whole chain — question, tool call, rows, sentence — and then asks two questions the data cannot answer, so you can see what the agent does when nothing grounds it.
Run one turn, then look underneath the answer
Check that Ollama is already serving, then start the agent’s developer UI:
mise run doctor:model
cd agents/go
mise run webThe doctor only probes — it starts nothing, and tells you so if Ollama is not answering on :11434. The last command blocks and serves the UI on http://localhost:8002.
Open it and select the agentops_agent application. Then ask exactly this:
List the three open incidents.Before you press enter, predict one thing: where can any incident number in the reply possibly come from? The model has never seen INC-002. If real identifiers come back, something other than the model’s memory produced them.
Now open that turn’s Events view. The model’s first move is not an answer at all — it is a request, which the view shows as a functionCall:
{ "name": "list_incidents", "args": { "status": "open" } }Given a question it could not answer and a tool that could, the model asked for the tool. That request is the boundary between what the model knows and what your systems know, and everything else in this course is built on top of it.
What comes back is not model output either. It is a functionResponse carrying exactly what the tool read out of agents/data/incidents.db. Below is that tool payload, captured by calling list_incidents directly rather than retyped out of the UI. The tool returns incidents newest first, so INC-010 leads; the third row, INC-005, is cut here for length and nothing else is altered:
{
"count": 3,
"incidents": [
{
"id": "INC-010",
"opened_at": "2026-07-08T06:30:00Z",
"service": "api-gateway",
"severity": "SEV3",
"status": "open",
"summary": "Gateway worker RSS climbs about 80MB per hour and p99 latency doubles before each scheduled restart clears it; unclear whether a slow leak or a slow upstream dependency is primary.",
"title": "Gateway workers degrade over uptime"
},
{
"id": "INC-002",
"opened_at": "2026-07-05T09:02:00Z",
"service": "inventory",
"severity": "SEV1",
"status": "open",
"summary": "Inventory pods are crash-looping; stock lookups return HTTP 503.",
"title": "Inventory service unavailable"
}
]
}Every field the answer can legitimately mention is in there — the crash loop, the severity, the service — arriving as data rather than as recollection. Only then does the model write prose.
That payload is what the tool returns. It is not quite what the model reads: before the text reaches it, a policy callback fences the free-text fields — title and summary among them — between <<<TOOL_DATA data-not-instructions>>> markers, so a sentence arriving from a database is read as data rather than as a new instruction. The identifiers and enums stay plain, because the model has to use those as arguments. 4.5. Guardrails is where that fence becomes the subject rather than a footnote.
Question, tool call, tool result, answer: that chain is what grounded means here, and the Events view lets you check every link of it instead of trusting the paragraph.
So check the identifiers in the final paragraph against those rows now. Every one must appear there. Wording and ordering can vary as much as they like; that part is the model’s job. The identifiers are not negotiable.
You just ran an agent on your own hardware — no account, no API key, no per-token fee, and nothing left the machine.
Budget for the shape of the turn rather than for the load. This is not one model call: the agent calls the model to choose a tool, runs the tool, then calls the model again to answer from the result — and every call re-reads the whole context, so the second is the larger of the two. On a GPU that is seconds. On a laptop CPU, take mise run doctor:model’s inference line, the time one single-token call cost, and expect a turn to run several multiples of it.
One failure on this page looks nothing like a deadline and is one. If the browser shows TypeError: network error while the terminal is still busy, the answer did not fail — the event stream carrying it was closed underneath it. ADK bounds that stream separately from the model call and defaults it to two minutes, which is shorter than one grounded turn on a CPU-only host, so mise run web passes -sse-write-timeout 60m. Reach for that flag before you suspect the model, and note the shape of the tell: the UI errors while ollama ps still shows the model at 100%.
If a turn gives up for real, the message names which of three things happened. The model did not answer within AGENT_MODEL_TIMEOUT_S means raise that budget — it defaults to 60 seconds and accepts up to 3600. Nothing is listening at the configured model endpoint means Ollama is not running. The model provider rejected the request means it is running and refused. None of the three prints the provider’s reply, which may echo the prompt.
Your run will differ in the details, and the differences are worth noticing rather than smoothing over. A small model may call list_incidents with no filter and get all ten rows, or call it twice; setting the temperature to zero narrows that spread without removing it, which is one of the four distinctions 0.2. Evidence owns so that no later page has to restate them. What must not vary is the shape: an identifier in the answer with no row behind it is the failure this page exists to teach you to see.
A confident answer shows almost none of that. Ask the same question through the plain console:
cd agents/go
mise run runYou will probably get the same three incident numbers, and that is the problem. The console prints the final answer and nothing else, and from the outside a grounded answer and a lucky one are the same paragraph.
There are at least three ways to produce INC-002 without reading anything: repeat a value from earlier in the conversation, pattern-match the shape of an incident id and guess a plausible number, or echo something the question itself leaked. None of them survives the Events view, because none of them leaves a list_incidents call with matching rows underneath.
This is the habit the rest of the course is built on. When someone shows you an agent transcript, read what the agent had to touch in order to produce the answer, not the answer itself.
How the agent is assembled, and why the order is load-bearing
Here is the whole agent. It is a list — an instruction, the read tools, the two guarded writes, memory, and skills — bound to one model. The list_incidents you just watched fire is literally an element of cfg.Tools.
func (c *Compose) conversationalConfig() llmagent.Config {
localReads, readToolsets := c.readTools()
cfg := c.baseConfig(AgentName, AgentDescription, c.instruction)
cfg.Tools = concatTools(localReads, c.tools.ActionTools(), c.memory)
cfg.Toolsets = append(readToolsets, c.skills)
return cfg
}
// ConversationalAgent builds the default entrypoint.
func (c *Compose) ConversationalAgent() (agent.Agent, error) {
return newAgent(c.conversationalConfig())
}Every one of those arrived on the Compose value that New(cfg Config) validated at startup; nothing is discovered by import convention. That is why a test can replace any one of them with a fake, and why nothing process-wide changes when it does. Two consequences land immediately, and both matter later:
- Importing the agent package starts nothing. No database opens, no exporter dials, no
.envloads. The command layer owns those effects and threads the constructed values downward, so importingcomposein a test cannot accidentally start a runtime. - Bad configuration fails before anything runs.
config.Load()parses the environment and validates it before a runtime exists, reporting the problems it found together rather than dying on the first one. Runmise run config:checkfromagents/go: it prints the effective settings with secrets masked and exits non-zero on a combination that could never have worked.
The tools live in a few named places — reads in agents/go/tools, runbooks and retrieval in agents/go/memory, runtime state in agents/go/data and agents/go/state, and the policy plugin in agents/go/policy, attached once at the app boundary so that adding a sub-agent later cannot quietly lose it.
The order those pieces are built in is not a style choice, and the file that builds them says why:
// newAgentRuntime assembles every plane, in dependency order.
//
// The order is not incidental, and four constraints fix it.
//
// Skills before policy: the trust carve-out is keyed on the identity of the
// load_skill tool value that toolset built, never on its name.
//
// Policy before observability: installTelemetry redacts every durable log path
// through the policy, so the plane cannot be installed until the redactor
// exists. It is installed at the first line where that is true and no later,
// because a record written before slog.SetDefault reaches a handler that is
// neither correlated nor exported — nothing above installTelemetry may write a
// durable record.
//
// Policy before tools: a rationale is redacted by the policy before it reaches
// the append-only audit trail.
//
// Tools before compositions: least-privilege delegation is a statement about
// which tool values each agent holds.
//
// ownsProviders selects who installs the OpenTelemetry tracer and logger
// providers; see [processInstallsProviders].Read that as four constraints rather than one paragraph. The skill toolset comes before the policy plane, because the trust carve-out that lets reviewed skill text count as instruction is keyed on the identity of the load_skill tool value that toolset built, not on its name; a second tool called load_skill would not inherit it. The policy plane comes before observability, because every durable log path redacts through it, so the plane goes in at the first line where a redactor exists and not one line earlier — and nothing above it may write a durable record, since a record written before slog.SetDefault reaches a handler that is neither correlated nor exported. The policy plane also comes before the tools, whose rationales it redacts before they reach the append-only audit trail. And the tools come before the compositions, because least-privilege delegation is a statement about which tool values each agent holds.
That order matters to you and not just to this repository, because getting it wrong is silent. Reorder those constructors in your own project and nothing fails: the build succeeds, the tests pass, the agent answers — and the audit trail carries unredacted rationales while the startup records go to a handler nobody reads. Construction order is a correctness property here, and it is one of the few that no test in this repository can catch for you.
One capability is deliberately absent from that swap. Setting AGENT_MCP_URL moves the six read tools to a remote MCP server, but the guarded writes — the ones that can restart a service — always stay in this process. A remote server may advertise whatever tools it likes; it cannot acquire the authority to change anything.
You can check every one of those claims without a model, because none of them needs one:
cd agents/go
go clean -testcache
go test ./compose ./tools ./policy ./cmd/agentok github.com/MLOps-Courses/agentops-open-course/agents/go/compose 0.315s
ok github.com/MLOps-Courses/agentops-open-course/agents/go/tools 0.895s
ok github.com/MLOps-Courses/agentops-open-course/agents/go/policy 0.019s
ok github.com/MLOps-Courses/agentops-open-course/agents/go/cmd/agent 0.792sThe go clean -testcache line is what makes those four numbers appear at all; without it a second run prints (cached) and tells you only that nothing changed since the last one. Those times are the tests alone, so they do not add up to what you wait for. Put time in front of the go test line and you get that number instead: compile, link, and run together took 2.8 seconds of wall clock here, on a warm build cache, and considerably longer on the first run after a clone, which has the dependency graph to build. Either way — four green packages, no model running, no network. Hold on to that contrast: the composition, the tool contracts, and the policy are all decidable offline, and the only thing that needed a model was the paragraph.
Your turn: ask two questions the data cannot answer
You have watched grounding work. Now find its edge, while the cost of finding it is nothing.
Predict before you run: the agent’s instruction forbids inventing incidents, services, and statuses, and tells it to say so plainly when a tool returns nothing. Will a 4B model obey that under pressure? That question is the reason the rest of this course exists, and this is the cheapest place to start answering it.
- Mode:
inspect— the agent only reads, so there is nothing to revert. - Goal: ask two questions the seed data cannot answer, and read what the agent does instead of reading the database.
- Files to touch: none. The seed under
agents/data/stays exactly as it is; breaking the data would teach you that the tool broke, which is not the lesson. - Preflight: with
mise run webrunning, confirm the grounded turn above still shows itslist_incidentscall and its rows. - Steps: ask
What is the status of INC-999?and thenSummarize incident INC-002 and tell me which engineer resolved it.Open the Events view for each, and predict the shape of each answer before you read it. - Gate that proves completion: for each question, say which of three things happened — the agent refused, it hedged, or it invented — and point at the tool result that justifies your reading. The seed holds no engineer names anywhere, so a name in the second answer came from the model alone. Whichever way your run goes, you have now seen the boundary between what the tools returned and what the sentence claimed.
- Final state: no files changed. Write the one sentence you would use to explain to a colleague why the second answer is more dangerous than the first.
What you can do now
- You can open a turn’s Events view and trace its answer back to the
list_incidentscall and rows underneath. - You can say why the console’s identical-looking answer is not evidence of anything.
- You can name the three ways a turn ends when the seed cannot answer — refused, hedged, invented — and say why an invented engineer name is the dangerous one.
- The offline suite and
mise run config:checkpass without a model call.
An hour ago, an agent was a box that produced confident paragraphs. You can now open one and check whether the paragraph is attached to reality — which is the skill that makes everything after this page safe to attempt.
Continue to 2.2. Models, where the same composition meets a different model and the answers start to move.