Lab 12 · The Northwind research analyst
Build a deep agent that plans, delegates research to a subagent, follows a skill, obeys its AGENTS.md memory and writes a customer health report to a virtual filesystem.
What you will build
A research analyst for Northwind Cloud Customer Success. Ask it for a health report on ACCT-1042 and it plans the work with a todo list, sends a researcher subagent to dig through the knowledge base, scores the account with the rules in a skill, writes the report to /reports/ in its own virtual filesystem, and grades the result against a rubric before it stops.
This is the first lab where the agent does long-horizon work, so it is also the first where context management decides whether it succeeds.
What you will learn
- How
create_deep_agentadds filesystem tools, subagents, skills and memory on top ofcreate_agent - How a
CompositeBackendmixes read-only files on disk with scratch space in thread state - Why a subagent's
descriptiondecides whether the parent delegates - How skills load in three layers (progressive disclosure) and why
AGENTS.mdcosts tokens on every call - How
RubricMiddlewaremakes the agent grade and revise its own output
One run makes 10 to 20 model calls (planner, subagent, rubric grader) and takes one to three minutes. That is normal for a deep agent: it is doing real multi-step work.
Check your prerequisites
You need three things on your machine and three values from your instructor.
- Python 3.11, 3.12 or 3.13. Check with the command below. On Windows, install it from python.org and tick Add python.exe to PATH.
- A code editor. VS Code with the Python extension works well.
- A terminal. PowerShell on Windows, Terminal on macOS or Linux.
python --versionpython3 --versionFrom your instructor you get an Azure OpenAI endpoint, an API key and a deployment name. Keep the key private: never paste it into code or share it in chat.
Create the project folder and virtual environment
Create a new folder for this lab, open it in a terminal, and create a virtual environment. A virtual environment is a private copy of Python for this project, so the libraries you install here never clash with other projects.
mkdir C:\labs\lab-12-research-analyst
cd C:\labs\lab-12-research-analyst
python -m venv .venv
.venv\Scripts\Activate.ps1mkdir -p ~/labs/lab-12-research-analyst
cd ~/labs/lab-12-research-analyst
python3 -m venv .venv
source .venv/bin/activateKeep lab folders on a short path such as C:\labs. Deep folders, for example inside OneDrive or Desktop, can exceed the Windows path limit. Some libraries then fail to load with The filename or extension is too long. OneDrive syncing can also lock files the labs write.
When activation works, your prompt starts with (.venv). Activate it again every time you open a new terminal for this lab. Open the folder in VS Code with code . if you like.
PowerShell says running scripts is disabled on this system? Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, answer Y, and activate again.
This is what your folder will look like at the end of the lab:
Install the libraries
Create a file named requirements.txt in your lab folder with the content below. The versions are pinned to the ones this lab was tested with.
langchain==1.4.2
langchain-core==1.6.4
langchain-openai==1.6.2
python-dotenv==1.2.3
deepagents==0.7.18
langgraph==1.2.11deepagents is LangChain's agent harness. It pulls in a few provider packages of its own (Anthropic, Google), so the install takes a little longer than usual. You still use only your Azure OpenAI deployment.
Then install everything into your virtual environment. It takes a minute or two.
python -m pip install --upgrade pip
pip install -r requirements.txtpython -m pip install --upgrade pip
pip install -r requirements.txtAdd your Azure OpenAI settings
Create a file named .env (the name starts with a dot) in your lab folder. Paste the template and replace the three placeholders with the values from your instructor.
# Azure OpenAI settings. Your instructor gives you these three values.
AZURE_OPENAI_ENDPOINT=https://<your-resource>.openai.azure.com/openai/v1
AZURE_OPENAI_API_KEY=<paste-your-key-here>
AZURE_OPENAI_DEPLOYMENT=<your-chat-deployment-name>
# Only used when your endpoint does NOT end in /openai/v1.
OPENAI_API_VERSION=2025-04-01-preview- AZURE_OPENAI_ENDPOINT is your resource URL. If it ends in
/openai/v1, keep it that way. If it is justhttps://<resource>.openai.azure.com/, paste that; the helper in the next step handles both. - AZURE_OPENAI_API_KEY is your secret key.
- AZURE_OPENAI_DEPLOYMENT is the deployment name, for example
gpt-5-mini. On Azure you call a deployment, not a model name.
Never commit .env to Git or share it. If you use Git, add a .gitignore file containing .env and .venv/.
Create the Azure OpenAI helper
Every lab talks to Azure OpenAI through one small helper file. Create model_setup.py and add the blocks below one after another. You write it once per lab folder; the lab code then only needs from model_setup import get_model.
Block 1Imports, .env loading and a console fix
load_dotenv() reads your .env file and puts every line into environment variables, so your key never appears in code.
The loop below it switches the console to UTF-8. Windows terminals default to an older encoding and crash when a model prints curly quotes or dashes.
"""Azure OpenAI model setup shared by every lab.
Import it in lab code as: from model_setup import get_model, get_embeddings
"""
import asyncio
import os
import sys
from dotenv import load_dotenv
from langchain_core.embeddings import Embeddings
from langchain_core.language_models import BaseChatModel
# Read AZURE_OPENAI_* values from the .env file next to your lab code.
load_dotenv()
# Windows consoles default to cp1252 and crash on the curly quotes models love to print.
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
_stream.reconfigure(encoding="utf-8", errors="replace")Block 2Read settings safely
_setting() fetches a value from the environment and stops with a friendly message if it is missing, instead of a long stack trace.
_uses_v1_api() checks the shape of your endpoint. Azure offers two:
https://<resource>.openai.azure.com/openai/v1is the newer OpenAI-compatible API.https://<resource>.openai.azure.com/is the classic API that needs anapi_version.
def _setting(name: str) -> str:
"""Return a required setting from .env, or stop with a readable message."""
value = os.getenv(name, "").strip()
if not value:
sys.exit(f"Missing {name} in your .env file. Open .env and fill it in.")
return value
def _uses_v1_api(endpoint: str) -> bool:
"""True for the OpenAI-compatible Azure endpoint that ends in /openai/v1."""
return endpoint.rstrip("/").endswith("/openai/v1")Block 3Build the chat model
get_model() returns a LangChain chat model connected to your deployment.
- With a
/openai/v1endpoint it usesChatOpenAIwithbase_url. On Azure the deployment name goes inmodel. - With a classic endpoint it uses
AzureChatOpenAIwithazure_deploymentandapi_version.
Either way the lab code just calls get_model() and never cares which one it got.
def get_model(deployment: str | None = None, **kwargs) -> BaseChatModel:
"""Return a chat model for your Azure OpenAI deployment."""
endpoint = _setting("AZURE_OPENAI_ENDPOINT")
api_key = _setting("AZURE_OPENAI_API_KEY")
name = deployment or _setting("AZURE_OPENAI_DEPLOYMENT")
if _uses_v1_api(endpoint):
from langchain_openai import ChatOpenAI
return ChatOpenAI(model=name, base_url=endpoint, api_key=api_key, **kwargs)
from langchain_openai import AzureChatOpenAI
return AzureChatOpenAI(
azure_deployment=name,
azure_endpoint=endpoint,
api_key=api_key,
api_version=os.getenv("OPENAI_API_VERSION", "2025-04-01-preview"),
**kwargs,
)Block 4Build the embeddings model
get_embeddings() does the same for embeddings, which turn text into vectors for search. Only the retrieval labs use it, but keeping one helper for every lab is simpler.
def get_embeddings(deployment: str | None = None) -> Embeddings:
"""Return an embeddings model for your Azure OpenAI embeddings deployment."""
endpoint = _setting("AZURE_OPENAI_ENDPOINT")
api_key = _setting("AZURE_OPENAI_API_KEY")
name = deployment or _setting("AZURE_OPENAI_EMBEDDING_DEPLOYMENT")
if _uses_v1_api(endpoint):
from langchain_openai import OpenAIEmbeddings
return OpenAIEmbeddings(model=name, base_url=endpoint, api_key=api_key)
from langchain_openai import AzureOpenAIEmbeddings
return AzureOpenAIEmbeddings(
azure_deployment=name,
azure_endpoint=endpoint,
api_key=api_key,
api_version=os.getenv("OPENAI_API_VERSION", "2025-04-01-preview"),
)Block 5A Windows fix for async code
use_stable_event_loop() matters only for the async MCP lab. The default Windows event loop can crash on exit after async network sessions close. On macOS and Linux it does nothing.
def use_stable_event_loop() -> None:
"""On Windows, use the Selector event loop so async MCP code exits cleanly."""
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())Check that the helper can reach your deployment with a one-line test:
python -c "from model_setup import get_model; print(get_model().invoke('Say hello in 5 words').text)"python -c "from model_setup import get_model; print(get_model().invoke('Say hello in 5 words').text)"You should see a short greeting. If you see an error instead, check the troubleshooting step at the end.
Download the lab files
The agent reads two kinds of project files from disk: an operating memory file and a skill. Download both and put them in your lab folder:
- Save
AGENTS.mddirectly in the lab folder, next to whereresearch_analyst.pywill go. - Unzip
skills.zipinto the lab folder. It contains askillsfolder, so you end up withskills/report-writing/SKILL.md(notskills/skills/...). Check the folder tree at the top of this guide.
Open both files and skim them. They are plain Markdown, and they are the whole point of this lab:
AGENTS.mdis memory. It is injected into the system prompt at start, every time, so it holds short, durable rules: cite the account id first, ISO dates, never invent a metric.skills/report-writing/SKILL.mdis a skill. Only its frontmatternameanddescriptiongo into the prompt at start. The agent reads the body withread_filewhen it decides the skill applies, and opensreferences/health-scoring.mdandassets/report-template.mdonly because the body points at them. That is progressive disclosure: ten skills cost ten short descriptions, not ten documents.
Write the research analyst
A plain tool-calling loop fails on long tasks in four ways: the context fills with raw tool output, there is no plan, one context does all the work, and quality drifts. A deep agent adds a fix for each: a virtual filesystem, write_todos, subagents, and skills plus rubrics. create_deep_agent is still create_agent underneath, with a curated middleware stack.
Create an empty file named research_analyst.py in your lab folder. Then copy each block below into it, in order, one after another.
Block 1Imports and the lab folder
create_deep_agentbuilds the harness;RubricMiddlewareadds self-grading;FilesystemPermissionrestricts file access.- The three backends decide where the agent's files really live.
TodoListMiddlewareprovides thewrite_todosplanning tool.
MODULE_DIR is the folder this file sits in, so AGENTS.md and skills/ are found no matter where you start Python from.
"""Lab 12: the Northwind research analyst, a deep agent that writes customer health reports.
Run: python research_analyst.py
"""
from pathlib import Path
from typing import Any
from deepagents import FilesystemPermission, RubricMiddleware, create_deep_agent
from deepagents.backends import CompositeBackend, FilesystemBackend, StateBackend
from langchain.agents.middleware import TodoListMiddleware
from langchain.tools import tool
from langchain_core.language_models import BaseChatModel
from model_setup import get_model
# The lab folder: AGENTS.md and skills/ live next to this file.
MODULE_DIR = Path(__file__).resolve().parentBlock 2A pretend knowledge base
Two accounts stand in for a real CRM. ACCT-1042 is deliberately in trouble (usage down, tickets up 62 %, two P1 incidents, no exec sponsor) so the report has something to say.
ACCOUNTS: dict[str, str] = {
"ACCT-1042": (
"Contoso Metals. Plan: Enterprise, 120 seats, 96,000 USD ARR, renews 2026-11-30. "
"Weekly active users 49 of 120 seats (down from 78 last quarter). "
"Support tickets last 90d: 34 (up 62%). P1 incidents: 2. NPS: 6. "
"Exec sponsor left in July; no replacement named."
),
"ACCT-2210": (
"Fabrikam Logistics. Plan: Growth, 40 seats, 28,800 USD ARR, renews 2027-03-15. "
"Weekly active users 33 of 40 seats (flat). Support tickets last 90d: 6 (down 25%). "
"P1 incidents: 0. NPS: 9. Exec sponsor: VP Ops, engaged."
),
}Block 3Two system prompts
The parent prompt lists the workflow: plan, delegate, follow the skill, save to /reports/. It also forbids inventing metrics.
The researcher prompt is for the subagent. A subagent returns exactly one message to its parent, so the prompt says precisely what that message contains (a compact findings block) and what to leave out (preamble, recommendations).
SYSTEM_PROMPT = """You are the Northwind Cloud research analyst. You write customer
health reports for CSMs preparing a renewal call.
1. Plan the task with write_todos before you start.
2. Delegate the digging to the `researcher` subagent so raw tool output never fills
your own context.
3. Follow the report-writing skill for structure, scoring and tone.
4. Save the finished report to /reports/<account>.md and tell the CSM where it is.
Never state a metric the research did not return: write `not available` and raise it
as an action instead.
"""
RESEARCHER_PROMPT = """You dig into one account with search_kb and return a single
compact findings block: plan, ARR, seats, usage trend, ticket volume and trend, P1
count, NPS, renewal date and any relationship risk. No preamble, no recommendations.
Write `not available` for anything the knowledge base does not have.
"""Block 4The rubric
The grader sees only the finished report, not the run. It cannot re-run your tools, so every criterion must be checkable by reading the report alone. "The account id appears in the first sentence" works; "is accurate" does not. The rubric is passed at invoke time, not to the constructor, so one agent can be held to a different bar per request.
REPORT_RUBRIC = """Grade the health report. Every criterion must pass.
- The account id appears in the first sentence.
- A health band (Green / Amber / Red) is stated with the inputs that produced it.
- Missing metrics read `not available` rather than a guess.
- The report ends with at least two actions, each with an owner and a due horizon.
- Money is formatted `96,000 USD` and dates are ISO (`2026-11-30`).
"""Block 5The one tool you write
A case-insensitive lookup. An unknown id returns a helpful message listing the known ids instead of raising, so the model can recover. Only the researcher subagent gets this tool.
@tool
def search_kb(account_id: str) -> str:
"""Look up a Northwind account in the customer knowledge base.
Use whenever you need facts about an account: plan, ARR, seat count, usage trend,
support ticket volume, P1 incidents, NPS or renewal date. Takes an id like ACCT-1042.
"""
record = ACCOUNTS.get(account_id.strip().upper())
if record:
return record
return f"No account found with id {account_id}. Known accounts: {', '.join(sorted(ACCOUNTS))}."Block 6Where the files live: a CompositeBackend
The filesystem tools (ls, read_file, write_file, ...) are an interface; the backend decides what is behind each path:
- Everything by default goes to
StateBackend: files live in the conversation state and vanish with it. Good for/research/notes and/reports/. /skills/maps to the realskillsfolder on disk./memories/maps to your lab folder, so/memories/AGENTS.mdis the real file.
FilesystemBackend gives the model real file access, and your lab folder also holds .env with your API key. The permission rules are checked top to bottom and the first match wins: the agent may read AGENTS.md, and nothing else under /memories/, and may never write there.
# The skill and AGENTS.md are real files on disk; everything the agent writes stays
# in thread state and disappears with the conversation.
BACKEND = CompositeBackend(
default=StateBackend(),
routes={
"/skills/": FilesystemBackend(root_dir=str(MODULE_DIR / "skills"), virtual_mode=True),
"/memories/": FilesystemBackend(root_dir=str(MODULE_DIR), virtual_mode=True),
},
)
# /memories/ is your lab folder, which also holds .env with your API key.
# Rules are checked in order and the first match wins: the agent may read AGENTS.md
# and nothing else there, and may never write there.
PERMISSIONS = [
FilesystemPermission(operations=["read"], paths=["/memories/AGENTS.md"], mode="allow"),
FilesystemPermission(operations=["read", "write"], paths=["/memories/**"], mode="deny"),
]Block 7The researcher subagent
A subagent is a dict: name, description, system prompt and tools. The parent calls it through the built-in task tool. The subagent runs in a fresh context, makes as many calls as it needs, and hands back one message, so the parent's context grows by two entries however messy the research was.
The description is the only thing the parent reads when deciding whether to delegate. If your agent never delegates, fix this text first.
# The parent reads `description` when it decides whether to delegate. Write it for that
# reader: the subagent is surfaced to the parent as the `task` tool.
RESEARCHER: dict[str, Any] = {
"name": "researcher",
"description": (
"Digs into ONE account and returns a compact findings block. Use it for every "
"account you need facts about."
),
"system_prompt": RESEARCHER_PROMPT,
"tools": [search_kb],
}Block 8Assemble the deep agent
model: always pass one. Withmodel=None,create_deep_agentfalls back to a deprecated Anthropic default and fails with a confusing auth error.skills=["/skills/"]: where to find skill folders (through the backend).memory=["/memories/AGENTS.md"]: files injected into the system prompt.TodoListMiddleware(): planning has been opt-in since deepagents 0.7, so without it there is nowrite_todostool.RubricMiddleware(max_iterations=2): after the agent finishes, a grader checks the rubric and, if a criterion fails, sends the agent back to revise, at most twice.on_evaluationlets you watch each verdict.
def build_agent(
model: BaseChatModel | None = None,
todos: bool = True,
rubric: bool = False,
on_evaluation: Any = None,
) -> Any:
"""Build the analyst deep agent. Pass a model to override the Azure OpenAI default."""
model = model or get_model()
middleware: list[Any] = [TodoListMiddleware()] if todos else []
if rubric:
middleware.append(RubricMiddleware(model=model, max_iterations=2, on_evaluation=on_evaluation))
return create_deep_agent(
# Always pass a model: create_deep_agent(model=None) falls back to a deprecated
# hard-coded Anthropic default.
model=model,
tools=[search_kb],
system_prompt=SYSTEM_PROMPT,
subagents=[RESEARCHER],
skills=["/skills/"],
memory=["/memories/AGENTS.md"],
permissions=PERMISSIONS,
backend=BACKEND,
middleware=middleware,
name="northwind-research-analyst",
)Block 9Run it and show the work
- The
rubrickey goes into the invoke input next tomessages. recursion_limit: 100: the default of 25 steps is not enough for a planner with a subagent.
Then the demo prints four things: the parent's tool calls (look for a single task call), the todo list left in state, the grader's verdict per iteration, and the virtual files, including the full report the agent wrote. Nothing was written to your disk: result["files"] is thread state.
if __name__ == "__main__":
evaluations: list[dict[str, Any]] = []
agent = build_agent(rubric=True, on_evaluation=evaluations.append)
print("Writing the health report for ACCT-1042 (this takes a minute or two)...\n")
result = agent.invoke(
{
"messages": [{"role": "user", "content": "Health report for ACCT-1042, please."}],
"rubric": REPORT_RUBRIC,
},
{"recursion_limit": 100}, # 25 is not enough for a planner with a subagent
)
# 1. What the parent agent did. The researcher's search_kb calls happened inside
# its own run, so they do not appear here: only the single `task` call does.
calls = [call for message in result["messages"] for call in getattr(message, "tool_calls", [])]
print("Parent tool calls:")
for call in calls:
args = call["args"]
target = args.get("file_path") or args.get("subagent_type") or args.get("path") or ""
if args.get("pattern"):
target = f"{args['pattern']!r} in {target or '/'}"
print(f" {call['name']:<12} {target}")
# 2. The plan the agent kept in state with write_todos.
print("\nPlan (write_todos):")
for todo in result.get("todos", []):
print(f" [{todo['status']}] {todo['content']}")
# 3. The rubric grader's verdict for each attempt.
print("\nRubric grading:")
for evaluation in evaluations:
print(f" iteration {evaluation['iteration']}: {evaluation['result']}")
for criterion in evaluation.get("criteria", []):
print(f" [{'pass' if criterion['passed'] else 'FAIL'}] {criterion['name']}")
# 4. The virtual filesystem: files the agent wrote into thread state.
files: dict[str, Any] = result.get("files", {})
print("\nVirtual files:", ", ".join(sorted(files)) or "none")
reports = sorted(path for path in files if path.startswith("/reports/"))
if reports:
print(f"Report saved to: {reports[-1]}\n")
content = files[reports[-1]]["content"]
print("-" * 60)
print(("\n".join(content) if isinstance(content, list) else content).strip())
print("-" * 60)
else:
print("The agent did not save a report under /reports/. Run it again or check the prompt.")
print("\nAgent's final message:")
print(result["messages"][-1].text)Run the analyst
python research_analyst.pypython3 research_analyst.pyYour output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.
Writing the health report for ACCT-1042 (this takes a minute or two)...
Parent tool calls:
write_todos
task researcher
read_file /skills/report-writing/SKILL.md
read_file references/health-scoring.md
grep 'health-scoring' in /
write_todos
write_file /reports/ACCT-1042.md
write_todos
Plan (write_todos):
[completed] Run researcher subagent to collect ACCT-1042 account facts (ARR, seats, usage trend, support volume, P1 incidents, NPS, renewal date, exec sponsor, last communication, recent tickets, billing, contract term, product modules, health_score).
[completed] Draft the Northwind customer health report for ACCT-1042 and save to /reports/ACCT-1042.md.
[in_progress] Notify CSM where the report is saved and list any actions required.
Rubric grading:
iteration 0: satisfied
[pass] The account id appears in the first sentence.
[pass] A health band (Green / Amber / Red) is stated with the inputs that produced it.
[pass] Missing metrics read `not available` rather than a guess.
[pass] The report ends with at least two actions, each with an owner and a due horizon.
[pass] Money is formatted `96,000 USD` and dates are ISO (`2026-11-30`).
Virtual files: /reports/ACCT-1042.md
Report saved to: /reports/ACCT-1042.md
------------------------------------------------------------
## Summary
ACCT-1042 (Contoso Metals) — RED health; renewal on 2026-11-30. Usage and engagement are falling, support volume is rising, NPS is low (6), and the executive sponsor left in July with no replacement named.
## Signals
- ARR: 96,000 USD — direction: not available (pull ARR trend).
- Seats: 120 — direction: not available.
- Weekly active users: 49 of 120 seats — down (was 78 last quarter).
- Usage trend (90d): down.
- Support tickets (90d): 34 — up 62%.
- P1 incidents (12mo): 2 — direction: not available.
- NPS: 6 — direction: not available.
- Executive sponsor: not available (left in July; no replacement named).
- Renewal date: 2026-11-30.
## Risks
- Missing executive sponsor → evidence: relationship_risk entry: "Exec sponsor left in July; no replacement named." (threatens renewal advocacy).
- Declining adoption → evidence: weekly active users down from 78 to 49; usage_trend_90d = down.
- Rising support burden and low satisfaction → evidence: 34 tickets in last 90d (up 62%), NPS 6, 2 P1s in last 12 months.
- Unknown commercial/engagement details → evidence: billing_status, contract_term_months, last_communication_date all returned not available (could block renewal logistics).
## Actions
1) Owner: CSM — This week: Reach out to Contoso executive team to identify/replace sponsor and schedule an executive alignment meeting. (High priority)
2) Owner: Solutions Engineer / CSM — This month: Launch a targeted adoption play (refresher training + success plan) for the 71 at-risk seats; propose a 30/60/90-day engagement plan.
3) Owner: Support Lead — This week: Triage the recent 34 tickets, produce root-cause summary, and propose mitigations to reduce P1 risk.
4) Owner: CSM / Researcher — This week: Pull missing KB fields: active_users_30d, recent_open_tickets, billing_status, contract_term_months, product_modules, executive_sponsor, last_communication_date, health_score. (Action text: "pull <metric>")
5) Owner: Finance Ops / CSM — This month: Confirm billing status and renewal terms; surface any payment or contract changes before 2026-11-30.
## Sources
- search_kb (researcher) findings for ACCT-1042: ARR 96,000 USD; seats 120; weekly active users 49 of 120 (down from 78 last quarter); usage_trend_90d = down; support_ticket_count_90d = 34 (up 62%); P1_incidents_12mo = 2; NPS = 6; renewal_date = 2026-11-30; relationship_risk = "Exec sponsor left in July; no replacement named." Fields listed as "not available" were not returned by search_kb and are flagged above for follow-up.
------------------------------------------------------------
Agent's final message:
I wrote the ACCT-1042 health report and saved it to /reports/ACCT-1042.md.
Quick summary for the renewal call:
- Account: ACCT-1042 (Contoso Metals)
- Health band: RED
- Renewal date: 2026-11-30
- Key concerns: falling usage (weekly active users 49/120, down from 78), rising support tickets (34 in 90d, up 62%), low NPS (6), and no named executive sponsor after July departure.
- Critical actions: re-establish executive sponsor (this week), run targeted adoption play and training (this month), triage support ticket root causes (this week), and pull missing KB fields (this week): active_users_30d, recent_open_tickets, billing_status, contract_term_months, product_modules, executive_sponsor, last_communication_date, health_score.
Report location: /reports/ACCT-1042.md
If you want I can:
- Send a templated outreach email to Contoso execs for the sponsor request.
- Open the KB pulls and try to fetch the missing fields now. Which would you prefer?Read the output top to bottom:
- Parent tool calls.
write_todosfirst (the plan), thenread_fileof/skills/report-writing/SKILL.mdand its reference files (progressive disclosure), a singletask researchercall (delegation), andwrite_fileto/reports/. There is nosearch_kbin the parent's list: that happened inside the subagent's own context. You may also see aread_fileof a relative path such asreferences/health-scoring.mdfollowed by agrep: the model tried the path as written inSKILL.md, got an error, and searched for the file instead. - Plan. The todo list the agent kept in state, all
completed. - Rubric grading. One line per grader pass.
satisfiedon iteration 0 means the first draft passed every criterion. If a criterion fails, the agent revises and you see an iteration 1 (at most two, because ofmax_iterations=2). - The report. The five sections from the skill, a Red band for ACCT-1042 with the inputs that produced it, ISO dates and money as
96,000 USDfromAGENTS.md, and numbered actions with owners.
The exact tool order and wording change from run to run.
Check your work
You are done when
- The parent's tool calls include
task researcherand nosearch_kb. - The agent read
/skills/report-writing/SKILL.mdbefore writing the report. - A report was saved under
/reports/, and its first sentence namesACCT-1042. - The report states a health band with its inputs and ends with at least two actions, each with an owner and a due horizon.
- Every number in the report appears in the
ACCT-1042knowledge-base record.
Troubleshooting
GraphRecursionError: Recursion limit of 25 reached
Pass {"recursion_limit": 100} as the second argument to agent.invoke. A planner with a subagent easily needs more than 25 steps.
The agent never reads the skill, or reports /skills/ is empty
Check the folder layout: the file must be skills/report-writing/SKILL.md next to research_analyst.py. If you unzipped into a new folder you may have skills/skills/...; move it up one level.
The agent never calls task
Improve the subagent's description. That text, not the system prompt, is what the parent reads when deciding whether to delegate.
Authentication error that mentions Anthropic
create_deep_agent was called without a model. Pass model=get_model() (or the model you built) explicitly.
The run is slow or costs more than expected
Each rubric iteration is another full revision plus a grading call. Run with build_agent(rubric=False) while you work on prompts, then turn the rubric back on.
401 Unauthorized or 'Access denied due to invalid subscription key'
The key in .env is wrong or has spaces around it. Copy it again from your instructor, with no quotes.
404 'DeploymentNotFound' or 'The API deployment for this resource does not exist'
AZURE_OPENAI_DEPLOYMENT must be the deployment name exactly as your instructor gave it, and the endpoint must be the same resource.
429 Too Many Requests
The shared deployment is busy. Wait 30 seconds and run again.
ModuleNotFoundError: No module named 'model_setup' (or another package)
Run the command from inside your lab folder, with the virtual environment activated. Reinstall with pip install -r requirements.txt if a library is missing.
'python' is not recognized, or the wrong Python runs
Activate the virtual environment first. On Windows you can also try py -3.11 instead of python when creating the venv.
Go further
- Ask for
ACCT-2210and check that the band comes out Green with the same structure. - Run with
build_agent(todos=False)and confirmwrite_todosdisappears from the tool calls. - Add a second subagent (
competitor-research) and watch how the parent chooses between them. - Swap the default route to a
StoreBackendso notes survive across threads, and see what changes for a second conversation about the same account. - Add
interrupt_on={"write_file": True}and approve each write. Useful when the filesystem is real. - Replace the dict knowledge base with the Module 06 vector store and compare report quality.
Stuck?
Download the finished project and compare it with yours. It contains every file from this guide except .env and .venv, so you still add your own settings.