Packages / agents · source

agents

The schema for a set of agents: what each one is, which model it runs on, which prompt it uses, which MCP servers it may reach, and which agents it delegates to.

Everything is a row. Nothing is compiled in, so a change through an API is visible to the next request without a restart — which is the requirement the rest of this follows from.

One read gives a runnable agent

let agent: AgentView = JSON.parse<AgentView>(findById(database, agentsFull(database), "a1"));
{"id":"a1","agentName":"lead","enabled":true,
 "prompt":{"id":"p2","promptName":"lead","version":2,"body":"You lead, briefly."},
 "config":{"id":"c1","modelId":"m1","temperature":0.2,"maxTokens":8192,"topP":0.95},
 "servers":[{"id":"s1","serverName":"filesystem","transport":"stdio","endpoint":"mcp-fs","enabled":true}],
 "subAgents":[{"id":"a2","agentName":"scout","enabled":true}]}

One query, not five, and no N+1: each relation is a correlated subquery the database nests, so an agent with three servers and two sub-agents is still one row.

The decisions worth knowing

A model name is a column. models.api_name holds claude-opus-5; label is what a human picks from a list. Renaming the label never changes a request, and moving an agent to a newer model is an UPDATE.

A prompt is versioned and never edited. A change writes a new row with the next version; the agent points at one. Rolling back is an UPDATE, and the version you rolled back from is still there.

That invariant needs guarding, not just stating. persist is an upsert — the right default for a mapper and the wrong one for a create — so POST /prompts carrying an id that already exists used to replace that version's text in place and answer as though it had made a new one, while every agent pointing at it silently changed behaviour. Now the version and the id are both assigned by the server, and a taken id is refused by name. The same guard is on every create in the API: a POST that overwrites is a PUT wearing the wrong verb.

Config is separate from the model, because two agents on one model routinely want different knobs. extra carries whatever a provider accepts that the named columns do not, so an unfamiliar parameter needs no migration.

Sub-agents read one level at a time. subAgents names an agent's children, not its grandchildren. A cycle in the delegation graph is therefore a row rather than an infinite query — and the suite inserts one to prove it. What refuses to walk the cycle is the run; see Delegation below.

The schema is generated from the mappings. schemaPlan builds each CREATE TABLE with createTableSql, so the schema a program expects and the schema a migration builds cannot drift. Only the two link tables are written by hand, because they hold keys rather than an entity.

Serving it

api.ts is the schema behind a rest controller:

GET    /agents            every agent, ordered, with ?enabled=true
GET    /agents/:id        the whole agent in one query
POST   /agents            create
PUT    /agents/:id        edit the whole row — name, description, model config,
                          prompt, enabled, default
POST   /agents/:id/servers  attach an MCP server
DELETE /agents/:id

Every read goes to the database. Nothing is cached and nothing is compiled in, so a change — through this API or from anything else touching the same tables — is visible to the very next request. That is met by not doing the thing that would break it, rather than by machinery.

Credentials, over the API

GET    /providers              which providers have a key — names only
GET    /providers/:provider    {"provider":"mistral","configured":true}
PUT    /providers/:provider/key  {"apiKey":"sk-..."} — stored encrypted
DELETE /providers/:provider/key

A key can be written and named; it can never be read back. Nothing in this module returns an envelope or a plaintext, so a listing endpoint cannot leak one by accident, and configured answers the only question a caller actually has.

The server refuses to start without a usable LUMEN_MASTER_KEY, rather than serving with credentials it cannot open and failing every provider call later, far from the cause.

Talking to what the rows describe

mcp.ts mounts an MCP server from its row, and provider.ts calls a model from its row. Neither file names a server or a model.

mounting  demo-mcp at http://127.0.0.1:8200
initialize ok=true
tools     add, echo
add(2,40) 42
disabled  ok=false demo-mcp is disabled
model     Mistral Small -> mistral-small-latest
ok=true status=200
disabled  Mistral Small is disabled

In both, the last line is the point: enabled was flipped in the database and the next call refused, with no restart.

MCP is HTTP-transport only. The other transport is stdio, which needs to spawn a process, and Lumen has no subprocess API — so a stdio server has to be fronted by something speaking HTTP. The client says exactly that rather than failing obscurely.

Credentials

A provider's API key is a row, encrypted:

storeCredential(db, { provider: "mistral", apiKey: "sk-...", masterKey: masterKey(), now: now });
complete(model, config, prompt, text, credentialFor(db, "mistral", masterKey()));
stored for  mistral
envelope    yV23Bn0vGDrN3+lEt04PhvRyL4jYo1JVWajaKcaW7m3J…
plaintext in the row? false
call        ok=true status=200
wrong master ok=false no API key for mistral

The ciphertext is a row; the key that opens it is not. LUMEN_MASTER_KEY comes from the environment, so the database never sees it. Encrypting a credential with a key stored beside it protects nothing. The trade is real and intended: losing the environment loses the credentials.

AES-256-GCM, so it is authenticated — a row edited in the database refuses to open rather than decrypting to something plausible that would then be sent to a provider. A test does exactly that edit.

Every failure to open answers the same way: absent, wrong master key, and tampered all return "". A caller that could tell them apart could use this table to test master keys against.

An empty key is refused at the door, because an empty plaintext encrypts to a valid envelope that decrypts to "" — indistinguishable from a failure.

providersWithCredentials returns names only; nothing in this module returns an envelope, so a listing endpoint cannot leak one by accident.

Running an agent

let answer = runAgent(db, "a1", "What is 2 plus 40?", masterKey());
answer.text            // "42"
answer.promptVersion   // which prompt actually served it
answer.modelApiName    // which model actually served it
agent=calculator prompt=v1 model=mistral-small-latest
ok=true status=200
reply 42

after UPDATE: prompt=v2 ok=true
disabled -> calculator is disabled

The prompt, the model, its wire name, the temperature and the key are all rows. Rolling a prompt back or moving an agent to another model is an UPDATE, and it takes effect on the next call.

Each row is read on its own rather than through agentsFull. A relation that matches nothing is null, and a run needs its prompt, config and model to exist — so a dangling reference is named rather than turned into a parse failure against a type that declares them present.

The run reports which prompt version and model answered, so a caller records what happened rather than what it assumed would.

Calling tools

An agent reaches an MCP server because a row links the two. That link is all it takes — the tools are fetched from the server on each run, described to the model in its own format, called, and their results fed back until the model stops asking.

mounted   2 tools from 1 server(s)
  - warehouse_stock: How many units of a part are in a named warehouse.
  - part_price: The unit price of a part in euros.

user      We need 40 units of A-114. Is there enough in Rotterdam, and what
          would 40 cost?

-- what the model did (context) --------------------------------
0 ok  parts.warehouse_stock {"part": "A-114", "warehouse": "Rotterdam"}
      -> 37 units of A-114 in Rotterdam.
1 ok  parts.part_price {"part": "A-114"}
      -> A-114 costs EUR 12.50 per unit.
rounds    2, stopped: final

-- what the user sees (conversation) ---------------------------
agent     There are 37 units of A-114 in Rotterdam, so you're short by 3.
          The cost for 40 units would be EUR 500.00 (40 × €12.50).

examples/run-with-tools.ts, against a live model and a live MCP server. Nothing in it names a tool: INSERT INTO agent_mcp_servers is what gave the agent both of them.

A run has two kinds of state and they are different things. The context is everything the model was shown — every turn, every call, every result. The conversation is what a person reads. Neither is a filter of the other: the context holds a tool's four thousand lines of output that nobody wants to read, and a transcript holds nothing about the six calls behind one sentence. AgentRun carries both, separately, and text is the only field meant for display.

Tool results are asked for, not cached. Each run asks every linked server what it offers. That is a round trip per server per run, and it is deliberate: a cached list is a list that is wrong the first time somebody deploys a tool.

What could not be mounted is reported, never silent. A disabled server, a stdio one, an unreachable one — the agent still answers, and notes says what it answered without. A tool the model was never told about is a failure that looks exactly like a bad answer.

A failed tool call goes back to the model, not to the caller. A tool that reports an error, or a name the model invented, comes back as a result it can act on. Stopping the run instead would turn a recoverable mistake into a dead one.

The step budget bounds calls, not just rounds. One reply can ask for an unbounded number of calls, so both are counted against the same budget.

Three formats, not one abstraction: OpenAI and Mistral wrap a tool in a function object and send arguments as a string holding JSON; Anthropic names the schema input_schema, carries calls as content blocks, and needs every result of one turn inside a single user message. Those are the differences, and wire.test.ts is the record of them.

Delegation

An agent's children are tools too. A parent that can ask a specialist is the same shape as a parent that can read a file — a name, a description of when to use it, one argument — so one loop, one trace and one budget cover both.

user      Can we ship 40 units of A-114 from Rotterdam today, and what is the bill?

0 ok  parts-desk.ask_parts-desk {"question":"What is the price and stock level
                                 of part A-114 in Rotterdam?"}
      -> A-114 costs €12.50 per unit, and there are 37 units in Rotterdam.
1 ok  parts-desk.ask_parts-desk {"question":"Can we ship 40 units of A-114
                                 from Rotterdam today?"}
      -> No, only 37 units are available in Rotterdam.

lead      We cannot ship 40 units today, as only 37 are in stock; the bill for
          37 would be €462.50.

examples/delegate.ts. The lead reaches no MCP server at all — two rows give it everything: one links the desk to the parts server, one makes the desk the lead's child.

A child is a separate run. It has its own prompt, its own model, its own tools and its own context. It cannot see the parent's conversation, which is why the argument's description insists the question repeat every name it depends on. That is not a style note: asked "stock of A-114?" with Rotterdam left out, a child picked a warehouse, its lookup failed, and it answered "no stock" — which the parent passed on as fact. The wording that stops it is in delegateSchema, and the reason is in the comment beside it.

A cycle is refused by name, not by the depth limit. agent_sub_agents accepts a cycle deliberately — the schema suite inserts one — so a run also refuses to enter an agent already on its path. Stopping three levels down would report the wrong cause.

Past the depth limit an agent runs alone, rather than not at all. Refusing the whole run because a child was out of reach turns a bounded plan into no answer.

What went wrong below surfaces above. A child's own notes are re-reported under its name, and a child that answered after its tool calls failed is recorded as such — in the notes, never in the result, because the result goes to the model and a warning it can quote is a warning that reaches the user as an answer.

Evaluations

Cases live in a Langfuse dataset, so the people who write them do not need a programmer. Running them is a request, not a deployment — the agent, its sub-agents, its tools and the judge are all rows.

let out = runEvals(db, {
  agentId: "a1",
  judgeAgentId: "judge1",
  dataset: "parts-desk-evals",
  runName: "nightly",
  master: master,
  maxItems: 50,
}, tracerFor(db, master));

A case says what to ask, what a good answer looks like, and — when it cares — the route the run should take:

{"input":  {"question": "Can we ship 40 units of A-114 from Rotterdam today?"},
 "expectedOutput": {"answer": "No — only 37 units are in stock in Rotterdam.",
                    "tools":  ["warehouse_stock", "part_price"],
                    "agents": ["parts-desk"]}}

The route is scored separately from the answer, and that is the point:

PASS  answer 1  tools 0.5  agents 1   Can we ship 40 units of A-114 today?
      route   : tools [warehouse_stock]  agents [parts-desk]
      MISSING tools : part_price

The answer was right and the run never called part_price, so it had no basis for the bill it quoted. An outcome-only suite records that as a clean pass and finds out when the prices change. The mirror case is an agent that answers a stock question from its own head — agents 0, no delegation — which reads as a good answer until the stock moves.

So tool-use and delegation go to Langfuse as their own scores beside correctness. Averaging them into one number would hide both failures.

What was reached is collected from the whole tree. AgentRun.steps holds only what that agent did; a tool called inside a sub-agent appears nowhere in it. calledTools and calledAgents accumulate through the delegations, because "did this run reach the stock tool" is a question about the tree and not about the top of it.

A case that names no route cannot fail one — it scores 1, rather than being dragged to zero by a check it never asked for.

The judge is an agent, so which model judges and how strictly are rows. With none configured, a built-in judge compares the numbers in the reference against the answer and says that is what it did; a suite that refuses to run until someone sets up a judge is a suite nobody sets up a judge for. A judge that was configured and failed to run is reported rather than fallen back from — those are different problems, and grading anyway would hide one. A judge answering in prose has not judged, which is not the same as scoring zero.

The backend is a row, not an assumption. trace_config.backend names it — langfuse, otlp, braintrust, langsmith, phoenix, arize — and the tracing package turns that name into how a request authenticates, which extra headers it wants, which attribute scheme it reads, and which encoding it takes.

Researching five vendors changed the design twice. One extra header was not enough: Arize AX wants space_id and api_key together, so extras are a list. And an attribute prefix was the wrong abstraction entirely — OpenInference names things by meaning, not by vendor (openinference.span.kind, input.value, llm.model_name), which no prefix substitution reaches. What varies is the whole scheme.

Sending to a real Phoenix then found a third thing, which no amount of reading had: it answers 415 Unsupported content type: application/json. OTLP defines a JSON mapping and a protobuf one, and a receiver may implement either. This package writes JSON, so a protobuf-only backend is refused before the request is made rather than after someone else's server rejects it. Against a plain OpenTelemetry collector the same run produces the same 15-span tree with the standard gen_ai.* attributes and no langfuse.* at all; against Langfuse it carries both. Datasets are the one thing that does not generalise — they are not an OpenTelemetry concept, so a backend either has an API for them or says it has none, and evaluations report that rather than building a URL that 404s.

Langfuse's own LLM-as-a-judge can run alongside this one: it scores server-side on new dataset runs, needs an LLM connection configured in Langfuse, and in v3.224 its evaluator config has no public API. Both scores then sit on the same trace, and they do disagree at the margins — which is an argument for keeping both rather than trusting either alone.

Retrieval

Documents live in folders and an agent reads the folders it is granted.

uploaded  /engineering/plume  chunks=1
uploaded  /hr/policies        chunks=1

engineer granted /engineering
people   granted /hr

-- eng: How many days of annual leave do we get?
   read  /engineering/plume/plume_relations  distance 0.377
   said  Your documents do not cover it.

-- hr:  How many days of annual leave do we get?
   read  /hr/policies/leave_policy  distance 0.147
   said  You get 28 days of annual leave, including public holidays.

examples/scoped-rag.ts, against PostgreSQL. One table, one embedding model, one question — and the engineer cannot see the HR document. The isolation is the agent_scopes rows, not separate corpora, which is what makes it survive somebody adding a folder.

That was a disclosure before scopes existed: retrieve filtered on model_id alone, so any two agents sharing an embedding model read each other's documents. A demo with one corpus never shows it.

Scopes match on segment boundaries. /specs covers /specs/plume and must not cover /specifications. A string prefix would hand a folder to anyone granted a name that starts the same way.

An empty grant reads nothing, never everything. The alternative makes revoking an agent's access the most destructive edit available, and the failure looks like the system working.

Retrieval happens in the run, not in the caller. It was a thing you did before calling runAgent, which meant an agent's knowledge lived in whoever remembered to fetch it — two callers of the same agent got different behaviour. Now agent_scopes says what it may read and agent_retrieval says how.

Every way it can come up short is in notes — not PostgreSQL, switched off, no embedding model, no scopes, no credential, nothing close enough. An agent that answered without its documents looks exactly like one that answered from them.

Retrieval, the older way

pgvector, so PostgreSQL only — SQLite and MySQL have no vector type. An agent runs anywhere; it retrieves against Postgres.

let width = embedText(embedModel, "probe", key).dimensions;   // ask the model
createDocuments(db, width);
indexDocument(db, embedModel, {
  id: "d1", source: "plume", scope: "/specs/plume",
  body: "Plume maps records to tables…",
}, key);

let found = retrieve(db, embedModel, ["/specs/plume"], "Why is an unordered page refused?", 2, key);
runAgent(db, "a1", asContext(found.found) + "\nQuestion: " + question, masterKey());
embedding width 1024
indexed 3 documents

retrieved plume/d2  distance 0.19052260549339184
retrieved plume/d1  distance 0.23477740073896414

Plume refuses an unordered page because two requests for the first twenty rows
can overlap or skip records when the database answers in any order.

The embedding model is a row like every other. models carries kind"chat" or "embedding" — and dimensions, the width of the vectors it produces. Pointing a corpus at a different embedding model is an INSERT and an UPDATE, not an edit to any file.

That makes three things checkable that were not:

  • A chat model cannot embed. A provider offers both and they answer different endpoints; refusing here beats a 404 from the provider.
  • A corpus needs a width. createDocuments refuses a model that does not say how wide its vectors are, rather than creating a column of some default.
  • A search only sees its own model's chunks. documents.model_id records which model embedded each one, and retrieve filters on it. Two models' vectors sit at the same width and are not comparable; mixing them returns confident nonsense rather than an error.

And what the model says it produces is checked against what it returned, so a mismatch is a sentence rather than a wire error about column widths.

The query vector is bound, not interpolated: it came from a provider's reply and is data like any other. distance is returned so a caller can decide what is too far, rather than trusting the ranking blindly.

The console

A web frontend for this package lives in app/: a Lit element built on LumenUI's <nr-chatbot>, served by Vite, talking to the API above through one origin.

Run everything — database, API, console — with one command:

cd packages/agents
cp .env.example .env      # set LUMEN_MASTER_KEY (openssl rand -hex 16)
docker compose up --build

Console on http://localhost:8080, the API behind it on /api, PostgreSQL with pgvector underneath so documents and retrieval work.

The console has a chat surface per conversation, a workspace panel over the thread's files, a Knowledge page — folder rail, the folder's sources, and upload, where typing a new path is how a folder is created, since a scope exists by carrying documents — and a settings overlay reached from the account block: agents (an edit form per agent), models, prompt versions, MCP servers, provider keys and tracing.

One PUT per resource

A row is edited by sending the row. There is no route per attribute: the rules that must hold — exactly one default agent, exactly one enabled embedding model — live in the row's own PUT, so they hold whichever way the row is written. A rule enforced at one door and not another is not enforced.

PUT  /agents/:id          the whole agent row
PUT  /models/:id          the whole model row
PUT  /servers/:id         the whole MCP server row

What is not an attribute keeps its own route: POST /models/:id/test calls the model and reports what happened; PUT /servers/:id/auth carries a token that never becomes a column, since it goes to the encrypted store; the link collections (/servers, /sub-agents, /scopes) add and remove relationships, which a row cannot express; and /retrieval is its own record.

Indexing is a queue

Uploading a document writes a job and answers 202 — the work is taken, not done. A worker drains the queue: agents-indexer, its own process in the compose file, because embedding a corpus is one model call per chunk and must not compete with serving requests. Two workers can run against one database; the claim is a single UPDATE … FOR UPDATE SKIP LOCKED, so they never take the same job.

The queue is a table rather than a broker. PostgreSQL is already required for documents, so a job table adds no service — and the rows are the journal the console renders, which a broker would not keep. GET /jobs is that journal; GET /documents?scope= puts queued and failed rows above the indexed ones, so a file you just dropped is visible instead of looking lost.