Cortex · agents

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 →
Why Nisc has agents

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.

How one is written

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.

An agent, and its tool
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 },
});
Running it
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).

One that ships

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.

Running Prism’s agent
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));
The mapping it wrote
{
  "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.

Gates

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.

AllowThe call goes on to the tool, with those arguments or with ones the gate put in their place.
DenyThe tool does not run. The model reads the reason as the result of its call, and the run goes on.
AskThe run waits until a person approves the call, changes it, or denies it.

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.

In an app

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.

An assistant on Moss →

Asked of an agent runtime

What an engineer asks.

Does a run that fails throw?Norun.result gives you ok: false and the reason. Only a wrong definition throws, at run(): no model, or two tools with one name.
Does a tool that hangs stop the run?NoAfter the tool’s time limit, thirty seconds by default, the model reads the timeout as an error.
Does it keep history between runs?NoA run starts from what you pass in. An earlier conversation is passed as messages.
Do tools run in parallel?NoOne at a time, in the order the model asked for them.
Can I change what a tool returned before the model reads it?YesAn onToolResult hook is given each result, and may put another in its place.
Can I read the answer while it is written?Yesoutput-partial events carry what has been written so far. It is checked when it is whole.
Can I see what will be sent before a model is called?Yesagent.preview(input, { llm }) returns the messages and the tools for that model, and calls no model.
Can a run wait for a person across a restart?Yesrun.snapshot() is a plain object you can store. resumeRun goes on from it, and the gate asks again.
Can one agent call another?YesasTool makes an agent a tool. The inner run’s events arrive in the same stream.