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¶

In [ ]:
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.

In [ ]:
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.

In [ ]:
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.

In [ ]:
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.

In [ ]:
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.