← Agent Engine

Data model

agent-engine · workzone

Agent Engine stores agent definitions and the run journal, plus a thin agent_tools link — the choice of optional tools. An agent's identity is not a separate entity but an FK to its owner (agents); the weekly budget is not a counter table but a sum over agent_runs. Knowledge lives in the Knowledge Store, and the weekly budget cap lives in platform_settings; the only foreign data kept here is two references — the owner's user_id and the chosen model's model_id — everything else is read from neighbors on the fly.

Agent definition: agents

What the employee set up: a name, a prompt (its composition layer), a schedule, and two locks — the owner's switch and the admin's sticky lock. The FK to the owner does double duty: both the delete cascade and the identity under which the agent reads the knowledge base.

1 Agent What the employee set up — prompt, schedule, stops.
agents agent definitions
FK → users
id BigInteger PK
user_id BigInteger FK→usersIDX owner · CASCADE · their ACL = the agent's identity before the KS
name Text NOT NULL agent name — for the “my agents” list
description Text NULL short description — the agent card subtitle · optional · not prompt: for the human, not the model
prompt Text NOT NULL owner layer · composed beneath the platform prompt at runtime
schedule JSONB NULL simple schedule — a tagged union (interval / calendar) · NULL = manual run only · named by meaning, not meta
model_id BigInteger FK→agent_modelsNULLIDX run model · owner's choice from the allowed list · SET NULL: model removed from the list → NULL = agent stopped, owner picks again
enabled Boolean NOT NULLDEFAULT owner switch · true = agent live (scheduled + manual); false — full stop
admin_paused Boolean NOT NULLDEFAULT admin's sticky lock · the owner cannot release it (two locks)
next_run_at DateTime(tz) NULLIDX computed next slot · NULL = manual only / switched off by owner / locked by admin · the scheduler scans by it
meta JSONB NULL flexible agent attributes
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger · edits to prompt, schedule, stops
The agent's identity is an FK to the owner, not a separate model
Before the knowledge base, the agent is its owner: user_id leads straight to the creator's ACL, and the cascade runs down the same edge. There is no separate table for identities, service accounts, or a “whose access” choice; v2, the full identity model. Linking is cheap: the employee's identity is already reconciled with their account by the identity.user_id bridge in the identity model.
Two locks — both must be open
enabled is the owner's switch, admin_paused is the admin's lock; these are different axes, not one state field. The agent starts — scheduled or manual — only if it is enabled by the owner and not locked by the admin. The admin's lock is sticky: the owner edits their own flag, not the other. Owner deactivation adds no third axis: it writes the same enabled=false, and only the owner themselves may restore it (life cycle).
The model is a reference to the allowed list, with no implicit default
model_id points at a row in agent_models — the list of chat models the admin has allowed for agents. The owner picks from it; on creation the field is set (preselected — the list default). There is no implicit runtime default: NULL does not mean “take the default” but “no model — agent stopped”. Two paths lead here: the model is not yet chosen, or the admin removed it from the list — ON DELETE SET NULL clears the reference, and model_id IS NOT NULL becomes the fourth condition for starting. Referencing a list row rather than the model catalog is deliberate: removal stops the agent at once, with no “is the model still in the list” check, and re-adding it does not silently revive it — the owner confirms the choice.
schedule — a tagged union on type, not free-form JSON
The field's shape is held by the backend (Pydantic v2, discriminator type) — the source of truth; the surface editor receives it typed. Two variants. Interval — { "type": "interval", "every_hours": N }, a “every N hours” period. Calendar — { "type": "calendar", "cadence": "daily" | "weekly", "weekday": 0–6, "time": "HH:MM" }: weekday (0 = Monday) matters only for weekly, and time is the owner's local time. NULL — no schedule, the agent is purely manual. The day encoding and time format are the same as the platform's curation_* (weekday 0–6, 'HH:MM') — we do not introduce a second schedule dialect.
Run journal: agent_runs

A row per run — both the ones that happened and the ones rejected. A state machine (queued → running → succeeded / failed) plus the agent's output and token spend. The journal is the home of the result (output) and the source of the weekly budget: its tokens_used are summed against the cap.

2 Run One run — outcome, output, spend.
agent_runs run journal
FK → agents state machine one active per agent
id BigInteger PK
agent_id BigInteger FK→agentsIDX whose run · CASCADE · its history
trigger Text NOT NULLCHECK how it started · manual · scheduled
state Text NOT NULLCHECK queued · running · succeeded · failed · skipped
reason Text NULLCHECK why skipped / failed · machine code, not free text · skipped: budget_exceeded · already_running · failed: iteration_cap · error · stale
output Text NULL agent's final answer · the whole output, read on the agent screen
tokens_used BigInteger NOT NULLDEFAULT run spend · summed into the weekly budget
error Text NULL failure detail on failed
started_at DateTime(tz) NULL when it entered running · NULL for skipped
finished_at DateTime(tz) NULLIDX when it finished · the weekly budget window is measured by it
heartbeat_at DateTime(tz) NULL run worker's liveness · staleness releases the lock of a hung running
created_at DateTime(tz) DEFAULT now() · queued
A run is a state machine, but the journal carries only created_at
The row lives by transitions: queued on enqueue, running on start, terminal at the end — this is an in-place UPDATE. But the run's progress is carried by the domain milestones started_at / finished_at / heartbeat_at (a heartbeat every ~30 s — finer than a generic updated_at), so a separate updated_at is redundant: the run journal carries only created_at. The same run-journal pattern as sync_runs (Harvester) and curation_runs / backup_snapshots (Knowledge Store).
One active run per agent — a lock in the DB, not just a check in code
The lock is a partial unique index UNIQUE (agent_id) WHERE state IN ('queued','running'): an agent has at most one unfinished run. A race (a manual start coinciding with an arrived slot, two workers grabbing the row) is settled by the database itself — the second INSERT of an unfinished run hits the lock; the engine catches the rejection and writes a terminal skipped with reason=already_running (that state is outside the lock's predicate, so it does not block the write). If a worker dies without closing the run, unattended the row would hang in running forever: it would both lock the agent and hold tokens outside the budget. It is cleared by heartbeat_at: a stale run is reaped into failed with reason=stale, the lock is released, and the agent starts again. The same lock and the same heartbeat are carried by Harvester's sync_runs and Knowledge Store's curation_runs — a single run-journal pattern across the platform. The staleness threshold is a module constant (proximity principle), not a magic number in the reaper's code: the worker bumps heartbeat_at every ~30 s, a run is considered hung after ~90 s of silence (three missed beats) — the same order of magnitude as the neighbors' run journals.
The budget is derived — a sum over tokens_used, not a counter
There is no separate spend table: the weekly cap is checked as SUM(tokens_used) over the runs of all the owner's agents where finished_at falls in the current week (calendar week, Sunday 00:00 in the organization's zone — budget). Windowing by finished_at means a still-running run's spend enters the sum only at the end: the run starting next sees it as zero, and within a week a small cap overrun is possible, tapering to nothing as runs finish. For rare personal runs the overrun is narrow and acceptable. At this scale (personal agents, rare runs) the sum copes without an aggregate; a materialized counter is an optimization for later. The limit itself is not stored here — it is a platform value, in platform_settings (boundary).
skipped is a record, not the absence of a run
A state=skipped row is written only when the engine set out to start a run but the runtime gate closed: the weekly budget is spent (budget_exceeded) or the agent's previous run is still going (already_running). Both are invisible from the agent's static config — which is why the record is needed: the rejection is visible in the journal (“limit exceeded”) rather than lost in silence; tokens_used = 0, no loop ran. The locks, by contrast, stop the agent before the gate: with the owner's switch or the admin's lock, next_run_at is cleared, the scheduler does not scan the agent, and the manual start button is disabled — a durable stop spawns no rows, it is shown by the agent's status, not by a stream of skipped.
Tool selection: agent_tools

Which optional tools the owner attached to the agent on top of the core — a thin many-to-many link between agents and the catalog (tools). The core three KS tools are locked and not stored in the table — every agent has them by default; the rows here are only about the selection on top of it.

3 Link Optional tools chosen by the owner.
agent_tools agent's tool selection
FK → agents FK → tools UNIQUE (agent, tool)
id BigInteger PK
agent_id BigInteger FK→agentsIDX whose selection · CASCADE · goes away with the agent
tool_id BigInteger FK→toolsIDX which tool from the catalog · CASCADE
created_at DateTime(tz) DEFAULT now() · append-only selection link, no updated_at
Removing a tool is graceful degradation, not a stop
Access can be revoked two ways, and neither brings the agent down. If the admin removes the tool from the allowlist (tools.agents_allowed = false), the link row stays, but the runtime does not offer it in the toolset, filtering the selection by the current flag at the run's start. If the tool itself is deleted from the catalog (or the agent altogether), the link is cleaned up by ON DELETE CASCADE. In both cases the agent continues on the KS core; this does not enter the start gate — unlike a removed model, which stops the run. Write access and MCP tool sources are v2.
Boundary: what the engine stores, what belongs to neighbors

Agent Engine keeps agents and runs — and only that. Everything else the agent touches belongs to neighbors: account and identity — Auth and Knowledge Store; the model registry and budget cap — Admin; the knowledge itself — Knowledge Store. The only foreign data kept here is two references — the owner's user_id and the chosen model's model_id; the budget cap is not even kept as a reference — the engine reads it from platform_settings at the start of each run.

Agent Engine stores

agents (definitions) and agent_runs (the journal with output and spend). It does not store identity — it takes the ACL by user_id. It does not store the model — model_id references the allowed list. It does not store the weekly spend as an aggregate — it sums from runs. It does not store the cap — it reads it from platform_settings. It does not store knowledge — it reads from the KS under permissions.

Neighbors own

users and identities — Auth and Knowledge Store; allowed models (agent_models) and the token cap (platform_settings) — Admin; the body of knowledge, the graph, the vectors — Knowledge Store, from which the agent only reads.