Files
2026-11-14 09:56:45 +00:00

147 lines
11 KiB
Markdown

# agent
The bounded agent runtime's app integration: durable runs with step state, the approval queue a
person reviews, and the run controls. The run job drives `@conusai-kit/agent`'s loop on TanStack
AI's server `chat()`: the model (`AGENT_MODEL`, the AI gateway's `conusaiText` adapter) calls the
admitted steps in order and answers the goal from their results, and the conversation is kept in
`agent_thread` through `@tanstack/ai-persistence`. The run's tool calls go through the
organization's tool registry with the app's scoped deployment credentials (`apps/worker/src/ports.ts`
binds `AGENT_TOOLS` and `AGENT_MODEL`); the platform owns dispatch policy, metering, spend admission
and the run ledger.
A plan step with `"needsApproval": true` is approved by a person before it runs: the run parks in
`awaiting_approval` with the step's exact tool and arguments in the approval queue, and
`agent.approval.review` records the decision on the step (approve resumes the run, reject cancels
it; a step is decided once). A completed run's answer is AI-generated text: `agent.run.status` and
the run page show it with the Art. 50 disclosure (`AiDisclosureBadge`).
## The grant a run's tools need
A run's tool calls go to the organization's MCP endpoint with the app's deployment token, and that
endpoint admits a deployment token only with `ai_tools:call`. The scope is not a default and `*`
never holds it, so `conusai.json` requests it, in the root file and in every profile copy that has a
`token` block (`profiles/convex`, `profiles/mariadb`; `profiles/mongodb` has none). This module is
Postgres-only, so nothing calls the endpoint on those profiles and their request can stay undecided.
A request is not a grant. A deployment that asks for the scope runs without it until an organization
owner or admin approves it, in the project's Settings, Token permissions screen or with `conusai
tokens approve --id <token_id> ai_tools:call`; anyone else gets a 403, and the approval is in the
audit log. Approve it before starting runs.
Without the grant a run's tool call is refused with a 403: the run ends `failed` with the failure code
`tool_error` and the reason in `failureDetail` and on its step, and the queue does not run it again. A
policy denial ends the run the same way, and so does a tool that answers an error: its step is not
committed, and none of the tool's text is kept on the run. A failed or cancelled run is final, and a
later approval does not resume it: start a new run.
A failure that can pass later is left to the queue: a 429 or a 5xx, a network failure, busy compute, a
catalog that is still indexing, or a `runtime-unavailable` denial. The next delivery resumes the step on
its idempotency key. When the queue proves this is the final attempt (`ctx.finalAttempt`), an
unhandled failure ends the run as `failed/internal` with a fixed exhaustion message. Worker shutdown
preserves the recoverable state. Terminal replay never calls the model or tools again.
The job reads the initiating member from the stored run's `actorId`, and checks real current app
membership and `agent.run.start` through `TENANCY.authorizeIn` at entry, before each model turn,
and before each tool effect, including approval resumption. A removal or permission revocation
ends the run with a fixed failure message; a request's cached permission never authorizes worker
execution. App tenant UUIDs remain separate from Cloud deployment authority. The scoped run store
matches org, workspace and project; wrong-scope lookups cannot execute another tenant's run.
The supported Postgres worker exposes its actual envelope and claim through `JobContext.execution`.
Agent verifies scope and attribution and probes the data source's real transaction hook before any
run/provider work. A raw factory without that hook fails closed. Every scoped repository operation
and UOW locks the exact current claim in its own transaction until commit/rollback; this lock
survives loss of the separate guard session. Owned-client error/end events abort a sticky signal;
same-session health checks surround authorization, model/tool/resume, store and error boundaries.
Claim loss remains recoverable queue interruption and never becomes terminal business failure.
The tools run with the token's own permissions, so a tool that needs another scope needs that scope
requested and approved as well. A step calls only the tool and arguments a person admitted in the
plan.
**Tool cost is not reported yet.** The registry's call result carries no metered usage, so a step
records a cost of 0 (`registryAgentTools`): the budget guard counts a run's steps and time, and no
tool's cost. Model calls are metered by the AI gateway, under the organization's spend cap.
## Procedures
| Procedure | Kind | Permission | What it answers |
|---|---|---|---|
| `agent.run.start` | write | `agent.run.start` | a queued run with its admitted plan |
| `agent.run.status` | read (MCP tool) | `agent.run.read` | the run, its step states and progress |
| `agent.run.events` | effect, `GET` stream | `agent.run.read` | the run's AG-UI events as Server-Sent Events |
| `agent.run.list` | read | `agent.run.read` | the project's runs, a page at a time |
| `agent.run.cancel` | write | `agent.run.cancel` | the persisted cancel request |
| `agent.approval.list` | read | `agent.approval.review` | the parked approvals with their exact arguments |
| `agent.approval.review` | write | `agent.approval.review` | the run after a person's decision |
`agent.run.events` (`GET /api/v1/agent.run.events?org=…&workspace=…&project=…&runId=…[&after=…]`)
answers `text/event-stream` from `@conusai-kit/agent/agui`'s projection of the durable record: what
`agent.run.status` shows the same reader, and nothing more (no tool results, artifacts or an
approval's proposed arguments). `after` is the last delivered sequence number or SSE id
(`agk1:{runId}:{seq}`); the stream replays strictly after it, re-reads the record every second
until the run's terminal event, and closes a 25-second window with an untagged notice so the client
reconnects and is authorized again. Another project's or organization's run is not found, exactly as
`agent.run.status` answers, and an offset of another run is refused (422). The browser client is
`@conusai-kit/agent/client` (`agentRunConnection`, `watchAgentRun`): the run page server-renders
`agent.run.status` as the seed of a client island (`ui/live-run.tsx`) that follows this stream, so
step states, a parked approval and the answer appear without a reload and nothing polls the run.
The run list and the approval queue are live too. The module declares the live collection `agent.run`
(`contract/collections.ts`: table `agent_run`, key `runId`, batch-get `agent.run.getMany` under
`agent.run.read`, at most 100 ids). The list keeps its server-rendered first page as the seed of a
`liveCollection` (`ui/live-runs.tsx`), so a run another member starts appears and each run's status and
progress move as its steps commit. The approval queue is a `useRead` of `agent.approval.list` read again on
every `agent.run` change (a run that parks an approval or leaves one changes its status) and after the
reviewer's own decision, which changes a step and not the run. Starting a run is a `useWrite` that
resolves once the live stream has seen the new row; no page re-runs its loader after a write
(`test/live-writes.test.ts`).
The procedure is declared `effect` rather than `read` only because the kernel admits a streamed
HTTP response (`.download()`) for that kind alone; its handler reads and never writes, and it counts
against the `default` rate-limit bucket like a read. Being an effect, a session caller's token is
re-checked as high-risk on every connection.
## Why the module is Postgres-only (no Convex profile yet)
`module.convex.ts` ships a fail-closed Convex binding for a future wiring, but the module is
**not** registered in the Convex profile (`convex/lib/runtime.ts`), and the manifest stays
`profiles: ["postgres"]`. The integration review (2026-10-23) kept it Postgres-only because the
run cannot execute on the Convex runtime today:
1. The `agent.run` **job** leaves the process on every step — the tool registry, the AI gateway,
and whatever the managed tools call. Convex queries/mutations cannot perform network I/O; the
Convex binding fails closed (`… must run through the Convex Node action boundary`) and, unlike
documents' `documentsActions:process`, no composed Node action exists for the run job yet.
2. A run's steps must recheck policy and meter through the platform between actions; that
orchestration lives in the worker, and a partial registration would let a project start runs
that always dispatch into the fence.
The read/write procedures (run list/get, cancel, approval review) would technically run in Convex
transactions, but registering the module for metadata alone — while every started run fails — is
a registered-broken feature, not a fail-closed gate. Wiring the profile means composing the run
job's Node action first (mirror `convex/documentsActions.ts`).
S08 hardening rechecks current local membership and `agent.run.read` before every stream poll,
without the request's permission memo. Revocation closes the body; reconnect is a fresh authorized
request. Approval interrupts compare the resumed tool and canonical arguments with the stored
review; drift fails durably, rejection cancels without a second cancel write, and an awaiting run
expires on observation at its admitted duration. These local controls do not establish live model,
registry-tool or sandbox readiness. See the S08 evidence boundaries.
Durable Agent jobs require `JobContext.execution` from the supported Postgres worker factory
(`createPostgresWorker` in `@conusai-kit/host/worker`). The actual pg-boss envelope must match
`AGENT_SCOPE`, the job name, real system actor and immutable initiating run owner. The handler
holds the adapter guard through all awaited work while its session is alive; domain transactions
also hold their own compatible claim lock until commit/rollback. Missing or lost claims refuse
separately from business failure or retry exhaustion. The guard uses its own bounded pool;
shutdown aborts and drains callbacks without releasing live sessions early. Raw queue constructors
without the factory's exact timestamp evidence, BullMQ and Convex supply no accepted Agent claim
guarantee. Current membership checks still repeat between model/tool calls and approved resumes.
External tool keys remain `${record.id}:${planned.key}`. Providers must enforce idempotency/fencing
and cooperate with cancellation; an already-started action cannot be undone by an abort signal.
The supported domain fence and external-provider guarantees are separate. See the
[adapter contract](../../../../sdks/node/packages/kit-jobs-pgboss/README.md#durable-claim-guards).