This commit is contained in:
@@ -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)]
|
||||
Reference in New Issue
Block a user