The loop that runs an agent.
An agent is a definition: instructions, the tools a model may call, and a schema for its answer. Cortex runs it. It keeps the list of messages, sends all of it to the model at each step, runs each tool the model asks for, and checks the answer against the schema before you get it.
pnpm add @niscorp/cortex @niscorp/signal zod openai · 0.1.4 · Apache-2.0
A run ends when an answer fits. It also ends at a limit, which by default is the third answer that does not fit, or twenty calls to the model. A reply that is not JSON goes back too, and is not counted among the three.
First steps →Four packages ship an agent.
A Nisc app is made of documents: a query, a mapping, a layout, an automation. Each kind has a schema. So a model can write one, the schema can check it, and what was wrong can go back to the model in the same run. Four packages ship an agent that does this for their own kind of document.
What an agent wrote is run by its package, with no model. Each of the four packages works without its agent: only the /agent import needs Cortex.
Writing your own.
You write an agent with defineAgent. The model is a Signal client, and Signal is Nisc’s client for model providers. You pass it in when you run the agent, or set it on the agent, so the same agent runs on any provider Signal has.
import { z } from 'zod';
import { defineAgent, defineTool } from '@niscorp/cortex';
const PRICES: Record<string, number> = { coffee: 3.5, tea: 3 };
const priceOf = defineTool({
id: 'shop.price',
name: 'priceOf',
description: 'The price of one item on the menu.',
input: z.object({ item: z.string() }),
execute: ({ item }) => ({ item, price: PRICES[item] }),
});
const Order = z.object({
item: z.string(),
quantity: z.number(),
total: z.number(),
});
export const orderReader = defineAgent({
id: 'shop.order',
description: 'Reads an order out of a message.',
instructions: 'Read the order and work out its total.',
tools: [priceOf],
output: { schema: Order },
});import { createSignal } from '@niscorp/signal';
// the key is read from GROQ_API_KEY
const llm = createSignal('groq');
const run = orderReader.run('Two coffees, please.', { llm });
const result = await run.result;
if (result.ok) console.log(result.output.data);
else console.log(result.error.message);Run here with the model’s replies written by hand, it made 3 calls to the model and 1 tool call. The model first wrote the quantity as a word. Cortex sent that back with data.quantity: Invalid input: expected number, received string, and the next answer fit: {"item":"coffee","quantity":2,"total":7}.
The model answers in JSON of one shape: reasoning for its notes, response for words meant for a person, and data. Your schema checks data, and result.output.data is what passed. Without a schema the answer is text, in response.
An agent can also take context (more messages for the start of the transcript), toolGates, output.validate (your own check on the answer) and stopWhen (the limits of a run, in place of the two defaults).
A shipped agent runs the same way.
You run Prism’s agent and Nova’s the way you ran your own. This is Prism’s, given a row and an example of the row it should become.
import { createSignal } from '@niscorp/signal';
import { evaluate } from '@niscorp/prism';
import { mappingAgent } from '@niscorp/prism/agent';
const llm = createSignal('groq');
const row = { first: 'Ada', last: 'Lovelace', born: 1815 };
const wanted = { fullName: '', born: 0 };
const run = mappingAgent.run(
{ sampleInput: row, targetShape: wanted },
{ llm },
);
const result = await run.result;
if (!result.ok) throw new Error(result.error.message);
const mapping = result.output.data;
const other = { first: 'Grace', last: 'Hopper', born: 1906 };
console.log(evaluate(mapping, other));{
"fullName": {
"$interpolate": {
"template": "{{f}} {{l}}",
"values": {
"f": {
"$ref": "$.first"
},
"l": {
"$ref": "$.last"
}
}
}
},
"born": {
"$ref": "$.born"
}
}That mapping is what the agent handed back, checked by Prism’s schema in the run. evaluate is Prism’s own function and calls no model. For Grace Hopper’s row it gave {"fullName":"Grace Hopper","born":1906}.
Vex runs its agent for you: pass what createQueryDsl returns to the engine as generateDsl, and Vex calls it when no stored query fits a request. Tide’s is built from your effects: createReflexAgent({ effects }) returns the agent, and you run that.
Your code decides before a tool runs.
A gate is a function you write. Cortex calls it before every tool call, with the tool’s id and the arguments the model wrote. It gives one of three answers.
Gates go on the agent, as toolGates. When one asks, your app shows the call to a person, and their answer is run.approve or run.deny.
An assistant is an agent your app defines.
In Nisc’s own apps the assistant is one agent, run for one user at a time. Each run is given that user’s screen, the actions they may use and the conversation so far, and reads only what that user can read. It answers in a sentence or two and can open a form on their screen, filled in. The user presses the button.
What an engineer asks.
run.result gives you ok: false and the reason. Only a wrong definition throws, at run(): no model, or two tools with one name.onToolResult hook is given each result, and may put another in its place.output-partial events carry what has been written so far. It is checked when it is whole.agent.preview(input, { llm }) returns the messages and the tools for that model, and calls no model.run.snapshot() is a plain object you can store. resumeRun goes on from it, and the gate asks again.asTool makes an agent a tool. The inner run’s events arrive in the same stream.