AI Coding Copilot
Give a model eyes — tool use and the agentic loop, so it reads your code instead of guessing
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-schemaparameters, 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
argumentsarrives as a JSON string — and bound the loop withmax_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.
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."
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), ormessage.tool_calls— a list of{id, function:{name, arguments}}whereargumentsis 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 hitmax_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
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
- Define the tools. Write
TOOL_SCHEMAS: aname, a carefuldescription(the model picks from this alone), and aparametersJSON Schema. Implement the matching functions. - (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.
- Call the model.
litellm.completion(messages, tools=TOOL_SCHEMAS, tool_choice="auto"). Withauto, the model decides each turn whether to call a tool or answer. - Parse tool calls. If
message.tool_callsis non-empty, build(id, name, arguments)andjson.loadsthe arguments string. If empty, the model answered — return its content. - Execute (dispatch). For each call,
dispatch_tool(name, arguments, repo_root)routes to the right function. Every path argument goes throughsafe_resolvefirst. - Feed results back, then loop. Append the assistant message (with its
tool_calls), then onetoolmessage per call — order matters. Go back to step 3, bounded bymax_steps.
Key Concepts
| Concept | Definition | Why 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 result | Turns a text-in/text-out model into one that can act |
| Tool schema | A tool's name, description, and JSON-schema parameters | The only thing the model sees about a tool; "the description is the API" |
| Tool call / tool result | The model's structured call (id, name, args) and the value you feed back | The two halves of the protocol; the result is the model's "observation" |
| Agentic loop | call model → run requested tools → feed results back → repeat until it answers | The program itself; the model drives control flow |
| ReAct | Interleaving reasoning traces with actions (Thought → Action → Observation) | Acting fetches ground truth, cutting the hallucination of reasoning-only prompts |
| Context injection | Retrieve the top-k relevant files up front and put them in the prompt | Cheap one-shot grounding; the model still can't reach beyond what you injected |
| Sandboxed execution | Resolving tool file paths under a fixed repo root and rejecting escapes | A tool that takes a path is an attack surface; ../../etc/passwd must fail closed |
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)
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)
The model's request arrives as objects whose arguments is a JSON string — parse_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
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
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
- In your own words, why does giving a model tools beat writing a cleverer prompt for "why does
auth.pyfail on login?" Use the word hallucination and connect it to ReAct. - "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.
- 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.loadscome in, and why? - Why must the assistant message be appended before the tool-result messages? What breaks if you skip appending the assistant turn?
- Predict the three failure modes of removing, respectively: (a) the
max_stepscap, (b)safe_resolve, (c) feeding tool results back. Which design decision causes each? - 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?
- 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.py—safe_resolve(path sandbox) anddispatch_tool(route a tool call) are the learner core;read_file/list_directory/search_codeandTOOL_SCHEMASare provided.agent_loop.py—parse_tool_calls(extract calls,json.loadsthe args) andrun_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.
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.
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.
M1 — Sandbox paths
tools.safe_resolve(repo_root, rel) — resolve under root, reject escapes. Validation: valid path resolves; ../../etc/passwd raises ValueError (offline test).
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).
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).
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).
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.
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.
| Criterion | Does your implementation... | Pass? |
|---|---|---|
| Tool schemas | define each tool with a name, a specific description, and a JSON-schema parameters? | ☐ |
| Parse correctly | json.loads the arguments JSON string into a dict before dispatching? | ☐ |
| The loop | call the model, dispatch requested tools, feed results back, and repeat until it answers? | ☐ |
| Loop cap | bound the loop with max_steps and return cleanly when hit (no infinite loop)? | ☐ |
| Message order | append the assistant turn before the tool-result messages? | ☐ |
| Sandbox | route every file-path argument through safe_resolve and reject escapes? | ☐ |
| Grounded answer | answer using files it actually read, ideally with a file/line citation — not a guess? | ☐ |
Your implementation may have problems if:
- Your loop is
while Truewith no cap — one confused response burns your budget. read_filewill return a path outside the repo (you never sandboxed).- You pass
tc.function.argumentsstraight into the tool withoutjson.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
| Mistake | Why It Happens | Consequence | Fix |
|---|---|---|---|
arguments used as a dict directly | It looks like an object | TypeError / wrong args; tool gets a string | json.loads(tc.function.arguments) first |
No max_steps cap | Happy-path demo always terminates | A looping model spins forever, burning tokens | Bound the loop; return a sentinel at the cap |
| Unsandboxed file paths | "It's just reading a file" | ../../etc/passwd traversal; data exfiltration | Route every path through safe_resolve; fail closed |
| Tool results without the assistant turn | Appending only the tool message | Provider rejects the request (orphan tool result) | Append the assistant message (with tool_calls) then the results |
| Vague tool descriptions | Treating descriptions as comments | Model picks the wrong tool or fills bad args | "The description is the API" — be specific, give boundaries |
| Crashing on a tool error | Tool raises, loop dies | One bad arg kills the whole answer | Catch 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 saw | Give it tools; acting beats reasoning-only (ReAct) |
| Injecting the whole repo | "More context is better" | Context-window blowout and cost; buries the answer | Retrieve 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
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 outputsources/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— thetools/tool_choiceparams and the OpenAI-styletool_callsshape (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 terminatesources/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 andtool_resultas a standardized wire format: the samename+description+input_schemayou wrote, but provider- and host-agnostic.sources/official-docs/mcp-security-best-practices.md(optional — depth) — trust boundaries, least privilege, local-server risk: yoursafe_resolvecontainment check, generalized into protocol-level guidance.