GizStack
View .mdOpen in ChatGPTOpen in Claude

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

gizlang
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 Err goes 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.tool makes a tool that may write; each of its calls is decided by approve.
  • 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.
gizlang
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.kindIts text or call
textA part of the model's answer, as it is written
reasoningA part of the model's reasoning summary
callA call the model made (call: its name and JSON arguments)
resultThe text a tool answered, as the model reads it
failedThe error of a call (the model reads it too)
waitingA call left to the person by approve (see below)
compactedThe handoff summary that replaced the conversation (see compactAt)
doneThe final answer
stoppedThe text written before stop ended the run
errorWhy 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.

gizlang
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.

FieldDefaultWhat it bounds
rounds16Tool rounds in one run
wrapUp0The last rounds of a run, in which the model is told to finish what it has
maxTokens0Tokens one run may write (0: no limit)
maxMs0Milliseconds one run may take (0: no limit)
repeats3How many rounds in a row the same call (same tool, same arguments) may be made before it is refused
mostRead0Characters of one tool result the model reads (0: all)
compactAt0Tokens 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.

gizlang
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: () => bool is asked between pieces and between rounds. When it returns true the run ends and keeps what it wrote, with a stopped piece. 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, a waiting piece 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.DECLINED is 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.

  • effort is "" (the default), "low", "medium", "high" or "xhigh". A request that names no effort uses the default, which is the AI_EFFORT environment 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") from gizlang:openai fails, and so does an AI_EFFORT that names no effort.
  • sampling holds maxTokens (the most tokens one turn writes), temperature, topP, seed and stop strings. A negative temperature or top_p, and a seed of -1, mean the API's default.
gizlang
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:

FieldMeaning
a.tokensTokens the runs wrote
a.inputTokens the runs read (the prompt)
a.cachedOf those, the tokens the provider had cached
a.contextTokens the conversation holds after the last turn
a.windowThe 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.

gizlang
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.