In-product AI agents
Constellation seeds a synthetic agent user per agent class (agent+<class>@constellation.local, INF-113/INF-125). Historically those users were just a badge — assigning a task to agent+claude-catalog-developer signalled "an agent owns this" but nothing ran. The assignable-agents epic (PLT-296) makes the badge executable: in an allowlisted tenant, assigning a task to an agent — or @mentioning one in a task comment — claims a run in coordinator.agent_runs and enqueues a bounded coordinator loop that executes as that agent.
Shipped: the assignment / @mention trigger (PLT-296a / PLT-343), the execution runtime (PLT-296b / PLT-344), the agent reporting back in-thread (PLT-296c / PLT-345) — start / progress / final comments posted into the task thread as the agent user — and the guardrails (PLT-296d / PLT-373): a destructive suggested action is never auto-executed — the run stages it as a confirm-gated pending action and an authorized human confirms or dismisses it from the task (with an advisory comment as the fallback for un-stageable proposals), budget exhaustion terminates the run cleanly, and a per-run LoopTrace is persisted (see "What the agent can do" below). All gated by a tenant allowlist (default-off — see below).
The run record: coordinator.agent_runs
An agent run is one row in coordinator.agent_runs (migration 012_agent_runs.sql, in the shared coordinator schema — there is no Prisma model; all access is raw SQL). Key fields:
| Field | Meaning |
|---|---|
task_id | Soft reference to projects.tasks (no cross-schema FK — schema-per-module). The task the run works on. |
agent_user_id | The synthetic agent user the run executes as (FK into the shared identity.users). |
trigger_kind | 'assignment' (an assignee transition) or 'mention' (an @mention in a task comment). |
trigger_ref | Informational: the comment id for a mention, or an update-correlation marker for an assignment. |
status | queued → running → terminal (succeeded / failed / cancelled). |
budget_spent | Token / wall-clock / tool-call totals the run consumed (NULL until it executes). |
trace | The full per-step coordinator LoopTrace (PLT-247), NULL pre-execution. |
outcome | Free-form terminal summary (e.g. completed, timeout, budget-tokens, an error excerpt). |
Rows are never deleted — there is no DELETE policy, so FORCE ROW LEVEL SECURITY denies deletes and the table is an append-and-settle audit trail of every run. Two idempotency guards keep triggers single-flight:
UNIQUE (task_id, agent_user_id, trigger_kind, trigger_ref)— a replayed trigger (e.g. a re-delivered mention) collapses onto the same row.- A partial unique index on
(task_id, agent_user_id) WHERE status IN ('queued','running')— at most one active run per (task, agent). Unassign→reassign only starts a new run once the previous one settles.
RLS is tenant-only (not author-scoped like pending_actions): the processor acts as the agent, not the enqueuing human, so any actor in the tenant context may read/settle the tenant's runs. There is no app.cron_bypass on this table — the processor sets app.tenant_id from the claimed job's tenant for every agent_runs statement, and (because prod runs under a BYPASSRLS role) explicit tenant_id = $tenant predicates on every statement are the load-bearing isolation; RLS is defence-in-depth.
How a run executes
The trigger only ever claims + enqueues — it never runs the LLM loop inline in the mutating request. Two paths fire it (agent-trigger.service.ts, PLT-343), both gated by the tenant allowlist so a non-allowlisted tenant claims nothing:
- Assignment — an assignee transition to a synthetic agent user, evaluated against the task row locked and re-read (
SELECT … FOR UPDATE) inside the write transaction (never the pre-transaction snapshot), with theagent_runsclaim in that same transaction so concurrent PATCHes serialise on the row lock and cannot double-trigger from stale state.trigger_kind = 'assignment'. - @mention — a validated agent @mention in a task comment;
trigger_kind = 'mention',trigger_ref= the comment id. Comments authored by an agent user never re-trigger (loop guard).
Both claim one agent_runs row (INSERT … ON CONFLICT DO NOTHING, deduped by the migration-012 constraints) and, only if a row is returned, enqueue a bounded agent-run job on jobs.queue. The cron processor then does the work:
- Cron route:
POST /api/cron/agent-runs/process(GET-aliased for Vercel), gated byAuthorization: Bearer <CRON_SECRET>,maxDuration = 300. Scheduled every minute (* * * * *) inapps/project-tracker/vercel.json. - Each pass claims and executes one job (
MAX_RUNS_PER_INVOCATION = 1— a single run's wall-clock budget nearly fills the 300s route budget, so a backlog drains at one run per minute), then runs a staleness sweep. - Execution (
agent-execution.service.ts): a short tenant-scoped transaction flips the rowqueued → running; the coordinator tool loop then runs outside any DB transaction; a second short transaction settles the terminal state. - Runs as the agent. The actor context pins
actorType: 'AGENT'with the agent user id, so every audit entry the run produces attributes to the agent user — not the human who assigned the task.
Budgets
Each run is bounded (agent-execution.service.ts):
| Budget | Value |
|---|---|
| Wall clock | 240_000 ms (240 s, below the 300 s route budget) |
| Tokens | 200_000 |
| Tool calls | 20 |
Exceeding a budget settles the run failed with the matching typed abort reason as its outcome (budget-wall-clock / budget-tokens / budget-tool-calls); the separate timeout outcome is the stale-run sweep’s settle, not a loop abort. There are no automatic retries — a domain-terminal settle (including failed) completes the job; a human re-triggers a run by re-assigning or re-mentioning. A crashed/abandoned run is reclaimed by the sweep: a running row older than 6 minutes is settled failed('timeout') (posting the thread's closing comment — see below), and a queued row older than 2 minutes with no live job is re-enqueued.
In-thread reporting
The run reports into the task's comment thread, authored by the agent user (agent-report.service.ts, PLT-345) — markdown bodies, normalised to sanitised HTML at the write boundary like every other comment (Comment rich text), no new transport (live streaming is a separate concern, PLT-297):
- a start comment when the run flips to
running; - bounded progress comments while the loop works — at most one every 3rd tool-calling step, hard-capped at 5 per run, so a busy run never floods the thread;
- a final-result comment on every terminal settle: the extracted answer on success, or a typed "did not complete" message on budget exhaustion, provider failure, or a sweep-recovered timeout — the thread always closes.
Contract details that matter operationally:
- Best-effort by design. Every post is its own short tenant-scoped transaction through the normal comment service; a comment-post failure is logged and the run carries on — the
agent_runsterminal settle is the authoritative record. The queue row is settled before the final comment, so a hung post can never leave a delivered job stuckrunning. - No feedback loops. Reporter comments are agent-authored, so the mention loop guard means they can never trigger further runs — even when the model's answer contains
@[…](uuid)mention markup. - No leaked internals. Comments never echo raw provider/tool error text; failure comments use typed phrasing and the diagnostic detail stays on the run row (
outcome,trace) and in server logs.
What the agent can do (v1)
The v1 toolset (agent-coordinator-toolset.service.ts) is deliberately read-only over PT's own data — listTasks, getTask, listIssues, and a decision-log search over coordinator.consults. readWikiPage and searchLessons (PLT-370) ground a run in the wiki KB and recorded engineering lessons the same way a human-driven consult does — see Wiki KB and lessons grounding below. Initiative-summary reads still return a safe empty stub. Because there are no write tools, the only mutations a run can propose arrive as suggested actions in its final synthesis — and the PLT-296d guardrail (agent-guardrails.service.ts) is what decides their fate:
- Destructive actions are staged for human confirmation (PLT-373). A destructive / high-blast-radius suggestion (today, a
transition_statusintoCANCELLED— the PLT-248 taxonomy, viaisDestructiveSuggestedAction) is never auto-executed. The runner stages it as a task-anchored, confirm-gatedcoordinator.pending_actionsrow (agent_staged = true, attributed to the agent, staged durably before the run's terminal settle so no crash can leave a succeeded run with an un-staged proposal) and posts an "🔒 Awaiting human confirmation" comment carrying thependingActionId. An authorized human — a programme member (delegate) of the target task's initiative — reviews everything staged against a task viaGET /api/projects/{id}/tasks/{taskId}/pending-actions, then confirms withPOST /api/coordinator/pending-actions/{id}/executeor dismisses with…/{id}/cancel; the confirm executes through PT's normal task-update authorization and its audit entry attributes to the human confirmer, never the agent. The staging agent can never confirm its own proposal (the routes reject synthetic agent users, and the transition SQL's explicit programme-member predicate — load-bearing until INF-184 lands — excludes it), and unconfirmed actions expire on their TTL. A proposal that can't be staged (its target task doesn't resolve, or the project has no initiative to authorize a confirmer) falls back to the PLT-346 advisory comment ("⚠ Recommended, not performed") for manual human action. - Reads + non-destructive work run autonomously within the run's budget — with a read-only toolset, that is the reads themselves; non-destructive write execution arrives when the write toolset lands.
The confirm/execute routes above are shared with the human-driven coordinator consult path, where a person can stage any coordinator SuggestedAction for confirmation — the artefact-promotion kinds (create_task, draft_spec, link_to_existing), the PLT-248 ticket-management kinds (update_priority, transition_status, assign, update_due_date, edit_description), and the PT-797 cycle-planning kind assign_task_to_cycle (non-destructive → baseline confirm-required tier), which sets a task's cycleId through the shipped validateCycleMembership path (a non-archived, project-scoped cycle in the task's own project — initiative-scoped cycle assignment is deferred to PT-794). Per-kind executability and request/response shapes are in the generated Project Tracker API reference — note draft_spec maps to draft_spec_pr, whose execution is not yet implemented in PT (returns 501). The agent runner itself auto-stages only the destructive subset described above; other suggestions stay advisory until the write toolset lands.
Budget exhaustion (token / wall-clock / tool-call caps) terminates the run with a typed abort, settles it failed, and posts a "did not complete" final comment; the run is never automatically re-executed.
Wiki KB and lessons grounding
readWikiPage and searchLessons (agent-wiki-grounding.service.ts, PLT-370) ground a run the same way the consult path does. readWikiPage resolves a page by slug through the wiki client. searchLessons reuses the SAME queryKnowledgeBase retrieval the consult path and the query_knowledge_base MCP tool use — with a FIXED lessons-navigation question and a single-spoke budget, never the caller's search text (the question drives KB spoke selection, so passing the query through would steer navigation to whatever topic the search text resembles and surface that topic's non-lesson pages as pseudo-lessons; the query only refines the parsed lesson entries afterwards). A resolved index_spoke is itself a catalog page (its ## Read first table points at the actual synthesis/source-summary page by slug, it doesn't inline that page's content), so searchLessons additionally dereferences the referenced page(s) and parses them into entries — canonical .ai/lessons.md-shaped date-headed entries when present, otherwise one entry per ### theme section (the live themed digest's actual shape), so the corpus stays searchable rather than collapsing to an intro excerpt. Both tools apply the consult path's UNCLASSIFIED classification cap AND the PLT-291 freshness gate (archived / expires_at) — a page above UNCLASSIFIED, missing the classification field, archived, or expired is treated as not-found rather than returned to the model, since a run's final answer is posted back into an UNCLASSIFIED task-comment thread.
The key difference from the consult path: the agent runner executes from a cron-driven job with no inbound HTTP request to forward a human caller's auth from, so it authenticates to the wiki with a per-tenant service token (WIKI_SERVICE_TOKENS, one dedicated token per AGENT_EXECUTION_TENANT_ALLOWLIST tenant) instead, pinning x-act-as-org to the run's own tenant. Each token MUST be provisioned as an API-key-kind credential (the same class as PT_AUTH_TOKEN / MCP tokens) bound to the tenant it's paired with — the wiki only honours x-act-as-org for that token kind. Three fail-closed guards enforce this rather than trusting the operator to get it right:
- API-key-kind proof (enforced at runtime). Before any wiki call, PT decodes the resolved token's JWT header and refuses (degrades to empty, logs a warning) unless
kid === API_KEY_KID— the exact condition under which the wiki routes the token to thex-act-as-org-honouring path. A session/internal token mistakenly pasted into a slot (which would silently read its OWN bound tenant) is rejected here, before any cross-tenant read can happen. - Membership check.
verifyServiceTokenTenantMembership(via the wiki'sGET /api/auth/memberships, which always reflects the token's own identity regardless ofx-act-as-org) verifies the resolved token has an active membership in the run's tenant, catching a wrong-but-still-API-key token in a slot. - Per-tenant provisioning. Each slot holds a token bound to its tenant, so even the residual is a wrong-tenant read within the allowlist, never a cross-boundary leak.
Both tools are strictly read-only: the agent-run KB source is built without the recordQueryMiss write sink, so a search_lessons miss never writes a tenant-visible ingestion-candidate record. And because wiki/lesson bodies are authored by other users, the agent system prompt marks all read_wiki_page / search_lessons content as untrusted reference material whose embedded instructions must never be obeyed (prompt-injection boundary).
See .env.local.example for the full env-var description. Unset WIKI_ZONE_URL, no (or a non-API-key) WIKI_SERVICE_TOKENS entry for the run's tenant, or a failed membership check all degrade both tools to a safe empty result (found: false / lessons: []) rather than erroring — a dogfood deployment can ship this wiring ahead of tokens being provisioned.
Prompt-injection defence
Everything an agent run reads is written by people: the task title and description it is assigned, and every read-tool result (task descriptions, issue titles, the initiative profile, wiki bodies, recorded lessons, prior consults). Inside the dogfood tenant those authors are colleagues. Beyond it they are arbitrary tenant users, so the run prompt has to be treated as carrying attacker-controlled text. Four deterministic layers cover it — no extra model call, so the defence costs nothing per run and cannot itself be talked out of doing its job:
-
Untrusted-content demarcation. Every tenant-authored string enters the prompt inside a per-run fence (
-----BEGIN UNTRUSTED TENANT DATA <nonce> (label)-----…-----END …-----). Forging this run's fence is impossible rather than merely improbable, and it is worth being precise about which pass carries that: every literal occurrence of the run's nonce is stripped out of the content before it is wrapped — unconditionally, no pattern involved — so content cannot produce a delimiter that names the enclosing block. Marker-shaped tokens are stripped too, but by a bounded pattern (an unbounded one is a denial-of-service risk on attacker-controlled text), so an unusually padded shape can survive as inert text reading-----END UNTRUSTED TENANT DATA [removed]-----. Shape stripping is hygiene; the nonce removal is the guarantee. Two chokepoints carry the wrapper — the run's user prompt and every serialised tool result.The system-prompt contract that gives the fence meaning is label-aware, and that matters: the assigned task's description is the work request, and it is legitimately imperative ("review the API and propose a migration"). A blanket "never act on anything inside the markers" would tell the agent not to do its job. So the contract separates what to work on — blocks labelled
task-*may direct the work — from the rules of engagement, which no block of either kind may change, whatever authority it claims: not the rules, not the output contract, not which tools may be called, not the canary rule, and not which task the run is about. Blocks labelledtool-result:*may direct neither; they are reference data to read, quote, and summarise. This is safe to key on because labels are host-supplied constants, never read out of the content. -
Per-run canary. Each run gets a fresh secret in its system prompt with a never-emit clause. If it appears in any tool-call argument, the run aborts before that tool is dispatched — nothing is read, nothing is staged, no model text reaches the thread. If it appears in the synthesis, the answer is withheld from the thread entirely. The canary is never persisted or logged in cleartext: it is not a span attribute or a token-usage field, and the run's
trace/outcomeare scrubbed before the terminal settle. -
Output filtering. The whole synthesis is screened before it is parsed, persisted, or posted: canary leaks block it, known credential shapes (
sk-…,ghp_…,AKIA…, JWTs, PEM keys, connection strings with inline passwords, bearer tokens) are redacted. Because every model-authored string the thread shows derives from that text, one screen covers the answer, the staged-confirmation bullets, and the advisory bullets. Volume is bounded too — a per-comment cap, a per-run comment-character budget, and a ceiling on destructive proposals per run — so comment flooding cannot turn the thread into an unbounded write channel. -
Detection telemetry. A detection writes a first-writer-wins
coordinator.agent_runs.injection_flag(canary-tool-args|canary-output|output-secret-redacted|output-flood-capped) and acoordinator.agent_run.injection_detectedaudit entry attributed to the agent — always in the same transaction, so a recorded verdict can never disagree with the universal audit log. The write is attempted before any side effect (retried, since a lost detection is exactly the event that must not go unrecorded) and once more inside the terminal settle; it is idempotent, so at most one audit entry exists per detected run. Because that final attempt runs inside the settle, a run row that commits has provably had its flag and audit entry recorded — with one exception, listed under "What this does NOT defend" below. Flagged runs are queryable per tenant, newest first, for run scorers and observability.
A run stopped this way settles failed with outcome = injection-blocked:<flag>, and the thread comment says only that a safety check stopped it — no model text, and explicitly no invitation to retry (re-assigning would replay the payload). A human should review the task content first.
What this does NOT defend
Stated plainly, because the bound matters more than the layer count:
- Semantic manipulation under the demarcation. Demarcation stops content from impersonating the operator; it does not stop content from being persuasive data. A description that argues "the right call here is to cancel PLT-123" can still shift the model's judgement. What bounds it: the loop is read-only, destructive proposals stay staged behind a human confirm, and every proposal is attributed to the agent in the audit chain.
- Intra-tenant disclosure. A manipulated run can summarise initiative-wide data into its own thread comment. The run reads only what its tenant's agent identity may read, so this is disclosure to someone who is already a tenant member — not a cross-tenant leak. Narrowing an agent's read scope to its own task is a product decision, not an injection fix.
- Canary evasion by encoding. A canary emitted base64-encoded or hyphen-split defeats substring detection. The canary is a tripwire for the common case, not a containment proof.
- Novel credential shapes. The secret-pattern list is a denylist, belt-and-braces over the fact that the loop has no credential access to begin with.
- One detection can be lost: a comment-flood detected after the run has settled. Flood suppression discovered while posting the deferred thread comments happens once the terminal settle has committed — there is no outcome left to mark and no in-settle attempt left to make. If every telemetry retry then fails, the run row will not show
output-flood-cappedand no audit entry is written; the loss is logged aterrorso it is visible in logs, but it is a real gap rather than a covered case. Making it durable needs an outbox/retry job, judged disproportionate for this one signal today. Every other detection path — any canary verdict, secret redaction, and loop-time flood suppression — is folded into the effective detection recorded before the settle and retried inside it, so those cannot be lost by a committed run.
Heavier layers (a classifier, secondary-model transcript review, ensemble voting) are deliberately out of scope for v1: each adds a model call per run, and each added model call is itself an injection surface. They get revisited on evidence — a confirmed semantic-manipulation incident, or sustained injection_flag detections in a widened-allowlist tenant.
Enabling it — the tenant allowlist
The whole feature is default-off. It runs only for tenants named in the AGENT_EXECUTION_TENANT_ALLOWLIST environment variable (comma-separated tenant UUIDs, read on each call so changes are honoured without a restart). The gate is enforced at the trigger and again at execution:
- At the trigger — for a non-allowlisted tenant, assignment/@mention claims nothing and enqueues nothing, so assignment stays badge-only: the assignee is set, but no run is created.
- At execution (defence-in-depth) — even if a job somehow reached the queue, the cron processor no-ops when the allowlist is empty, and a job for a non-allowlisted tenant is completed as a skip (dispatch delivered, nothing run), not an infra failure.
AGENT_EXECUTION_TENANT_ALLOWLIST must not be widened beyond the dogfood tenant until the prompt-injection defence above is verified in production (PLT-397). Beyond dogfood, task descriptions and comments are written by arbitrary tenant users, and the confirm gate alone does not cover the non-destructive surface — in-thread comments as an exfiltration channel, initiative-wide read tools as a snooping channel.
This allowlist is a tenant-level gate — distinct from the two claim-time gates below, which apply to whether an agent may take on work at all.
Relationship to the flow gates
The runtime composes with the flow engine's two hard gates for agent claims:
- Readiness (
TASK_NOT_READY, PT-643) — an agent may not be left in an in-progress status while aBLOCKS-predecessor is unfinished. - Agent WIP cap (
WIP_LIMIT_REACHED, PT-651) — a project holds at mostPT_AGENT_WIP_LIMIT(default 3) agent-assigned in-progress tasks;claim-next-readyreturns "nothing to claim" at the cap so a fleet loop moves on.
Those gates decide whether an agent takes the task; the allowlist decides whether taking it actually runs a coordinator loop. Humans are never subject to either flow gate or the execution allowlist.
Operations
The env vars — AGENT_EXECUTION_TENANT_ALLOWLIST, CRON_SECRET, and PT_AGENT_WIP_LIMIT — are documented in Project Tracker deployment.
See also
- Flow engine — the ready set, review queue, and the
TASK_NOT_READY/WIP_LIMIT_REACHEDclaim gates. - Agents overview — the repo-side Claude Code subagents (a different thing: those drive development in this repo; the agents on this page run inside the product).
- Project Tracker deployment — the cron and allowlist environment variables.