Skip to content

15. Agents and runs

The ai module

This chapter covers the ai module, which ships in the next release.

Most of the work around a language model is not the call itself. Choosing evidence, building the context, writing the prompt, giving the model tools, continuing a conversation and keeping what came back are dataflow, and HQL writes them as dataflow. Every stage is a typed value: what reached the model, what it called and what it answered are read the way a card is read.

The constructors in this chapter are pure and need no import. The steps that reach a model need import ai, which is how a program states that it may spend money and send text to a third party.

An agent is a card

An agent is behaviour and configuration, written as a card. Its body is its instructions, and its agent header table configures it. This one is added to the reading-club vault as librarian.hmd:

---
title: Librarian
agent:
  model: default
  tools: [books_by]
  max_turns: 4
---
# Librarian

Answer questions about the club's shelf from the cards you are given and the
tools you have. Name the books you rely on. Say so when the shelf does not
answer the question.

Agent reads one from a card:

librarian = @[[librarian]] | Agent
librarian.tools
["books_by"]

A card is an agent only by saying it is one: Agent refuses a card with no agent table, and an unknown key in it, so a misspelt max_turn does not quietly select the default. model names a key of [ai.models] in the vault's hql.toml, never a provider, so a committed card does not fix a vendor:

[ai]
recordings = ".hql/runs"

[ai.models.default]
api = "messages"
endpoint = "https://api.anthropic.com/v1/messages"
model = "claude-sonnet-5-5"
max_tokens = 1024
temperature = 0

Context and prompt

Context(label) turns values into what a model reads: one item per element, each written as markdown writes it, with where it came from:

cards
| filter(c => c.metadata.status == "todo")
| Context("unread")
| map(i => {label: i.label, source: i.provenance.name})
[{label: "unread", source: "leaves-of-grass"}, {label: "unread", source: "the-common-reader"}, {label: "unread", source: "the-hobbit"}]

A context is a Seq<ContextItem>, so contexts are combined with concat and narrowed with filter, like any other sequence. A ranking from chapter 8 keeps each hit's score and retrieval in its item, so cards | semantic(question) | take(10) | Context("evidence") is evidence that says how it was found.

Prompt(instructions) puts the task in front of the context. Its rendered field is the text exactly as sent:

p = cards
| filter(c => c.metadata.status == "todo")
| Context("unread")
| Prompt("Which unread book should the club start with?")

p.rendered
Which unread book should the club start with?

## unread

Source: leaves-of-grass

# Leaves of Grass

Poems by [[walt-whitman]], revised and enlarged for the rest of his life.

Source: the-common-reader

# The Common Reader

Essays by [[virginia-woolf]] on reading, written for readers rather than critics.

Source: the-hobbit

# The Hobbit

A novel by [[jrr-tolkien]]. A hobbit is talked into an adventure and comes home changed.

Tools are functions

A tool is a named HQL function. Tool records its name, a description and the JSON Schema of its parameters, derived from their types:

fn books_by(author: String) : List<String> =
    cards | filter(c => c.metadata.author == author) | map(c => c.title) | List

Tool(books_by, "The titles on the shelf by one author")
Tool {name: "books_by", description: "The titles on the shelf by one author", parameters: {"properties":{"author":{"type":"string"}},"required":["author"],"type":"object"}}

A tool is pure. While the harness evaluates one for a model, every effectful step is refused, so no path through the function reaches a file, a network or another model. The tools a run is given must be exactly those the agent's card lists: the card is where a reader learns what an agent can do.

Running an agent

run(agent, tools = …) executes the agent over a context or a prompt until it stops, and returns a Run. This program is pick.hql:

import ai

fn books_by(author: String) : List<String> =
    cards | filter(c => c.metadata.author == author) | map(c => c.title) | List

librarian = @[[librarian]] | Agent

pick =
    cards
    | filter(c => c.metadata.status == "todo")
    | Context("unread")
    | Prompt("Which unread book should the club start with?")
    | run(librarian, tools = [Tool(books_by, "The titles on the shelf by one author")])

pick.result
"Start with The Common Reader. It is the only unread book by Virginia Woolf, and the club has already read A Room of One's Own and is reading Mrs Dalloway, so it completes the club's Woolf."

The answer is one field among many. The harness never collapses a run into the text the model last wrote:

pick.calls | map(c => {tool: c.tool, status: c.status, output: c.output})
[{output: "[\"A Room of One's Own\",\"Mrs Dalloway\",\"The Common Reader\"]", status: "ok", tool: "books_by"}]

turns is the conversation, stop says why it ended — end, max_tokens, max_turns or refused — and model and usage are what the provider reported, not what configuration asked for. trace puts everything that happened in one order:

pick | trace | map(e => e.kind)
["prompt", "request", "response", "tool_call", "request", "response", "stop"]

Continuing and delegating

A Run never changes. ask(message) continues its conversation with the same agent and tools, and returns a new run whose parent is the first:

why = pick | ask("What did you rely on?")
why.result
"The books_by tool listed three Woolf titles, and the card for The Common Reader is the only one marked todo."

delegate(agent, task) starts a fresh conversation for another agent, given the first run's context and its result. An agent named in a card's agents: list is offered to the model as a tool of that name, so a model may delegate by itself; each child run is kept in children, and delegation stops at depth three.

remember(name) writes a run's result, or a string, as a card in the vault's [ai.memory] dir. Memory is cards: writing it is one explicit step, and reading it is a query like any other.

Recordings: a run that answers again

A program that reaches a model would answer differently on every evaluation. HQL keeps each response as a recording, keyed by the SHA-256 of the request it answers, under the vault's [ai] recordings directory. --ai chooses what may happen:

Mode What it does
replay the default: answers from recordings only, and never reaches a network
record sends what is not recorded yet, and records it
live always sends

The mode is never read from hql.toml, so spending money is always a choice made on the command line. Record once, and the same program then evaluates offline, byte for byte:

$ hql --vault . --ai record run pick.hql
"Start with The Common Reader. …"
$ hql --vault . run pick.hql
"Start with The Common Reader. …"

A request with no recording is a diagnostic, not an empty answer:

evaluation failure: no recording for this request (5e10ae23…); run with --ai record
  --> pick.hql:13:7

hql check checks the whole program, its type is Run, and no model is reached. Committed recordings make a document that calls a model as reproducible as one that does not.

Agents inside documents

In a cell, this is the card the cell is written in, and the prose of the cell's section — from the nearest heading above it down to the cell — is the default instructions of every Prompt it builds. The question is written once, as prose, next to its answer:

## What should we read next?

Pick one unread book for the next meeting, and say why.

```hql#eval id=next
import ai

fn books_by(author: String) : List<String> =
    cards | filter(c => c.metadata.author == author) | map(c => c.title) | List

cards
| filter(c => c.metadata.status == "todo")
| Context("unread")
| run(@[[librarian]] | Agent, tools = [Tool(books_by, "The titles on the shelf by one author")])
```

The knowledge, the prompt and the program that assembles the prompt stay visible, editable and queryable in one document. hql render replays the cell from its recording; --ai host instead leaves the request for the agent an editor is chatting with, and hql ai pending and hql ai answer complete it.

Where to go from here

You have met the whole language: values and data, collections and functions, questions in the style of SQL, a vault of documents, graphs, retrieval, streams, formats, documents that compute themselves, data read and written through endpoints, channels and logs, and agents whose runs are values. The reference states each part precisely, and the roadmap says what comes next.