Deep Dive: Agent Knobs Under the Hood
The lab ran the Incident Crew end to end and watched it produce two outcomes: APPROVED for a
runbook-backed fix, REJECTED/escalate when the relevance gate found nothing to work with. It
never asked why each of the four agents runs at the temperature it runs at, why the pipeline is
one pass through four functions instead of a loop that can retry, or why the gates that decide
APPROVED vs REJECTED live in Python string checks instead of another LLM call asking "is this
okay?" This page opens crew.py itself: the exact temperature each agent gets and why, why this
crew has no iteration cap because it was never built to loop, how a finding moves from one agent
to the next as a plain string, where the code-level gates actually sit, and what happens when you
turn one off. It closes with a same-prompt, sequential comparison across three knob variants.
You need the m7 stack up — ChromaDB and the built crew image. This works whether the stack is
currently running or was torn down after the lab; up.sh is idempotent, so re-running it is
safe.
Every command on this page is run from the root of your course-repo clone (the directory
containing labs/, site/, planning/ — wherever you ran git clone for this course). Paths
below are always relative to that root, exactly like the lab page.
cd labs/m7
bash up.sh
Expected output
m7 ready: chromadb healthy, crew image built.
1 — Temperature, per agent, not per crew
The lab treated qwen3.5:2b as one model behind four roles. It is — but each role calls that
model with a different temperature, and that difference is deliberate, not incidental.
Analogy: think of temperature as how much a musician improvises around a melody. At low temperature the musician plays the melody exactly as written, every time — reliable, boring, correct. At high temperature the musician takes liberties: more expressive, sometimes brilliant, sometimes off-key. You want the string section reading sheet music at low temperature during a recording take. You want the jazz soloist's improvised break at higher temperature — that's where the interesting ideas live. An incident-response crew is almost entirely string section: the moments where a wrong note is expensive far outnumber the moments where creativity helps.
Reading crew.py's four llm() call sites directly, the actual values are:
| Agent | Call site | Temperature | Why |
|---|---|---|---|
| Triage | llm(f"Incident: {incident}", profile("triage"), temperature=0) | 0 | Classification — one correct AREA / SEV label exists per incident; no benefit to variety |
| Investigator (relevance gate) | llm(..., profile("investigator"), temperature=0).upper().startswith("YES") | 0 | A yes/no safety-relevant decision gets fed straight into an if — variety here means flaky gating |
| Fixer | llm(f"Incident: {incident}\nRunbook passage:\n{runbook}\n\n...", profile("fixer")) | 0.2 (default) | No explicit temperature= argument — falls through to llm()'s default parameter. Some room to phrase the recommendation naturally, but still low: the command itself has to come verbatim from the runbook |
| Reviewer | llm(f"Incident: {incident}\nProposed fix:\n{fix}\n\nReview it.", profile("reviewer"), temperature=0) | 0 | Safety gate — APPROVED/REJECTED is a binary decision the code branches on; it cannot be allowed to drift |
Three of four agents run at temperature=0. The one exception — the Fixer — doesn't even set
the argument explicitly; it inherits llm()'s function default of 0.2 (see the signature,
def llm(prompt, system, temperature=0.2):). That's not an oversight to fix — it's the right
default for the one call in the pipeline that generates prose around a fact (the command) rather
than deciding a fact. Nothing in this crew calls llm() above 0.2. This crew has no
brainstorming role; every agent's job is to be right, not interesting, which is why the whole
pipeline clusters at the low end of the temperature range.
Three of the four calls pin temperature=0 explicitly. The Fixer is the only one that leaves
room for phrasing — and even that room is small.
Raising a classification or gating agent's temperature does not make it "more creative" in a
useful sense — it makes its correctness less repeatable. §6 measures this directly rather than
asserting it: the same incident, run three times at temperature=0 vs temperature=0.9 on the
Triage call, to see what actually moves and what doesn't on a model this size.
2 — Iteration control: why this crew has no loop to bound
The lesson's Mermaid diagram showed a straight pipeline: Triage → Investigator → Fixer →
Reviewer. Reading crew.py's run() function confirms it literally is that — four sequential
calls, no while, no retry, no "try again with a different approach" branch anywhere in the
file. This section is about why that absence is a design decision worth naming, not an
accident.
Analogy: picture someone who doesn't find the right answer on their first web search, so they re-google the same question with slightly different wording — and keeps doing that, tweaking a word each time, hoping the next search nails it. Nothing stops them; there's no rule saying "you get three tries." An agent given a tool and no iteration cap can do exactly this: call the tool, look at the result, decide it's not good enough, call the tool again with slightly different arguments, and repeat — burning time and cost with no guarantee the next call is any better than the last, and no built-in stopping point except a wall-clock timeout somewhere upstream.
That failure mode belongs to a different shape of agent than this crew: a ReAct-style loop, where a single agent reasons, calls a tool, observes the result, and decides whether to answer or retry — potentially many times per task.
A ReAct-style loop reasons → calls → observes → decides, and can cycle back to "reason" again. Without an explicit iteration cap, that cycle only stops when something outside the loop (a timeout, an API error) forces it to.
The Incident Crew has none of this. retrieve() is called exactly once by the Investigator
— there is no "the first result looked weak, try a different query" branch. If ChromaDB's
nearest-neighbour search returns a bad match, the relevance gate (§4) catches it by saying NO to
that one candidate; the code does not respond by retrying the query with different phrasing. Each
of the four agents gets exactly one shot: one llm() call each (the Investigator's relevance
gate is a second, separate llm() call, not a retry of the first — it's checking the retrieved
candidate, not re-querying).
This is a bounded-latency, bounded-cost design choice, not a limitation the crew happens to have. A single incident report costs exactly 5 model calls, always: 1 Triage + 1 retrieval-embedding + 1 relevance-gate + (0 or 1) Fixer + (0 or 1) Reviewer. You can predict the p99 latency and the p99 cost of running this crew because there is no code path where it runs 10 times instead of 1. An unbounded ReAct loop trades that predictability for the ability to self-correct — worth it when a task genuinely benefits from "try again, smarter," expensive when a bug or a bad prompt sends the loop searching for an answer that was never going to be found, burning tokens and wall-clock time until something external kills it. In production this shows up as a very concrete SLO question: an on-call responder wants a crew that answers in a bounded, known time, every time — even if the answer is sometimes "REJECTED, escalate" — over a crew that might occasionally find a better answer after ten retries but might just as easily spin for two minutes on an incident it was never going to solve.
3 — Delegation: what actually moves between agents
The lesson called this a "sequential pipeline." Reading run() line by line shows exactly what
crosses each hop, and it's worth being precise about it, because the shape of that handoff is
what makes small-model multi-agent work reliably at all.
Trace the actual variables run() passes forward:
incident (the raw string argument)
-> Triage: llm(incident) -> triage (printed, but NOT passed to Investigator)
-> Investigator: retrieve(cid, incident) -> candidate (queries with the ORIGINAL incident text, not triage's output)
llm(candidate, incident) -> relevant (bool)
candidate or "" -> runbook
-> Fixer: llm(incident, runbook) -> fix
-> Reviewer: llm(incident, runbook, fix) -> verdict
The detail worth catching: retrieve() queries ChromaDB with the raw incident string, not
with Triage's output. Triage's AREA: ... | SEV: ... | ... line is printed to the console for
the human reading the transcript, but run() never feeds it into the Investigator's retrieval
call or its prompt. Triage's actual job in this pipeline is display and (implicitly) an early
opportunity to notice a garbled incident report — it does not narrow what the Investigator
searches for. If you wanted Triage's classification to steer retrieval (e.g. filter ChromaDB by
AREA), that's a real extension, but it isn't what this crew does today.
Every other handoff is a plain Python string, passed as an f-string argument into the next
llm() call — no structured object, no JSON schema, no message history the receiving agent can
inspect beyond what's interpolated into its prompt. This is "structured" only in the loose sense
that each string has a known shape by convention (the Reviewer's prompt is built assuming fix
looks like the Fixer's profile says it will) — nothing in the code validates that shape before
using it. Compare this to a framework like CrewAI, where delegation can be dynamic: an agent
can decide at runtime to hand a sub-task to a different agent, or a manager agent can route work
based on the content of a request, rather than a human-authored run() function hard-coding the
order. This crew's declarative pipeline is the opposite of dynamic delegation — the order
Triage → Investigator → Fixer → Reviewer is fixed in the Python source, not decided by any agent
at runtime.
That fixed order is a strength for this task, not a missing feature. Two dynamic-delegation failure modes it sidesteps entirely:
- Context dilution. Each agent's prompt here is exactly the fields it needs — the Fixer never sees the Triage output, the Reviewer never sees the raw retrieval candidate that got rejected. In a framework where every agent shares a growing conversation history, later agents can end up reasoning over context that has nothing to do with their actual job, and a small model's limited attention gets spent parsing irrelevant history instead of doing its one task.
- Error cascade. Because each hop passes forward only what the previous stage decided to
output, a mistake at one stage is visible and inspectable at the next — the Reviewer sees the
Fixer's exact
fixstring, not a summarized or re-interpreted version of it. In a framework where a manager agent re-phrases or re-summarizes what a sub-agent found before passing it on, an early misunderstanding can get "smoothed over" in the rephrasing and become invisible to everything downstream — the later agent inherits the error without ever seeing the original evidence that would have let it catch it.
The cost of delegation depth is the same in both designs: every hop is another LLM call, and every LLM call is latency and, if you're paying for tokens, cost. This crew already pays for four hops on every run (five, counting the relevance-gate's separate call). A framework that adds dynamic re-routing — "let me ask a different agent to double-check this" — adds hops on top of that, and each added hop is a further chance for the crew to drift off the original incident.
4 — Guardrail placement: where the code actually gates, not the prompt
The lesson called the relevance gate "not optional" and the Reviewer "the crew's most important member." This section is about where in the pipeline those checks physically sit in the code, and why that placement — not just their existence — is what makes them trustworthy.
Both gates in this crew are Python string checks on an LLM's output, not a second LLM call asked to police the first LLM call's own answer:
relevant = llm(..., profile("investigator"), temperature=0).upper().startswith("YES")
verdict.upper().startswith('APPROVED')
Neither gate trusts the model's output as-is. Each one takes a string the model produced and
runs it through an unambiguous, deterministic Python check — .startswith("YES"),
.startswith('APPROVED') — before that answer is allowed to change what happens next. A model
that responds "Yes, I believe this passage is relevant because..." still passes the gate
(.upper().startswith("YES") matches the leading YES); a model that hedges with "Possibly, but I'm not certain" fails it, correctly, because the code isn't parsing intent — it's checking
a prefix. This is why a code-level gate beats asking the model "and are you sure that's safe?" in
a follow-up prompt: a follow-up prompt is still just another probabilistic text generation that
can itself say the wrong thing. A string check on a constrained output format cannot be talked
out of its answer.
Mapped onto the pipeline, the two gates sit at two different points:
Gate 1 sits after a tool call, before the Fixer acts — it validates a retrieval result before anything downstream trusts it. Gate 2 sits at final-answer time, before a human sees it — it validates the Fixer's proposed action before it reaches the person who might run it.
Neither gate sits at the third possible point: before a tool call, validating arguments going
in. This crew doesn't need one there, because its one tool call (retrieve()) takes the
unmodified incident text as input — there's no LLM-constructed query string or LLM-chosen
argument that could be malformed before the call happens. That third gate type earns its keep in
agents that let the model choose what to call and with what arguments — validate the
arguments before the tool executes, not just the result after. This crew's Investigator has no
such freedom: it always calls the same retrieval function the same way.
This module's gates are about behavior — tuning what a specific agent does on a specific
input, verified by running it. That's a different concern from governance — auditing,
logging, and policy enforcement across a whole fleet of agent deployments, which is M8's
territory. A useful way to keep the two apart: this page asks "does this one gate correctly
reject a bad Kafka retrieval right now," M8 asks "can I prove, after the fact, that every
deployed crew has a gate like this one at all, and that nobody quietly removed it." Where you'd
add a third layer beyond this crew's two: input validation (reject a malformed or empty
incident string before Triage ever runs), and an explicit output schema for the Reviewer's
verdict (today, verdict.upper().startswith('APPROVED') trusts the model to put APPROVED or
REJECTED at the very start of its response — a model that instead opens with an explanation
before the verdict would silently fail the gate and read as REJECTED by default, which is a safe
failure direction here, but worth knowing about before you change the Reviewer's profile).
5 — Observing what each agent actually receives
There's no debug flag or verbose environment variable in crew.py — grep confirms it (no
DEBUG, no --verbose, no log-level env var anywhere in the file). The observation tooling this
crew has is docker compose run output plus the structured stage markers already in the code
— [TRIAGE], [INVESTIGATOR], [FIXER], [REVIEWER], OUTCOME: — every one of which is a
plain print() in run(), not a logging framework. That's sufficient here because every stage's
input and output is fully visible in that transcript already; there's no hidden internal
reasoning step the prints don't surface.
What the prints do not show is the exact prompt text sent to the model — triage,
candidate, fix, and verdict are the outputs of each llm() call, not the full prompt
that produced them. To see the actual prompt an agent receives, read the profile file it's built
from directly — that plus the f-string in run() (§3, "what actually moves") is the complete
prompt, since nothing else gets concatenated in:
cat labs/m7/crew/profiles/investigator.md
Expected output
# Investigator
**Role:** Incident investigator. Given a triaged incident, you use the **Acme runbook knowledge base**
(agentic RAG) to find the relevant runbook. Report the single most relevant runbook passage verbatim.
If no runbook covers it, say `NO RUNBOOK FOUND`. Do not invent procedures. Do not run commands — you
only gather the relevant runbook for the Fixer.
That's the entire system argument the Investigator's llm() calls receive — the prompt
argument is the f-string built in run() (the incident text, plus the candidate passage and the
relevance question on the gate call). Between the profile file and run()'s f-strings, you can
reconstruct the exact prompt any agent saw for any run without needing a debug flag the code
doesn't have.
6 — Experiment: knob variants, one at a time, sequential
Everything below runs sequentially, one variant at a time — never two docker compose run
crew invocations in parallel. This machine budgets one crew container (~15 MB) plus the shared
ChromaDB container (~15 MB) at a time; running variants back to back keeps the stack at or under
4 GB total, almost all of it the one shared model. It also matters for a second reason: on qwen3.5:2b, small-model output varies run
to run even at the same temperature, so overlapping variants would make it impossible to tell
whether a difference in output came from the knob you changed or from ordinary small-model noise.
qwen3.5:2b-class models respond to temperature and prompt changes less predictably than
larger models. The comparisons below are illustrative of the mechanism — what changing a
knob does to a pipeline's stability — not a universal recipe for what temperature to pick.
Judge the deterministic side of every result strictly, but note that "deterministic" here is
conditional on the gates being intact: with the relevance gate in place (Baseline, Variant A),
the OUTCOME: marker and which stages ran or short-circuited are exact, reproducible facts about
this run — Investigator and Reviewer both stay pinned at temperature=0, so the gate's YES/NO
and the final APPROVED/REJECTED don't move even when Triage's temperature does. Variant B
removes that gate, and §6's Variant B section shows directly what happens to the OUTCOME:
marker once it's gone: it stays stable, but on the wrong answer. Judge the prose — the Triage summary sentence, the
Reviewer's stated reason — by shape only in every variant: whether it stayed on topic and matched
the expected disposition, not its exact wording. If you swap in a larger model
later, expect the outcome markers to stay just as stable at low
temperature, but expect the prose to vary less at a given temperature than it does here — a
bigger model needs less determinism-by-temperature to stay on-message. One caveat: qwen3:4b
on Ollama 0.40.7 ignores think: false and prints its reasoning inline, which breaks the
YES/NO gate (see the lab's Troubleshooting), so pick a model that honours the switch-off.
Baseline: the lab's own run, as printed
This is Step 4 from the lab, unmodified — included here so the variants below have something concrete to diff against without flipping back to the lab page.
cd labs/m7
docker compose run --rm crew "The checkout page is returning HTTP 503 errors for all users."
Expected output
Container chromadb Running
Container m7-crew-run-3d9f87ee9d45 Creating
Container m7-crew-run-3d9f87ee9d45 Created
[crew] Acme Incident Crew: Triage -> Investigator -> Fixer -> Reviewer (4 profiles, one shared model: qwen3.5:2b)
======================================================================
INCIDENT: The checkout page is returning HTTP 503 errors for all users.
======================================================================
[TRIAGE] AREA: web/checkout | SEV: SEV1 | Checkout API returning 503 errors.
[INVESTIGATOR] ## Checkout 503 errors
If the checkout page returns HTTP 503, the web tier is saturated. Scale it up:
`kubectl scale deploy/web --replicas=5 -n prod`. Then check the load balancer
health in the Acme dashboard.
[FIXER] Scale up the web tier by running `kubectl scale deploy/web --replicas=5 -n prod`.
[REVIEWER] APPROVED: Command matches runbook and is a non-destructive scaling action.
======================================================================
OUTCOME: APPROVED — ready for a human to apply
On a small model, exact wording varies run to run — judge by the stage markers and the
OUTCOME: line, the same guidance the lab itself gives.
Variant A: Triage temperature raised (0 → 0.9), 3 repeats
crew.py has no environment-variable override for temperature — it's a literal
temperature=0 in the Triage call site. To change it without editing the tracked source, copy
crew.py, patch the copy, and rebuild the image from a scratch build context that swaps in the
patched file. This keeps the original file in the repo untouched and gives you a one-line revert
(delete the copy, rebuild from the unmodified source).
REPO_ROOT="$(pwd)"
mkdir -p ~/crew-deepdive-lab && cd ~/crew-deepdive-lab
cp "$REPO_ROOT/labs/m7/crew/crew.py" ./crew-hot-triage.py
sed -i.bak 's/llm(f"Incident: {incident}", profile("triage"), temperature=0)/llm(f"Incident: {incident}", profile("triage"), temperature=0.9)/' ./crew-hot-triage.py
diff "$REPO_ROOT/labs/m7/crew/crew.py" ./crew-hot-triage.py
(Run this from your repo root — REPO_ROOT="$(pwd)" captures it before the cd below, so every
later command in this section can find the tracked source again without you retyping the path.)
Expected output
73c73
< triage = llm(f"Incident: {incident}", profile("triage"), temperature=0)
---
> triage = llm(f"Incident: {incident}", profile("triage"), temperature=0.9)
(diff exits non-zero when it finds a difference — that non-zero exit is the expected, correct
result here, not a failure.)
Build a one-off image from this patched copy, reusing the lab's own build context so the profiles and Dockerfile don't have to be duplicated:
cd "$REPO_ROOT/labs/m7"
cp ~/crew-deepdive-lab/crew-hot-triage.py crew/crew.py.deepdive-hot-triage
docker build -t acme-incident-crew:hot-triage \
--build-arg CREW_FILE=crew.py.deepdive-hot-triage \
-f - . << 'EOF'
FROM python:3.12-slim
WORKDIR /app
COPY crew/ ./crew/
COPY docs/ ./docs/
ARG CREW_FILE=crew.py
RUN cp crew/${CREW_FILE} crew/crew.py
ENTRYPOINT ["python", "crew/crew.py"]
EOF
rm crew/crew.py.deepdive-hot-triage
Expected output
#10 unpacking to docker.io/library/acme-incident-crew:hot-triage
#10 unpacking to docker.io/library/acme-incident-crew:hot-triage 0.3s done
#10 DONE 1.3s
Run it 3 times against the same incident, sequentially, logging each transcript:
cd "$REPO_ROOT/labs/m7"
for i in 1 2 3; do
docker run --rm --network m7_default \
-e OLLAMA_BASE_URL=http://host.docker.internal:11434 \
-e CHROMA_HOST=chromadb -e CHROMA_PORT=8000 \
-e LLM_MODEL=qwen3.5:2b -e EMBEDDING_MODEL=nomic-embed-text \
acme-incident-crew:hot-triage \
"The checkout page is returning HTTP 503 errors for all users." \
| tee ~/crew-deepdive-lab/variant-a-run${i}.log
done
Expected output
=== run 1 ===
[crew] Acme Incident Crew: Triage -> Investigator -> Fixer -> Reviewer (4 profiles, one shared model: qwen3.5:2b)
======================================================================
INCIDENT: The checkout page is returning HTTP 503 errors for all users.
======================================================================
[TRIAGE] AREA: web/checkout | SEV: SEV2 | Checkout API returning 503 errors
[INVESTIGATOR] ## Checkout 503 errors
If the checkout page returns HTTP 503, the web tier is saturated. Scale it up:
`kubectl scale deploy/web --replicas=5 -n prod`. Then check the load balancer
health in the Acme dashboard.
[FIXER] Run the following command to scale up the web tier:
```bash
kubectl scale deploy/web --replicas=5 -n prod
```
[REVIEWER] APPROVED: Command matches runbook and is a non-destructive scaling action.
======================================================================
OUTCOME: APPROVED — ready for a human to apply
=== run 2 ===
[crew] Acme Incident Crew: Triage -> Investigator -> Fixer -> Reviewer (4 profiles, one shared model: qwen3.5:2b)
======================================================================
INCIDENT: The checkout page is returning HTTP 503 errors for all users.
======================================================================
[TRIAGE] AREA: web/checkout | SEV: SEV1 | Payment gateway outage impacting all checkout attempts.
[INVESTIGATOR] ## Checkout 503 errors
If the checkout page returns HTTP 503, the web tier is saturated. Scale it up:
`kubectl scale deploy/web --replicas=5 -n prod`. Then check the load balancer
health in the Acme dashboard.
[FIXER] Run the following command to scale up the web tier:
```bash
kubectl scale deploy/web --replicas=5 -n prod
```
[REVIEWER] APPROVED: Command matches runbook and is a non-destructive scaling action.
======================================================================
OUTCOME: APPROVED — ready for a human to apply
=== run 3 ===
[crew] Acme Incident Crew: Triage -> Investigator -> Fixer -> Reviewer (4 profiles, one shared model: qwen3.5:2b)
======================================================================
INCIDENT: The checkout page is returning HTTP 503 errors for all users.
======================================================================
[TRIAGE] AREA: web/checkout | SEV: SEV2 | Checkouts returning 503 errors
[INVESTIGATOR] ## Checkout 503 errors
If the checkout page returns HTTP 503, the web tier is saturated. Scale it up:
`kubectl scale deploy/web --replicas=5 -n prod`. Then check the load balancer
health in the Acme dashboard.
[FIXER] Run the following command to scale up the web tier:
```bash
kubectl scale deploy/web --replicas=5 -n prod
```
[REVIEWER] APPROVED: Command matches runbook and is a non-destructive scaling action.
======================================================================
OUTCOME: APPROVED — ready for a human to apply
All three runs landed on OUTCOME: APPROVED — the structural decision held steady across a
0 → 0.9 temperature jump. The Triage prose did move. On this model the format held (one terse
AREA: ... | SEV: ... line every time, no run-on or multi-section write-up), but the content
drifted: the severity came back as SEV2, SEV1 and SEV2 for the same incident, and run 2
invented a cause the incident never mentioned (Payment gateway outage) for what is a checkout
web-tier problem. That is the small-model-variance note above made concrete: the classification
label is not a stable fact at high temperature. The crew's overall behavior — retrieve the
checkout runbook, propose the scale command, get it APPROVED — never wavered, because Triage's
output is printed but never fed forward (§3), and Investigator, Fixer and Reviewer all stayed at
their original low/zero temperatures. Raising just the Triage temperature made Triage noisier
without making the crew's final decision any less reliable — evidence for why temperature is a
per-role knob, not a per-crew one (§1).
Variant B: guardrail-off — bypass the relevance gate
Patch a second copy so the relevance gate always passes, regardless of what the model says —
this simulates a guardrail that got silently disabled (a real production failure mode: a feature
flag left in a bypass state, a refactor that accidentally always sets relevant = True). The
gate's whole decision collapses to one unambiguous suffix, .startswith("YES") — replacing just
that suffix with or True is a one-line, unambiguous patch that doesn't depend on matching the
multi-line f-string above it:
cd ~/crew-deepdive-lab
cp "$REPO_ROOT/labs/m7/crew/crew.py" ./crew-no-gate.py
python3 -c "
import pathlib
p = pathlib.Path('crew-no-gate.py')
src = p.read_text()
needle = '.upper().startswith(\"YES\")'
assert src.count(needle) == 1, f'expected exactly 1 match, found {src.count(needle)}'
p.write_text(src.replace(needle, '.upper().startswith(\"YES\") or True # DEEP-DIVE: gate bypassed on purpose'))
"
diff "$REPO_ROOT/labs/m7/crew/crew.py" ./crew-no-gate.py
Expected output
81c81
< profile("investigator"), temperature=0).upper().startswith("YES")
---
> profile("investigator"), temperature=0).upper().startswith("YES") or True # DEEP-DIVE: gate bypassed on purpose
Build and run it against the Kafka incident — the one the baseline crew correctly escalates because no runbook covers it:
cd "$REPO_ROOT/labs/m7"
cp ~/crew-deepdive-lab/crew-no-gate.py crew/crew.py.deepdive-no-gate
docker build -t acme-incident-crew:no-gate \
--build-arg CREW_FILE=crew.py.deepdive-no-gate \
-f - . << 'EOF'
FROM python:3.12-slim
WORKDIR /app
COPY crew/ ./crew/
COPY docs/ ./docs/
ARG CREW_FILE=crew.py
RUN cp crew/${CREW_FILE} crew/crew.py
ENTRYPOINT ["python", "crew/crew.py"]
EOF
rm crew/crew.py.deepdive-no-gate
docker run --rm --network m7_default \
-e OLLAMA_BASE_URL=http://host.docker.internal:11434 \
-e CHROMA_HOST=chromadb -e CHROMA_PORT=8000 \
-e LLM_MODEL=qwen3.5:2b -e EMBEDDING_MODEL=nomic-embed-text \
acme-incident-crew:no-gate \
"The Kafka event streaming cluster has stopped processing messages." \
| tee ~/crew-deepdive-lab/variant-b-kafka-no-gate.log
Captured output — one real run
[crew] Acme Incident Crew: Triage -> Investigator -> Fixer -> Reviewer (4 profiles, one shared model: qwen3.5:2b)
======================================================================
INCIDENT: The Kafka event streaming cluster has stopped processing messages.
======================================================================
[TRIAGE] AREA: database | SEV: 1 | Kafka event stream processing halted
[INVESTIGATOR] ## Payments service
To restart the Acme payments service, run:
`kubectl rollout restart deploy/payments -n prod`.
The payments service depends on the Postgres primary in the `prod` namespace.
[FIXER] Restart the payments deployment to resolve the dependency issue.
```bash
kubectl rollout restart deploy/payments -n prod
```
[REVIEWER] APPROVED: Command is a non-destructive, runbook-backed remediation for restarting a dependent service.
======================================================================
OUTCOME: APPROVED — ready for a human to apply
With the gate bypassed, runbook is whatever ChromaDB's nearest-neighbour search returned for
the Kafka query — the payments runbook chunk, retrieved and printed by [INVESTIGATOR] above
with total confidence and no hint that it's the wrong section. The baseline crew (§ Baseline
above, and the lab's own Step 5) correctly says NO RUNBOOK FOUND for this exact incident,
because the relevance gate answers NO when asked whether the payments passage addresses a Kafka
failure. With the gate always returning True, the Fixer treats the mismatched passage as
confirmed-relevant and quotes its payments command. We ran this exact image six times against the
Kafka incident. Every run retrieved the payments chunk, every Fixer proposed the payments restart,
and every Reviewer answered APPROVED.
That is the sharper finding, and it differs from what a weaker Reviewer would show. The Reviewer here is given the runbook passage along with the fix, so it checks the command against that passage — and the command does match it. The Reviewer is doing its job and still approves a payments restart for a Kafka outage, because the thing it compares against is the wrong runbook. A reviewer can only be as right as the evidence handed to it. Removing the gate does not make the crew flaky here; it makes the crew consistently, confidently wrong, and the APPROVED marker now certifies a mismatch. The gate, not the Reviewer, is what stopped this. That is the finding.
Across the six runs the command never changed: every run quoted the payments restart. Small models can still vary their wording around it, which is why you judge by the command and the marker, not the prose. This is why you test a guardrail by removing it and watching, not by assuming what the downstream agents will do.
The tracked crew.py itself was never touched — only the temporary crew.py.deepdive-* copies
inside the build contexts (removed by the rm crew/crew.py.deepdive-* line right after each
build) and the two extra local images (removed together in the section teardown below). Confirm
the source tree is clean:
git -C "$REPO_ROOT" status --short labs/m7/crew/crew.py
Expected output
(no output — a clean `git status --short` on this path prints nothing)
Comparison table
| Variant | Knob changed | Wall time | Tool/model calls | OUTCOME marker | Consistency vs. baseline |
|---|---|---|---|---|---|
| Baseline | none (Triage=0, gate=0, Fixer=0.2, Reviewer=0) | ~9.5s | 5 (fixed, per §2) | APPROVED | — |
| A (×3 repeats) | Triage temperature 0 → 0.9 | ~6.6–8.6s per run | 5 per run | APPROVED, all 3 runs | Structural outcome stable; Triage stayed one terse line but its content drifted: SEV2, SEV1, SEV2 for the same incident, and run 2 invented a "payment gateway outage" the report never mentioned |
| B | Relevance gate bypassed (relevant = True, always) | ~4.5s (3.9–9.7s over 6 runs) | 5 (same count — gate bypass changes the decision, not the call count) | APPROVED in 6 of 6 runs — pinned to the wrong answer | Retrieves the wrong (payments) runbook every time, the Fixer quotes the payments restart, and the Reviewer, comparing it to that same wrong passage, approves it. The gate, not the Reviewer, is what made the Kafka incident escalate in the Baseline |
Wall times are one machine, and the first (Baseline) run also paid for loading the model, so read them as the same order of magnitude, not a benchmark.
Judge the deterministic side of this table strictly, and note what "deterministic" is
conditional on. In Baseline and Variant A, the relevance gate is intact, so the OUTCOME: line
and which stages ran are exact, reproducible facts about each run (per the small-model-variance
note above) — the gate pins the verdict regardless of what Triage's temperature does upstream. In
Variant B, the gate itself is the thing removed, and the table row above documents what breaks:
the OUTCOME: marker is still stable, but it is stable on the wrong answer. Judge the Triage/Fixer/Reviewer prose in every variant
by shape — did it stay on-topic, did it reach a defensible disposition — not by exact wording,
which will vary between the three Variant A repeats even though nothing else changed between
them. Wall times above are single-machine, single-run measurements on this
laptop, included to show the same order of magnitude across variants (temperature and a gate
bypass do not change the crew's call count, so wall time stays roughly flat) — not a timing
benchmark to reproduce exactly.
Teardown for this section only — remove the two deep-dive-only images; this does not touch
the tracked acme-incident-crew:latest image the lab itself built:
docker rmi acme-incident-crew:hot-triage acme-incident-crew:no-gate 2>/dev/null || true
Expected output
Untagged: acme-incident-crew:hot-triage
Deleted: sha256:bba6a0cebfdbf3382d0a1ab9f4ce32961430439947705fb62e7f9d2a34a734cf
Untagged: acme-incident-crew:no-gate
Deleted: sha256:a980d19975f15730511c5dfcff4c9023b14be6a34be44f0592f126bd2f2aea51
Teardown
Page-scoped only. Remove the local working directory this page created:
rm -rf ~/crew-deepdive-lab
Leave the m7 stack as the lab's own teardown (labs/m7/down.sh) expects to find it — this page
never ran docker compose down itself, and the two extra images it built (hot-triage,
no-gate) are already removed above. If you're done with the module entirely, run the lab's own
teardown:
cd labs/m7 && bash down.sh
Expected output
Container chromadb Stopping
Container chromadb Stopped
Container chromadb Removing
Container chromadb Removed
Network m7_default Removing
Network m7_default Removed
- Temperature is a per-agent-role decision, not a per-crew setting. Use it when: you're
tuning a real multi-agent pipeline — pin classification and safety-gate calls at
0, leave headroom (0.2–0.4) only on the one or two calls that generate prose around an already-determined fact, the way this crew's Fixer does. - A sequential, single-pass pipeline trades self-correction for predictable cost and latency — that's a deliberate choice, not a missing feature. Use it when: you're bounding an agent's cost/latency for an SLO — a fixed-hop pipeline like this crew gives you an exact worst-case call count; an unbounded ReAct loop needs an explicit iteration cap before it's safe to put in front of the same SLO.
- Delegation between agents is only as safe as what actually crosses each hop — check what's really being passed, not what the diagram implies. Use it when: debugging a multi-agent pipeline that "isn't using" information you thought it had — as this crew shows, Triage's classification is displayed but never fed into the Investigator's query; the same gap can exist silently in a framework crew.
- A code-level gate (
.startswith("YES"),.startswith("APPROVED")) beats a prompt-level ask because it can't be talked out of its answer. Use it when: you're deciding where to add a safety check in a new pipeline — put it after the tool call that produced risky input, and again at final-answer time before a human sees the result; a follow-up prompt asking the model to double-check itself is not a substitute for either. - A code-level gate, not low temperature by itself, is what pins the outcome marker run to
run. Variant A raised Triage's temperature and the
OUTCOME:marker held steady — because the gate was still there to pin it. Variant B left every temperature untouched and removed only the gate. Without the gate the marker stayed stable, APPROVED in 6 of 6 runs, but it certified the wrong runbook. Use it when: you're evaluating whether a crew's output is reliable enough to trust. Don't read a stable outcome marker as a correct one until you've confirmed there's still a code-level gate between the model's opinion and the marker; a stable-looking pipeline with no gate can be consistently, confidently wrong.