This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# 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'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`); the platform owns dispatch policy, spend admission and the run ledger.
|
||||
|
||||
## 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`).
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
// 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 inProjectOf = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug, 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.cancel": { input: (t) => ({ ...inProjectOf(t), runId: runOf(t) }) },
|
||||
"agent.run.list": { input: (t) => inProjectOf(t) },
|
||||
"agent.approval.list": { input: (t) => inProjectOf(t) },
|
||||
"agent.approval.review": {
|
||||
input: (t) => ({ ...inProjectOf(t), runId: runOf(t), stepKey: "read", decision: "approve" }),
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// The module's migration is its own file and exactly `schemaStatements()` over the agent runtime's
|
||||
// two entities (decision AT-D20).
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { AGENT_ENTITIES } 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`
|
||||
}
|
||||
|
||||
describe("the migration of the agent module", () => {
|
||||
it("is its own file, exactly its two tables", async () => {
|
||||
await expect(
|
||||
`-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0\n${tablesOf(AGENT_ENTITIES)}`,
|
||||
).toMatchFileSnapshot("../migrations/0001_agent.sql")
|
||||
})
|
||||
|
||||
it("is declared by the manifest, and every table is row-level secured", () => {
|
||||
expect(agentModule.migrations).toEqual({ postgres: "migrations" })
|
||||
const sql = readFileSync(new URL("../migrations/0001_agent.sql", import.meta.url), "utf8")
|
||||
for (const table of ["agent_run", "agent_step"]) {
|
||||
expect(sql).toContain(`create table "${table}"`)
|
||||
expect(sql).toContain(`alter table "${table}" force row level security`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
// 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 } 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, 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" },
|
||||
status: "pending",
|
||||
attempts: 0,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
key: "propose",
|
||||
label: "Propose lines",
|
||||
tool: "documents.extract",
|
||||
args: { fileId: "f-1", schema: "line" },
|
||||
status: "pending",
|
||||
attempts: 0,
|
||||
error: null,
|
||||
},
|
||||
])
|
||||
expect(run.percent).toBe(0)
|
||||
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("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.beginStep(run.runId, { key: "read", index: 0, label: "Read the document" }, new Date().toISOString())
|
||||
await store.parkStepApproval(
|
||||
run.runId,
|
||||
"read",
|
||||
{
|
||||
actionId: "act-7",
|
||||
tool: "documents.read",
|
||||
arguments: { fileId: "f-1", dryRun: false },
|
||||
reviewUrl: "https://console/review?action=act-7",
|
||||
},
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
// 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 { TransportProvider } from "@conusai-kit/ui"
|
||||
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(
|
||||
<TransportProvider call={async () => undefined} describe={() => "x"}>
|
||||
{node}
|
||||
</TransportProvider>,
|
||||
)
|
||||
|
||||
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} approvals={[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(""total": 4200")
|
||||
expect(html).toContain(""currency": "EUR"")
|
||||
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} approvals={[]} labels={labels} />)
|
||||
expect(empty).toContain(t("approvals.empty"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("the run controls", () => {
|
||||
const labels = {
|
||||
cancel: t("runs.cancel"),
|
||||
cancelling: t("runs.cancelling"),
|
||||
refresh: t("runs.refresh"),
|
||||
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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll(String.fromCharCode(34), """)
|
||||
.replaceAll(String.fromCharCode(39), "'"),
|
||||
)
|
||||
expect(uncertain).not.toContain(`>${t("runs.cancel")}<`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
// 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_TOOLS, type AgentStep, dataRunStore, executeRun } from "@conusai-kit/agent"
|
||||
import type { Container } from "@conusai-kit/core"
|
||||
import { type EventContext, implement, internal, type JobDef, subscribe } from "@conusai-kit/core"
|
||||
import { DATA_SOURCE, JOB_QUEUE } from "@conusai-kit/data"
|
||||
import { createRegistryClient, type RegistryClient } from "@conusai-sdk/ai"
|
||||
import { z } from "zod"
|
||||
import {
|
||||
approvalList,
|
||||
approvalReview,
|
||||
RUN_JOB,
|
||||
runCancel,
|
||||
runList,
|
||||
runResumeRequested,
|
||||
runStart,
|
||||
runStatus,
|
||||
} from "../contract/index.js"
|
||||
import { AGENT_SCOPE, type AgentActionScope, type AgentDeps, type AgentScope } from "../core/ports.js"
|
||||
import { cancelRun, getRun, listApprovals, listRuns, 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),
|
||||
)
|
||||
|
||||
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 AGENT_PROCEDURE_IMPLS = [
|
||||
runStartImpl,
|
||||
runStatusImpl,
|
||||
runCancelImpl,
|
||||
runListImpl,
|
||||
approvalListImpl,
|
||||
approvalReviewImpl,
|
||||
]
|
||||
|
||||
// --- The tools gateway -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 })
|
||||
if (result.status === "completed") {
|
||||
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 data = ctx.container.resolve(DATA_SOURCE)
|
||||
const tools = ctx.container.resolve(AGENT_TOOLS)
|
||||
const store = dataRunStore(data, resolved)
|
||||
const record = await store.get(payload.runId)
|
||||
if (!record) throw internal("the run this job belongs to is gone")
|
||||
const plan: AgentStep[] = record.plan.map((planned) => ({
|
||||
key: planned.key,
|
||||
label: planned.label,
|
||||
invoke: async (stepCtx) => {
|
||||
const result = await tools.call(planned.tool, planned.args, {
|
||||
signal: stepCtx.signal,
|
||||
idempotencyKey: `${record.id}:${planned.key}`,
|
||||
})
|
||||
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 }],
|
||||
}
|
||||
}
|
||||
return { ok: false as const, awaitingApproval: result.approval }
|
||||
},
|
||||
}))
|
||||
await executeRun({ store }, payload.runId, plan, { ...(ctx.signal === undefined ? {} : { signal: ctx.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}` })
|
||||
}
|
||||
|
||||
export const AGENT_JOBS = { [RUN_JOB]: runJob }
|
||||
export const AGENT_SUBSCRIPTIONS = [subscribe(runResumeRequested, onRunResumeRequested)]
|
||||
@@ -0,0 +1,9 @@
|
||||
// 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"
|
||||
|
||||
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"
|
||||
@@ -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]
|
||||
@@ -0,0 +1,25 @@
|
||||
// The public API of the agent module: what a page, another module and the OpenAPI document may
|
||||
// import.
|
||||
|
||||
export {
|
||||
APPROVAL_REVIEWED,
|
||||
RUN_CANCELLED_ACTION,
|
||||
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,
|
||||
runList,
|
||||
runStart,
|
||||
runStatus,
|
||||
runViewSchema,
|
||||
} from "./procedures.js"
|
||||
@@ -0,0 +1,155 @@
|
||||
// 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: 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 of them.
|
||||
|
||||
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({
|
||||
key: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.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()),
|
||||
})
|
||||
.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()),
|
||||
/** 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(),
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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, runCancel, runList, approvalList, approvalReview]
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// 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, admitPlan, agentRuns, clampBudget, dataRunStore } from "@conusai-kit/agent"
|
||||
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,
|
||||
status: logged?.status ?? ("pending" as const),
|
||||
attempts: logged?.attempts ?? 0,
|
||||
error: logged?.error ?? null,
|
||||
}
|
||||
})
|
||||
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,
|
||||
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> }[]
|
||||
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,
|
||||
})),
|
||||
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)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
const steps = await dataRunStore(deps.data, scope).listSteps(run.id)
|
||||
for (const step of steps) {
|
||||
if (step.approval === null) 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 and resumes the run. Approval re-enters the parked step: the tools
|
||||
* adapter re-calls with the step's idempotency key, and the platform executes the approved,
|
||||
* persisted proposal exactly once. Rejection cancels the run: the proposal will not be executed.
|
||||
*/
|
||||
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")
|
||||
const steps = await store.listSteps(input.runId)
|
||||
const step = steps.find((candidate) => candidate.key === input.stepKey)
|
||||
if (!step || step.approval === null) {
|
||||
throw validationFailed([{ path: "stepKey", message: "this step has no parked approval" }], "nothing to review")
|
||||
}
|
||||
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 }
|
||||
@@ -0,0 +1,73 @@
|
||||
// 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",
|
||||
usage: "{steps} steps, {cost} microcents",
|
||||
failure: "Failure",
|
||||
cancel: "Cancel run",
|
||||
cancelling: "Cancelling",
|
||||
refresh: "Refresh",
|
||||
back: "All runs",
|
||||
plan: "Plan",
|
||||
step: "Step",
|
||||
tool: "Tool",
|
||||
stepPending: "Pending",
|
||||
stepCommitted: "Done",
|
||||
stepFailed: "Failed",
|
||||
stepCancelled: "Cancelled",
|
||||
stepUncertain: "Uncertain",
|
||||
attempts: "{count} attempt(s)",
|
||||
},
|
||||
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: "AI-proposed change: a model produced these arguments. Approve only what you would type 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
|
||||
@@ -0,0 +1,2 @@
|
||||
export { en } from "./en.js"
|
||||
export { lt } from "./lt.js"
|
||||
@@ -0,0 +1,79 @@
|
||||
// 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",
|
||||
usage: "{steps} žingsniai, {cost} mikrocentų",
|
||||
failure: "Nepavyko",
|
||||
cancel: "Atšaukti vykdymą",
|
||||
cancelling: "Atšaukiama",
|
||||
refresh: "Atnaujinti",
|
||||
back: "Visi vykdymai",
|
||||
plan: "Planas",
|
||||
step: "Žingsnis",
|
||||
tool: "Įrankis",
|
||||
stepPending: "Laukia",
|
||||
stepCommitted: "Atlikta",
|
||||
stepFailed: "Nepavyko",
|
||||
stepCancelled: "Atšaukta",
|
||||
stepUncertain: "Neaišku",
|
||||
attempts: "{count} bandymas(-ai)",
|
||||
},
|
||||
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:
|
||||
"DI pasiūlytas pakeitimas: šiuos argumentus sugeneravo modelis. Patvirtinkite tik tai, ką patys įvestumėte.",
|
||||
},
|
||||
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.",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// The agent module's public surface: the manifest and what the composition roots bind.
|
||||
|
||||
export {
|
||||
AGENT_JOBS,
|
||||
AGENT_PROCEDURE_IMPLS,
|
||||
AGENT_SUBSCRIPTIONS,
|
||||
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, reviewApproval, startRun } from "./core/runs.js"
|
||||
export { convexAgentModule } from "./module.convex.js"
|
||||
export { agentModule } from "./module.js"
|
||||
export { AGENT_MODULE_BASE } from "./module.shared.js"
|
||||
@@ -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,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 },
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
// 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 { 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,
|
||||
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 },
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// 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 { 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"
|
||||
|
||||
export const agentModule = defineModule({
|
||||
...AGENT_MODULE_BASE,
|
||||
jobs: AGENT_JOBS,
|
||||
events: { publishes: [...AGENT_EVENTS], subscribes: AGENT_SUBSCRIPTIONS },
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
"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.
|
||||
|
||||
import { AiDisclosureBadge, type ProjectSlugs, useTransport } from "@conusai-kit/ui"
|
||||
import { type ReactElement, useState } from "react"
|
||||
import type { ApprovalView } 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,
|
||||
approvals,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
approvals: readonly ApprovalView[]
|
||||
labels: ApprovalQueueLabels
|
||||
}): ReactElement {
|
||||
const { call, describe } = useTransport()
|
||||
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 call("agent.approval.review", { ...slugs, runId: approval.runId, stepKey: approval.stepKey, decision })
|
||||
setMessage(labels.decided)
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
setMessage(labels.failed.replace("{detail}", describe(error)))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="approvals-heading">
|
||||
<h2 id="approvals-heading">{labels.heading}</h2>
|
||||
{approvals.length === 0 ? <p>{labels.empty}</p> : null}
|
||||
{approvals.map((approval) => (
|
||||
<article key={`${approval.runId}:${approval.stepKey}`} aria-label={approval.tool}>
|
||||
<h3>{approval.tool}</h3>
|
||||
<p>{approval.goal}</p>
|
||||
<AiDisclosureBadge label={labels.disclosure} />
|
||||
<h4>{labels.proposed}</h4>
|
||||
<pre>{JSON.stringify(approval.arguments, null, 2)}</pre>
|
||||
<div>
|
||||
<button type="button" disabled={busy !== null} onClick={() => void decide(approval, "approve")}>
|
||||
{labels.approve}
|
||||
</button>
|
||||
<button type="button" disabled={busy !== null} onClick={() => void decide(approval, "reject")}>
|
||||
{labels.reject}
|
||||
</button>
|
||||
{approval.reviewUrl !== null ? (
|
||||
<a href={approval.reviewUrl} target="_blank" rel="noreferrer">
|
||||
{labels.review}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{message !== null ? (
|
||||
<p role="status" aria-live="polite">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { ApprovalQueue } from "./approval-queue.js"
|
||||
export { agentPage, runStatusKey } from "./pages.js"
|
||||
export { RunControls } from "./run-controls.js"
|
||||
export { StartForm } from "./start-form.js"
|
||||
@@ -0,0 +1,179 @@
|
||||
// The agent module's page, mounted by the web shell under `/<org>/<workspace>/<project>/agent`.
|
||||
//
|
||||
// /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
|
||||
//
|
||||
// Every read is a procedure through the same caller the API uses. Progress is rendered from the
|
||||
// durable step log, so a refresh after a restart tells the truth; the progress bar is a real
|
||||
// <progress> element, keyboard-operable controls only.
|
||||
|
||||
import type { Container, TransportCaller } from "@conusai-kit/core"
|
||||
import type { I18n } from "@conusai-kit/i18n"
|
||||
import type { ProjectPage } from "@conusai-kit/ui"
|
||||
import type { ReactElement } from "react"
|
||||
import { type ApprovalView, approvalList, type RunView, runList, runStatus } from "../contract/index.js"
|
||||
import { ApprovalQueue } from "./approval-queue.js"
|
||||
import { RunControls } from "./run-controls.js"
|
||||
import { StartForm } from "./start-form.js"
|
||||
|
||||
export type AgentPage = ProjectPage<TransportCaller, I18n, Container>
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
export const agentPage: AgentPage = async ({ slugs, segments, caller, i18n }): Promise<ReactElement> => {
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`agent.${key}`, values)
|
||||
const failed = i18n.message("app.failed")
|
||||
|
||||
const [runs, approvals] = await Promise.all([caller.call(runList, slugs), caller.call(approvalList, slugs)])
|
||||
|
||||
const startLabels = {
|
||||
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,
|
||||
}
|
||||
const 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,
|
||||
}
|
||||
const controlsLabels = {
|
||||
cancel: t("runs.cancel"),
|
||||
cancelling: t("runs.cancelling"),
|
||||
refresh: t("runs.refresh"),
|
||||
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"),
|
||||
}
|
||||
|
||||
if (segments.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<h1>{t("title")}</h1>
|
||||
<p>{t("intro")}</p>
|
||||
<ApprovalQueue slugs={slugs} approvals={approvals.approvals as ApprovalView[]} labels={approvalLabels} />
|
||||
<StartForm slugs={slugs} labels={startLabels} />
|
||||
<section aria-labelledby="runs-heading">
|
||||
<h2 id="runs-heading">{t("runs.heading")}</h2>
|
||||
{runs.runs.length === 0 ? <p>{t("runs.empty")}</p> : null}
|
||||
<table>
|
||||
<caption>{t("runs.heading")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t("runs.goal")}</th>
|
||||
<th scope="col">{t("runs.status")}</th>
|
||||
<th scope="col">{t("runs.progress")}</th>
|
||||
<th scope="col">{t("runs.startedBy")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.runs.map((run) => (
|
||||
<tr key={run.runId}>
|
||||
<td>
|
||||
<a href={`/${slugs.org}/${slugs.workspace}/${slugs.project}/agent/${run.runId}`}>{run.goal}</a>
|
||||
</td>
|
||||
<td>{t(runStatusKey[run.status])}</td>
|
||||
<td>
|
||||
<progress value={run.percent} max={1}>
|
||||
{Math.round(run.percent * 100)}%
|
||||
</progress>
|
||||
</td>
|
||||
<td>{run.actorId}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const [runId, ...rest] = segments
|
||||
if (runId === undefined || rest.length > 0) throw notFoundPage()
|
||||
const run = await caller.call(runStatus, { ...slugs, runId })
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<a href={`/${slugs.org}/${slugs.workspace}/${slugs.project}/agent`}>{t("runs.back")}</a>
|
||||
</p>
|
||||
<h1>{run.goal}</h1>
|
||||
<dl>
|
||||
<dt>{t("runs.status")}</dt>
|
||||
<dd>{t(runStatusKey[run.status])}</dd>
|
||||
<dt>{t("runs.startedBy")}</dt>
|
||||
<dd>{run.actorId}</dd>
|
||||
<dt>{t("runs.usage")}</dt>
|
||||
<dd>{t("runs.usage", { steps: run.usageSteps, cost: run.usageCostMicrocents })}</dd>
|
||||
</dl>
|
||||
<progress value={run.percent} max={1} aria-label={t("runs.progress")}>
|
||||
{Math.round(run.percent * 100)}%
|
||||
</progress>
|
||||
<RunControls slugs={slugs} run={run} labels={controlsLabels} />
|
||||
<section aria-labelledby="plan-heading">
|
||||
<h2 id="plan-heading">{t("runs.plan")}</h2>
|
||||
<table>
|
||||
<caption>{t("runs.plan")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t("runs.step")}</th>
|
||||
<th scope="col">{t("runs.tool")}</th>
|
||||
<th scope="col">{t("runs.status")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{run.plan.map((step) => (
|
||||
<tr key={step.key}>
|
||||
<td>{step.label}</td>
|
||||
<td>
|
||||
<code>{step.tool}</code>
|
||||
</td>
|
||||
<td>
|
||||
{t(stepStatusKey[step.status])}
|
||||
{step.attempts > 1 ? ` (${t("runs.attempts", { count: step.attempts })})` : ""}
|
||||
{step.error !== null ? `: ${step.error}` : ""}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const notFoundPage = () => {
|
||||
// A segment that is not a run id is a page that does not exist.
|
||||
return Object.assign(new Error("no such page"), { status: 404 })
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
// One run's controls: cancel while it runs, and recovery from its terminal states. 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, useTransport } from "@conusai-kit/ui"
|
||||
import { type ReactElement, useState } from "react"
|
||||
import type { RunView } from "../contract/index.js"
|
||||
|
||||
export interface RunControlsLabels {
|
||||
readonly cancel: string
|
||||
readonly cancelling: string
|
||||
readonly refresh: 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 { call } = useTransport()
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const cancel = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await call("agent.run.cancel", { ...slugs, runId: run.runId })
|
||||
window.location.reload()
|
||||
} 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>
|
||||
{active ? (
|
||||
<button type="button" 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}
|
||||
<a href="?">{labels.refresh}</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"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.
|
||||
|
||||
import { type ProjectSlugs, useTransport } from "@conusai-kit/ui"
|
||||
import { type FormEvent, type ReactElement, useId, useState } 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
|
||||
}
|
||||
|
||||
type State = { kind: "idle" } | { kind: "busy" } | { kind: "failed"; detail: string }
|
||||
|
||||
const EXAMPLE = JSON.stringify([
|
||||
{ key: "read", label: "Read the document", tool: "documents.read", args: { fileId: "…" } },
|
||||
])
|
||||
|
||||
export function StartForm({ slugs, labels }: { slugs: ProjectSlugs; labels: StartFormLabels }): ReactElement {
|
||||
const { call, describe } = useTransport()
|
||||
const goalId = useId()
|
||||
const planId = useId()
|
||||
const [state, setState] = useState<State>({ kind: "idle" })
|
||||
|
||||
const start = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
const form = new FormData(event.currentTarget)
|
||||
const goal = String(form.get("goal") ?? "").trim()
|
||||
let plan: unknown
|
||||
try {
|
||||
plan = JSON.parse(String(form.get("plan") ?? ""))
|
||||
} catch {
|
||||
setState({ kind: "failed", detail: "invalid JSON" })
|
||||
return
|
||||
}
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
await call("agent.run.start", { ...slugs, goal, plan })
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: describe(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby={`${goalId}-heading`}>
|
||||
<h2 id={`${goalId}-heading`}>{labels.heading}</h2>
|
||||
<form onSubmit={(event) => void start(event)}>
|
||||
<label htmlFor={goalId}>{labels.goalLabel}</label>
|
||||
<input id={goalId} name="goal" required maxLength={2_000} aria-describedby={`${goalId}-hint`} />
|
||||
<p id={`${goalId}-hint`}>{labels.goalHint}</p>
|
||||
<label htmlFor={planId}>{labels.planLabel}</label>
|
||||
<textarea
|
||||
id={planId}
|
||||
name="plan"
|
||||
required
|
||||
rows={6}
|
||||
defaultValue={EXAMPLE}
|
||||
aria-describedby={`${planId}-hint`}
|
||||
/>
|
||||
<p id={`${planId}-hint`}>{labels.planHint.replace("{example}", EXAMPLE)}</p>
|
||||
<button type="submit" disabled={state.kind === "busy"}>
|
||||
{state.kind === "busy" ? labels.starting : labels.submit}
|
||||
</button>
|
||||
</form>
|
||||
{state.kind === "failed" ? <p role="alert">{labels.failed.replace("{detail}", state.detail)}</p> : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user