~10–14 hrs
Requires: Project 01, Project 03
Project 06 of 9

AI Coding Copilot

Give a model eyes — tool use and the agentic loop, so it reads your code instead of guessing

📖 Read the lesson ✍ Fill in understanding 🛠 Build the project 🚫 Break it 📊 Evaluate it

Learning Objectives

By the end of this project, you will be able to:

  • Explain why an LLM with no tools can only guess about your codebase, and why tools are "one of the highest-leverage primitives you can give an agent."
  • Define a tool as a name + description + JSON-schema parameters, and explain why "the description is the API."
  • Trace the tool-use protocol: the model returns a structured call, your code executes it, you feed the result back, the model continues.
  • Implement the agentic loop — call → dispatch tools → feed results back → repeat — and explain why the loop, not a clever prompt, is the program.
  • Explain ReAct (interleaved reasoning + acting) and why acting reduces the hallucination a reasoning-only prompt produces.
  • Distinguish context injection (retrieve relevant files up front) from tool-fetched context (the model pulls what it needs), and say when each wins.
  • Parse provider tool calls correctly — including that arguments arrives as a JSON string — and bound the loop with max_steps.
  • Name the major failure modes of a tool-using copilot (runaway loop, path traversal, vague descriptions, no result feedback) and which decision causes each.

1. Motivation

Why This Exists

An LLM is a pure text function: it cannot read your files, list your directory, or run your tests. Ask a bare model "why does auth.py throw on login?" and it has never seen auth.py — so it guesses, fluently and wrongly. The two non-answers before tool use were: paste your whole repo into the prompt (blows the context window and the bill), or accept generic advice untethered from your code. Tool use is the fix: give the model a few functions — read a file, list a directory, search the code — and let it pull exactly the context it needs.

The Core Problem

You want an assistant that answers questions about your codebase, grounded in the real files, not a plausible hallucination. You can't fit the repo in the prompt, and you can't predict which files matter ("fix this bug" might touch one file or five). The answer is an agent: "an LLM using tools based on environmental feedback in a loop." It reasons about what it needs, calls a tool to get it, observes the result, and repeats until it can answer.

What Breaks Without It

A tool that takes a file path is an attack surface — an unsandboxed read_file will happily return ../../../../etc/passwd. A loop with no cap spins forever, burning tokens. A vague tool description makes the model call the wrong tool. None of these show up in the happy-path demo — they show up in production.

Real-World Stakes

This is the most widely adopted AI product category: GitHub Copilot, Cursor, Claude Code, and every internal "ask our codebase" tool are this pattern. On real software-engineering benchmarks like SWE-bench, "adding even basic tools produces outsized capability gains."

Startup Lens

Developer tools are the fastest category to monetize — developers pay for tools that save time. A copilot that reads your code and answers in-context removes the constant context-switch from "writing code" to "grepping and reading docs" — immediately measurable value.

2. Mental Model

Explain Like I'm 12

Imagine you text a very smart friend "why won't my game start?" — but they can't see your computer. They'll guess, and probably guess wrong. Now imagine you give them three buttons they can press that you answer for them: "show me a file," "list a folder," and "search for a word." Now the conversation changes: they press "list folder," you tell them what's there; they press "show me game.py," you paste it; they read it and say "line 12 — you forgot to load the level." They didn't get smarter — they got eyes. The whole project is: (1) build those buttons, (2) let your friend keep pressing buttons until they actually know the answer, and (3) make sure "show me a file" can't be tricked into showing them your password file.

Explain Like I'm a Software Engineer

  • A tool is a function you expose to the model as a schema: {"type":"function","function":{"name","description","parameters"}}. The model never sees the body — only the schema.
  • You call litellm.completion(messages, tools=...). The model replies one of two ways: plain content (it's done), or message.tool_calls — a list of {id, function:{name, arguments}} where arguments is a JSON string.
  • The loop: parse the calls → execute each (dispatch_tool(name, json.loads(arguments), repo_root)) → append one {"role":"tool","tool_call_id":id,"content":result} per call → call the model again. Repeat until it returns content with no tool calls, or you hit max_steps.
  • This is ReAct: the model's text is the Thought, the tool call is the Action, the tool result is the Observation you feed back. Every file-taking tool routes through safe_resolve(repo_root, path), which fails closed on escapes.

Real-World Analogy

Analogy

A copilot is a new contractor on their first day in your codebase, not a psychic. A bad onboarding hands them nothing and asks "fix the bug" — they guess. A good onboarding gives them a badge that opens specific doors: they can read files, list directories, and grep — but only inside this building (the repo root), and the badge won't open the server room (the sandbox). They walk around, read what they need, and then tell you the fix. The loop is them walking the building; the tool schema is what's printed on each door; the sandbox is what the badge refuses to open.

How It Works (Diagram)

 USER: "Why does config default to Groq?"
   │  (optionally: inject top-k relevant files as starting context — P03/P04 retrieval)
   ▼
 run_agent loop  (max_steps cap):
   step 1: model ──► tool_call: search_code(query="default_model")
           dispatch ─► safe_resolve+grep ─► "config.py:40 def default_model(): ..."
           append tool_result ──► loop
   step 2: model ──► tool_call: read_file(path="config.py")
           dispatch ─► safe_resolve+open ─► "<contents of config.py>"
           append tool_result ──► loop
   step 3: model ──► (no tool calls) "It defaults to Groq because PROVIDER_DEFAULTS
                      lists GROQ_API_KEY first; see config.py:23." ◄── ANSWER

3. Technical Explanation

Formal Definition

A tool is (name, description, parameters) where parameters is a JSON Schema; the set of tools is the model's action space. A tool call is (id, name, arguments) emitted by the model — in the LiteLLM/OpenAI shape, an entry in message.tool_calls with function.arguments as a JSON string. A tool result is the value your code returns for a given tool_call_id, appended as a {"role":"tool", ...} message. An agent is "an LLM using tools based on environmental feedback in a loop."

How It Works Step by Step

  1. Define the tools. Write TOOL_SCHEMAS: a name, a careful description (the model picks from this alone), and a parameters JSON Schema. Implement the matching functions.
  2. (Optional) Inject context. Embed the question, retrieve the top-k most relevant files (Project 03/04), and prepend them — cheap grounding so the model doesn't have to discover obvious files via tools.
  3. Call the model. litellm.completion(messages, tools=TOOL_SCHEMAS, tool_choice="auto"). With auto, the model decides each turn whether to call a tool or answer.
  4. Parse tool calls. If message.tool_calls is non-empty, build (id, name, arguments) and json.loads the arguments string. If empty, the model answered — return its content.
  5. Execute (dispatch). For each call, dispatch_tool(name, arguments, repo_root) routes to the right function. Every path argument goes through safe_resolve first.
  6. Feed results back, then loop. Append the assistant message (with its tool_calls), then one tool message per call — order matters. Go back to step 3, bounded by max_steps.

Key Concepts

ConceptDefinitionWhy It Matters
Tool use (function calling)Letting the model emit a structured request to call a function you defined; your code runs it and returns the resultTurns a text-in/text-out model into one that can act
Tool schemaA tool's name, description, and JSON-schema parametersThe only thing the model sees about a tool; "the description is the API"
Tool call / tool resultThe model's structured call (id, name, args) and the value you feed backThe two halves of the protocol; the result is the model's "observation"
Agentic loopcall model → run requested tools → feed results back → repeat until it answersThe program itself; the model drives control flow
ReActInterleaving reasoning traces with actions (Thought → Action → Observation)Acting fetches ground truth, cutting the hallucination of reasoning-only prompts
Context injectionRetrieve the top-k relevant files up front and put them in the promptCheap one-shot grounding; the model still can't reach beyond what you injected
Sandboxed executionResolving tool file paths under a fixed repo root and rejecting escapesA tool that takes a path is an attack surface; ../../etc/passwd must fail closed
Common Misconception

Tool use does not make the model "smarter" — it gives the model eyes. The intelligence is the loop + good tools, not a clever prompt. And the loop is not while True: the model controls it, so an unbounded loop is a runaway-budget risk. max_steps is mandatory.

4. Guided Examples

The lab stack: litellm (chat + tools via config.py), provided sandboxed file tools, and a pure-Python loop. Examples mirror the guiding tests.

Example 1: Simplest Case — sandbox a tool path

from tools import safe_resolve

repo = "/work/myrepo"
safe_resolve(repo, "src/config.py")     # -> Path("/work/myrepo/src/config.py")  (ok)
safe_resolve(repo, "./README.md")       # -> Path("/work/myrepo/README.md")       (ok)
safe_resolve(repo, "../../etc/passwd")  # -> raises ValueError  (escapes repo root)
What to Observe

A file tool that takes a path is an attack surface. safe_resolve resolves the path and fails closed if it leaves the repo root. Every tool that touches the filesystem goes through this first — the model's arguments are untrusted input.

Example 2: Real-World Case — parse the model's tool calls

from types import SimpleNamespace
from agent_loop import parse_tool_calls

# Shape LiteLLM returns: message.tool_calls[i].function.arguments is a JSON *string*.
msg = SimpleNamespace(content=None, tool_calls=[
    SimpleNamespace(id="call_1", function=SimpleNamespace(
        name="read_file", arguments='{"path": "config.py"}')),
])
calls = parse_tool_calls(msg)
print(calls[0].name)        # 'read_file'
print(calls[0].arguments)   # {'path': 'config.py'}   ← a dict, json.loads'd from the string
print(parse_tool_calls(SimpleNamespace(content="done", tool_calls=None)))  # []  (model answered)
What to Observe

The model's request arrives as objects whose arguments is a JSON stringparse_tool_calls must json.loads it into a dict. When there are no tool calls, it returns [], which is the loop's signal that the model is done.

Example 3: When It Fails — the loop without a cap runs forever

from agent_loop import run_agent
from types import SimpleNamespace

# A broken/looping model that ALWAYS asks to read the same file, never answers:
def always_calls_tool(messages, tools):
    return SimpleNamespace(content=None, tool_calls=[
        SimpleNamespace(id="c", function=SimpleNamespace(
            name="read_file", arguments='{"path": "config.py"}'))])

answer = run_agent(messages=[{"role":"user","content":"hi"}],
                   repo_root=".", complete=always_calls_tool, max_steps=3)
print(answer)   # a "max steps reached" sentinel — NOT an infinite loop
Why This "Fails"

The model controls the loop, so a confused model can spin forever (or burn your whole budget). max_steps is the seatbelt: the loop terminates and returns a clear sentinel. This is the failure you reproduce in M6 — and the reason the agentic loop is never an unbounded while True.

5. Reflection Before Building

Stop Here

Before writing any code, fill in UNDERSTANDING.md in your own words. No copy-pasting from the lesson. No AI assistance for writing — only for checking. If you can't explain it simply, go back and re-read.

Knowledge Check — Answer These in UNDERSTANDING.md

  1. In your own words, why does giving a model tools beat writing a cleverer prompt for "why does auth.py fail on login?" Use the word hallucination and connect it to ReAct.
  2. "The description is the API." Explain what the model actually sees about a tool, and predict what happens if two tools have near-identical descriptions.
  3. Walk the protocol: the model emits a tool call, then what are the exact steps your code takes before the model speaks again? Where does json.loads come in, and why?
  4. Why must the assistant message be appended before the tool-result messages? What breaks if you skip appending the assistant turn?
  5. Predict the three failure modes of removing, respectively: (a) the max_steps cap, (b) safe_resolve, (c) feeding tool results back. Which design decision causes each?
  6. Context injection vs. tool-fetched context: give one question where injecting the top-k files is enough, and one where the model must use tools mid-task. Why?
  7. The one thing you still don't fully understand about the agentic loop.

After filling in UNDERSTANDING.md, use the AI mentor pattern in docs/meta/learning-flow.md to get feedback. Record the feedback in UNDERSTANDING_FEEDBACK.md.

6. Project Assignment

See PROJECT.md and source/project.md for the full specification including file structure and extended requirements.

Core Requirement

Build a context-aware coding copilot in code/ that answers questions about a target repository by reading it through tools:

  • tools.pysafe_resolve (path sandbox) and dispatch_tool (route a tool call) are the learner core; read_file / list_directory / search_code and TOOL_SCHEMAS are provided.
  • agent_loop.pyparse_tool_calls (extract calls, json.loads the args) and run_agent (the ReAct loop) are the learner core — the learning target.
  • context_selector.py — embed the question, rank files by cosine, return top-k as starting context. (Provided — Project 02/03 reuse.)
  • copilot.py — the orchestrator: build the prompt, inject context, run the agent, print the answer. (Provided.)

Extended: a reasoning trace (full ReAct); a sandboxed write_file with dry-run; structured output via a forced emit_answer tool with citations; parallel tool calls.

Definition of Done

Every file-path tool routes through safe_resolve (traversal fails closed); parse_tool_calls json.loads the arguments string and returns [] when the model answers; run_agent appends the assistant turn before the tool results and is bounded by max_steps; the copilot answers using files it actually read; the guiding tests pass; UNDERSTANDING.md done before any code; FAILURE_ANALYSIS.md has ≥3 experiments; EVALUATION.md is concrete; STARCALLOS_REFLECTION.md names ≥1 concrete pattern.

Start building

Open code/README.md for setup, the milestone build order, and the file roles (which files are provided vs. learner-owned). Run python -m pytest to see the failing guiding tests, then implement the learner-owned functions in milestone order until they pass.

7. Project Milestones

Work through these in order. Each milestone should produce runnable code before moving on.

1

M1 — Sandbox paths

tools.safe_resolve(repo_root, rel) — resolve under root, reject escapes. Validation: valid path resolves; ../../etc/passwd raises ValueError (offline test).

2

M2 — Parse tool calls

agent_loop.parse_tool_calls(message) — extract (id,name,args), json.loads arguments. Validation: a fake message yields one call with a dict arguments; no calls → [] (offline test).

3

M3 — Dispatch

tools.dispatch_tool(name, args, repo_root) — route to read/list/search, handle unknown. Validation: read_file dispatch returns the fixture file's contents; unknown tool returns an error string (offline test).

4

M4 — The loop

agent_loop.run_agent(messages, repo_root, complete, max_steps) — call → dispatch → feed back → repeat. Validation: a scripted complete executes a tool then returns the answer; an always-calling complete stops at max_steps (offline test).

5

M5 — Copilot end-to-end

copilot.py injects context + runs the agent against a real repo. Validation: ask "what does config default to?" and it reads files and answers with a file/line citation.

6

M6 — Break / Evaluate

Remove max_steps, remove safe_resolve, or stop feeding results back. Validation: runaway loop / path escape / model ignores its own output — each recorded in FAILURE_ANALYSIS.md.

8. Self-Evaluation

After building, honestly evaluate your implementation against these criteria. Record your answers in EVALUATION.md.

CriterionDoes your implementation...Pass?
Tool schemasdefine each tool with a name, a specific description, and a JSON-schema parameters?
Parse correctlyjson.loads the arguments JSON string into a dict before dispatching?
The loopcall the model, dispatch requested tools, feed results back, and repeat until it answers?
Loop capbound the loop with max_steps and return cleanly when hit (no infinite loop)?
Message orderappend the assistant turn before the tool-result messages?
Sandboxroute every file-path argument through safe_resolve and reject escapes?
Grounded answeranswer using files it actually read, ideally with a file/line citation — not a guess?
Red Flags

Your implementation may have problems if:

  • Your loop is while True with no cap — one confused response burns your budget.
  • read_file will return a path outside the repo (you never sandboxed).
  • You pass tc.function.arguments straight into the tool without json.loads.
  • You feed tool results back but forgot to append the assistant message first (provider error).
  • The model "answers" without ever calling a tool, and you call that success — it guessed.
  • You injected the whole repo into the prompt instead of retrieving the top-k.

9. Common Mistakes

MistakeWhy It HappensConsequenceFix
arguments used as a dict directlyIt looks like an objectTypeError / wrong args; tool gets a stringjson.loads(tc.function.arguments) first
No max_steps capHappy-path demo always terminatesA looping model spins forever, burning tokensBound the loop; return a sentinel at the cap
Unsandboxed file paths"It's just reading a file"../../etc/passwd traversal; data exfiltrationRoute every path through safe_resolve; fail closed
Tool results without the assistant turnAppending only the tool messageProvider rejects the request (orphan tool result)Append the assistant message (with tool_calls) then the results
Vague tool descriptionsTreating descriptions as commentsModel picks the wrong tool or fills bad args"The description is the API" — be specific, give boundaries
Crashing on a tool errorTool raises, loop diesOne bad arg kills the whole answerCatch and return an error string; let the model react
No tools, clever prompt instead"The model is smart enough"Confident hallucination about code it never sawGive it tools; acting beats reasoning-only (ReAct)
Injecting the whole repo"More context is better"Context-window blowout and cost; buries the answerRetrieve top-k relevant files; let tools fetch the rest

10. Connections

Builds On

The loop's inner call is Project 01's litellm.completion — you've just added tools= and wrapped it in a while. The context injection half (embed the question, rank files by cosine, take top-k) is Project 02's similarity and Project 03/04's retrieval applied to source files, unchanged — which is why this lesson leaves it provided and spends your effort on the genuinely new thing: the tool loop.

Enables

Project 07 (evaluation) is how you'd measure this copilot — correctness, relevance, and whether it hallucinated vs. read the file. Project 08 (agent) is this loop generalized: more tools, planning, sub-tasks, and self-editing memory — "LLMs using tools in a loop" scaled up. The tool-use protocol you build here is the substrate for every later agent.

Production Patterns

Real copilots are this loop plus engineering: retrieval to seed context, a curated tool set with carefully documented schemas (the ACI), strict sandboxing and permissioning on every tool, a bounded agentic loop with retries and a step budget, structured/cited final output, and evaluation in the loop. GitHub Copilot, Cursor, and Claude Code are all variations on "augmented LLM (retrieval + tools) run in a loop" with production-grade interfaces and guardrails.

StarcallOS Relevance

StarcallOS Connection

Any StarcallOS capability that acts on the user's behalf — reading their files, searching their data, calling an API, editing a document — is this tool loop. The disciplines here decide whether those actions are safe and useful: sandboxed tools (never let a tool escape its scope), a bounded loop (never burn the user's budget on a confused agent), grounded answers (act on real data, don't hallucinate), and a clean agent-computer interface. The copilot is the smallest complete instance of "StarcallOS does something for you."

Sources

See source/resources.md for the complete annotated source list.

Tier 1 — Official Documentation

  • sources/official-docs/anthropic-tool-use.md — tool schemas, the tool_use/tool_result cycle, the agentic loop, tool_choice, tools as structured output
  • sources/articles/building-effective-agents.md — the definition of an agent ("LLMs using tools in a loop"), the augmented LLM, and the agent-computer interface (ACI)
  • sources/official-docs/litellm-completion.md — the tools / tool_choice params and the OpenAI-style tool_calls shape (arguments as a JSON string)

Tier 2 — Foundational Papers

  • sources/papers/react-paper.md — Yao et al. 2022: ReAct — interleaving reasoning and acting; why acting reduces hallucination and makes loops terminate
  • sources/papers/rag-paper.md — Lewis et al. 2020: retrieval-augmented generation — the context-injection half (carried from Project 04)

Tier 3 — Engineering Guides

  • sources/articles/chunking-strategies.md — chunking source files for the embeddings index (carried from Project 03/04; for the extended per-chunk retrieval)

Tier 4 — Educational Sources

  • None specific to this lesson — the Anthropic tool-use docs and the ReAct paper are themselves the primary teaching sources.

Optional — Going Deeper (MCP)

Read these after the lab works and you can explain your own tool loop. The Model Context Protocol is what that hand-wired loop becomes when many hosts must reuse the same tools over a wire protocol — it reframes the project, it isn't required to finish it. The MCP Interface Layer elective builds on them directly.

  • sources/official-docs/mcp-architecture.md (optional — depth) — the client-host-server split: a host coordinates clients, each client talks to one focused server. Your monolithic copilot loop, decomposed into a reusable protocol surface.
  • sources/official-docs/mcp-tools.md (optional — depth) — tool schema and tool_result as a standardized wire format: the same name + description + input_schema you wrote, but provider- and host-agnostic.
  • sources/official-docs/mcp-security-best-practices.md (optional — depth) — trust boundaries, least privilege, local-server risk: your safe_resolve containment check, generalized into protocol-level guidance.