Notebook 03 — The Loop¶
Premise: A model that can only talk is a chatbot. A model that can call tools is an agent. The 'agentic loop' is the simplest possible thing: ask the model what to do, do it, show it the result, ask again, until it says it's done.
Notebook 02 showed the plan · do · check pattern by hand. The loop is what happens when you let the model run that pattern, picking tools as it goes.
arcrun.run() is that loop. ~150 lines of code in arc, but conceptually:
while not done:
response = await model.invoke(messages)
if response.tool_calls:
for call in response.tool_calls:
result = await tool.execute(call.args)
messages.append(tool_result(result))
else:
done = True
By the end you will have:
- Defined a Python function as a tool the model can call
- Run a multi-step task with
arcrun.run() - Streamed live tool/token events
- Watched the model decide which tool to use when
Setup¶
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
from arcllm import load_model
from arcrun import run, run_stream, Tool, ToolContext
from arcrun import ToolStartEvent, ToolEndEvent, TokenEvent, TurnEndEvent
from rich import print
model = load_model('anthropic')
1. Define a tool¶
A tool is a Python callable + a JSON schema that tells the model how to call it. That is all a tool is.
async def list_files(args: dict, ctx: ToolContext) -> str:
p = Path(args['path']).expanduser()
if not p.exists():
return f'Path not found: {p}'
if not p.is_dir():
return f'Not a directory: {p}'
entries = sorted(f.name for f in p.iterdir() if not f.name.startswith('.'))
return '\n'.join(entries) or '(empty)'
list_files_tool = Tool(
name='list_files',
description='List entries in a directory.',
input_schema={
'type': 'object',
'properties': {'path': {'type': 'string', 'description': 'Absolute or ~ path'}},
'required': ['path'],
},
execute=list_files,
)
2. Run the loop¶
Give the model a task. Watch it call the tool, see the result, and then write a summary. This is the 'aha' — the model decided to call the tool. We didn't tell it to.
result = await run(
model=model,
tools=[list_files_tool],
system_prompt='You are a helpful research assistant. Use tools when relevant.',
task='What files are in ~/projects/ai-roadshow/notebooks/? Summarize what you see in one sentence.',
)
print(result.content)
print()
print(f'turns: {result.turns} tool_calls: {result.tool_calls_made} cost: ${result.cost_usd:.4f}')
3. Stream events live¶
run() returns the final result. run_stream() yields events as they
happen — every tool start, tool end, and token. This is what powers
live UIs.
stream = await run_stream(
model=model,
tools=[list_files_tool],
system_prompt='You are a helpful research assistant.',
task='What is in ~/projects/ai-roadshow/? One sentence.',
)
async for ev in stream:
if isinstance(ev, ToolStartEvent):
print(f'[yellow][tool ▶ {ev.name}][/yellow] {ev.args}')
elif isinstance(ev, ToolEndEvent):
snippet = ev.result[:80].replace(chr(10), ' ⏎ ')
print(f'[green][tool ◀][/green] {snippet}...')
elif isinstance(ev, TurnEndEvent):
print(f'\n[bold]done[/bold] — {ev.turns} turns, {ev.tool_calls_made} tool calls, ${ev.cost_usd:.4f}')
4. Add a second tool — composition¶
With two tools the agent has to choose. Watch it call list_files
first to discover, then read_file to actually answer the question.
We didn't script that order — the model planned it.
async def read_file(args: dict, ctx: ToolContext) -> str:
p = Path(args['path']).expanduser()
if not p.exists():
return f'Path not found: {p}'
return p.read_text()[:2000]
read_file_tool = Tool(
name='read_file',
description='Read up to 2000 chars from a file.',
input_schema={
'type': 'object',
'properties': {'path': {'type': 'string'}},
'required': ['path'],
},
execute=read_file,
)
result = await run(
model=model,
tools=[list_files_tool, read_file_tool],
system_prompt='You are a helpful research assistant.',
task='Find the README in ~/projects/ai-roadshow/ and tell me the curriculum premise in 2 sentences.',
on_event=lambda e: print(f' [dim]{e.type}[/dim]') if e.type.startswith('tool') else None,
)
print()
print(result.content)
Takeaway¶
- A loop is just
LLM + tools + memory. That's it. - Every Python function you write becomes a capability the model can use without retraining anything.
- The model decides which tool when. You provide the toolbox.
- This is how Claude Code, Cursor, and every coding agent work under the hood. None of it is magic.
Next: 04 — Skills and Prompts. Tools give the model abilities. Skills give it expertise.