Agents
gizai:agent runs a language model in a loop. The model writes text or calls tools; each call runs in your program and
its result goes back to the model, until the model answers. A tool is a Gizlang function with a record for its input and
a record for its output, so the model's arguments are checked before your code runs, and a wrong answer is sent back to
the model as an error it can fix.
This guide covers the whole surface: tools, running an agent, its limits and approvals, reasoning effort, caching, the
numbers it reports, and how to test an agent with a scripted model. The model itself is set up in
AI models; the names here are the ones in gizai:agent.
One agent, one tool
import {System} from "gizlang:system";
import * as Agent from "gizai:agent";
/** The weather of a city. */
type Weather = {
/** The city's name in English. */
city: string;
};
type Forecast = { city: string; high: i64; };
function weather(w: Weather): Forecast throws string {
return { city: w.city, high: 24 };
}
function main(sys: System): void {
const model = Agent.endpoint(sys)!;
let a = Agent.agent(model, "You answer questions about the weather.", [
Agent.readTool<Weather, Forecast>("weather", weather)
]);
print(Agent.run(sys, a, "Is it warm in Seoul today?")!);
}The tool's description is the doc comment of its input record, and each field's doc describes that field. Nothing else is needed to describe a tool to the model.
Tools
Agent.tool<I, O>(name, run) makes a tool from run, a function from the input record I to O that throws a string.
- An
Errgoes back to the model as the text of the call's error. The run goes on. Agent.readTool<I, O>(name, run)makes a tool that only reads. A call of it runs without approval (see Approvals).Agent.toolmakes a tool that may write; each of its calls is decided byapprove.- A tool that writes but must see the calls before it finish first is
serial: it runs after the calls before it in the same turn and before the calls after it. Other calls of a turn run at once. Agent.schema<T>()is the JSON Schema of a record, the one a tool sends to the model.
import * as Agent from "gizai:agent";
/** A file to write. */
type Write = {
/** The path, relative to the project. */
path: string;
/** The whole text of the file. */
text: string;
};
type Written = { bytes: i64; };
function write(w: Write): Written throws string {
return { bytes: w.text.byteLength };
}
/** The tool that writes a file: it runs alone in its turn, after the reads before it. */
function writer(): Agent.Tool {
let t = Agent.tool<Write, Written>("write_file", write);
t.serial = true;
return t;
}Running an agent
Agent.run(sys, a, prompt, each?, files?) adds the prompt to the agent's conversation, runs the loop, and returns the
model's final answer. each hears each piece as it happens; the pieces are the model's text and reasoning, each call,
each result, and how the run ended.
Piece.kind | Its text or call |
|---|---|
text | A part of the model's answer, as it is written |
reasoning | A part of the model's reasoning summary |
call | A call the model made (call: its name and JSON arguments) |
result | The text a tool answered, as the model reads it |
failed | The error of a call (the model reads it too) |
waiting | A call left to the person by approve (see below) |
compacted | The handoff summary that replaced the conversation (see compactAt) |
done | The final answer |
stopped | The text written before stop ended the run |
error | Why the run failed |
PieceKind is a literal union: the compiler refuses a kind the agent never sends, so a switch on it cannot miss a
case that is spelled wrong.
import * as Agent from "gizai:agent";
function show(p: Agent.Piece): void {
if (p.kind === "text") { print(p.text); }
if (p.kind === "call") { print(`calling ${p.call.name}`); }
}To ask for a record instead of text, use Agent.ask<T>(sys, a, prompt): the model answers by calling a tool whose input is
T, which is read and checked as T.
Agent.resume(sys, a) goes on with a conversation that is already in a.history, for example one kept in a database.
Agent.title(sys, model, first) makes a short title for a conversation that starts with first.
Attachments
files takes Agent.Attachment records (name, type, path). A picture goes to the model as a picture; a document's text
joins the message, and a long one is read in parts with the tool read_file.
Limits and control
An agent has defaults, and every one of them is a field you can set before a run.
| Field | Default | What it bounds |
|---|---|---|
rounds | 16 | Tool rounds in one run |
wrapUp | 0 | The last rounds of a run, in which the model is told to finish what it has |
maxTokens | 0 | Tokens one run may write (0: no limit) |
maxMs | 0 | Milliseconds one run may take (0: no limit) |
repeats | 3 | How many rounds in a row the same call (same tool, same arguments) may be made before it is refused |
mostRead | 0 | Characters of one tool result the model reads (0: all) |
compactAt | 0 | Tokens past which the conversation is replaced by the model's own handoff summary (0: never) |
The last round of a run takes no tool: the model is asked for its answer alone. wrapUp tells the model once, when that many
rounds are left, to finish the most important part.
import * as Agent from "gizai:agent";
function fixer(model: Agent.Model, tools: Vec<Agent.Tool>): Agent.Agent {
let a = Agent.agent(model, "You fix the project in the working folder.", tools);
a.rounds = 200;
a.wrapUp = 10;
a.maxMs = 30 * 60 * 1000;
a.compactAt = 150000;
a.mostRead = 16000;
return a;
}Stopping and steering
stop: () => boolis asked between pieces and between rounds. When it returns true the run ends and keeps what it wrote, with astoppedpiece. The default never stops.steer: () => Vec<Message>gives the messages a person sent while the run worked. They are read before each round after the first.
Approvals
approve: (c: Call) => string decides each call that is not read-only, before it runs. It returns one of:
Agent.RUN: run the call.Agent.WAIT: leave the call to the person. The call is not run, awaitingpiece is sent, and the run ends with one more answer that says what is left.- any other text: refuse the call. That text is the call's error, which the model reads;
Agent.DECLINEDis the text for a person's "no".
Reasoning effort and sampling
An agent's settings say how the model is asked: settings.effort and settings.sampling.
effortis""(the default),"low","medium","high"or"xhigh". A request that names no effort uses the default, which is theAI_EFFORTenvironment variable when it is set, and otherwise the model's own default.- An unknown effort is an error, not the model's default:
parseEffort("extreme")fromgizlang:openaifails, and so does anAI_EFFORTthat names no effort. samplingholdsmaxTokens(the most tokens one turn writes),temperature,topP,seedandstopstrings. A negative temperature or top_p, and a seed of -1, mean the API's default.
import * as Agent from "gizai:agent";
function careful(model: Agent.Model): Agent.Agent {
let a = Agent.agent(model, "You review code.", []);
a.settings.effort = "xhigh";
a.settings.sampling.temperature = 0.0;
return a;
}Responses API and caching
With AI_API=responses, a model is called through the OpenAI Responses API. Two things follow:
- The model's encrypted reasoning comes back with each tool round, so a reasoning model goes on from where it was instead of thinking afresh after every call.
- Each conversation sends a
prompt_cache_key, a digest of its instructions and first message. Every round of one conversation reaches the server that cached its prefix, and the history only grows at its end, so the prefix is reused.
The numbers of a run show how much was read from the cache:
| Field | Meaning |
|---|---|
a.tokens | Tokens the runs wrote |
a.input | Tokens the runs read (the prompt) |
a.cached | Of those, the tokens the provider had cached |
a.context | Tokens the conversation holds after the last turn |
a.window | The model's context window in tokens (0: unknown) |
Models
Agent.endpoint(sys, model?) is the model of the OpenAI-compatible API at AI_URL, with AI_API_KEY as its key and
AI_MODEL (or the name you pass) as the model. AI models covers the other ways to name a model.
Any model is a record with a name and a turn function, so you can write your own, for example one that routes between
two models. Agent.scripted(turns) is a model that replies with the turns you give it: Agent.says(text) and
Agent.calls(calls) make one turn each.
Testing an agent without a network
A test gives the agent a scripted model, so it runs without a model API and always gives the same answers.
import {System} from "gizlang:system";
import * as Agent from "gizai:agent";
/** The weather of a city. */
type Weather = { city: string; };
type Forecast = { city: string; high: i64; };
function weather(w: Weather): Forecast throws string { return { city: w.city, high: 24 }; }
function main(sys: System): void {
const model = Agent.scripted([
Agent.calls([{ id: "c1", name: "weather", arguments: "{\"city\":\"Seoul\"}" }]),
Agent.says("It is warm in Seoul."),
]);
let a = Agent.agent(model, "You answer questions about the weather.", [Agent.readTool<Weather, Forecast>("weather", weather)]);
print(Agent.run(sys, a, "Is it warm in Seoul?")!);
}A scripted run checks the whole loop: the calls a model makes, what a tool answers, the limits, the approvals and the stop. The tests of the agent in the Gizlang repository do this for each of them.