ConusAI vendor v0.1.0-beta.114
ci / gate (push) Waiting to run

This commit is contained in:
ConusAI vendor v0.1.0-beta.114
2026-11-14 09:56:45 +00:00
commit 1ce8d23c35
867 changed files with 122104 additions and 0 deletions
+146
View File
@@ -0,0 +1,146 @@
# 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).
+173
View File
@@ -0,0 +1,173 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Real pg-boss claims for Agent tests, including local engine/provider tests.
import { buildRegistry, Container, defineModule, type JobContext, newId, type ProjectScope } from "@conusai-kit/core"
import { systemActor } from "@conusai-kit/core/worker"
import { type DataSource, entitiesOf } from "@conusai-kit/data"
import { connectKyselyDataSourceFromEnv, type KyselyDataSource } from "@conusai-kit/data-kysely"
import {
createPgBoss,
createPgBossClaimGuard,
createPgBossJobQueue,
createWorker,
fromKyselyTransaction,
withJobTransaction,
} from "@conusai-kit/jobs-pgboss"
import { Pool, type PoolClient } from "pg"
import { afterAll, beforeAll } from "vitest"
import { z } from "zod"
import { runJob } from "../adapters/procedures.js"
import { RUN_JOB } from "../contract/index.js"
import { AGENT_SCOPE } from "../core/ports.js"
import { grantPgBoss, migrate, type Postgres, startPostgres } from "./claimed-postgres.js"
import { workerRegistry } from "./worker-fixture.js"
let postgres: Postgres
let boss: ReturnType<typeof createPgBoss>
let guard: ReturnType<typeof createPgBossClaimGuard>
let sql: Pool
let ownedClient: PoolClient
/** Terminate only the client acquired from this test's dedicated guard pool. */
export async function terminateClaimSession() {
const {
rows: [{ pid }],
} = await ownedClient.query("select pg_backend_pid() as pid")
await sql.query("select pg_terminate_backend($1)", [pid])
}
export type ClaimChange = "cancel" | "reclaim" | "delete" | "scope" | "actor" | "owner"
const workers: ReturnType<typeof createWorker>[] = []
const pending = new Map<
string,
{
container: Container
extra: Partial<JobContext>
change?: ClaimChange
resolve: () => void
reject: (error: unknown) => void
}
>()
let domainData: KyselyDataSource | undefined
/** Queue and domain tables share the actual database, as in the supported host. */
export async function claimedDataSource(): Promise<KyselyDataSource> {
if (!domainData) {
const entities = entitiesOf(workerRegistry, "postgres")
await migrate(postgres, entities)
// Migrations grant access to public tables; queue objects remain owned by pg-boss.
domainData = await connectKyselyDataSourceFromEnv(
{ POSTGRES_URL: postgres.runtimeUrl, POSTGRES_READER_URL: postgres.readerUrl },
{ entities, transactionContext: (trx, run) => withJobTransaction(fromKyselyTransaction(trx), run) },
)
}
return domainData
}
let currentData: DataSource
const registry = buildRegistry([
defineModule({
id: "agent",
version: "1.0.0",
kit: "^0.1.0",
jobs: {
[RUN_JOB]: {
payload: z.object({ runId: z.string(), delivery: z.string() }),
async handler(payload, ctx) {
const target = pending.get(payload.delivery)
if (!target) throw new Error("unregistered test delivery")
try {
if (!ctx.execution) throw new Error("supported adapter did not supply execution")
const id = ctx.execution.id
if (target.change === "delete") await sql.query("delete from pgboss.job where id = $1", [id])
if (target.change === "cancel")
await sql.query("update pgboss.job set state = 'cancelled' where id = $1", [id])
if (target.change === "reclaim")
await sql.query("update pgboss.job set retry_count = retry_count + 1 where id = $1", [id])
if (target.change === "scope")
await sql.query(
"update pgboss.job set data = jsonb_set(data, '{scope,projectId}', to_jsonb($2::text)) where id = $1",
[id, newId()],
)
if (target.change === "actor")
await sql.query(
"update pgboss.job set data = jsonb_set(data, '{onBehalfOf}', to_jsonb($2::text)) where id = $1",
[id, newId()],
)
await runJob.handler(
{ runId: payload.runId },
{ ...ctx, ...target.extra, execution: ctx.execution, container: target.container },
)
target.resolve()
} catch (error) {
target.reject(error)
} finally {
pending.delete(payload.delivery)
}
},
},
},
}),
])
beforeAll(async () => {
postgres = await startPostgres()
boss = createPgBoss({ connectionString: postgres.ownerUrl })
await boss.start()
await boss.createQueue(RUN_JOB)
await grantPgBoss(postgres)
sql = new Pool({ connectionString: postgres.runtimeUrl, max: 2 })
const guardPool = new Pool({ connectionString: postgres.runtimeUrl, max: 2 })
guardPool.on("acquire", (client) => {
ownedClient = client
})
guard = createPgBossClaimGuard(guardPool)
}, 300_000)
afterAll(async () => {
for (const worker of workers) await worker.stop()
await guard?.close()
await sql?.end()
await boss?.stop({ graceful: true })
await domainData?.close()
await postgres?.stop()
})
let started = false
export async function claimedDelivery(
data: DataSource,
container: Container,
scope: ProjectScope,
runId: string,
extra: Partial<JobContext> = {},
change?: ClaimChange,
) {
currentData = data
if (!started) {
// Source forwarding is needed because a file exercises several independent tenancy fixtures.
const source = new Proxy({} as DataSource, {
get(_target, property) {
const value = Reflect.get(currentData, property)
return typeof value === "function" ? value.bind(currentData) : value
},
})
workers.length = 0
const worker = createWorker({
registry,
boss,
claimGuard: guard,
dataSource: source,
container: new Container(),
relay: { enabled: false },
})
workers.push(worker)
await worker.start()
started = true
}
// Mirror the actual adapter scope into the app's independently bound scope port.
const base = extra.container ?? container
const child = base.child()
child.value(AGENT_SCOPE, { ...base.resolve(AGENT_SCOPE), actor: systemActor(RUN_JOB) })
const delivery = newId()
const settled = new Promise<void>((resolve, reject) =>
pending.set(delivery, { container: child, extra, ...(change ? { change } : {}), resolve, reject }),
)
await createPgBossJobQueue({ boss }).enqueue(
change === "owner" ? { ...scope, actor: { userId: newId() } } : scope,
RUN_JOB,
{ runId, delivery },
)
return settled
}
+103
View File
@@ -0,0 +1,103 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Disposable local PostgreSQL for the app's actual claim tests. No SDK-private test code.
import { execFileSync } from "node:child_process"
import type { AnyEntityDef } from "@conusai-kit/data"
import { readerMigration, runMigrationsFromEnv, schemaMigration } from "@conusai-kit/data-kysely"
import { Pool } from "pg"
export interface Postgres {
ownerUrl: string
runtimeUrl: string
readerUrl: string
stop(): Promise<void>
}
const docker = (...args: string[]) =>
execFileSync("docker", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
export async function startPostgres(): Promise<Postgres> {
const name = `agent-claim-${crypto.randomUUID()}`
const container = docker(
"run",
"--detach",
"--rm",
"--name",
name,
"--label",
"conusai.test=app-agent-claim",
"--publish",
"127.0.0.1::5432",
"--env",
"POSTGRES_USER=owner",
"--env",
"POSTGRES_PASSWORD=owner",
"--env",
"POSTGRES_DB=agent",
"pgvector/pgvector:pg18",
)
const stop = async () => {
docker("rm", "--force", "--volumes", container)
}
let owner: Pool | undefined
try {
// Docker Desktop can return before the random published port is visible. An empty
// port makes pg silently use 5432, so wait for the actual mapping before connecting.
const mappingDeadline = Date.now() + 10_000
let port = ""
while (!/^[1-9][0-9]*$/.test(port)) {
port = docker("port", container, "5432/tcp").split(":").at(-1) ?? ""
if (/^[1-9][0-9]*$/.test(port)) break
if (Date.now() >= mappingDeadline) throw new Error("Postgres test port was not published")
await new Promise((resolve) => setTimeout(resolve, 100))
}
const ownerUrl = `postgresql://owner:owner@127.0.0.1:${port}/agent`
owner = new Pool({ connectionString: ownerUrl, max: 1, connectionTimeoutMillis: 1000 })
const deadline = Date.now() + 60_000
while (true) {
try {
await owner.query("select 1")
break
} catch (error) {
if (Date.now() >= deadline) throw error
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
await owner.query("create role app_runtime login password 'runtime' nosuperuser nobypassrls")
await owner.query("create role app_reader login password 'reader' nosuperuser nobypassrls")
return {
ownerUrl,
runtimeUrl: ownerUrl.replace("owner:owner@", "app_runtime:runtime@"),
readerUrl: ownerUrl.replace("owner:owner@", "app_reader:reader@"),
stop,
}
} catch (error) {
await stop()
throw error
} finally {
await owner?.end()
}
}
export async function migrate(postgres: Postgres, entities: readonly AnyEntityDef[]) {
await runMigrationsFromEnv({ POSTGRES_MIGRATE_URL: postgres.ownerUrl }, [
schemaMigration(entities),
readerMigration("app_reader", []),
])
const owner = new Pool({ connectionString: postgres.ownerUrl, max: 1 })
try {
await owner.query("grant usage on schema public to app_runtime, app_reader")
await owner.query("grant select, insert, update, delete on all tables in schema public to app_runtime")
} finally {
await owner.end()
}
}
export async function grantPgBoss(postgres: Postgres) {
const owner = new Pool({ connectionString: postgres.ownerUrl, max: 1 })
try {
await owner.query("grant usage on schema pgboss to app_runtime")
await owner.query("grant select, insert, update, delete on all tables in schema pgboss to app_runtime")
await owner.query("grant execute on all functions in schema pgboss to app_runtime")
} finally {
await owner.end()
}
}
+23
View File
@@ -0,0 +1,23 @@
// The module's messages: en and lt declare the same keys, or a page in one language breaks.
import { buildRegistry } from "@conusai-kit/core"
import { tenancyModule } from "@conusai-kit/tenancy"
import { describe, expect, it } from "vitest"
import { agentModule } from "../index.js"
describe("the agent module's messages", () => {
const catalogues = buildRegistry([tenancyModule, agentModule]).catalogues
it("declares en and lt, and every en key resolves in lt", () => {
const en = catalogues.en?.agent ?? {}
const lt = catalogues.lt?.agent ?? {}
const flatten = (tree: Record<string, unknown>, prefix = ""): string[] =>
Object.entries(tree).flatMap(([key, value]) =>
typeof value === "string" ? [`${prefix}${key}`] : flatten(value as Record<string, unknown>, `${prefix}${key}.`),
)
const enKeys = flatten(en)
const ltKeys = new Set(flatten(lt))
expect(enKeys.length).toBeGreaterThan(0)
for (const key of enKeys) expect(ltKeys.has(key), `agent.${key} has no Lithuanian message`).toBe(true)
})
})
+51
View File
@@ -0,0 +1,51 @@
// What the generated tenant-isolation suite needs of this module: seeds and one fixture per
// tenant-scoped procedure, built from the OTHER tenant's ids.
import type { TransportCaller } from "@conusai-kit/core"
import type { IsolationFixture, IsolationTenant } from "@conusai-kit/testing"
export interface ModuleIsolation {
seed(
call: TransportCaller,
inProject: { org: string; workspace: string; project: string },
): Promise<Record<string, string>>
fixtures: Record<string, IsolationFixture>
}
const NOWHERE = "00000000-0000-0000-0000-000000000000"
const inWorkspaceOf = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug })
const inProjectOf = (t: IsolationTenant) => ({ ...inWorkspaceOf(t), project: t.projectSlug })
const runOf = (t: IsolationTenant) => t.extra.agentRunId ?? NOWHERE
const PLAN = [{ key: "read", label: "Read", tool: "documents.read", args: {} }]
export const isolation: ModuleIsolation = {
async seed(call, inProject) {
const started = (await call.callByName("agent.run.start", { ...inProject, goal: "Seed run", plan: PLAN })) as {
runId: string
}
return { agentRunId: started.runId }
},
fixtures: {
"agent.run.start": { input: (t) => ({ ...inProjectOf(t), goal: "Intruder", plan: PLAN }) },
"agent.run.status": { input: (t) => ({ ...inProjectOf(t), runId: runOf(t) }) },
"agent.run.events": { input: (t) => ({ ...inProjectOf(t), runId: runOf(t) }) },
"agent.run.cancel": { input: (t) => ({ ...inProjectOf(t), runId: runOf(t) }) },
"agent.run.list": { input: (t) => inProjectOf(t) },
// The live collection's read-back: another tenant's run id answers nothing.
"agent.run.getMany": { input: (t) => ({ ...inProjectOf(t), ids: [runOf(t)] }) },
"agent.approval.list": { input: (t) => inProjectOf(t) },
"agent.approval.review": {
input: (t) => ({ ...inProjectOf(t), runId: runOf(t), stepKey: "read", decision: "approve" }),
},
// The conversation behind the Agent in the right panel (kit-agent's assistant module, mounted by this one).
"assistant.turn.send": { input: (t) => ({ ...inProjectOf(t), turnId: NOWHERE, message: "Intruder" }) },
"assistant.conversation.list": { input: (t) => inProjectOf(t) },
"assistant.rootAction.reconcile": { input: (t) => ({ ...inProjectOf(t), conversationId: NOWHERE }) },
"assistant.conversation.get": { input: (t) => ({ ...inProjectOf(t), conversationId: NOWHERE }) },
"assistant.receipt.list": { input: (t) => inProjectOf(t) },
// The Agent's own workspace reads, offered to a turn with no project open.
"assistant.workspace.projects": { input: (t) => inWorkspaceOf(t) },
"assistant.workspace.openProject": { input: (t) => ({ ...inWorkspaceOf(t), slug: t.projectSlug }) },
},
}
+215
View File
@@ -0,0 +1,215 @@
// The run page's live island: what it shows from the server's seed before the first event, and
// from the run's AG-UI events (the projection `agent.run.events` serves, folded by the TanStack AI
// client's reducer) after it — step states, a parked approval, the completed run's answer with its
// Art. 50 disclosure, and a lost stream. Server-rendered, the island is exactly the seed.
import type { AgentRunRecord, AgentStepRecord } from "@conusai-kit/agent"
import { runEventsOf } from "@conusai-kit/agent/agui"
import { type AgUiChunk, initialAgentRunView, reduceAgentRunEvent } from "@conusai-kit/agent/client"
import { buildRegistry } from "@conusai-kit/core"
import { createI18n } from "@conusai-kit/i18n"
import { createDataClient, DataProvider } from "@conusai-kit/ui/data"
import { renderToStaticMarkup } from "react-dom/server"
import { describe, expect, it } from "vitest"
import { appModules } from "../../index.js"
import type { RunView } from "../contract/index.js"
import { LiveRun } from "../ui/live-run.js"
import { liveRunState } from "../ui/live-run-state.js"
const RUN = "01a19940-0000-7000-0000-000000000001"
const PLAN = [
{ key: "read", label: "Read", tool: "documents.read", args: {} },
{ key: "order", label: "Order", tool: "orders.create", args: { total: 4200 }, needsApproval: true },
]
const seed: RunView = {
runId: RUN,
goal: "Order the parts",
status: "queued",
failureCode: null,
failureDetail: null,
plan: PLAN.map((step) => ({
...step,
needsApproval: step.needsApproval === true,
status: "pending" as const,
attempts: 0,
error: null,
})),
usageCostMicrocents: 0,
usageSteps: 0,
percent: 0,
cancelRequested: false,
answer: null,
answerProvenance: null,
actorId: "user-1",
createdAt: "2026-11-02T10:00:00.000Z",
startedAt: null,
finishedAt: null,
}
const record = (status: AgentRunRecord["status"]): AgentRunRecord => ({
id: RUN,
version: 1,
status,
failureCode: null,
failureDetail: null,
goal: seed.goal,
plan: PLAN,
actorId: "user-1",
budget: { maxCostMicrocents: 1_000_000, maxSteps: 10, maxDurationSeconds: 600 },
usageCostMicrocents: 0,
usageSteps: 0,
cancelRequestedAt: null,
startedAt: "2026-11-02T10:00:01.000Z",
finishedAt: null,
createdAt: seed.createdAt,
answerProvenance: null,
})
const step = (
index: number,
status: AgentStepRecord["status"],
approval: AgentStepRecord["approval"] = null,
): AgentStepRecord => ({
id: `s-${index}`,
runId: RUN,
key: PLAN[index]?.key ?? "missing",
index,
label: PLAN[index]?.label ?? "missing",
status,
attempts: 1,
usageCostMicrocents: 0,
usageTokens: 0,
error: null,
externalEffect: false,
artifacts: [],
result: null,
approval,
startedAt: null,
finishedAt: null,
})
const parked = {
actionId: null,
tool: "orders.create",
arguments: { total: 4200 },
reviewUrl: null,
interrupt: { id: "approval_c-1", runId: `${RUN}.chat` },
decision: null,
}
const fold = (events: readonly unknown[]) =>
(events as AgUiChunk[]).reduce(reduceAgentRunEvent, initialAgentRunView(RUN))
describe("the live run state", () => {
it("is the seed until the first event", () => {
const state = liveRunState(seed, initialAgentRunView(RUN))
expect(state.status).toBe("queued")
expect(state.steps.map((s) => s.status)).toEqual(["pending", "pending"])
expect(state.answer).toBeNull()
})
it("follows the run's events: a committed step, then a parked approval", () => {
const view = fold(runEventsOf(record("awaiting_approval"), [step(0, "committed"), step(1, "pending", parked)]))
const state = liveRunState(seed, view)
expect(state.status).toBe("awaiting_approval")
expect(state.steps.map((s) => s.status)).toEqual(["committed", "pending"])
expect(state.percent).toBe(0.5)
})
it("shows a completed run's answer and a failed run's code", () => {
const done = fold(
runEventsOf(
record("completed"),
[step(0, "committed"), step(1, "committed", { ...parked, decision: "approved" })],
"Ordered.",
),
)
expect(liveRunState(seed, done)).toMatchObject({ status: "completed", percent: 1, answer: "Ordered." })
const failed = fold(
runEventsOf(
{ ...record("failed"), failureCode: "budget_exhausted", failureDetail: "the run reached its budget" },
[step(0, "committed")],
),
)
expect(liveRunState(seed, failed)).toMatchObject({
status: "failed",
failureCode: "budget_exhausted",
answer: null,
})
})
it("names a lost stream and keeps the last state it knew", () => {
const view = {
...fold(runEventsOf(record("running"), [step(0, "committed")])),
error: { source: "transport" as const, code: "reconnect_limit", message: "gone", status: null },
}
const state = liveRunState(seed, view)
expect(state.streamError).toBe("reconnect_limit")
expect(state.steps[0]?.status).toBe("committed")
})
})
describe("the live run island", () => {
const catalogues = buildRegistry([...appModules]).catalogues
const i18n = createI18n("en", { en: { agent: catalogues.en?.agent ?? {} } })
const t = (key: string) => i18n.message(`agent.${key}`)
const labels = {
status: t("runs.status"),
progress: t("runs.progress"),
plan: t("runs.plan"),
step: t("runs.step"),
tool: t("runs.tool"),
needsApproval: t("runs.needsApproval"),
answer: t("runs.answer"),
answerDisclosure: t("runs.answerDisclosure"),
streamLost: t("runs.streamLost"),
statuses: {
queued: t("runs.statusQueued"),
running: t("runs.statusRunning"),
awaiting_approval: t("runs.statusAwaitingApproval"),
completed: t("runs.statusCompleted"),
failed: t("runs.statusFailed"),
cancelled: t("runs.statusCancelled"),
uncertain: t("runs.statusUncertain"),
},
stepStatuses: {
pending: t("runs.stepPending"),
committed: t("runs.stepCommitted"),
failed: t("runs.stepFailed"),
cancelled: t("runs.stepCancelled"),
uncertain: t("runs.stepUncertain"),
},
controls: {
cancel: t("runs.cancel"),
cancelling: t("runs.cancelling"),
failedHint: t("recovery.failedHint"),
cancelledHint: t("recovery.cancelledHint"),
uncertainHint: t("recovery.uncertainHint"),
budgetExhausted: t("recovery.budgetExhausted"),
approvalUnavailable: t("recovery.approvalUnavailable"),
toolError: t("recovery.toolError"),
planRefused: t("recovery.planRefused"),
internal: t("recovery.internal"),
},
}
const render = (run: RunView) =>
renderToStaticMarkup(
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
<LiveRun slugs={{ org: "acme", workspace: "ws", project: "p" }} seed={run} labels={labels} />
</DataProvider>,
)
it("renders the seed on the server, the approval mark on a gated step, and no answer yet", () => {
const html = render(seed)
expect(html).toContain(labels.statuses.queued)
expect(html).toContain(labels.needsApproval)
expect(html).not.toContain(labels.answer)
})
it("renders a completed run's answer with the AI disclosure", () => {
const html = render({ ...seed, status: "completed", answer: "Ordered the parts." })
expect(html).toContain("Ordered the parts.")
expect(html).toContain(labels.answerDisclosure.replaceAll("'", "&#x27;"))
})
})
+59
View File
@@ -0,0 +1,59 @@
// The module's migrations are its own files, each exactly `schemaStatements()` over the entities it
// added (decision AT-D20: the entity definition is the one source of a column). Forward-only: the
// first file stays the run and step tables as first shipped; the second adds the step's recorded
// tool result and the run's conversation table (`agent_thread`, TanStack AI's message store); the
// third adds the run answer's provenance (EU AI Act Art. 50(2)).
import { readFileSync } from "node:fs"
import { AGENT_ENTITIES, agentRuns, agentSteps, agentThreads } from "@conusai-kit/agent"
import { schemaStatements } from "@conusai-kit/data-kysely"
import { describe, expect, it } from "vitest"
import { agentModule } from "../index.js"
function tablesOf(entities: Parameters<typeof schemaStatements>[0]): string {
const kernel = schemaStatements([]).length
return `${schemaStatements(entities)
.slice(kernel)
.map((statement) => `${statement};`)
.join("\n\n")}\n`
}
const migration = (name: string) => readFileSync(new URL(`../migrations/${name}`, import.meta.url), "utf8")
const HEADER = "-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0\n"
describe("the migrations of the agent module", () => {
it("are the run and step tables as first shipped, then the step's result column", () => {
const added = ',\n "result" jsonb\n'
const provenance = ',\n "answer_provenance" jsonb\n'
const generated = tablesOf([agentRuns, agentSteps])
expect(generated).toContain(added)
expect(generated).toContain(provenance)
expect(`${HEADER}${generated.replace(added, "\n").replace(provenance, "\n")}`).toBe(migration("0001_agent.sql"))
expect(migration("0002_agent_thread.sql")).toContain(
`${HEADER}alter table "agent_step" add column "result" jsonb;\n\n`,
)
})
it("add the run's conversation table in the second file, exactly its table", async () => {
await expect(
`${HEADER}alter table "agent_step" add column "result" jsonb;\n\n${tablesOf([agentThreads])}`,
).toMatchFileSnapshot("../migrations/0002_agent_thread.sql")
expect(AGENT_ENTITIES).toEqual([agentRuns, agentSteps, agentThreads])
})
it("add the run answer's provenance column in the third file, exactly that column", () => {
expect(tablesOf([agentRuns])).toContain(',\n "answer_provenance" jsonb\n')
expect(migration("0003_agent_answer_provenance.sql")).toBe(
`${HEADER}alter table "agent_run" add column "answer_provenance" jsonb;\n`,
)
})
it("are declared by the manifest, and every table is row-level secured", () => {
expect(agentModule.migrations).toEqual({ postgres: "migrations" })
const sql = migration("0001_agent.sql") + migration("0002_agent_thread.sql")
for (const table of ["agent_run", "agent_step", "agent_thread"]) {
expect(sql).toContain(`create table "${table}"`)
expect(sql).toContain(`alter table "${table}" force row level security`)
}
})
})
+156
View File
@@ -0,0 +1,156 @@
// The agent page on F10: its loader reads through the caller and returns plain data with every label
// translated, and its view renders what reached the browser, the run page's first paint being the seed of
// the live island. A path that is not one run is the kernel's 404, which is what the shell answers.
import { AppError, buildRegistry, Container, forbidden, type TransportCaller } from "@conusai-kit/core"
import { builtInCatalogues, createI18n, uiLabels } from "@conusai-kit/i18n"
import { createDataClient, DataProvider } from "@conusai-kit/ui/data"
import { UiLabelsProvider } from "@conusai-kit/ui/primitives"
import { renderToStaticMarkup as renderMarkup } from "react-dom/server"
import { describe, expect, it, vi } from "vitest"
import { appModules } from "../../index.js"
import { modulePages } from "../../pages.js"
import type { ApprovalView, RunView } from "../contract/index.js"
import { agentPage } from "../ui/pages.js"
const kitStrings = uiLabels(createI18n("en", builtInCatalogues))
const renderToStaticMarkup = (node: React.ReactNode) =>
renderMarkup(<UiLabelsProvider labels={kitStrings}>{node}</UiLabelsProvider>)
const catalogues = buildRegistry([...appModules]).catalogues
const i18n = createI18n("en", {
en: { agent: catalogues.en?.agent ?? {}, app: { failed: "That did not work: {detail}" } },
})
const slugs = { org: "acme", workspace: "ws", project: "p" }
const run: RunView = {
runId: "01a19940-0000-7000-0000-000000000001",
goal: "Order the parts",
status: "running",
failureCode: null,
failureDetail: null,
plan: [
{
key: "read",
label: "Read",
tool: "documents.read",
args: {},
needsApproval: false,
status: "pending",
attempts: 0,
error: null,
},
],
usageCostMicrocents: 120,
usageSteps: 3,
percent: 0.5,
cancelRequested: false,
answer: null,
answerProvenance: null,
actorId: "user-1",
createdAt: "2026-11-02T10:00:00.000Z",
startedAt: "2026-11-02T10:00:01.000Z",
finishedAt: null,
}
const approval: ApprovalView = {
runId: run.runId,
stepKey: "read",
goal: run.goal,
tool: "orders.create",
arguments: { total: 4200 },
reviewUrl: null,
createdAt: "2026-11-02T10:00:00.000Z",
}
const callerOf = (answers: Record<string, unknown>, asked: string[] = []): TransportCaller =>
({
call: async (contract: { name: string }) => {
asked.push(contract.name)
const answer = answers[contract.name]
if (answer instanceof Error) throw answer
return answer
},
}) as unknown as TransportCaller
const load = (segments: string[], answers: Record<string, unknown>, asked?: string[]) =>
agentPage.load({ slugs, segments, search: {}, caller: callerOf(answers, asked), i18n, container: new Container() })
/** The page as the shell renders it: the loader's data through JSON, as the browser receives it, then the view. */
const render = async (segments: string[], answers: Record<string, unknown>) => {
const data = await load(segments, answers)
expect(JSON.parse(JSON.stringify(data))).toStrictEqual(data)
const { View } = agentPage
return renderToStaticMarkup(
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
<View slugs={slugs} segments={segments} locale="en" data={JSON.parse(JSON.stringify(data))} />
</DataProvider>,
)
}
const lists = { "agent.run.list": { runs: [run] }, "agent.approval.list": { approvals: [approval] } }
describe("the agent page", () => {
it("is listed for the shell", async () => {
await expect(modulePages.agent?.()).resolves.toBe(agentPage)
})
it("lists the runs, each with its status in words, its progress and a link to it", async () => {
const html = await render([], lists)
const link = html.match(/<a\b[^>]*>Order the parts<\/a>/)?.[0] ?? ""
expect(link.match(/class="([^"]*)"/)?.[1]?.split(/\s+/)).toContain("kit-row-link")
expect(link).toContain(`href="/acme/ws/p/agent/${run.runId}"`)
expect(link).toContain('data-slot="row-link"')
expect(html).toContain('<progress value="0.5" max="1">50%</progress>')
expect(html).toContain("user-1")
expect(html).toContain("orders.create")
})
it("says when there is no run", async () => {
const data = await load([], { "agent.run.list": { runs: [] }, "agent.approval.list": { approvals: [] } })
if (data.kind !== "list") throw new Error("not the list")
expect(data.runs).toEqual([])
expect(await render([], { ...lists, "agent.run.list": { runs: [] } })).toContain(data.runLabels.empty)
})
it("renders one run from its status, the seed of the live island", async () => {
const asked: string[] = []
const html = await render([run.runId], { "agent.run.status": run })
await load([run.runId], { "agent.run.status": run }, asked)
expect(asked).toEqual(["agent.run.status"])
expect(html).toContain(">Order the parts</h1>")
expect(html).toContain('href="/acme/ws/p/agent"')
expect(html).toContain("<dt>Usage</dt><dd>3 steps, 120 microcents</dd>")
expect(html).toContain('data-live="agent.run"')
})
it("answers a path that is not one run with the kernel's 404, without reading", async () => {
const asked: string[] = []
const refused = load([run.runId, "extra"], lists, asked)
await expect(refused).rejects.toBeInstanceOf(AppError)
await expect(refused).rejects.toMatchObject({ status: 404 })
expect(asked).toEqual([])
})
it("hands a procedure's refusal on, as the AppError the shell answers", async () => {
const call = vi.fn(async () => {
throw forbidden("this page needs agent.run.read")
})
await expect(
agentPage.load({
slugs,
segments: [],
search: {},
caller: { call } as unknown as TransportCaller,
i18n,
container: new Container(),
}),
).rejects.toMatchObject({ status: 403 })
})
})
// This journey requires explicit optional-module admission.
vi.mock("../../inclusion.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../inclusion.js")>()),
...(await import("../../../test/support/native-readiness.js")).selectedModules(["agent"]),
}))
+35
View File
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Follow the offered step using a public gateway adapter with an in-process transport.
// No network/provider call and no SDK-private or transitive TanStack testing import.
import { conusaiText } from "@conusai-sdk/ai/tanstack"
export function planFollower() {
return conusaiText("scripted-agent-test", {
apiUrl: "https://gateway.example.invalid/api",
token: "synthetic-test-token",
maxRetries: 0,
fetch: async (_input, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as { tool_choice?: { function?: { name?: string } } }
const step = body.tool_choice?.function?.name
const delta =
step === undefined
? { role: "assistant", content: "Done." }
: {
role: "assistant",
tool_calls: [
{ index: 0, id: `call-${step}`, type: "function", function: { name: step, arguments: "{}" } },
],
}
const chunk = {
id: "synthetic-completion",
object: "chat.completion.chunk",
created: 1,
model: "scripted-agent-test",
choices: [{ index: 0, delta, finish_reason: step === undefined ? "stop" : "tool_calls" }],
}
return new Response(`data: ${JSON.stringify(chunk)}\n\ndata: [DONE]\n\n`, {
headers: { "content-type": "text/event-stream" },
})
},
})
}
+135
View File
@@ -0,0 +1,135 @@
// The run retention sweep of the agent module (G3j): the hourly job deletes a terminal run past the
// retention with its steps and conversation, in each project the app serves, and keeps a run still going,
// a fresher one and another organization's. The job is the module's own (adapters/procedures.ts), so it runs
// on whatever data source the worker binds; this test uses the in-memory one.
import { AGENT_ENTITIES, agentRuns, agentSteps, agentThreads, threadIdOf } from "@conusai-kit/agent"
import { Container, type TenantScope } from "@conusai-kit/core"
import { createMemoryDataSource, DATA_SOURCE, type DataSource } from "@conusai-kit/data"
import { afterEach, describe, expect, it, vi } from "vitest"
import { RUN_EXPIRE_JOB } from "../contract/constants.js"
import { AGENT_SCOPE } from "../core/ports.js"
import { AGENT_JOBS, agentSchedules } from "../index.js"
const DAY = 86_400_000
const NOW = Date.parse("2026-11-02T12:00:00.000Z")
const USER = "01900000-0000-7000-0000-000000000009"
const ORG_A = "01900000-0000-7000-0000-000000000001"
const ORG_B = "01900000-0000-7000-0000-00000000000b"
const WORKSPACE = "01900000-0000-7000-0000-000000000002"
const PROJECT = "01900000-0000-7000-0000-000000000003"
const scopeOf = (orgId: string): TenantScope & { workspaceId: string; projectId: string } => ({
level: "project",
actor: { userId: USER as never, system: true },
orgId,
workspaceId: WORKSPACE,
projectId: PROJECT,
})
const daysAgo = (days: number): string => new Date(NOW - days * DAY).toISOString()
async function seed(data: DataSource, orgId: string, status: string, finishedDaysAgo: number | null): Promise<string> {
const repos = data.forScope(scopeOf(orgId))
const run = await repos.of(agentRuns).insert({
goal: "Review the contract",
plan: [{ key: "read", label: "Read", tool: "documents.read", args: {} }],
status,
failureCode: null,
failureDetail: null,
actorId: USER,
budgetCostMicrocents: 1_000_000,
budgetSteps: 10,
budgetSeconds: 300,
usageCostMicrocents: 0,
usageSteps: 0,
cancelRequestedAt: null,
startedAt: daysAgo((finishedDaysAgo ?? 1) + 1),
finishedAt: finishedDaysAgo === null ? null : daysAgo(finishedDaysAgo),
} as never)
await repos.of(agentSteps).insert({
runId: run.id,
key: "read",
index: 0,
label: "Read",
status: "committed",
attempts: 1,
usageCostMicrocents: 0,
usageTokens: 0,
error: null,
externalEffect: false,
artifacts: [],
approval: null,
startedAt: null,
finishedAt: null,
result: null,
} as never)
await repos.of(agentThreads).insert({ threadId: threadIdOf(run.id), messages: [] } as never)
return run.id
}
/** The retention job, as the module registers it. */
function expireJob() {
const job = AGENT_JOBS[RUN_EXPIRE_JOB]
if (!job) throw new Error("the agent module registers no run retention job")
return job
}
const runIdsOf = async (data: DataSource, orgId: string): Promise<string[]> =>
(await data.forScope(scopeOf(orgId)).of(agentRuns).list({})).items.map((run) => run.id).sort()
/** The job as the worker runs it: its container binds the data source and the project scope it runs in. */
function containerFor(data: DataSource, orgId: string): Container {
const container = new Container()
container.value(DATA_SOURCE, data)
container.value(AGENT_SCOPE, scopeOf(orgId))
return container
}
afterEach(() => vi.restoreAllMocks())
describe("the agent run retention job", () => {
it("deletes a terminal run past the retention with its steps and conversation, and keeps the rest", async () => {
vi.spyOn(Date, "now").mockReturnValue(NOW)
const data = createMemoryDataSource({ entities: [...AGENT_ENTITIES] })
await seed(data, ORG_A, "completed", 100)
await seed(data, ORG_A, "failed", 91)
const fresh = await seed(data, ORG_A, "completed", 89)
const running = await seed(data, ORG_A, "running", null)
const otherOrg = await seed(data, ORG_B, "completed", 400)
await expireJob().handler(
{ retentionDays: 90 },
{ container: containerFor(data, ORG_A), correlationId: "t", attempt: 1 },
)
expect(await runIdsOf(data, ORG_A)).toEqual([fresh, running].sort())
expect((await data.forScope(scopeOf(ORG_A)).of(agentSteps).list({})).items.map((s) => s.runId).sort()).toEqual(
[fresh, running].sort(),
)
expect((await data.forScope(scopeOf(ORG_A)).of(agentThreads).list({})).items.map((t) => t.threadId).sort()).toEqual(
[fresh, running].map(threadIdOf).sort(),
)
// Negative control: another organization's run, past the same retention, is untouched in its own project.
expect(await runIdsOf(data, ORG_B)).toEqual([otherOrg])
})
it("takes its retention from the schedule's payload, which accepts only whole days from 1 to 3650", () => {
const payload = expireJob().payload
expect(payload.safeParse({ retentionDays: 90 }).success).toBe(true)
expect(payload.safeParse({ retentionDays: 0 }).success).toBe(false)
expect(payload.safeParse({ retentionDays: 3651 }).success).toBe(false)
expect(payload.safeParse({ retentionDays: 2.5 }).success).toBe(false)
})
it("is scheduled hourly, once per project, with the retention it was given", () => {
expect(agentSchedules(90)).toEqual([
{
job: RUN_EXPIRE_JOB,
cron: "0 * * * *",
scope: { level: "each-project" },
payload: { retentionDays: 90 },
key: "agent.runs.expire",
missed: "skip",
},
])
})
})
+313
View File
@@ -0,0 +1,313 @@
// The run job over the real tools adapter, MCP client and gateway adapter; only the platform is
// answered, at `fetch`. What it pins is the line `StepOutcome` draws: a call a retry cannot fix ends
// the run with a typed failure and the job completes, so the queue does not run it again, while a
// transient failure rejects, so the queue does, and the step resumes on its idempotency key. A tool
// that answers an error is never a committed step: the model would otherwise read the error as the
// tool's result and the run would end `completed`.
import { AGENT_MODEL, AGENT_TOOLS, dataRunStore } from "@conusai-kit/agent"
import type { JobContext } from "@conusai-kit/core"
import { createMemoryDataSource, DATA_SOURCE } from "@conusai-kit/data"
import { afterEach, describe, expect, it, vi } from "vitest"
import { gatewayAgentModel, registryAgentTools, runJob } from "../adapters/procedures.js"
import { AGENT_SCOPE } from "../core/ports.js"
import { cancelRun, startRun } from "../core/runs.js"
import { claimedDataSource, claimedDelivery } from "./claimed-job.js"
import { workerFixture } from "./worker-fixture.js"
const API_URL = "https://cloud.example.test/api"
const MCP_URL = "https://cloud.example.test/v1/orgs/7/mcp"
const GATEWAY_URL = `${API_URL}/ai/v1/chat/completions`
const TOKEN = "dt_synthetic_fixture"
const TOOL_TEXT = "the backing service said something only its tenant may read"
interface ToolCall {
readonly name: string
readonly idempotencyKey: string
readonly authorization: string | null
}
type Mcp = (id: number) => Response
const rpc =
(result: unknown): Mcp =>
(id) =>
Response.json({ jsonrpc: "2.0", id, result })
const answered = rpc({
resultType: "complete",
isError: false,
structuredContent: { rows: 3 },
content: [{ type: "text", text: '{"rows":3}' }],
})
const denied = (reason: string) =>
rpc({
resultType: "complete",
isError: true,
structuredContent: { status: "denied", reason },
content: [{ type: "text", text: `refused: ${reason}` }],
})
const status =
(code: number): Mcp =>
() =>
new Response(null, { status: code })
/** The gateway as a model that follows the tool choice: it calls the step offered, then answers. */
function gatewayReply(body: { tool_choice?: unknown }): Response {
const choice = body.tool_choice
const step =
typeof choice === "object" && choice !== null
? (choice as { function?: { name?: string } }).function?.name
: undefined
const delta =
step === undefined
? { role: "assistant", content: "Done." }
: {
role: "assistant",
tool_calls: [{ index: 0, id: `call-${step}`, type: "function", function: { name: step, arguments: "{}" } }],
}
const chunk = {
id: "chatcmpl-1",
object: "chat.completion.chunk",
created: 1,
model: "gateway-model",
choices: [{ index: 0, delta, finish_reason: step === undefined ? "stop" : "tool_calls" }],
}
return new Response(`data: ${JSON.stringify(chunk)}\n\ndata: [DONE]\n\n`, {
status: 200,
headers: { "content-type": "text/event-stream" },
})
}
/** A queued run of one step, and a platform whose tool endpoint answers with `tool.mcp`. */
async function harness(toolName = "records__read") {
const tool: { mcp: Mcp; calls: ToolCall[] } = { mcp: answered, calls: [] }
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input)
const body = JSON.parse(String(init?.body ?? "{}"))
if (url === GATEWAY_URL) return gatewayReply(body)
if (url !== MCP_URL) throw new Error(`no such stand-in: ${url}`)
tool.calls.push({
name: body.params.name,
idempotencyKey: body.params._meta["com.conusai/idempotencyKey"],
authorization: new Headers(init?.headers).get("authorization"),
})
return tool.mcp(body.id)
}),
)
const data = await claimedDataSource()
const { container, scope } = await workerFixture(data)
container.value(AGENT_SCOPE, scope)
container.value(AGENT_MODEL, gatewayAgentModel({ apiUrl: API_URL, token: TOKEN, model: "gateway-model" }))
container.value(
AGENT_TOOLS,
registryAgentTools({
apiUrl: API_URL,
token: TOKEN,
orgId: "7",
scope: { orgSlug: "acme", workspaceSlug: "main", projectSlug: "p" },
}),
)
const started = await startRun({ data }, scope, {
goal: "Read the records",
plan: [{ key: "read", label: "Read the records", tool: toolName, args: { limit: 1 } }],
})
const store = dataRunStore(data, scope)
return {
tool,
data,
scope,
container,
runId: started.runId,
/** One delivery of the job: a rejection is what makes the queue deliver it again. */
deliver: (extra: Partial<JobContext> = {}) => claimedDelivery(data, container, scope, started.runId, extra),
cancel: () => cancelRun({ data }, scope, started.runId),
run: () => store.get(started.runId),
step: async () => (await store.listSteps(started.runId))[0],
}
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe("the agent run job's tool calls", () => {
it("calls the tool with the deployment token and completes the run", async () => {
const job = await harness()
await job.deliver()
expect(job.tool.calls).toEqual([
{ name: "records__read", idempotencyKey: `${job.runId}:read`, authorization: `Bearer ${TOKEN}` },
])
expect(await job.run()).toMatchObject({ status: "completed", failureCode: null })
expect(await job.step()).toMatchObject({ status: "committed", attempts: 1, result: { rows: 3 } })
})
it.each<[string, Mcp, string]>([
["a token without the grant (403)", status(403), "forbidden"],
["a token the endpoint does not accept (401)", status(401), "unauthenticated"],
["a policy denial", denied("forbidden"), "forbidden"],
["a plan denial", denied("plan-required"), "plan-required"],
])("ends the run failed/tool_error on %s, and the queue does not run it again", async (_case, mcp, reason) => {
const job = await harness()
job.tool.mcp = mcp
await expect(job.deliver()).resolves.toBeUndefined()
const run = await job.run()
expect(run).toMatchObject({ status: "failed", failureCode: "tool_error" })
expect(run?.failureDetail).toContain(reason)
expect(run?.finishedAt).not.toBeNull()
expect(await job.step()).toMatchObject({ status: "failed", attempts: 1, result: null })
// A redelivery reaches a terminal run: no second call.
await job.deliver()
expect(job.tool.calls).toHaveLength(1)
})
it("does not resume a refused run when the grant is approved later", async () => {
const job = await harness()
job.tool.mcp = status(403)
await job.deliver()
job.tool.mcp = answered
await job.deliver()
expect(await job.run()).toMatchObject({ status: "failed", failureCode: "tool_error" })
expect(job.tool.calls).toHaveLength(1)
})
it("does not resume a cancelled run: a run stuck on a transient failure can be ended by a person", async () => {
const job = await harness()
job.tool.mcp = status(503)
await expect(job.deliver()).rejects.toThrow()
await job.cancel()
job.tool.mcp = answered
await job.deliver()
expect(await job.run()).toMatchObject({ status: "cancelled", failureCode: null })
await job.deliver()
expect(job.tool.calls).toHaveLength(1)
})
it("does not commit a step whose tool answered an error, and keeps the tool's text out of the run", async () => {
const job = await harness()
job.tool.mcp = rpc({ resultType: "complete", isError: true, content: [{ type: "text", text: TOOL_TEXT }] })
await expect(job.deliver()).resolves.toBeUndefined()
const run = await job.run()
expect(run).toMatchObject({ status: "failed", failureCode: "tool_error", usageSteps: 0 })
expect(run?.failureDetail).toContain("records__read")
expect(run?.failureDetail).not.toContain(TOOL_TEXT)
const step = await job.step()
expect(step).toMatchObject({ status: "failed", result: null })
expect(step?.error).not.toContain(TOOL_TEXT)
await job.deliver()
expect(job.tool.calls).toHaveLength(1)
})
it.each<[string, Mcp, string?]>([
["a 503", status(503)],
["a 429", status(429)],
["a 500", status(500)],
[
"busy local compute",
rpc({
resultType: "complete",
isError: true,
structuredContent: { status: "compute_busy", retryAfterSeconds: 5 },
}),
],
[
"a catalog that is still indexing",
rpc({ resultType: "complete", isError: true, structuredContent: { status: "indexing", retryAfterSeconds: 5 } }),
"find_tools",
],
["a runtime the platform could not reach", denied("runtime-unavailable")],
])("leaves the run to the queue on %s, and the next delivery resumes the step", async (_case, mcp, toolName) => {
const job = await harness(toolName)
job.tool.mcp = mcp
await expect(job.deliver()).rejects.toThrow()
expect(await job.run()).toMatchObject({ status: "running", failureCode: null })
expect(await job.step()).toMatchObject({ status: "pending", attempts: 1 })
job.tool.mcp = answered
await job.deliver()
expect(await job.run()).toMatchObject({ status: "completed", failureCode: null })
expect(await job.step()).toMatchObject({ status: "committed", attempts: 2 })
// The same key both times: an effect the first call made is not made twice.
expect(new Set(job.tool.calls.map((call) => call.idempotencyKey))).toEqual(new Set([`${job.runId}:read`]))
})
it("leaves the run to the queue when the network fails", async () => {
const job = await harness()
job.tool.mcp = () => {
throw new TypeError("fetch failed")
}
await expect(job.deliver()).rejects.toThrow("fetch failed")
expect(await job.run()).toMatchObject({ status: "running", failureCode: null })
})
})
it("persists sanitized exhaustion on the actual final attempt, with no replay calls", async () => {
const job = await harness()
job.tool.mcp = status(503)
await job.deliver({ finalAttempt: true })
expect(await job.run()).toMatchObject({ status: "failed", failureCode: "internal" })
expect((await job.run())?.failureDetail).toBe("the durable job exhausted its retry attempts")
await job.deliver({ finalAttempt: true })
expect(job.tool.calls).toHaveLength(1)
})
it("a revoked initiating member is refused before any provider call despite the warm memo", async () => {
const job = await harness()
const { orgMemberships } = await import("@conusai-kit/tenancy")
const repos = job.data.forScope(job.scope)
const membership = (await repos.of(orgMemberships).list({ limit: 10 })).items[0]
if (!membership) throw new Error("expected membership")
await repos.of(orgMemberships).delete(membership.id)
await job.deliver()
expect(await job.run()).toMatchObject({ status: "failed", failureCode: "internal" })
expect(vi.mocked(fetch)).not.toHaveBeenCalled()
})
it("revocation after a tool prevents the next model call", async () => {
const job = await harness()
const { orgMemberships } = await import("@conusai-kit/tenancy")
const original = job.container.resolve(AGENT_TOOLS)
const child = job.container.child()
child.value(AGENT_TOOLS, {
async call(...args) {
const result = await original.call(...args)
const repos = job.data.forScope(job.scope)
const member = (await repos.of(orgMemberships).list({ limit: 10 })).items[0]
if (!member) throw new Error("expected membership")
await repos.of(orgMemberships).delete(member.id)
return result
},
})
await job.deliver({ container: child })
expect(await job.run()).toMatchObject({ status: "failed", usageSteps: 1 })
expect(job.tool.calls).toHaveLength(1)
expect(vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === GATEWAY_URL)).toHaveLength(1)
})
it("missing adapter execution refuses before durable or provider work even at exhaustion", async () => {
const job = await harness()
await expect(
runJob.handler(
{ runId: job.runId },
{
container: job.container,
correlationId: "unsupported-adapter",
attempt: 1,
finalAttempt: true,
},
),
).rejects.toMatchObject({ code: "job_claim_lost" })
expect((await job.run())?.status).toBe("queued")
expect(job.tool.calls).toHaveLength(0)
expect(vi.mocked(fetch)).not.toHaveBeenCalled()
})
it("an actual claim with an unsupported data source hook refuses before Agent effects", async () => {
const job = await harness()
const child = job.container.child()
child.value(DATA_SOURCE, createMemoryDataSource({ entities: [] }))
await expect(job.deliver({ container: child, finalAttempt: true })).rejects.toMatchObject({ code: "job_claim_lost" })
expect((await job.run())?.status).toBe("queued")
expect(job.tool.calls).toHaveLength(0)
expect(vi.mocked(fetch)).not.toHaveBeenCalled()
})
+201
View File
@@ -0,0 +1,201 @@
// The agent module's use cases on the in-memory adapter: starting lists and statuses, cancel's
// resume event, and the approval queue — which must surface the EXACT stored proposed arguments.
import { AGENT_ENTITIES, dataAgentPersistence, dataRunStore, threadIdOf } from "@conusai-kit/agent"
import type { TenantScope } from "@conusai-kit/core"
import { createMemoryDataSource } from "@conusai-kit/data"
import { describe, expect, it } from "vitest"
import { cancelRun, getManyRuns, getRun, listApprovals, listRuns, reviewApproval, startRun } from "../core/runs.js"
const USER = "01900000-0000-7000-0000-000000000009"
const scope: TenantScope = {
level: "project",
actor: { userId: USER },
orgId: "01900000-0000-7000-0000-000000000001",
workspaceId: "01900000-0000-7000-0000-000000000002",
projectId: "01900000-0000-7000-0000-000000000003",
}
const makeDeps = () => ({ data: createMemoryDataSource({ entities: [...AGENT_ENTITIES] }) })
const PLAN = [
{ key: "read", label: "Read the document", tool: "documents.read", args: { fileId: "f-1" } },
{ key: "propose", label: "Propose lines", tool: "documents.extract", args: { fileId: "f-1", schema: "line" } },
]
describe("the agent module's run use cases", () => {
const deps = makeDeps()
it("starts a run queued, with the plan visible, and lists it", async () => {
const run = await startRun(deps, scope, { goal: "Review the contract", plan: PLAN })
expect(run.status).toBe("queued")
expect(run.goal).toBe("Review the contract")
expect(run.plan).toEqual([
{
key: "read",
label: "Read the document",
tool: "documents.read",
args: { fileId: "f-1" },
needsApproval: false,
status: "pending",
attempts: 0,
error: null,
},
{
key: "propose",
label: "Propose lines",
tool: "documents.extract",
args: { fileId: "f-1", schema: "line" },
needsApproval: false,
status: "pending",
attempts: 0,
error: null,
},
])
expect(run.percent).toBe(0)
expect(run.answer).toBeNull()
const listed = await listRuns(deps, scope, {})
expect(listed.runs).toHaveLength(1)
expect((await getRun(deps, scope, run.runId)).runId).toBe(run.runId)
// The outbox event that enqueues the run job in the worker.
const pending = await deps.data.outbox.pending(10)
expect(pending.some((record) => record.name === "agent.runResumeRequested")).toBe(true)
})
it("reads runs back by id for the live list, and answers nothing for another project's or no run", async () => {
const own = makeDeps()
const run = await startRun(own, scope, { goal: "Read back", plan: PLAN })
const listed = (await listRuns(own, scope, {})).runs
expect(await getManyRuns(own, scope, [run.runId, run.runId])).toEqual(listed)
const elsewhere: TenantScope = { ...scope, projectId: "01900000-0000-7000-0000-000000000004" }
expect(await getManyRuns(own, elsewhere, [run.runId])).toEqual([])
expect(await getManyRuns(own, scope, ["01900000-0000-7000-0000-0000000000ff"])).toEqual([])
})
it("answers a completed run's answer with the provenance it is stored with (AI Act Art. 50(2))", async () => {
const fresh = makeDeps()
const run = await startRun(fresh, scope, { goal: "Review the contract", plan: PLAN })
expect(run.answerProvenance).toBeNull()
const provenance = {
ai_generated: true as const,
mode: "machine-generated" as const,
digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
generator: "conusai.ai-gateway",
provider: "openai",
model: "gpt-4o-mini-2024-07-18",
generated_at: "2026-11-02T10:30:00Z",
}
const store = dataRunStore(fresh.data, scope)
const record = await store.get(run.runId)
await store.transition(run.runId, record?.version ?? 0, {
status: "completed",
finishedAt: "2026-11-02T10:30:01.000Z",
answerProvenance: provenance,
})
await dataAgentPersistence(fresh.data, scope).stores.messages.saveThread(threadIdOf(run.runId), [
{ role: "user", content: "Review the contract" },
{ role: "assistant", content: "The contract was reviewed." },
])
const completed = await getRun(fresh, scope, run.runId)
expect(completed.answer).toBe("The contract was reviewed.")
expect(completed.answerProvenance).toEqual(provenance)
})
it("refuses an over-long or repeated plan before anything is written", async () => {
const fresh = makeDeps()
const tooLong = await startRun(fresh, scope, {
goal: "x",
plan: Array.from({ length: 26 }, (_, at) => ({ key: `s${at}`, label: `Step ${at}`, tool: "t", args: {} })),
}).catch((error: unknown) => error)
expect((tooLong as { status?: number }).status).toBe(422)
const repeated = await startRun(fresh, scope, {
goal: "x",
plan: [
{ key: "same", label: "One", tool: "t", args: {} },
{ key: "same", label: "Two", tool: "t", args: {} },
],
}).catch((error: unknown) => error)
expect((repeated as { status?: number }).status).toBe(422)
expect((await listRuns(fresh, scope, {})).runs).toHaveLength(0)
})
it("cancel persists the request and re-enqueues the resume; a parked approval surfaces its exact arguments", async () => {
const fresh = makeDeps()
const run = await startRun(fresh, scope, { goal: "Approve-aware run", plan: PLAN })
// Simulate the engine parking an approval by writing the step row as the engine would.
const { dataRunStore } = await import("@conusai-kit/agent")
const store = dataRunStore(fresh.data, scope)
await store.parkStepApproval(
run.runId,
{ key: "read", index: 0, label: "Read the document" },
{
actionId: "act-7",
tool: "documents.read",
arguments: { fileId: "f-1", dryRun: false },
reviewUrl: "https://console/review?action=act-7",
interrupt: null,
},
new Date().toISOString(),
)
await fresh.data
.forScope(scope)
// biome-ignore lint/suspicious/noExplicitAny: test adjusts the run's status as the engine would.
.of(AGENT_ENTITIES[0] as any)
.update(run.runId, { status: "awaiting_approval" }, 1)
const queue = await listApprovals(fresh, scope)
expect(queue.approvals).toHaveLength(1)
expect(queue.approvals[0]).toMatchObject({
runId: run.runId,
stepKey: "read",
tool: "documents.read",
arguments: { fileId: "f-1", dryRun: false },
reviewUrl: "https://console/review?action=act-7",
})
const cancelled = await cancelRun(fresh, scope, run.runId)
expect(cancelled.cancelled).toBe(true)
expect((await getRun(fresh, scope, run.runId)).cancelRequested).toBe(true)
// The cancel re-published the resume event: a parked run notices the request only when resumed.
const pending = await fresh.data.outbox.pending(10)
expect(pending.filter((record) => record.name === "agent.runResumeRequested")).toHaveLength(2)
const review = await reviewApproval(fresh, scope, { runId: run.runId, stepKey: "read", decision: "approve" })
expect(["queued", "running", "awaiting_approval", "cancelled"]).toContain(review.status)
const bad = await reviewApproval(fresh, scope, { runId: run.runId, stepKey: "propose", decision: "approve" }).catch(
(error: unknown) => error,
)
expect((bad as { status?: number }).status).toBe(422)
// The decision is recorded on the step: the queue no longer offers it, and it is not decided twice.
expect((await listApprovals(fresh, scope)).approvals).toHaveLength(0)
const again = await reviewApproval(fresh, scope, { runId: run.runId, stepKey: "read", decision: "reject" }).catch(
(error: unknown) => error,
)
expect((again as { status?: number }).status).toBe(409)
expect((await store.listSteps(run.runId))[0]?.approval?.decision).toBe("approved")
})
})
it("does not offer or accept a pending approval after its run failed", async () => {
const fresh = makeDeps()
const run = await startRun(fresh, scope, { goal: "Synthetic approval", plan: PLAN })
const store = dataRunStore(fresh.data, scope)
await store.parkStepApproval(
run.runId,
{ key: "read", index: 0, label: "Read" },
{
actionId: null,
tool: "records.write",
arguments: {},
reviewUrl: null,
interrupt: null,
},
new Date().toISOString(),
)
const record = await store.get(run.runId)
if (!record) throw new Error("expected a run")
await store.transition(run.runId, record.version, { status: "failed", failureCode: "budget_exhausted" })
expect((await listApprovals(fresh, scope)).approvals).toEqual([])
await expect(
reviewApproval(fresh, scope, { runId: run.runId, stepKey: "read", decision: "approve" }),
).rejects.toMatchObject({ status: 409 })
})
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The real procedure/kernel/tenancy path over memory repositories. No live Cloud/provider claim.
import { type Actor, buildRegistry, Container, createCaller } from "@conusai-kit/core"
import { createMemoryDataSource, DATA_SOURCE, entitiesOf, SYSTEM_DATA } from "@conusai-kit/data"
import { createTenancyHooks, orgMemberships, TENANCY, tenancyModule, users } from "@conusai-kit/tenancy"
import { expect, it } from "vitest"
import { AGENT_MODULE_BASE } from "../module.shared.js"
it("an open stream observes membership revocation and reconnect is refused", async () => {
const registry = buildRegistry([tenancyModule, AGENT_MODULE_BASE])
const data = createMemoryDataSource({ entities: entitiesOf(registry, "postgres") })
const root = new Container()
root.value(DATA_SOURCE, data)
root.value(SYSTEM_DATA, data)
const caller = (actor: Actor) => {
const tenancy = createTenancyHooks({ data, registry })
const container = root.child()
container.value(TENANCY, tenancy)
return createCaller({ registry, container, tenancy }, { actor })
}
const user = async (subject: string) => {
const row = await data.global(users).insert({
issuer: "https://id.example.test",
subject,
email: `${subject}@example.test`,
displayName: subject,
locale: "en",
})
return { userId: row.id }
}
const owner = await user("owner")
const member = await user("reader")
const call = caller(owner)
const { orgId } = (await call.callByName("tenancy.org.create", { slug: "alpha", name: "Alpha" })) as { orgId: string }
await call.callByName("tenancy.workspace.create", { org: "alpha", slug: "work", name: "Work", visibility: "org" })
await call.callByName("tenancy.project.create", {
org: "alpha",
workspace: "work",
slug: "project",
name: "Project",
moduleKey: "agent",
})
await data.unitOfWork.run({ level: "org", actor: owner, orgId }, (tx) =>
tx.of(orgMemberships).insert({ userId: member.userId, role: "viewer" }),
)
const slugs = { org: "alpha", workspace: "work", project: "project" }
const run = (await call.callByName("agent.run.start", {
...slugs,
goal: "Synthetic run",
plan: [{ key: "read", label: "Read", tool: "records.read", args: {} }],
})) as { runId: string }
const response = (await caller(member).callByName("agent.run.events", { ...slugs, runId: run.runId })) as Response
if (!response.body) throw new Error("expected a streamed response")
const reader = response.body.getReader()
expect(new TextDecoder().decode((await reader.read()).value)).toContain("RUN_STARTED")
await caller(owner).callByName("tenancy.member.remove", { org: "alpha", userId: member.userId })
expect(await reader.read()).toEqual({ done: true, value: undefined })
await expect(caller(member).callByName("agent.run.events", { ...slugs, runId: run.runId })).rejects.toMatchObject({
status: 404,
})
})
+104
View File
@@ -0,0 +1,104 @@
// The approval queue's markup: the EXACT stored proposed arguments are rendered (never a
// narration), the Art. 50 disclosure rides with them, and the controls are named for a keyboard.
import { buildRegistry } from "@conusai-kit/core"
import { createI18n } from "@conusai-kit/i18n"
import { createDataClient, DataProvider } from "@conusai-kit/ui/data"
import { renderToStaticMarkup } from "react-dom/server"
import { describe, expect, it } from "vitest"
import { appModules } from "../../index.js"
import type { ApprovalView } from "../contract/index.js"
import { ApprovalQueue } from "../ui/approval-queue.js"
import { RunControls } from "../ui/run-controls.js"
const catalogues = buildRegistry([...appModules]).catalogues
const i18n = createI18n("en", { en: { agent: catalogues.en?.agent ?? {} } })
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`agent.${key}`, values)
const failed = "That did not work: {detail}"
const slugs = { org: "acme", workspace: "ws", project: "p" }
const inProvider = (node: React.ReactNode) =>
renderToStaticMarkup(
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
{node}
</DataProvider>,
)
const approval: ApprovalView = {
runId: "r1",
stepKey: "read",
goal: "Review the contract",
tool: "orders.create",
arguments: { total: 4200, currency: "EUR" },
reviewUrl: "https://console/tool-approvals?action=act-1",
createdAt: "2026-10-23T00:00:00.000Z",
}
const labels = {
heading: t("approvals.heading"),
empty: t("approvals.empty"),
proposed: t("approvals.proposed"),
review: t("approvals.review"),
approve: t("approvals.approve"),
reject: t("approvals.reject"),
decided: t("approvals.decided"),
disclosure: t("approvals.disclosure"),
failed,
}
describe("the approval queue", () => {
const html = inProvider(<ApprovalQueue slugs={slugs} seed={[approval]} labels={labels} />)
it("renders the exact stored proposed arguments, pretty-printed, with the disclosure", () => {
// The exact stored arguments, HTML-escaped as any rendered text is.
expect(html).toContain("&quot;total&quot;: 4200")
expect(html).toContain("&quot;currency&quot;: &quot;EUR&quot;")
expect(html).toContain(t("approvals.disclosure"))
expect(html).toContain(t("approvals.proposed"))
})
it("names the decision controls and links the console review", () => {
expect(html).toContain(`>${t("approvals.approve")}<`)
expect(html).toContain(`>${t("approvals.reject")}<`)
expect(html).toContain('href="https://console/tool-approvals?action=act-1"')
})
it("announces an empty queue", () => {
const empty = inProvider(<ApprovalQueue slugs={slugs} seed={[]} labels={labels} />)
expect(empty).toContain(t("approvals.empty"))
})
})
describe("the run controls", () => {
const labels = {
cancel: t("runs.cancel"),
cancelling: t("runs.cancelling"),
failedHint: t("recovery.failedHint"),
cancelledHint: t("recovery.cancelledHint"),
uncertainHint: t("recovery.uncertainHint"),
budgetExhausted: t("recovery.budgetExhausted"),
approvalUnavailable: t("recovery.approvalUnavailable"),
toolError: t("recovery.toolError"),
planRefused: t("recovery.planRefused"),
internal: t("recovery.internal"),
}
it("offers cancel on an active run and recovery copy on an uncertain one", () => {
const active = inProvider(
<RunControls slugs={slugs} run={{ runId: "r1", status: "running", failureCode: null }} labels={labels} />,
)
expect(active).toContain(`>${t("runs.cancel")}<`)
const uncertain = inProvider(
<RunControls slugs={slugs} run={{ runId: "r1", status: "uncertain", failureCode: null }} labels={labels} />,
)
expect(uncertain).toContain(
t("recovery.uncertainHint")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll(String.fromCharCode(34), "&quot;")
.replaceAll(String.fromCharCode(39), "&#x27;"),
)
expect(uncertain).not.toContain(`>${t("runs.cancel")}<`)
})
})
+45
View File
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
import { buildRegistry, Container, createCaller } from "@conusai-kit/core"
import { DATA_SOURCE, type DataSource, SYSTEM_DATA, type SystemDataSource } from "@conusai-kit/data"
import { createTenancyHooks, TENANCY, tenancyModule, users } from "@conusai-kit/tenancy"
import type { AgentScope } from "../core/ports.js"
import { AGENT_MODULE_BASE } from "../module.shared.js"
export const workerRegistry = buildRegistry([tenancyModule, AGENT_MODULE_BASE])
/** Real app membership/permission resolution, including a retained memo to expose stale use. */
export async function workerFixture(data: DataSource & SystemDataSource) {
const container = new Container()
container.value(DATA_SOURCE, data)
container.value(SYSTEM_DATA, data)
const tenancy = createTenancyHooks({ data, registry: workerRegistry })
container.value(TENANCY, tenancy)
const user = await data.global(users).insert({
issuer: "https://id.example.test",
subject: crypto.randomUUID(),
email: "worker@example.test",
displayName: "Worker fixture",
locale: "en",
})
const actor = { userId: user.id }
const caller = createCaller({ registry: workerRegistry, container, tenancy }, { actor })
const org = `worker-${crypto.randomUUID()}`
const { orgId } = (await caller.callByName("tenancy.org.create", { slug: org, name: "Worker" })) as { orgId: string }
const { workspaceId } = (await caller.callByName("tenancy.workspace.create", {
org,
slug: "work",
name: "Work",
visibility: "org",
})) as { workspaceId: string }
const { projectId } = (await caller.callByName("tenancy.project.create", {
org,
workspace: "work",
slug: "project",
name: "Project",
moduleKey: "agent",
})) as { projectId: string }
const scope: AgentScope = { level: "project", actor, orgId, workspaceId, projectId }
// Warm the request memo: worker admission must read past this after a revocation.
await tenancy.authorize(scope, "agent.run.start", { name: "agent.run.start", kind: "effect" })
return { container, scope, tenancy }
}
@@ -0,0 +1,223 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Actual nonowner PostgreSQL + app tenancy + Agent job. Scripted model/tools, no live provider.
import { AGENT_MODEL, AGENT_TOOLS, dataRunStore } from "@conusai-kit/agent"
import { newId } from "@conusai-kit/core"
import type { KyselyDataSource } from "@conusai-kit/data-kysely"
import { orgMemberships, TENANCY } from "@conusai-kit/tenancy"
import { beforeAll, expect, it, vi } from "vitest"
import { AGENT_SCOPE } from "../core/ports.js"
import { startRun } from "../core/runs.js"
import { type ClaimChange, claimedDataSource, claimedDelivery, terminateClaimSession } from "./claimed-job.js"
import { planFollower } from "./plan-model.js"
import { workerFixture } from "./worker-fixture.js"
let data: KyselyDataSource
beforeAll(async () => {
data = await claimedDataSource()
}, 300_000)
async function fixture(needsApproval = false) {
const { container, scope } = await workerFixture(data)
// The actual worker binding uses a system actor; stored actorId alone supplies member authority.
container.value(AGENT_SCOPE, { ...scope, actor: { userId: "system:agent", system: true } })
let models = 0
let revokeDuringModel = false
let loseDuringModel = false
let tools = 0
const adapter = planFollower()
const stream = adapter.chatStream.bind(adapter)
adapter.chatStream = async function* (...args) {
models++
if (revokeDuringModel) await removeMember()
if (loseDuringModel) await terminateClaimSession()
yield* stream(...args)
}
container.value(AGENT_MODEL, adapter)
let revoke = false
let transient = false
const removeMember = async () => {
const repos = data.forScope(scope)
const member = (await repos.of(orgMemberships).list({ limit: 10 })).items[0]
if (!member) throw new Error("expected member")
await repos.of(orgMemberships).delete(member.id)
}
container.value(AGENT_TOOLS, {
async call() {
tools++
if (transient) throw new Error("private upstream failure")
if (revoke) await removeMember()
return { status: "completed", content: [], usage: { costMicrocents: 0, steps: 1, tokens: 0 } }
},
})
const { runId } = await startRun({ data }, scope, {
goal: "Synthetic worker verification",
plan: [
{ key: "read", label: "Read", tool: "records.read", args: {}, needsApproval },
{ key: "next", label: "Next", tool: "records.next", args: {} },
],
})
const store = dataRunStore(data, scope)
return {
container,
scope,
runId,
store,
removeMember,
counts: () => ({ models, tools }),
loseDuringModel: () => {
loseDuringModel = true
},
revokeDuringModel: () => {
revokeDuringModel = true
},
revoke: () => {
revoke = true
},
fail: () => {
transient = true
},
deliver: (finalAttempt = false, change?: ClaimChange) =>
claimedDelivery(data, container, scope, runId, { finalAttempt }, change),
}
}
it("executes and replays without repeating providers through the nonowner runtime", async () => {
const job = await fixture()
await job.deliver()
expect((await job.store.get(job.runId))?.status).toBe("completed")
const counts = job.counts()
expect(counts.tools).toBe(2)
await job.deliver()
expect(job.counts()).toEqual(counts)
})
it("refuses revoked membership before the first model and after a committed step", async () => {
const before = await fixture()
await before.removeMember()
await before.deliver()
expect(before.counts()).toEqual({ models: 0, tools: 0 })
expect((await before.store.get(before.runId))?.status).toBe("failed")
const between = await fixture()
between.revoke()
await between.deliver()
expect(between.counts()).toEqual({ models: 1, tools: 1 })
expect(await between.store.get(between.runId)).toMatchObject({ status: "failed", usageSteps: 1 })
})
it("wrong org, workspace or project cannot execute or alter the immutable stored run", async () => {
const job = await fixture()
for (const field of ["orgId", "workspaceId", "projectId"] as const) {
const container = job.container.child()
container.value(AGENT_SCOPE, { ...job.scope, [field]: newId() })
await expect(claimedDelivery(data, container, job.scope, job.runId, { finalAttempt: true })).rejects.toThrow()
}
expect(job.counts()).toEqual({ models: 0, tools: 0 })
expect((await job.store.get(job.runId))?.status).toBe("queued")
})
it("bounded exhaustion persists terminal failure, preserving committed/replay semantics", async () => {
const job = await fixture()
job.fail()
await expect(job.deliver()).rejects.toThrow()
expect((await job.store.get(job.runId))?.status).toBe("running")
await job.deliver(true)
expect(await job.store.get(job.runId)).toMatchObject({
status: "failed",
failureDetail: "the durable job exhausted its retry attempts",
})
expect((await job.store.listSteps(job.runId))[0]?.attempts).toBe(2)
const counts = job.counts()
await job.deliver(true)
expect(job.counts()).toEqual(counts)
})
it("revocation between model and tool refuses the effect before any tool call", async () => {
const job = await fixture()
job.revokeDuringModel()
await job.deliver()
expect(job.counts()).toEqual({ models: 1, tools: 0 })
expect((await job.store.get(job.runId))?.status).toBe("failed")
})
it("approval resumes the exact call, while revocation prevents approved resumption", async () => {
const job = await fixture(true)
await job.deliver(true)
expect((await job.store.get(job.runId))?.status).toBe("awaiting_approval")
expect(job.counts().tools).toBe(0)
await job.store.decideApproval(job.runId, "read", "approved")
await job.deliver()
expect((await job.store.get(job.runId))?.status).toBe("completed")
expect(job.counts().tools).toBe(2)
await job.deliver()
expect(job.counts().tools).toBe(2)
const revoked = await fixture(true)
await revoked.deliver()
await revoked.store.decideApproval(revoked.runId, "read", "approved")
await revoked.removeMember()
const counts = revoked.counts()
await revoked.deliver()
expect(revoked.counts()).toEqual(counts)
expect((await revoked.store.get(revoked.runId))?.status).toBe("failed")
})
it("a current role demotion removes execution permission despite the retained memo", async () => {
const job = await fixture()
const repos = data.forScope(job.scope)
const member = (await repos.of(orgMemberships).list({ limit: 10 })).items[0]
if (!member) throw new Error("expected membership")
await repos.of(orgMemberships).update(member.id, { role: "viewer" }, member.version)
await job.deliver()
expect(job.counts()).toEqual({ models: 0, tools: 0 })
expect((await job.store.get(job.runId))?.status).toBe("failed")
})
it.each(["cancel", "reclaim", "delete", "scope", "actor"] as const)(
"actual adapter refuses %s before provider or durable run mutation",
async (change) => {
const job = await fixture()
const before = await job.store.get(job.runId)
const storeCalls = vi.spyOn(data, "forScope")
await expect(job.deliver(true, change)).rejects.toMatchObject({ code: "job_claim_lost" })
expect(job.counts()).toEqual({ models: 0, tools: 0 })
expect(storeCalls).not.toHaveBeenCalled()
storeCalls.mockRestore()
expect(await job.store.get(job.runId)).toEqual(before)
expect(await job.store.listSteps(job.runId)).toEqual([])
},
)
it("actual original attribution must match the immutable run owner", async () => {
const job = await fixture()
const before = await job.store.get(job.runId)
await expect(job.deliver(true, "owner")).rejects.toMatchObject({ code: "job_claim_lost" })
expect(job.counts()).toEqual({ models: 0, tools: 0 })
expect(await job.store.get(job.runId)).toEqual(before)
expect(await job.store.listSteps(job.runId)).toEqual([])
})
it("actual owned guard backend loss during model admission prevents every later tool and terminal failure", async () => {
const job = await fixture()
job.loseDuringModel()
await expect(job.deliver(true)).rejects.toMatchObject({ code: "job_claim_lost" })
expect(job.counts()).toEqual({ models: 1, tools: 0 })
expect((await job.store.get(job.runId))?.status).not.toBe("failed")
})
it("actual guard loss during authorization refuses model admission before any provider call", async () => {
const job = await fixture()
const tenancy = job.container.resolve(TENANCY)
const child = job.container.child()
child.value(TENANCY, {
...tenancy,
async authorizeIn(...args) {
const result = await tenancy.authorizeIn(...args)
await terminateClaimSession()
return result
},
})
await expect(claimedDelivery(data, child, job.scope, job.runId, { finalAttempt: true })).rejects.toMatchObject({
code: "job_claim_lost",
})
expect(job.counts()).toEqual({ models: 0, tools: 0 })
expect((await job.store.get(job.runId))?.status).toBe("queued")
})
+353
View File
@@ -0,0 +1,353 @@
// The agent module's adapters: the procedure handlers, the tools gateway over the registry client,
// the run job that drives the engine, and the subscriber that enqueues it.
import {
AGENT_MODEL,
AGENT_TOOLS,
type AgentStep,
dataAgentPersistence,
dataRunStore,
executeRun,
expireRuns,
RunAuthorityDenied,
} from "@conusai-kit/agent"
import { runEventsResponse } from "@conusai-kit/agent/agui"
import type { Container } from "@conusai-kit/core"
import {
type EventContext,
implement,
internal,
isSystemActor,
JobClaimLost,
type JobDef,
subscribe,
} from "@conusai-kit/core"
import { DATA_SOURCE, JOB_QUEUE } from "@conusai-kit/data"
import { assertJobTransactionFence } from "@conusai-kit/jobs-pgboss"
import { TENANCY } from "@conusai-kit/tenancy"
import { createRegistryClient, type RegistryClient, ToolDeniedError } from "@conusai-sdk/ai"
import { type ConusaiTextAdapter, conusaiText } from "@conusai-sdk/ai/tanstack"
import { z } from "zod"
import {
approvalList,
approvalReview,
RUN_EXPIRE_JOB,
RUN_JOB,
runCancel,
runEvents,
runGetMany,
runList,
runResumeRequested,
runStart,
runStatus,
} from "../contract/index.js"
import { AGENT_SCOPE, type AgentActionScope, type AgentDeps, type AgentScope } from "../core/ports.js"
import {
cancelRun,
getManyRuns,
getRun,
listApprovals,
listRuns,
openRunEvents,
reviewApproval,
startRun,
} from "../core/runs.js"
export function agentDeps(container: Container): AgentDeps {
return { data: container.resolve(DATA_SOURCE) }
}
export const runStartImpl = implement(runStart, ({ input, ctx }) =>
startRun(agentDeps(ctx.container), ctx.scope, {
goal: input.goal,
plan: input.plan,
...(input.budget === undefined ? {} : { budget: input.budget }),
}),
)
export const runStatusImpl = implement(runStatus, ({ input, ctx }) =>
getRun(agentDeps(ctx.container), ctx.scope, input.runId),
)
/**
* The run's events as SSE: the request is authorized and the run found before the first byte, and
* the body then tails the durable record until the run's terminal event or the end of the stream
* window, when the client reconnects (and is authorized again) with its last offset. Each poll
* also rechecks current membership and permission without the request memo.
*/
export const runEventsImpl = implement(runEvents, async ({ input, ctx }) => {
const opened = await openRunEvents(agentDeps(ctx.container), ctx.scope, { runId: input.runId, after: input.after })
return runEventsResponse({
runId: input.runId,
after: opened.after,
load: async () => {
// Request authorization is memoised. A stream must observe a removal or demotion before
// reading another durable snapshot, rather than retaining that memo until reconnect.
const data = ctx.container.resolve(DATA_SOURCE)
const tenancy = ctx.container.resolve(TENANCY)
const allowed = await data.unitOfWork.run(ctx.scope, (tx) =>
tenancy.authorizeIn(tx, ctx.scope, runEvents.permission, "effect", runEvents.name),
)
return allowed ? opened.load() : null
},
...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
})
})
export const runCancelImpl = implement(runCancel, ({ input, ctx }) =>
cancelRun(agentDeps(ctx.container), ctx.scope, input.runId),
)
export const runListImpl = implement(runList, ({ input, ctx }) =>
listRuns(agentDeps(ctx.container), ctx.scope, {
...(input.cursor === undefined ? {} : { cursor: input.cursor }),
...(input.limit === undefined ? {} : { limit: input.limit }),
}),
)
export const approvalListImpl = implement(approvalList, ({ ctx }) => listApprovals(agentDeps(ctx.container), ctx.scope))
export const approvalReviewImpl = implement(approvalReview, ({ input, ctx }) =>
reviewApproval(agentDeps(ctx.container), ctx.scope, input),
)
export const runGetManyImpl = implement(runGetMany, async ({ input, ctx }) => ({
items: await getManyRuns(agentDeps(ctx.container), ctx.scope, input.ids),
}))
export const AGENT_PROCEDURE_IMPLS = [
runStartImpl,
runStatusImpl,
runEventsImpl,
runCancelImpl,
runListImpl,
runGetManyImpl,
approvalListImpl,
approvalReviewImpl,
]
// --- The model -------------------------------------------------------------
/**
* The run loop's model: the AI gateway's `model` as a TanStack AI text adapter, with the app's
* deployment credentials. The worker binds it as `AGENT_MODEL`; the gateway meters every call and
* applies the organization's spend cap, and no provider SDK or key is in the app.
*/
export function gatewayAgentModel(options: {
apiUrl: string
token: string
model: string
}): ConusaiTextAdapter<string> {
return conusaiText(options.model, { apiUrl: options.apiUrl, token: options.token })
}
// --- The tools gateway -----------------------------------------------------
/**
* A tool call no retry fixes by itself: the platform refused it, or the tool answered an error. The
* run job turns it into the step's typed failure. Its message goes on the run, so it names the tool
* and the refusal's reason and never the tool's own text, which may be a tenant's data.
*/
class ToolCallFailed extends Error {}
/**
* The tools gateway over the organization's registry, with the app's scoped credentials. Each
* step's call carries the step's idempotency key, so a resumed step re-calls the same persisted
* action instead of creating a second one. When a write comes back `approval_required`, the exact
* stored proposed arguments are parked for a person; this adapter never approves. A refusal and a
* tool's own error are `ToolCallFailed`; anything else that throws is transient, and leaves the run to
* the queue's retry.
*/
export function registryAgentTools(options: {
apiUrl: string
token: string
/** The organization's id the registry is addressed by. */
orgId: string
/** Fixed, or resolved per call: jobs of different tenants share the process binding. */
scope: AgentActionScope | (() => Promise<AgentActionScope>)
fetch?: typeof fetch
}): import("@conusai-kit/agent").AgentTools {
// Lazy: building the client validates the organization id, and a bare test container binds the
// tools without one until a call actually leaves the process.
let registry: RegistryClient | undefined
const client = (): RegistryClient => {
registry ??= createRegistryClient({
env: {
CONUSAI_API_URL: options.apiUrl,
CONUSAI_API_TOKEN: options.token,
CONUSAI_ORG_ID: options.orgId,
},
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
})
return registry
}
const resolveScope =
typeof options.scope === "function" ? options.scope : () => Promise.resolve(options.scope as AgentActionScope)
return {
async call(name, args, callOptions) {
const result = await client()
.tools.call(name, args, { idempotencyKey: callOptions.idempotencyKey })
.catch((error: unknown) => {
throw error instanceof ToolDeniedError && !error.retryable ? new ToolCallFailed(error.message) : error
})
if (result.status === "completed") {
if (result.isError) throw new ToolCallFailed(`the tool ${name} answered an error`)
return {
status: "completed",
content: result.content,
// The registry call result does not carry metered usage yet (the metering slice owns
// that); steps and duration are tracked, and cost lands when the platform answers it.
usage: { costMicrocents: 0, steps: 1, tokens: 0 },
}
}
return {
status: "approval_required",
approval: {
actionId: result.request.actionId ?? null,
tool: name,
arguments: result.request.arguments,
reviewUrl: result.request.actionId
? client()
.tools.actions(await resolveScope())
.reviewUrl(result.request.actionId)
: null,
},
}
},
}
}
// --- The run job and its subscriber ----------------------------------------
const runPayload = z.object({ runId: z.string() })
type RunJobPayload = z.output<typeof runPayload>
export const runJob: JobDef<RunJobPayload> = {
payload: runPayload,
async handler(payload, ctx) {
const resolved = ctx.container.resolve(AGENT_SCOPE)
if (resolved.level !== "project")
throw internal(`an agent job runs in a project scope, not a ${resolved.level} one`)
const execution = ctx.execution
const actual = execution?.scope
if (
execution?.adapter !== "pg-boss" ||
execution.job !== RUN_JOB ||
!actual ||
actual.level !== "project" ||
!isSystemActor(actual.actor) ||
!isSystemActor(resolved.actor) ||
actual.actor.userId !== resolved.actor.userId ||
actual.orgId !== resolved.orgId ||
actual.workspaceId !== resolved.workspaceId ||
actual.projectId !== resolved.projectId
)
throw new JobClaimLost()
return execution.run(async () => {
const data = ctx.container.resolve(DATA_SOURCE)
await assertJobTransactionFence(() => data.unitOfWork.run(resolved, async () => {}))
await execution.assertOwned()
const store = dataRunStore(data, resolved)
const record = await store.get(payload.runId)
if (!record) throw internal("the run this job belongs to is gone")
if (execution.onBehalfOf !== record.actorId) throw new JobClaimLost()
const authorize = async (): Promise<void> => {
await execution.assertOwned()
// The worker actor is a system actor. Only the immutable initiating member can authorize
// this run, and every boundary reads current membership instead of request memo state.
const member = { ...resolved, actor: { userId: record.actorId } }
const tenancy = ctx.container.resolve(TENANCY)
const allowed = await data.unitOfWork.run(member, (tx) =>
tenancy.authorizeIn(tx, member, runStart.permission, "effect", runStart.name),
)
await execution.assertOwned()
if (!allowed) throw new RunAuthorityDenied()
}
const plan: AgentStep[] = record.plan.map((planned) => ({
key: planned.key,
label: planned.label,
...(planned.needsApproval === true
? { requiresApproval: { tool: planned.tool, arguments: planned.args } }
: {}),
invoke: async (stepCtx) => {
await authorize()
const result = await ctx.container
.resolve(AGENT_TOOLS)
.call(planned.tool, planned.args, {
signal: stepCtx.signal,
idempotencyKey: `${record.id}:${planned.key}`,
})
.catch((error: unknown) => {
if (error instanceof ToolCallFailed) return { status: "failed" as const, detail: error.message }
throw error
})
await execution.assertOwned()
if (result.status === "failed") {
return { ok: false as const, failure: "tool_error" as const, detail: result.detail }
}
if (result.status === "completed") {
return {
ok: true as const,
usage: { costMicrocents: result.usage.costMicrocents, tokens: result.usage.tokens },
artifacts: [{ kind: "tool_result", ref: planned.tool }],
result: result.content,
}
}
return { ok: false as const, awaitingApproval: result.approval }
},
}))
await executeRun(
{
store,
authorize,
assertOwned: () => execution.assertOwned(),
// Resolve provider bindings only after entry admission; a denied/terminal run never
// constructs a gateway client. Metering and spend admission remain in the gateway.
get adapter() {
return ctx.container.resolve(AGENT_MODEL)
},
persistence: dataAgentPersistence(data, resolved),
},
payload.runId,
plan,
{
...(ctx.finalAttempt === undefined ? {} : { finalAttempt: ctx.finalAttempt }),
signal: ctx.signal ? AbortSignal.any([ctx.signal, execution.signal]) : execution.signal,
},
)
})
},
}
async function onRunResumeRequested(
event: { name: string; payload: { workspaceId: string; projectId: string; runId: string } },
ctx: EventContext,
): Promise<void> {
const ambient = ctx.container.resolve(AGENT_SCOPE)
if (ambient.level !== "project") throw internal("an agent event resolves to a project scope")
const scope: AgentScope = { ...ambient, workspaceId: event.payload.workspaceId, projectId: event.payload.projectId }
await ctx.container
.resolve(JOB_QUEUE)
.enqueue(scope, RUN_JOB, { runId: event.payload.runId }, { key: `agent.run.${event.payload.runId}` })
}
const runExpirePayload = z.object({ retentionDays: z.number().int().min(1).max(3650) })
type RunExpirePayload = z.output<typeof runExpirePayload>
/**
* The retention sweep: deletes the finished runs of one project that are past the retention, with their step
* log and conversation (`expireRuns`). Runs in the project the schedule fanned it out to, so another
* organization's runs are not reached; a suspended organization gets no run at all (the fan-out skips it).
*/
export const runExpireJob: JobDef<RunExpirePayload> = {
payload: runExpirePayload,
async handler(payload, ctx) {
const resolved = ctx.container.resolve(AGENT_SCOPE)
if (resolved.level !== "project")
throw internal(`the run retention sweep runs in a project scope, not a ${resolved.level} one`)
await expireRuns(ctx.container.resolve(DATA_SOURCE), resolved, { retentionDays: payload.retentionDays })
},
}
export const AGENT_JOBS: Record<string, JobDef<unknown>> = { [RUN_JOB]: runJob, [RUN_EXPIRE_JOB]: runExpireJob }
export const AGENT_SUBSCRIPTIONS = [subscribe(runResumeRequested, onRunResumeRequested)]
+20
View File
@@ -0,0 +1,20 @@
// The live collection of the agent module (umbrella TS6.1). The live server announces a change of `agent_run`
// -- a run started, a step committed, an approval parked, a run finished or cancelled -- as
// `{ entity: "agent.run", id }`, ids only; a reader reads the run back through `agent.run.getMany`, so the
// procedure's permission and the repository's tenant wall decide what a member sees. The run list is rows of
// this collection; the approval queue is read again on its changes, since a run that parks an approval or
// leaves one changes its status. `pnpm kit check` refuses a collection without its batch-get procedure.
import type { ModuleCollection } from "@conusai-kit/core"
export const RUN_COLLECTION = {
entity: "agent.run",
table: "agent_run",
key: "runId",
scopeLevel: "project",
read: "agent.run.read",
list: "agent.run.list",
getMany: "agent.run.getMany",
} as const satisfies ModuleCollection
export const AGENT_COLLECTIONS: readonly ModuleCollection[] = [RUN_COLLECTION]
+15
View File
@@ -0,0 +1,15 @@
// The constants of the agent module: its job, events and audit actions.
/** The worker job that executes (or resumes) one run. */
export const RUN_JOB = "agent.run"
/** The hourly sweep that deletes finished runs past the retention (AGENT_RUN_RETENTION_DAYS), per project. */
export const RUN_EXPIRE_JOB = "agent.runs.expire"
/** Top of every hour. */
export const RUN_EXPIRE_CRON = "0 * * * *"
export const RUN_STARTED = "agent.run.started"
export const RUN_RESUME_REQUESTED = "agent.run.resume_requested"
export const RUN_CANCELLED_ACTION = "agent.run.cancelled"
export const APPROVAL_REVIEWED = "agent.approval.reviewed"
+23
View File
@@ -0,0 +1,23 @@
// The events of the agent module. A web write publishes the request; the module's own subscriber
// (in the worker's outbox relay) enqueues the run job with the run id as its singleton key, so a
// start, a cancel or a review resumes the one job.
import { defineEvent } from "@conusai-kit/core"
import { z } from "zod"
const inProject = {
orgId: z.string(),
workspaceId: z.string(),
projectId: z.string(),
}
/** A run was asked to start (or resume after a cancel/review decision). */
export const runResumeRequested = defineEvent(
"agent.runResumeRequested",
z.object({
...inProject,
runId: z.string(),
}),
)
export const AGENT_EVENTS = [runResumeRequested]
+31
View File
@@ -0,0 +1,31 @@
// The public API of the agent module: what a page, another module and the OpenAPI document may
// import.
export { AGENT_COLLECTIONS, RUN_COLLECTION } from "./collections.js"
export {
APPROVAL_REVIEWED,
RUN_CANCELLED_ACTION,
RUN_EXPIRE_CRON,
RUN_EXPIRE_JOB,
RUN_JOB,
RUN_RESUME_REQUESTED,
RUN_STARTED,
} from "./constants.js"
export { AGENT_EVENTS, runResumeRequested } from "./events.js"
export type { ApprovalView, RunView } from "./procedures.js"
export {
AGENT_PROCEDURES,
approvalList,
approvalReview,
approvalViewSchema,
planStepSchema,
runBudgetSchema,
runCancel,
runEvents,
runEventsAfterSchema,
runGetMany,
runList,
runStart,
runStatus,
runViewSchema,
} from "./procedures.js"
+215
View File
@@ -0,0 +1,215 @@
// The procedure contracts of the agent module: schemas and names, no handler and no port.
//
// A run is a bounded, person-requested plan of tool calls that the worker executes durably, as a
// TanStack AI agent loop through the AI gateway (`@conusai-kit/agent`): the model calls the steps
// in order and answers the goal from their results. The progress a page renders is the run row
// and its step log, and the only writes a tool's result can cause are proposals a person decides.
// The approval queue renders the EXACT stored proposed arguments — never a model's narration.
import { answerProvenanceSchema } from "@conusai-kit/agent/client"
import { procedure } from "@conusai-kit/core"
import { z } from "zod"
import { APPROVAL_REVIEWED, RUN_CANCELLED_ACTION } from "./constants.js"
const inProject = { org: z.string(), workspace: z.string(), project: z.string() }
/** One step of the plan a person admits: one explicitly published tool, exact arguments. */
export const planStepSchema = z
.object({
// The key is also the step's tool name in the model loop: providers allow at most 64.
key: z
.string()
.min(1)
.max(64)
.regex(/^[a-z][a-z0-9-]*$/, "lower-case letters, digits and dashes"),
label: z.string().min(1).max(200),
tool: z.string().min(1).max(200),
args: z.record(z.string(), z.unknown()),
/** A person approves this call (its tool and exact arguments) before it runs. */
needsApproval: z.boolean().optional(),
})
.strict()
/** What a run may spend; the engine clamps it by the hard safety ceilings. */
export const runBudgetSchema = z
.object({
maxCostMicrocents: z.number().int().positive().optional(),
maxSteps: z.number().int().positive().optional(),
maxDurationSeconds: z.number().int().positive().optional(),
})
.strict()
export const runViewSchema = z.object({
runId: z.string(),
goal: z.string(),
status: z.enum(["queued", "running", "awaiting_approval", "completed", "failed", "cancelled", "uncertain"]),
failureCode: z
.enum(["tool_error", "budget_exhausted", "plan_refused", "approval_unavailable", "internal"])
.nullable(),
failureDetail: z.string().nullable(),
/** The plan as admitted: what the run will do, visible before it does it. */
plan: z.array(
z.object({
key: z.string(),
label: z.string(),
tool: z.string(),
args: z.record(z.string(), z.unknown()),
needsApproval: z.boolean(),
/** The step's state from the step log; steps not begun have no row yet. */
status: z.enum(["pending", "committed", "failed", "cancelled", "uncertain"]),
attempts: z.number().int(),
error: z.string().nullable(),
}),
),
usageCostMicrocents: z.number().int(),
usageSteps: z.number().int(),
/** Committed steps over the plan: the progress bar. */
percent: z.number(),
cancelRequested: z.boolean(),
/** A completed run's answer: AI-generated text (EU AI Act Art. 50), shown with its disclosure. */
answer: z.string().nullable(),
/**
* The answer's machine-readable marking (EU AI Act Art. 50(2)): the provenance the AI gateway
* returned with it (model, provider, generation time). Null without an answer, or when the gateway
* marked none; keep it with the answer wherever the answer is copied or exported.
*/
answerProvenance: answerProvenanceSchema.nullable(),
actorId: z.string(),
createdAt: z.string(),
startedAt: z.string().nullable(),
finishedAt: z.string().nullable(),
})
export type RunView = z.output<typeof runViewSchema>
/** One parked approval: the exact stored proposed arguments, copied before they were proposed. */
export const approvalViewSchema = z.object({
runId: z.string(),
stepKey: z.string(),
goal: z.string(),
tool: z.string(),
/** The exact arguments the tool was called with; a reviewer reads THESE, never a narration. */
arguments: z.record(z.string(), z.unknown()),
/** Where the persisted proposal is reviewed in the console, when the platform names one. */
reviewUrl: z.string().nullable(),
createdAt: z.string(),
})
export type ApprovalView = z.output<typeof approvalViewSchema>
export const runStart = procedure("agent.run.start")
.kind("write")
.scope("project")
.permission("agent.run.start")
.input(
z.object({
...inProject,
goal: z.string().min(1).max(2_000),
plan: z.array(planStepSchema).min(1).max(25),
budget: runBudgetSchema.optional(),
}),
)
.output(runViewSchema)
.contract()
export const runStatus = procedure("agent.run.status")
.kind("read")
.scope("project")
.permission("agent.run.read")
.tool()
.input(z.object({ ...inProject, runId: z.string() }))
.output(runViewSchema)
.contract()
/**
* The resume point of a run's event stream: the last delivered sequence number, or the SSE id
* `agk1:{runId}:{seq}` the client last saw (`-1` or absent: from the start).
*/
export const runEventsAfterSchema = z.string().regex(/^(?:-1|\d{1,9}|agk1:[A-Za-z0-9_-]{1,128}:\d{1,9})$/)
/**
* The run's AG-UI 1.0 events as Server-Sent Events (`@conusai-kit/agent/agui`), for TanStack AI's
* resumable client (`@conusai-kit/agent/client`). A reader sees exactly what `agent.run.status`
* shows, under the same permission; the stream only reads the durable record.
*
* It is declared an `effect` because the kernel admits a streamed HTTP response (`download`) only
* for that kind (kit-core registry); its handler reads and never writes. It counts against the
* `default` bucket, like a read, because a watched run reconnects every stream window.
*/
export const runEvents = procedure("agent.run.events")
.kind("effect")
.scope("project")
.permission("agent.run.read")
.input(z.object({ ...inProject, runId: z.string(), after: runEventsAfterSchema.optional() }))
.output(z.custom<Response>((value) => value instanceof Response, "a streamed event response"))
.download()
.rateLimit("default")
.contract()
export const runCancel = procedure("agent.run.cancel")
.kind("write")
.scope("project")
.permission("agent.run.cancel")
.input(z.object({ ...inProject, runId: z.string() }))
.output(z.object({ runId: z.string(), cancelled: z.boolean() }))
.audit(RUN_CANCELLED_ACTION)
.contract()
export const runList = procedure("agent.run.list")
.kind("read")
.scope("project")
.permission("agent.run.read")
.input(z.object({ ...inProject, cursor: z.string().optional(), limit: z.number().int().min(1).max(100).optional() }))
.output(z.object({ runs: z.array(runViewSchema), cursor: z.string().optional(), hasMore: z.boolean() }))
.contract()
/**
* The live collection's batch-get (contract C-05): the runs with these ids, under the list's permission. An
* id of another tenant, of another project or of nothing is left out of the answer.
*/
export const runGetMany = procedure("agent.run.getMany")
.kind("read")
.scope("project")
.permission("agent.run.read")
.input(z.object({ ...inProject, ids: z.array(z.string().min(1).max(64)).min(1).max(100) }))
.output(z.object({ items: z.array(runViewSchema) }))
.contract()
export const approvalList = procedure("agent.approval.list")
.kind("read")
.scope("project")
.permission("agent.approval.review")
.input(z.object({ ...inProject }))
.output(z.object({ approvals: z.array(approvalViewSchema) }))
.contract()
export const approvalReview = procedure("agent.approval.review")
.kind("write")
.scope("project")
.permission("agent.approval.review")
.input(
z.object({
...inProject,
runId: z.string(),
stepKey: z.string(),
decision: z.enum(["approve", "reject"]),
reason: z.string().max(500).optional(),
}),
)
.output(
z.object({
runId: z.string(),
status: z.enum(["queued", "running", "awaiting_approval", "completed", "failed", "cancelled", "uncertain"]),
}),
)
.audit(APPROVAL_REVIEWED)
.contract()
export const AGENT_PROCEDURES = [
runStart,
runStatus,
runEvents,
runCancel,
runList,
runGetMany,
approvalList,
approvalReview,
]
+39
View File
@@ -0,0 +1,39 @@
// The ports the agent module's use cases are written against, and the tokens the composition
// roots bind. The runtime itself (durable run record, step log, engine) is @conusai-kit/agent;
// this module is its template integration: procedures, job and pages.
import { type TenantScope, token } from "@conusai-kit/core"
import type { DataSource } from "@conusai-kit/data"
export type AgentScope = TenantScope & { workspaceId: string; projectId: string }
export interface AgentDeps {
readonly data: DataSource
}
/**
* The project a run's job executes in, with a system actor. The worker binds it per job from the
* enqueueing scope (the same arrangement as `DOCUMENTS_SCOPE`, decision AT-F6): a payload never
* names a tenant.
*/
export const AGENT_SCOPE = token<TenantScope>("agent.scope")
/** The scope the tools adapter needs to review persisted proposals in the console. */
export interface AgentActionScope {
readonly orgSlug: string
readonly workspaceSlug: string
readonly projectSlug: string
}
/** Resolves the slug scope the registry's tool-actions API is addressed by, from ids. */
export async function actionScopeOf(
data: import("@conusai-kit/data").DataSource,
scope: AgentScope,
): Promise<AgentActionScope> {
const { organizations, workspaces, projects } = await import("@conusai-kit/tenancy")
const org = await data.global(organizations).get(scope.orgId)
const workspace = await data.forScope(scope).of(workspaces).get(scope.workspaceId)
const project = await data.forScope(scope).of(projects).get(scope.projectId)
if (!org || !workspace || !project) throw new Error("the run's organization, workspace or project is gone")
return { orgSlug: org.slug, workspaceSlug: workspace.slug, projectSlug: project.slug }
}
+301
View File
@@ -0,0 +1,301 @@
// The agent module's use cases: start a run, read one, list them, cancel one, and the approval
// queue. Every write is the run row and the outbox event in one transaction; the worker's
// subscriber enqueues the run job, and the engine (@conusai-kit/agent) does the durable execution.
import {
AGENT_ENTITIES,
type AgentRunRecord,
type AgentStepRecord,
admitPlan,
agentRuns,
answerOf,
clampBudget,
dataAgentPersistence,
dataRunStore,
isTerminalRunStatus,
} from "@conusai-kit/agent"
import { resumeAfter } from "@conusai-kit/agent/agui"
import { notFound, validationFailed } from "@conusai-kit/core"
import { type ApprovalView, RUN_JOB, type RunView, runResumeRequested } from "../contract/index.js"
import type { AgentDeps, AgentScope } from "./ports.js"
const DEFAULT_BUDGET = { maxCostMicrocents: 100_000_000, maxSteps: 10, maxDurationSeconds: 600 }
const PAGE = 50
export const runViewOf = async (deps: AgentDeps, scope: AgentScope, run: { id: string }): Promise<RunView> => {
const store = dataRunStore(deps.data, scope)
const record = await store.get(run.id)
if (!record) throw notFound("no such run")
const steps = await store.listSteps(run.id)
const stepViews = record.plan.map((planned) => {
const logged = steps.find((candidate) => candidate.key === planned.key)
return {
key: planned.key,
label: planned.label,
tool: planned.tool,
args: planned.args,
needsApproval: planned.needsApproval === true,
status: logged?.status ?? ("pending" as const),
attempts: logged?.attempts ?? 0,
error: logged?.error ?? null,
}
})
const answer = await answerOf(dataAgentPersistence(deps.data, scope), record)
return {
runId: record.id,
goal: record.goal,
status: record.status,
failureCode: record.failureCode,
failureDetail: record.failureDetail,
plan: stepViews,
usageCostMicrocents: record.usageCostMicrocents,
usageSteps: record.usageSteps,
percent: record.plan.length === 0 ? 0 : Math.min(record.usageSteps, record.plan.length) / record.plan.length,
cancelRequested: record.cancelRequestedAt !== null,
answer,
answerProvenance: answer === null ? null : record.answerProvenance,
actorId: record.actorId,
createdAt: record.createdAt,
startedAt: record.startedAt,
finishedAt: record.finishedAt,
}
}
/**
* Starts a run: the plan is admitted against the hard ceilings before anything is written, the run
* row and the resume event land in one transaction, and the subscriber's enqueue (singleton on
* the run id) is what actually starts the work. Idempotency is the caller's idempotency key at
* the transport; a repeated start with the same key answers the first run.
*/
export async function startRun(
deps: AgentDeps,
scope: AgentScope,
input: {
goal: string
plan: {
key: string
label: string
tool: string
args: Record<string, unknown>
needsApproval?: boolean | undefined
}[]
budget?: {
maxCostMicrocents?: number | undefined
maxSteps?: number | undefined
maxDurationSeconds?: number | undefined
}
},
): Promise<RunView> {
const budget = clampBudget({
maxCostMicrocents: input.budget?.maxCostMicrocents ?? DEFAULT_BUDGET.maxCostMicrocents,
maxSteps: input.budget?.maxSteps ?? DEFAULT_BUDGET.maxSteps,
maxDurationSeconds: input.budget?.maxDurationSeconds ?? DEFAULT_BUDGET.maxDurationSeconds,
})
// The plan is data here; the engine's steps come from these rows in the worker.
admitPlan(
input.plan.map((planned) => ({
key: planned.key,
label: planned.label,
invoke: () => Promise.resolve({ ok: true as const }),
})),
budget,
)
const run = await deps.data.unitOfWork.run(scope, async (tx) => {
const row = await tx.of(agentRuns).insert({
goal: input.goal,
plan: input.plan.map((planned) => ({
key: planned.key,
label: planned.label,
tool: planned.tool,
args: planned.args,
...(planned.needsApproval === true ? { needsApproval: true } : {}),
})),
status: "queued",
failureCode: null,
failureDetail: null,
actorId: scope.actor.userId,
budgetCostMicrocents: budget.maxCostMicrocents,
budgetSteps: budget.maxSteps,
budgetSeconds: budget.maxDurationSeconds,
usageCostMicrocents: 0,
usageSteps: 0,
cancelRequestedAt: null,
startedAt: null,
finishedAt: null,
})
await tx.publish(runResumeRequested, {
orgId: scope.orgId,
workspaceId: scope.workspaceId,
projectId: scope.projectId,
runId: row.id,
})
return row
})
return runViewOf(deps, scope, run)
}
/**
* Opens a run's event stream: the run must be one this scope can read (another organization's or
* project's run is not found, exactly as `agent.run.status` answers), and the resume point must
* belong to it. Answers the offset to replay after and a reader of the durable record. The reader
* reads the run row before its step log: both only move forward, so a step log newer than its run
* row projects a prefix of the next projection, never a terminal event ahead of its steps.
*/
export async function openRunEvents(
deps: AgentDeps,
scope: AgentScope,
input: { runId: string; after?: string | undefined },
): Promise<{
after: number
load: () => Promise<{ run: AgentRunRecord; steps: readonly AgentStepRecord[]; answer: string | null } | null>
}> {
const store = dataRunStore(deps.data, scope)
const persistence = dataAgentPersistence(deps.data, scope)
if (!(await store.get(input.runId))) throw notFound("no such run")
let after: number
try {
after = resumeAfter(input.runId, input.after)
} catch {
throw validationFailed([{ path: "after", message: "the offset does not belong to this run" }], "invalid offset")
}
return {
after,
load: async () => {
const run = await store.get(input.runId)
return run ? { run, steps: await store.listSteps(input.runId), answer: await answerOf(persistence, run) } : null
},
}
}
export async function getRun(deps: AgentDeps, scope: AgentScope, runId: string): Promise<RunView> {
return runViewOf(deps, scope, { id: runId })
}
export async function listRuns(
deps: AgentDeps,
scope: AgentScope,
input: { cursor?: string | undefined; limit?: number | undefined },
): Promise<{ runs: RunView[]; cursor?: string; hasMore: boolean }> {
const store = dataRunStore(deps.data, scope, { pageSize: PAGE })
const page = await store.list({
...(input.cursor === undefined ? {} : { cursor: input.cursor }),
...(input.limit === undefined ? {} : { limit: input.limit }),
})
return {
runs: await Promise.all(page.runs.map((run) => runViewOf(deps, scope, run))),
...("cursor" in page ? { cursor: page.cursor } : {}),
hasMore: page.hasMore,
}
}
/** The most ids one batch-get reads (contract C-05). */
export const MAX_BATCH = 100
/**
* The live collection's read-back: the runs with these ids, as the list shows them. A run this caller
* cannot see is not in the answer: the repository's tenant wall leaves it out.
*/
export async function getManyRuns(deps: AgentDeps, scope: AgentScope, runIds: readonly string[]): Promise<RunView[]> {
const page = await deps.data
.forScope(scope)
.of(agentRuns)
.list({ where: [{ field: "id", op: "in", values: [...new Set(runIds)] }], limit: MAX_BATCH })
return Promise.all(page.items.map((run) => runViewOf(deps, scope, run)))
}
/** Cancels a run: the persisted request is honored between steps and inside the in-flight call. */
export async function cancelRun(
deps: AgentDeps,
scope: AgentScope,
runId: string,
): Promise<{ runId: string; cancelled: boolean }> {
const store = dataRunStore(deps.data, scope)
const record = await store.get(runId)
if (!record) throw notFound("no such run")
if (record.cancelRequestedAt !== null) return { runId, cancelled: true }
await store.requestCancel(runId, new Date().toISOString())
// A parked run only notices the request when it is resumed; enqueue the resume so the cancel
// lands even while the run waits in awaiting_approval.
await deps.data.unitOfWork.run(scope, async (tx) => {
await tx.publish(runResumeRequested, {
orgId: scope.orgId,
workspaceId: scope.workspaceId,
projectId: scope.projectId,
runId,
})
})
return { runId, cancelled: true }
}
/**
* The approval queue: every step that parked an approval, with the EXACT stored proposed
* arguments. Nothing here is a model's narration; the arguments are what the tool was called
* with, copied before they were proposed.
*/
export async function listApprovals(deps: AgentDeps, scope: AgentScope): Promise<{ approvals: ApprovalView[] }> {
const approvals: ApprovalView[] = []
let cursor: string | undefined
for (;;) {
const page = await deps.data
.forScope(scope)
.of(agentRuns)
.list({
order: [{ field: "createdAt", direction: "desc" }],
limit: PAGE,
...(cursor === undefined ? {} : { cursor }),
})
for (const run of page.items) {
if (isTerminalRunStatus(run.status)) continue
const steps = await dataRunStore(deps.data, scope).listSteps(run.id)
for (const step of steps) {
// Only what still waits for a person: a decided approval or a step that moved on is history.
if (step.approval === null || step.approval.decision !== null || step.status !== "pending") continue
approvals.push({
runId: run.id,
stepKey: step.key,
goal: run.goal,
tool: step.approval.tool,
arguments: step.approval.arguments,
reviewUrl: step.approval.reviewUrl,
createdAt: run.createdAt,
})
}
}
if (!page.hasMore) break
cursor = page.cursor
}
return { approvals }
}
/**
* Records a person's decision on the step and resumes the run. An approved `needsApproval` step
* resumes its TanStack AI interrupt and runs once; an approved platform proposal re-enters the
* parked step, whose tools adapter re-calls with the step's idempotency key so the platform executes
* the approved, persisted proposal exactly once. Rejection cancels the run: nothing is executed. A
* step with nothing parked, or one already decided, is refused.
*/
export async function reviewApproval(
deps: AgentDeps,
scope: AgentScope,
input: { runId: string; stepKey: string; decision: "approve" | "reject" },
): Promise<{ runId: string; status: RunView["status"] }> {
const store = dataRunStore(deps.data, scope)
const record = await store.get(input.runId)
if (!record) throw notFound("no such run")
await store.decideApproval(input.runId, input.stepKey, input.decision === "approve" ? "approved" : "rejected")
if (input.decision === "reject") {
await store.requestCancel(input.runId, new Date().toISOString())
}
await deps.data.unitOfWork.run(scope, async (tx) => {
await tx.publish(runResumeRequested, {
orgId: scope.orgId,
workspaceId: scope.workspaceId,
projectId: scope.projectId,
runId: input.runId,
})
})
return { runId: input.runId, status: (await store.get(input.runId))?.status ?? record.status }
}
export { AGENT_ENTITIES, RUN_JOB }
+78
View File
@@ -0,0 +1,78 @@
// English messages of the agent module. Every string a page of this module shows is a key here
// and in lt.ts; `__tests__/i18n.test.ts` fails when the two differ.
export const en = {
title: "Agent runs",
intro: "Bounded runs of published tools. A run proposes; a person approves. AI output is disclosed where it appears.",
runs: {
heading: "Runs",
empty: "No runs yet.",
goal: "Goal",
status: "Status",
statusQueued: "Queued",
statusRunning: "Running",
statusAwaitingApproval: "Awaiting approval",
statusCompleted: "Completed",
statusFailed: "Failed",
statusCancelled: "Cancelled",
statusUncertain: "Uncertain — needs reconciliation",
progress: "Progress",
percent: "{percent}%",
startedBy: "Started by",
startedAt: "Started",
finishedAt: "Finished",
usageLabel: "Usage",
usage: "{steps} steps, {cost} microcents",
failure: "Failure",
cancel: "Cancel run",
cancelling: "Cancelling",
needsApproval: "approved by a person before it runs",
answer: "Answer",
answerDisclosure:
"AI-generated: a model wrote this answer from the run's tool results. Check it before you rely on it.",
streamLost: "Live updates stopped. Reload the page to see the run's current state.",
back: "All runs",
plan: "Plan",
step: "Step",
tool: "Tool",
stepPending: "Pending",
stepCommitted: "Done",
stepFailed: "Failed",
stepCancelled: "Cancelled",
stepUncertain: "Uncertain",
},
start: {
heading: "Start a run",
goalLabel: "Goal",
goalHint: "What this run should achieve, in a sentence a reviewer reads.",
planLabel: "Plan",
planHint: "One tool call per step, as JSON: {example}",
submit: "Start run",
starting: "Starting",
failed: "That did not work: {detail}",
},
approvals: {
heading: "Approvals waiting for you",
empty: "Nothing is waiting for a decision.",
proposed: "Proposed arguments",
review: "Review in console",
approve: "Approve",
reject: "Reject",
decided: "Decision recorded; the run resumes.",
disclosure:
"Proposed by an agent run: these are the exact arguments the call will be made with. Approve only what you would do yourself.",
},
recovery: {
failedHint: "A failed run can be started again; the plan is visible above.",
cancelledHint: "This run was cancelled. Start a new run to do the work again.",
uncertainHint:
"This run timed out after an external effect. Check the affected service before deciding: reconcile by cancelling, or let the platform's reconciliation settle it.",
reconcileCancel: "Mark reconciled (cancel)",
budgetExhausted: "The run reached its budget.",
approvalUnavailable:
"The approval could not be completed here. Open the console to review the persisted proposal, then approve again.",
toolError: "A tool refused the call.",
planRefused: "The plan was refused at admission.",
internal: "The run failed internally.",
},
} as const
+2
View File
@@ -0,0 +1,2 @@
export { en } from "./en.js"
export { lt } from "./lt.js"
+84
View File
@@ -0,0 +1,84 @@
// Lietuviški agent modulio pranešimai. Kiekvienas puslapio tekstas yra raktas čia ir en.ts faile;
// `__tests__/i18n.test.ts` žlunga, kai jie skiriasi.
import type { en } from "./en.js"
type Messages<T> = { [K in keyof T]: T[K] extends string ? string : Messages<T[K]> }
export const lt: Messages<typeof en> = {
title: "Agentų vykdymai",
intro:
"Riboti paskelbtų įrankių vykdymai. Agentas pasiūlo; žmogus patvirtina. DI rezultatai atskleidžiami ten, kur jie rodomi.",
runs: {
heading: "Vykdymai",
empty: "Vykdymų dar nėra.",
goal: "Tikslas",
status: "Būsena",
statusQueued: "Laukia",
statusRunning: "Vyksta",
statusAwaitingApproval: "Laukia patvirtinimo",
statusCompleted: "Baigta",
statusFailed: "Nepavyko",
statusCancelled: "Atšaukta",
statusUncertain: "Neaišku — reikia suderinti",
progress: "Pažanga",
percent: "{percent} %",
startedBy: "Pradėjo",
startedAt: "Pradžia",
finishedAt: "Pabaiga",
usageLabel: "Sąnaudos",
usage: "{steps} žingsniai, {cost} mikrocentų",
failure: "Nepavyko",
cancel: "Atšaukti vykdymą",
cancelling: "Atšaukiama",
needsApproval: "prieš vykdant tvirtina žmogus",
answer: "Atsakymas",
answerDisclosure:
"Sukurta DI: šį atsakymą iš vykdymo įrankių rezultatų parašė modelis. Prieš juo remdamiesi, patikrinkite.",
streamLost:
"Tiesioginiai atnaujinimai sustojo. Įkelkite puslapį iš naujo, kad matytumėte dabartinę vykdymo būseną.",
back: "Visi vykdymai",
plan: "Planas",
step: "Žingsnis",
tool: "Įrankis",
stepPending: "Laukia",
stepCommitted: "Atlikta",
stepFailed: "Nepavyko",
stepCancelled: "Atšaukta",
stepUncertain: "Neaišku",
},
start: {
heading: "Pradėti vykdymą",
goalLabel: "Tikslas",
goalHint: "Ką šis vykdymas turi pasiekti — sakiniu, kurį perskaitys tikrintojas.",
planLabel: "Planas",
planHint: "Vienas įrankio iškvietimas žingsniui, kaip JSON: {example}",
submit: "Pradėti vykdymą",
starting: "Pradedama",
failed: "Nepavyko: {detail}",
},
approvals: {
heading: "Patvirtinimai, laukiantys jūsų",
empty: "Nieko nelaukia sprendimo.",
proposed: "Pasiūlyti argumentai",
review: "Peržiūrėti konsolėje",
approve: "Patvirtinti",
reject: "Atmesti",
decided: "Sprendimas įrašytas; vykdymas tęsiamas.",
disclosure:
"Pasiūlė agento vykdymas: tai tikslūs argumentai, su kuriais bus atliktas iškvietimas. Patvirtinkite tik tai, ką darytumėte patys.",
},
recovery: {
failedHint: "Nepavykusį vykdymą galima pradėti iš naujo; planas matomas aukščiau.",
cancelledHint: "Šis vykdymas atšauktas. Norėdami pakartoti darbą, pradėkite naują vykdymą.",
uncertainHint:
"Vykdymas baigėsi neaiškiai po išorinio poveikio. Prieš spręsdami patikrinkite paveiktą paslaugą: suderinkite atšaukdami arba leiskite platformos suderinimui užbaigti.",
reconcileCancel: "Pažymėti suderinta (atšaukti)",
budgetExhausted: "Vykdymas pasiekė biudžetą.",
approvalUnavailable:
"Patvirtinimo čia užbaigti nepavyko. Atidarykite konsolę ir peržiūrėkite įrašytą pasiūlymą, tada vėl patvirtinkite.",
toolError: "Įrankis atmetė iškvietimą.",
planRefused: "Planas atmestas priėmimo metu.",
internal: "Vykdymas nutrūko vidinės klaidos.",
},
}
+20
View File
@@ -0,0 +1,20 @@
// The agent module's public surface: the manifest and what the composition roots bind.
export { AGENT_MODEL, AGENT_TOOLS } from "@conusai-kit/agent"
export { ASSISTANT_SCOPE } from "@conusai-kit/agent/turn"
export { DATA_SOURCE as AGENT_DATA } from "@conusai-kit/data"
export {
AGENT_JOBS,
AGENT_PROCEDURE_IMPLS,
AGENT_SUBSCRIPTIONS,
gatewayAgentModel,
registryAgentTools,
} from "./adapters/procedures.js"
export * from "./contract/index.js"
export type { AgentActionScope, AgentDeps, AgentScope } from "./core/ports.js"
export { AGENT_SCOPE, actionScopeOf } from "./core/ports.js"
export { cancelRun, getRun, listApprovals, listRuns, openRunEvents, reviewApproval, startRun } from "./core/runs.js"
export { convexAgentModule } from "./module.convex.js"
export { agentModule, assistantAppModule } from "./module.js"
export { AGENT_MODULE_BASE } from "./module.shared.js"
export { agentSchedules } from "./schedules.js"
+66
View File
@@ -0,0 +1,66 @@
-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
create table "agent_run" (
"id" uuid primary key,
"version" integer not null,
"created_at" timestamptz not null,
"updated_at" timestamptz not null,
"org_id" uuid not null,
"workspace_id" uuid not null,
"project_id" uuid not null,
"goal" text not null,
"plan" jsonb not null,
"status" text not null,
"failure_code" text,
"failure_detail" text,
"actor_id" text not null,
"budget_cost_microcents" integer not null,
"budget_steps" integer not null,
"budget_seconds" integer not null,
"usage_cost_microcents" integer not null,
"usage_steps" integer not null,
"cancel_requested_at" text,
"started_at" text,
"finished_at" text
);
create index "agent_run_org_id_workspace_id_project_id_status_idx" on "agent_run" ("org_id", "workspace_id", "project_id", "status");
create table "agent_step" (
"id" uuid primary key,
"version" integer not null,
"created_at" timestamptz not null,
"updated_at" timestamptz not null,
"org_id" uuid not null,
"workspace_id" uuid not null,
"project_id" uuid not null,
"run_id" uuid not null,
"key" text not null,
"index" integer not null,
"label" text not null,
"status" text not null,
"attempts" integer not null,
"usage_cost_microcents" integer not null,
"usage_tokens" integer not null,
"error" text,
"external_effect" boolean not null,
"artifacts" jsonb not null,
"approval" jsonb,
"started_at" text,
"finished_at" text
);
alter table "agent_step" add constraint "agent_step_org_id_workspace_id_project_id_run_id_key_key" unique nulls not distinct ("org_id", "workspace_id", "project_id", "run_id", "key");
create index "agent_step_org_id_workspace_id_project_id_run_id_idx" on "agent_step" ("org_id", "workspace_id", "project_id", "run_id");
alter table "agent_run" enable row level security;
alter table "agent_run" force row level security;
create policy "agent_run_tenant" on "agent_run" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
alter table "agent_step" enable row level security;
alter table "agent_step" force row level security;
create policy "agent_step_tenant" on "agent_step" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
@@ -0,0 +1,22 @@
-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
alter table "agent_step" add column "result" jsonb;
create table "agent_thread" (
"id" uuid primary key,
"version" integer not null,
"created_at" timestamptz not null,
"updated_at" timestamptz not null,
"org_id" uuid not null,
"workspace_id" uuid not null,
"project_id" uuid not null,
"thread_id" text not null,
"messages" jsonb not null
);
alter table "agent_thread" add constraint "agent_thread_org_id_workspace_id_project_id_thread_id_key" unique nulls not distinct ("org_id", "workspace_id", "project_id", "thread_id");
alter table "agent_thread" enable row level security;
alter table "agent_thread" force row level security;
create policy "agent_thread_tenant" on "agent_thread" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
@@ -0,0 +1,2 @@
-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
alter table "agent_run" add column "answer_provenance" jsonb;
+105
View File
@@ -0,0 +1,105 @@
-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
create table "assistant_conversation" (
"id" uuid primary key,
"version" integer not null,
"created_at" timestamptz not null,
"updated_at" timestamptz not null,
"org_id" uuid not null,
"workspace_id" uuid not null,
"project_id" uuid not null,
"title" text not null,
"created_by" text not null,
"last_turn_at" text not null
);
create index "assistant_conversation_org_id_workspace_id_project_id_created_by_idx" on "assistant_conversation" ("org_id", "workspace_id", "project_id", "created_by");
create index "assistant_conversation_org_id_workspace_id_project_id_last_turn_at_idx" on "assistant_conversation" ("org_id", "workspace_id", "project_id", "last_turn_at");
create table "assistant_receipt" (
"id" uuid primary key,
"version" integer not null,
"created_at" timestamptz not null,
"updated_at" timestamptz not null,
"org_id" uuid not null,
"workspace_id" uuid not null,
"project_id" uuid not null,
"conversation_id" uuid not null,
"turn_id" text not null,
"tool_call_id" text not null,
"tool" text not null,
"kind" text not null,
"outcome" text not null,
"proposal_id" text,
"arguments_hash" text not null,
"created_by" text not null
);
alter table "assistant_receipt" add constraint "assistant_receipt_org_id_workspace_id_project_id_turn_id_tool_call_id_key" unique nulls not distinct ("org_id", "workspace_id", "project_id", "turn_id", "tool_call_id");
create index "assistant_receipt_org_id_workspace_id_project_id_created_by_idx" on "assistant_receipt" ("org_id", "workspace_id", "project_id", "created_by");
create index "assistant_receipt_org_id_workspace_id_project_id_conversation_id_idx" on "assistant_receipt" ("org_id", "workspace_id", "project_id", "conversation_id");
create table "assistant_thread" (
"id" uuid primary key,
"version" integer not null,
"created_at" timestamptz not null,
"updated_at" timestamptz not null,
"org_id" uuid not null,
"workspace_id" uuid not null,
"project_id" uuid not null,
"thread_id" text not null,
"messages" jsonb not null
);
alter table "assistant_thread" add constraint "assistant_thread_org_id_workspace_id_project_id_thread_id_key" unique nulls not distinct ("org_id", "workspace_id", "project_id", "thread_id");
create table "assistant_turn" (
"id" uuid primary key,
"version" integer not null,
"created_at" timestamptz not null,
"updated_at" timestamptz not null,
"org_id" uuid not null,
"workspace_id" uuid not null,
"project_id" uuid not null,
"turn_id" text not null,
"conversation_id" uuid not null,
"created_by" text not null,
"input_hash" text not null,
"status" text not null,
"steps" integer not null,
"tool_calls" integer not null,
"degraded" boolean not null,
"answer_provenance" jsonb,
"started_at" text not null,
"finished_at" text
);
alter table "assistant_turn" add constraint "assistant_turn_org_id_workspace_id_project_id_turn_id_key" unique nulls not distinct ("org_id", "workspace_id", "project_id", "turn_id");
create index "assistant_turn_org_id_workspace_id_project_id_conversation_id_idx" on "assistant_turn" ("org_id", "workspace_id", "project_id", "conversation_id");
alter table "assistant_conversation" enable row level security;
alter table "assistant_conversation" force row level security;
create policy "assistant_conversation_tenant" on "assistant_conversation" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
alter table "assistant_receipt" enable row level security;
alter table "assistant_receipt" force row level security;
create policy "assistant_receipt_tenant" on "assistant_receipt" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
alter table "assistant_thread" enable row level security;
alter table "assistant_thread" force row level security;
create policy "assistant_thread_tenant" on "assistant_thread" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
alter table "assistant_turn" enable row level security;
alter table "assistant_turn" force row level security;
create policy "assistant_turn_tenant" on "assistant_turn" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
@@ -0,0 +1,118 @@
-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
-- The Agent is the workspace's (2026-11-03): its conversations are a workspace's, with a project optional. Its tables
-- are made again from the kit's migrations/0001_assistant.sql, and what 0004 held goes with them: no conversation is
-- carried over, because there was none to keep (no user had the template's Agent yet).
drop table "assistant_receipt";
drop table "assistant_thread";
drop table "assistant_turn";
drop table "assistant_conversation";
create table "assistant_conversation" (
"id" uuid primary key,
"version" integer not null,
"created_at" timestamptz not null,
"updated_at" timestamptz not null,
"org_id" uuid not null,
"workspace_id" uuid not null,
"title" text not null,
"created_by" text not null,
"last_turn_at" text not null,
"project_id" uuid
);
create index "assistant_conversation_org_id_workspace_id_created_by_idx" on "assistant_conversation" ("org_id", "workspace_id", "created_by");
create index "assistant_conversation_org_id_workspace_id_last_turn_at_idx" on "assistant_conversation" ("org_id", "workspace_id", "last_turn_at");
create index "assistant_conversation_org_id_workspace_id_project_id_idx" on "assistant_conversation" ("org_id", "workspace_id", "project_id");
create table "assistant_receipt" (
"id" uuid primary key,
"version" integer not null,
"created_at" timestamptz not null,
"updated_at" timestamptz not null,
"org_id" uuid not null,
"workspace_id" uuid not null,
"conversation_id" uuid not null,
"turn_id" text not null,
"step" integer not null,
"tool_call_id" text not null,
"tool" text not null,
"kind" text not null,
"outcome" text not null,
"proposal_id" text,
"arguments_hash" text not null,
"created_by" text not null,
"project_id" uuid
);
alter table "assistant_receipt" add constraint "assistant_receipt_org_id_workspace_id_turn_id_step_tool_call_id_key" unique nulls not distinct ("org_id", "workspace_id", "turn_id", "step", "tool_call_id");
create index "assistant_receipt_org_id_workspace_id_created_by_idx" on "assistant_receipt" ("org_id", "workspace_id", "created_by");
create index "assistant_receipt_org_id_workspace_id_conversation_id_idx" on "assistant_receipt" ("org_id", "workspace_id", "conversation_id");
create table "assistant_thread" (
"id" uuid primary key,
"version" integer not null,
"created_at" timestamptz not null,
"updated_at" timestamptz not null,
"org_id" uuid not null,
"workspace_id" uuid not null,
"thread_id" text not null,
"messages" jsonb not null
);
alter table "assistant_thread" add constraint "assistant_thread_org_id_workspace_id_thread_id_key" unique nulls not distinct ("org_id", "workspace_id", "thread_id");
create table "assistant_turn" (
"id" uuid primary key,
"version" integer not null,
"created_at" timestamptz not null,
"updated_at" timestamptz not null,
"org_id" uuid not null,
"workspace_id" uuid not null,
"turn_id" text not null,
"conversation_id" uuid not null,
"created_by" text not null,
"input_hash" text not null,
"status" text not null,
"steps" integer not null,
"tool_calls" integer not null,
"degraded" boolean not null,
"answer_provenance" jsonb,
"started_at" text not null,
"deadline_at" text not null,
"finished_at" text
);
alter table "assistant_turn" add constraint "assistant_turn_org_id_workspace_id_turn_id_key" unique nulls not distinct ("org_id", "workspace_id", "turn_id");
create index "assistant_turn_org_id_workspace_id_conversation_id_idx" on "assistant_turn" ("org_id", "workspace_id", "conversation_id");
alter table "assistant_conversation" enable row level security;
alter table "assistant_conversation" force row level security;
create policy "assistant_conversation_tenant" on "assistant_conversation" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
alter table "assistant_receipt" enable row level security;
alter table "assistant_receipt" force row level security;
create policy "assistant_receipt_tenant" on "assistant_receipt" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
alter table "assistant_thread" enable row level security;
alter table "assistant_thread" force row level security;
create policy "assistant_thread_tenant" on "assistant_thread" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
alter table "assistant_turn" enable row level security;
alter table "assistant_turn" force row level security;
create policy "assistant_turn_tenant" on "assistant_turn" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
+23
View File
@@ -0,0 +1,23 @@
// The Convex profile binding of the agent module, shipped fail-closed for a future wiring: the
// integration review kept the module Postgres-only because a run's tool calls leave the process
// (the registry client, the AI gateway) and the run job has no composed Convex Node action yet
// (see README.md). The job fails closed here so the work can only reach the platform SDKs through
// a composed Node action, never the Convex transaction dispatcher — the same arrangement as
// documents' OCR.
import { defineModule, internal, type JobDef } from "@conusai-kit/core"
import { z } from "zod"
import { RUN_JOB } from "./contract/index.js"
import { AGENT_MODULE_BASE } from "./module.shared.js"
const actionOnly: JobDef<{ runId: string }> = {
payload: z.object({ runId: z.string() }),
async handler() {
throw internal(`${RUN_JOB} must run through the Convex Node action boundary`)
},
}
export const convexAgentModule = defineModule({
...AGENT_MODULE_BASE,
jobs: { [RUN_JOB]: actionOnly },
})
+40
View File
@@ -0,0 +1,40 @@
// Shared, runtime-neutral agent manifest data. The normal app binds the worker implementation of
// agent.run; the Convex binding file (module.convex.ts) ships fail-closed for a future wiring —
// the module stays Postgres-only until the run job has its composed Node action (see README.md).
import { AGENT_ENTITIES } from "@conusai-kit/agent"
import type { ModuleManifest } from "@conusai-kit/core"
import { repositoryBindings } from "@conusai-kit/data"
import { AGENT_PROCEDURE_IMPLS } from "./adapters/procedures.js"
import { AGENT_COLLECTIONS, APPROVAL_REVIEWED, RUN_CANCELLED_ACTION } from "./contract/index.js"
import { en, lt } from "./i18n/index.js"
const tables = repositoryBindings(...AGENT_ENTITIES)
export const AGENT_MODULE_BASE: ModuleManifest = {
id: "agent",
version: "0.1.0",
kit: "^0.1.0",
requires: ["tenancy"],
permissions: {
// Watching runs and reading one is open to the project's readers.
"agent.run.read": "viewer",
// Starting, cancelling and reviewing are a person's acts on the project's behalf.
"agent.run.start": "editor",
"agent.run.cancel": "editor",
"agent.approval.review": "editor",
},
procedures: AGENT_PROCEDURE_IMPLS,
// The runs are live (TS6.1): a change of agent_run reaches every member's open page.
collections: [...AGENT_COLLECTIONS],
repositories: { postgres: tables },
migrations: { postgres: "migrations" },
// The module ships the Postgres profile; module.convex.ts is the fail-closed Convex binding,
// deliberately not registered until the run job has its composed Node action (see README.md).
profiles: ["postgres"],
audit: [RUN_CANCELLED_ACTION, APPROVAL_REVIEWED],
ui: {
nav: [{ scope: "project", label: "agent.title", href: "agent", permission: "agent.run.read" }],
},
i18n: { en, lt },
}
+28
View File
@@ -0,0 +1,28 @@
// The agent module's manifest (spec §4.1): the one place it registers anything. The module's own
// outbox subscriber enqueues the run job (singleton on the run id), so a request writes the run
// row and answers; the worker executes and resumes it.
import { agentRunRetentionDaysOf } from "@app/config"
import { assistantModule, assistantSchedules } from "@conusai-kit/agent/turn"
import { defineModule } from "@conusai-kit/core"
import { AGENT_JOBS, AGENT_SUBSCRIPTIONS } from "./adapters/procedures.js"
import { AGENT_EVENTS } from "./contract/index.js"
import { AGENT_MODULE_BASE } from "./module.shared.js"
import { agentSchedules } from "./schedules.js"
export const agentModule = defineModule({
...AGENT_MODULE_BASE,
jobs: AGENT_JOBS,
// `agent.runs.expire`, hourly for every project: deletes the finished runs past the retention the environment
// sets (AGENT_RUN_RETENTION_DAYS), with their step logs and conversations.
schedules: (env) => agentSchedules(agentRunRetentionDaysOf(env)),
events: { publishes: [...AGENT_EVENTS], subscribes: AGENT_SUBSCRIPTIONS },
})
/**
* The conversation behind the Agent in the frame's right panel (`@conusai-kit/agent/turn`). The kit's module
* declares no migrations: its tables are this module's `0005_assistant_workspace.sql`, the kit's file
* after the drop of what `0004_assistant.sql` made. Its sweep
* deletes a conversation nobody wrote to for 90 days, with its turns, logs and receipts.
*/
export const assistantAppModule = defineModule({ ...assistantModule, schedules: () => assistantSchedules() })
+26
View File
@@ -0,0 +1,26 @@
// When the agent module's run retention sweep runs (G3j): hourly, in each project the app serves, with the
// retention the environment sets (AGENT_RUN_RETENTION_DAYS, config/src/agent.ts). The composition root turns
// it on (apps/worker/src/schedules.ts), as it does the account and files sweeps. The schedule is the
// adapter-neutral form, so the module imports no adapter.
import { RUN_EXPIRE_CRON, RUN_EXPIRE_JOB } from "./contract/constants.js"
export function agentSchedules(retentionDays: number): readonly {
job: string
cron: string
scope: { level: "each-project" }
payload: { retentionDays: number }
key: string
missed: "skip"
}[] {
return Object.freeze([
Object.freeze({
job: RUN_EXPIRE_JOB,
cron: RUN_EXPIRE_CRON,
scope: Object.freeze({ level: "each-project" as const }),
payload: Object.freeze({ retentionDays }),
key: "agent.runs.expire",
missed: "skip" as const,
}),
])
}
+112
View File
@@ -0,0 +1,112 @@
"use client"
// The approval queue: what a run's tool asked a person to decide. The panel renders the EXACT
// stored proposed arguments — a pretty-printed JSON of what the tool was called with — never a
// model's narration of them, and it carries the AI disclosure a reviewer reads before deciding.
//
// The queue is live: the page's view hands over the approvals it rendered as the seed, and this reads them
// again whenever the live stream announces a change of a run (`agent.run`) -- a run that parks an approval
// or leaves one changes its status -- and after this person's own decision, so nothing reloads the page.
import { AiDisclosureBadge, type ProjectSlugs } from "@conusai-kit/ui"
import { describeError, useRead, useWrite } from "@conusai-kit/ui/data"
import { PageSection, PageState } from "@conusai-kit/ui/patterns"
import { Button, ItemActions } from "@conusai-kit/ui/primitives"
import { type ReactElement, useState } from "react"
import { type ApprovalView, RUN_COLLECTION } from "../contract/index.js"
export interface ApprovalQueueLabels {
readonly heading: string
readonly empty: string
readonly proposed: string
readonly review: string
readonly approve: string
readonly reject: string
readonly decided: string
readonly disclosure: string
readonly failed: string
}
export function ApprovalQueue({
slugs,
seed,
labels,
}: {
slugs: ProjectSlugs
/** The approvals the server rendered: shown at once, then kept current. */
seed: readonly ApprovalView[]
labels: ApprovalQueueLabels
}): ReactElement {
const queue = useRead<{ approvals: readonly ApprovalView[] }>(
"agent.approval.list",
{ ...slugs },
{
live: {
org: slugs.org,
topic: { ws: slugs.workspace, project: slugs.project },
entities: [RUN_COLLECTION.entity],
},
initial: { approvals: seed },
},
)
const approvals = queue.data?.approvals ?? seed
const review = useWrite<ProjectSlugs & { runId: string; stepKey: string; decision: "approve" | "reject" }, unknown>(
"agent.approval.review",
)
const [busy, setBusy] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const decide = async (approval: ApprovalView, decision: "approve" | "reject"): Promise<void> => {
setBusy(`${approval.runId}:${approval.stepKey}`)
setMessage(null)
try {
await review.mutate({ ...slugs, runId: approval.runId, stepKey: approval.stepKey, decision })
setMessage(labels.decided)
// The decision is a step's, which the stream does not announce until the run moves: read it again now.
await queue.refetch()
} catch (error) {
setMessage(labels.failed.replace("{detail}", describeError(error)))
} finally {
setBusy(null)
}
}
return (
<PageSection title={labels.heading}>
{approvals.length === 0 ? <PageState title={labels.empty} /> : null}
{approvals.map((approval) => (
<PageSection
key={`${approval.runId}:${approval.stepKey}`}
title={approval.tool}
description={approval.goal}
level={3}
variant="card"
>
<AiDisclosureBadge label={labels.disclosure} />
<h4>{labels.proposed}</h4>
<pre>{JSON.stringify(approval.arguments, null, 2)}</pre>
<ItemActions>
<Button disabled={busy !== null} onClick={() => void decide(approval, "approve")}>
{labels.approve}
</Button>
<Button variant="outline" disabled={busy !== null} onClick={() => void decide(approval, "reject")}>
{labels.reject}
</Button>
{approval.reviewUrl !== null ? (
<Button asChild variant="link">
<a href={approval.reviewUrl} target="_blank" rel="noreferrer">
{labels.review}
</a>
</Button>
) : null}
</ItemActions>
</PageSection>
))}
{message !== null ? (
<p role="status" aria-live="polite">
{message}
</p>
) : null}
</PageSection>
)
}
+5
View File
@@ -0,0 +1,5 @@
export { ApprovalQueue } from "./approval-queue.js"
export { RunList, type RunListLabels, useLiveRuns } from "./live-runs.js"
export { agentPage, runStatusKey } from "./pages.js"
export { RunControls } from "./run-controls.js"
export { StartForm } from "./start-form.js"
+77
View File
@@ -0,0 +1,77 @@
// What the run page shows, from the server's first render (`agent.run.status`) and the run's AG-UI
// events as `@conusai-kit/agent/client` folds them. Until the first event arrives the seed is the
// answer, so the hydrated page matches the server's; from then on the events are: the stream replays
// the run from its start, so the view holds every step that began.
import type { AgentRunView } from "@conusai-kit/agent/client"
import type { RunView } from "../contract/index.js"
const FAILURE_CODES = ["tool_error", "budget_exhausted", "plan_refused", "approval_unavailable", "internal"] as const
type FailureCode = (typeof FAILURE_CODES)[number]
const isFailureCode = (code: string): code is FailureCode => (FAILURE_CODES as readonly string[]).includes(code)
export interface LiveRunState {
readonly status: RunView["status"]
readonly failureCode: RunView["failureCode"]
readonly failureDetail: string | null
readonly steps: readonly {
readonly key: string
readonly status: RunView["plan"][number]["status"]
readonly error: string | null
}[]
readonly percent: number
/** The AI-generated answer of a completed run. */
readonly answer: string | null
/**
* The answer's provenance (AI Act Art. 50(2)), from the server's render: a run that completes
* while the page is open shows its answer marked without it until the page is loaded again.
*/
readonly answerProvenance: RunView["answerProvenance"]
/** The stream could not be read: the page still shows the last state it knew. */
readonly streamError: string | null
}
export function liveRunState(seed: RunView, view: AgentRunView): LiveRunState {
const streamError = view.error?.source === "transport" ? view.error.code : null
if (view.events === 0) {
return {
status: seed.status,
failureCode: seed.failureCode,
failureDetail: seed.failureDetail,
steps: seed.plan.map((step) => ({ key: step.key, status: step.status, error: step.error })),
percent: seed.percent,
answer: seed.answer,
answerProvenance: seed.answerProvenance,
streamError,
}
}
const steps = seed.plan.map((planned) => {
const step = view.steps.find((candidate) => candidate.key === planned.key)
if (step === undefined || !step.finished) return { key: planned.key, status: "pending" as const, error: null }
return { key: planned.key, status: step.outcome ?? ("committed" as const), error: step.error }
})
const committed = steps.filter((step) => step.status === "committed").length
const runError = view.error?.source === "run" ? view.error : null
return {
status: statusOf(seed, view),
failureCode: runError !== null && isFailureCode(runError.code) ? runError.code : null,
failureDetail: runError?.message ?? null,
steps,
percent: seed.plan.length === 0 ? 0 : committed / seed.plan.length,
answer: view.phase === "completed" && view.text !== "" ? view.text : null,
answerProvenance: view.phase === "completed" ? seed.answerProvenance : null,
streamError,
}
}
function statusOf(seed: RunView, view: AgentRunView): RunView["status"] {
switch (view.phase) {
case "connecting":
return seed.status
// The stream opens every run with RUN_STARTED; a run the worker has not claimed is still queued.
case "running":
return seed.status === "queued" && view.steps.length === 0 ? "queued" : "running"
default:
return view.phase
}
}
+113
View File
@@ -0,0 +1,113 @@
"use client"
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@conusai-kit/ui/primitives"
// One run, live (umbrella TS5.8): the page's loader reads the run from `agent.run.status` and its view
// hands it over as the seed, so the page arrives complete; this island then follows the run's AG-UI
// event stream (`agent.run.events`) through TanStack AI's resumable connection
// (`agentRunConnection`, `watchAgentRun` from `@conusai-kit/agent/client`) — step states, the
// status, a parked approval and the completed run's answer appear without a reload, and no request
// polls the run. A dropped stream reconnects with its last offset inside the connection.
import { agentRunConnection, initialAgentRunView, watchAgentRun } from "@conusai-kit/agent/client"
import { AiDisclosureBadge, type ProjectSlugs } from "@conusai-kit/ui"
import { type ReactElement, useEffect, useState } from "react"
import type { RunView } from "../contract/index.js"
import { liveRunState } from "./live-run-state.js"
import { RunControls, type RunControlsLabels } from "./run-controls.js"
export interface LiveRunLabels {
readonly status: string
readonly progress: string
readonly plan: string
readonly step: string
readonly tool: string
readonly needsApproval: string
readonly answer: string
readonly answerDisclosure: string
readonly streamLost: string
/** The message of each run status, by status. */
readonly statuses: Record<RunView["status"], string>
/** The message of each step status, by status. */
readonly stepStatuses: Record<RunView["plan"][number]["status"], string>
readonly controls: RunControlsLabels
}
export function LiveRun({
slugs,
seed,
labels,
}: {
slugs: ProjectSlugs
seed: RunView
labels: LiveRunLabels
}): ReactElement {
const [view, setView] = useState(() => initialAgentRunView(seed.runId))
useEffect(() => {
const query = new URLSearchParams({ org: slugs.org, workspace: slugs.workspace, project: slugs.project })
const connection = agentRunConnection({ url: `/api/v1/agent.run.events?${query.toString()}` })
const stop = new AbortController()
void watchAgentRun({ connection, runId: seed.runId, signal: stop.signal, onChange: setView })
return () => stop.abort()
}, [slugs.org, slugs.workspace, slugs.project, seed.runId])
const state = liveRunState(seed, view)
return (
<div data-live="agent.run">
<dl>
<dt>{labels.status}</dt>
<dd role="status">{labels.statuses[state.status]}</dd>
</dl>
<progress value={state.percent} max={1} aria-label={labels.progress}>
{Math.round(state.percent * 100)}%
</progress>
{state.streamError !== null ? <p role="alert">{labels.streamLost}</p> : null}
<RunControls
slugs={slugs}
run={{ runId: seed.runId, status: state.status, failureCode: state.failureCode }}
labels={labels.controls}
/>
<section aria-labelledby="plan-heading">
<h2 id="plan-heading">{labels.plan}</h2>
<Table>
<TableCaption>{labels.plan}</TableCaption>
<TableHeader>
<TableRow>
<TableHead scope="col">{labels.step}</TableHead>
<TableHead scope="col">{labels.tool}</TableHead>
<TableHead scope="col">{labels.status}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{seed.plan.map((step, index) => {
const live = state.steps[index]
return (
<TableRow key={step.key}>
<TableCell>
{step.label}
{step.needsApproval ? ` (${labels.needsApproval})` : ""}
</TableCell>
<TableCell>
<code>{step.tool}</code>
</TableCell>
<TableCell>
{labels.stepStatuses[live?.status ?? step.status]}
{live?.error ? `: ${live.error}` : ""}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</section>
{state.answer !== null ? (
<section aria-labelledby="answer-heading">
<h2 id="answer-heading">{labels.answer}</h2>
<AiDisclosureBadge label={labels.answerDisclosure} provenance={state.answerProvenance} />
<p>{state.answer}</p>
</section>
) : null}
</div>
)
}
+96
View File
@@ -0,0 +1,96 @@
"use client"
// The project's runs, live (umbrella TS6.4 and TS6.6): the page's view renders the first page and hands it
// over as the seed, so the page arrives complete; this keeps it current through the organization's live
// stream -- a run another member starts appears, and each run's status and progress move as its steps
// commit, without a reload. The rows come from `agent.run.getMany`, under the same permission as the list,
// so the stream itself never carries a run's goal or plan.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { liveCollection, useDataClient, useLive } from "@conusai-kit/ui/data"
import { PageSection, type ResourceColumn, ResourceList } from "@conusai-kit/ui/patterns"
import { type ReactElement, useState } from "react"
import { RUN_COLLECTION, type RunView } from "../contract/index.js"
export interface RunListLabels {
readonly heading: string
/** What to say when there is no run. */
readonly empty: string
readonly goal: string
readonly status: string
readonly progress: string
readonly startedBy: string
/** The message of each run status, by status. */
readonly statuses: Record<RunView["status"], string>
}
export function useLiveRuns(slugs: ProjectSlugs, seed: readonly RunView[]): readonly RunView[] {
const client = useDataClient()
// One collection for the life of the page; the seed is the first render's.
const [runs] = useState(() =>
liveCollection<RunView>({
client,
org: slugs.org,
topic: { ws: slugs.workspace, project: slugs.project },
entity: RUN_COLLECTION.entity,
key: RUN_COLLECTION.key,
seed,
list: {
procedure: RUN_COLLECTION.list,
input: { ...slugs },
rows: (out) => (out as { runs: RunView[] }).runs,
},
getMany: {
procedure: RUN_COLLECTION.getMany,
input: { ...slugs },
rows: (out) => (out as { items: RunView[] }).items,
},
}),
)
// Newest first, as the list procedure orders them.
const { data, isReady } = useLive(runs, (q, rows) =>
q.from({ run: rows }).orderBy(({ run }: { run: RunView }) => run.createdAt, "desc"),
)
// Until the collection is ready the server's rows are the answer, so the first paint matches it.
return isReady ? data : seed
}
export function RunList({
slugs,
seed,
labels,
}: {
slugs: ProjectSlugs
seed: readonly RunView[]
labels: RunListLabels
}): ReactElement {
const rows = useLiveRuns(slugs, seed)
const pageHref = `/${slugs.org}/${slugs.workspace}/${slugs.project}/agent`
const columns: ResourceColumn<RunView>[] = [
{ id: "goal", header: labels.goal, wrap: true },
{ id: "status", header: labels.status, cell: (run) => labels.statuses[run.status] },
{
id: "percent",
header: labels.progress,
cell: (run) => (
<progress value={run.percent} max={1}>
{Math.round(run.percent * 100)}%
</progress>
),
},
{ id: "actorId", header: labels.startedBy },
]
return (
<PageSection title={labels.heading}>
<ResourceList
label={labels.heading}
columns={columns}
data={rows}
rowId={(run) => run.runId}
rowHref={(run) => `${pageHref}/${run.runId}`}
rowLabel={(run) => run.goal}
empty={{ title: labels.empty }}
/>
</PageSection>
)
}
+187
View File
@@ -0,0 +1,187 @@
// The agent module's page, on the shell's F10 contract (docs/reference/app-kit.md): a loader the web shell
// runs on the server under `/<org>/<workspace>/<project>/agent`, and the view of what it returned.
//
// /agent the run list, the approval queue and the start form
// /agent/<runId> one run: the plan with per-step progress, the budget, the recovery controls and
// a completed run's AI-generated answer, live through the run's event stream
//
// Every read is a procedure through the same caller the API uses, and every label is translated in the
// loader. The run page's first render is `agent.run.status`, the seed of a client island that follows the
// run's AG-UI events (live-run.tsx); both come from the durable record, so a reload after a restart tells
// the truth. The progress bar is a real <progress> element, keyboard-operable controls only.
import { type Container, notFound, type TransportCaller } from "@conusai-kit/core"
import type { I18n } from "@conusai-kit/i18n"
import { type ProjectPage, type ProjectPageViewProps, projectPage } from "@conusai-kit/ui"
import { DetailLayout, FactList, Page, PageHeader } from "@conusai-kit/ui/patterns"
import type { ReactElement } from "react"
import { type ApprovalView, approvalList, type RunView, runList, runStatus } from "../contract/index.js"
import { ApprovalQueue, type ApprovalQueueLabels } from "./approval-queue.js"
import { LiveRun, type LiveRunLabels } from "./live-run.js"
import { RunList, type RunListLabels } from "./live-runs.js"
import { StartForm, type StartFormLabels } from "./start-form.js"
/** The run list, the approval queue and the start form. */
export interface AgentListData {
readonly kind: "list"
readonly title: string
readonly intro: string
readonly approvals: readonly ApprovalView[]
readonly approvalLabels: ApprovalQueueLabels
readonly start: StartFormLabels
readonly runLabels: RunListLabels
/** The first page of runs: the live list's seed. */
readonly runs: readonly RunView[]
}
/** One run: the seed of the live island, and the facts that do not change while it runs. */
export interface AgentRunData {
readonly kind: "run"
readonly back: string
readonly startedBy: string
readonly usageLabel: string
readonly usage: string
readonly run: RunView
readonly labels: LiveRunLabels
}
export type AgentPageData = AgentListData | AgentRunData
export type AgentPage = ProjectPage<TransportCaller, I18n, Container, AgentPageData>
export const runStatusKey: Record<RunView["status"], string> = {
queued: "runs.statusQueued",
running: "runs.statusRunning",
awaiting_approval: "runs.statusAwaitingApproval",
completed: "runs.statusCompleted",
failed: "runs.statusFailed",
cancelled: "runs.statusCancelled",
uncertain: "runs.statusUncertain",
}
const stepStatusKey: Record<RunView["plan"][number]["status"], string> = {
pending: "runs.stepPending",
committed: "runs.stepCommitted",
failed: "runs.stepFailed",
cancelled: "runs.stepCancelled",
uncertain: "runs.stepUncertain",
}
const mapValues = <K extends string>(keys: Record<K, string>, t: (key: string) => string): Record<K, string> =>
Object.fromEntries(Object.entries<string>(keys).map(([status, key]) => [status, t(key)])) as Record<K, string>
export const agentPage: AgentPage = projectPage({
async load({ slugs, segments, caller, i18n }): Promise<AgentPageData> {
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`agent.${key}`, values)
const failed = i18n.message("app.failed")
if (segments.length === 0) {
const [runs, approvals] = await Promise.all([caller.call(runList, slugs), caller.call(approvalList, slugs)])
return {
kind: "list",
title: t("title"),
intro: t("intro"),
approvals: approvals.approvals as ApprovalView[],
approvalLabels: {
heading: t("approvals.heading"),
empty: t("approvals.empty"),
proposed: t("approvals.proposed"),
review: t("approvals.review"),
approve: t("approvals.approve"),
reject: t("approvals.reject"),
decided: t("approvals.decided"),
disclosure: t("approvals.disclosure"),
failed,
},
start: {
heading: t("start.heading"),
goalLabel: t("start.goalLabel"),
goalHint: t("start.goalHint"),
planLabel: t("start.planLabel"),
planHint: t("start.planHint"),
submit: t("start.submit"),
starting: t("start.starting"),
failed,
},
runLabels: {
heading: t("runs.heading"),
empty: t("runs.empty"),
goal: t("runs.goal"),
status: t("runs.status"),
progress: t("runs.progress"),
startedBy: t("runs.startedBy"),
statuses: mapValues(runStatusKey, t),
},
runs: runs.runs,
}
}
// A path that is not one run id is a page that does not exist.
const [runId, ...rest] = segments
if (runId === undefined || rest.length > 0) throw notFound("no such page")
const run = await caller.call(runStatus, { ...slugs, runId })
return {
kind: "run",
back: t("runs.back"),
startedBy: t("runs.startedBy"),
usageLabel: t("runs.usageLabel"),
usage: t("runs.usage", { steps: run.usageSteps, cost: run.usageCostMicrocents }),
run,
labels: {
status: t("runs.status"),
progress: t("runs.progress"),
plan: t("runs.plan"),
step: t("runs.step"),
tool: t("runs.tool"),
needsApproval: t("runs.needsApproval"),
answer: t("runs.answer"),
answerDisclosure: t("runs.answerDisclosure"),
streamLost: t("runs.streamLost"),
statuses: mapValues(runStatusKey, t),
stepStatuses: mapValues(stepStatusKey, t),
controls: {
cancel: t("runs.cancel"),
cancelling: t("runs.cancelling"),
failedHint: t("recovery.failedHint"),
cancelledHint: t("recovery.cancelledHint"),
uncertainHint: t("recovery.uncertainHint"),
budgetExhausted: t("recovery.budgetExhausted"),
approvalUnavailable: t("recovery.approvalUnavailable"),
toolError: t("recovery.toolError"),
planRefused: t("recovery.planRefused"),
internal: t("recovery.internal"),
},
},
}
},
View({ slugs, data }: ProjectPageViewProps<AgentPageData>): ReactElement {
if (data.kind === "list") {
return (
<Page>
<PageHeader title={data.title} description={data.intro} />
<ApprovalQueue slugs={slugs} seed={data.approvals} labels={data.approvalLabels} />
<DetailLayout aside={<StartForm slugs={slugs} labels={data.start} />}>
<RunList slugs={slugs} seed={data.runs} labels={data.runLabels} />
</DetailLayout>
</Page>
)
}
const { run } = data
return (
<Page>
<PageHeader
title={run.goal}
breadcrumb={<a href={`/${slugs.org}/${slugs.workspace}/${slugs.project}/agent`}>{data.back}</a>}
/>
<FactList
facts={[
{ label: data.startedBy, value: run.actorId },
{ label: data.usageLabel, value: data.usage },
]}
/>
<LiveRun slugs={slugs} seed={run} labels={data.labels} />
</Page>
)
},
})
+87
View File
@@ -0,0 +1,87 @@
"use client"
import { Button } from "@conusai-kit/ui/primitives"
// One run's controls: cancel while it runs, and recovery from its terminal states. The run page is
// live (live-run.tsx), so a cancel shows up there without a reload. The recovery
// copy tells the person what the state means and what they can do — failed runs can be started
// again from the same plan, cancelled runs are simply over, and an uncertain run (a timeout after
// an external effect) is reconciled by a person, not retried blindly.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { useWrite } from "@conusai-kit/ui/data"
import { type ReactElement, useState } from "react"
import type { RunView } from "../contract/index.js"
export interface RunControlsLabels {
readonly cancel: string
readonly cancelling: string
readonly failedHint: string
readonly cancelledHint: string
readonly uncertainHint: string
readonly budgetExhausted: string
readonly approvalUnavailable: string
readonly toolError: string
readonly planRefused: string
readonly internal: string
}
export function RunControls({
slugs,
run,
labels,
}: {
slugs: ProjectSlugs
run: Pick<RunView, "runId" | "status" | "failureCode">
labels: RunControlsLabels
}): ReactElement {
const cancelRun = useWrite<ProjectSlugs & { runId: string }, unknown>("agent.run.cancel")
const [problem, setProblem] = useState(false)
const [busy, setBusy] = useState(false)
const cancel = async (): Promise<void> => {
setBusy(true)
setProblem(false)
try {
await cancelRun.mutate({ ...slugs, runId: run.runId })
} catch {
setProblem(true)
} finally {
setBusy(false)
}
}
const active = run.status === "queued" || run.status === "running" || run.status === "awaiting_approval"
const failureText = (code: RunView["failureCode"]): string => {
switch (code) {
case "budget_exhausted":
return labels.budgetExhausted
case "approval_unavailable":
return labels.approvalUnavailable
case "tool_error":
return labels.toolError
case "plan_refused":
return labels.planRefused
default:
return labels.internal
}
}
return (
<div>
{problem ? <p role="alert">{labels.internal}</p> : null}
{active ? (
<Button type="button" variant="outline" disabled={busy} onClick={() => void cancel()}>
{busy ? labels.cancelling : labels.cancel}
</Button>
) : null}
{run.status === "failed" ? (
<p role="alert">
{failureText(run.failureCode)} {labels.failedHint}
</p>
) : null}
{run.status === "cancelled" ? <p role="status">{labels.cancelledHint}</p> : null}
{run.status === "uncertain" ? <p role="alert">{labels.uncertainHint}</p> : null}
</div>
)
}
+65
View File
@@ -0,0 +1,65 @@
"use client"
// Starting a run: the goal and the plan as JSON. The plan is what the run will do, shown to the
// person before they start it, and admitted by the server against the hard ceilings. The start resolves
// once the live stream has seen the new run, so the page's live run list already shows it.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { useWrite } from "@conusai-kit/ui/data"
import { FormLayout, PageSection, TextField } from "@conusai-kit/ui/patterns"
import type { ReactElement } from "react"
export interface StartFormLabels {
readonly heading: string
readonly goalLabel: string
readonly goalHint: string
readonly planLabel: string
readonly planHint: string
readonly submit: string
readonly starting: string
readonly failed: string
}
const EXAMPLE = JSON.stringify([
{ key: "read", label: "Read the document", tool: "documents.read", args: { fileId: "…" } },
])
const parses = (text: string): boolean => {
try {
JSON.parse(text)
return true
} catch {
return false
}
}
export function StartForm({ slugs, labels }: { slugs: ProjectSlugs; labels: StartFormLabels }): ReactElement {
const startRun = useWrite<ProjectSlugs & { goal: string; plan: unknown }, unknown>("agent.run.start", {
org: slugs.org,
})
return (
<PageSection title={labels.heading} variant="card">
<FormLayout
submitLabel={labels.submit}
busyLabel={labels.starting}
validate={({ plan = "" }) =>
parses(plan) ? undefined : { plan: labels.failed.replace("{detail}", "invalid JSON") }
}
onSubmit={async ({ goal = "", plan = "" }) => {
await startRun.mutate({ ...slugs, goal, plan: JSON.parse(plan) })
}}
>
<TextField name="goal" label={labels.goalLabel} description={labels.goalHint} required maxLength={2_000} />
<TextField
multiline
name="plan"
label={labels.planLabel}
description={labels.planHint.replace("{example}", EXAMPLE)}
required
rows={6}
defaultValue={EXAMPLE}
/>
</FormLayout>
</PageSection>
)
}