import type { AwilixContainer } from 'awilix' import type { ZodTypeAny } from 'zod' import type { EntityManager } from '@mikro-orm/postgresql' import { AgentProposal } from '../../data/entities' import { ensureAgentsLoaded, listAgentEntries } from '../sdk/defineAgent' import { resolveAgentOutcomeZod } from '../sdk/agentOutcomeContract' import { processSubjectSchema, type ProcessSubject } from '../../data/validators' import type { AgentRuntimeService } from './agentRuntime' import type { AgentRunAs } from './persistence' import { resolveAgentPrincipal } from '../identity/agentPrincipalService' import { withProcessSubject } from '../processes/subjectContext' import type { AgentDispositionReview, DispositionService, DispositionOnResult, } from '../disposition/dispositionService' /** * DI bridge consumed by the workflows `INVOKE_AGENT` activity executor. It keeps * all `AgentProposal` access inside `agent_orchestrator` so the workflows module * never imports this module's entities (workflows treats it as an optional peer * resolved via `tryResolve('agentWorkflowBridge')`). */ export type InvokeAgentForWorkflowArgs = { agentId: string input: unknown onResult: DispositionOnResult ctx: { tenantId: string organizationId: string userId?: string workflowInstanceId: string stepId: string /** * The step's attempt id. With the instance and the step it IS this * invocation's identity, which is what lets the run it produces be found by * name instead of by creation time. */ invocationId?: string /** * The INVOKE_AGENT node's already-interpolated `subject` descriptor (process * projection spec, 2026-06-25). Additive + optional: forwarded opaquely into * the async-scoped subject binding so `proposals.create` can attach it to * the `proposal.created` event payload. Never persisted on run/proposal rows. */ subject?: unknown /** * The INVOKE_AGENT node's already-resolved Review section (spec §7.5): who * reviews the proposal this step raises, and by when. Additive + optional — * absent means the unassigned disposition task this service raised before * the section existed. Resolution (interpolation, dynamic-assignee fallback) * happens in the workflows engine, which owns the run context; this module * only carries the answer onto the task it creates. */ review?: AgentDispositionReview } } export type InvokeAgentForWorkflowOutcome = | { kind: 'research'; data: unknown } | { kind: 'auto_approved'; proposalId: string; payload: unknown } | { kind: 'user_task'; proposalId: string } /** * The agent returned an EMPTY option set — it looked and had nothing to propose. * Terminal like `research`: the step resumes instead of parking on a decision * nobody can make, and routes onto the `outcome:researcher` handle — that * routing vocabulary is core-owned and keeps its own spelling. */ | { kind: 'none_proposed'; proposalId: string; payload: unknown } /** * The agent PRODUCED something — a draft, a report, a generated document. * Terminal like `research` and routed onto the same governance handle: it * mutates nothing, so there is no decision for anyone to dispose. The workflow * decides what the files are for. */ | { kind: 'artifact'; artifacts: unknown[]; summary?: string } /** * One agent's declared OUTCOME contract, as the workflows context ledger needs * it: the result kind decides whether the OUTCOME lands under the envelope's * `data` or `proposalPayload` key, and the schema types everything below it. */ export type AgentOutcomeContractSnapshot = { agentId: string resultKind: 'research' | 'proposal' schema: ZodTypeAny } export interface AgentWorkflowBridge { invokeAgentForWorkflow( args: InvokeAgentForWorkflowArgs, ): Promise /** * OUTCOME contracts of every registered agent, for the workflows INVOKE_AGENT * output contract. OPTIONAL on the interface so an older bridge implementation * stays valid — core treats its absence as "agents cannot be typed here" and * falls back to `unknown` ledger entries. */ listAgentOutcomeContracts?(): Promise } export type AgentWorkflowBridgeDeps = { container: AwilixContainer agentRuntime: AgentRuntimeService dispositionService: DispositionService } export class AgentWorkflowBridgeService implements AgentWorkflowBridge { private readonly container: AwilixContainer private readonly agentRuntime: AgentRuntimeService private readonly dispositionService: DispositionService constructor(deps: AgentWorkflowBridgeDeps) { this.container = deps.container this.agentRuntime = deps.agentRuntime this.dispositionService = deps.dispositionService } async invokeAgentForWorkflow( args: InvokeAgentForWorkflowArgs, ): Promise { const { agentId, input, onResult, ctx } = args // On-behalf-of attribution (Wave 4 P2): if this agent has a provisioned // principal, run as the agent (actor) on behalf of the invoking human, so // every ActionLog the run writes is attributed agent→human, sourced 'agent', // through the SAME audited Command path. When no principal is provisioned yet // the run keeps its prior `userId`-derived attribution (additive, fail-open). const runAs = await this.resolveRunAs(agentId, ctx) // Subject binding (fail-open): a malformed descriptor is dropped, never a // reason to refuse the run — the projection then simply lists the process // by workflow name with no business facets. const subject = this.parseSubject(ctx.subject) // The run this call caused, learned from the run itself rather than guessed // afterwards. `onRunPersisted` fires again for each nested sub-agent // delegation, so only the FIRST invocation is the top-level run. let topLevelRunId: string | null = null const result = await withProcessSubject(subject, () => this.agentRuntime.run(agentId, input, { tenantId: ctx.tenantId, organizationId: ctx.organizationId, userId: ctx.userId ?? '', workflowInstanceId: ctx.workflowInstanceId, stepId: ctx.stepId, invocationId: ctx.invocationId, onRunPersisted: (runId) => { if (!topLevelRunId) topLevelRunId = runId }, ...(runAs ? { runAs } : {}), }), ) if (result.kind === 'research') { return { kind: 'research', data: result.data } } if (result.kind === 'artifact') { return { kind: 'artifact', artifacts: result.artifacts, ...(result.summary ? { summary: result.summary } : {}), } } if (!topLevelRunId) { throw new Error('[internal] agent run id was never reported') } const em = (this.container.resolve('em') as EntityManager).fork() // Correlated by the RUN that produced it, never by "the newest pending // proposal on this step". Two invocations of the same step — a retry, a // parallel branch, a concurrent instance — are indistinguishable to a // temporal lookup, and it would dispose the wrong one. // // `none_proposed` is stamped at creation for an empty option set, so the // lookup must accept it too: the run that proposed nothing is not a run whose // proposal went missing. const proposal = await em.findOne(AgentProposal, { runId: topLevelRunId, disposition: { $in: ['pending', 'none_proposed'] }, tenantId: ctx.tenantId, organizationId: ctx.organizationId, }) if (!proposal) { throw new Error('[internal] agent proposal not found after run') } const outcome = await this.dispositionService.dispose(proposal, onResult, { tenantId: ctx.tenantId, organizationId: ctx.organizationId, userId: ctx.userId, workflowInstanceId: ctx.workflowInstanceId, stepId: ctx.stepId, ...(ctx.review ? { review: ctx.review } : {}), }) if (outcome.kind === 'auto_approved') { return { kind: 'auto_approved', proposalId: outcome.proposalId, payload: proposal.payload } } if (outcome.kind === 'none_proposed') { return { kind: 'none_proposed', proposalId: outcome.proposalId, payload: proposal.payload } } return { kind: 'user_task', proposalId: outcome.proposalId } } /** * Projects the agent registry into OUTCOME contracts for the workflows module. * Agents load lazily, so this awaits the registry first; an agent whose result * schema is not the declared envelope contributes nothing rather than a guess. */ async listAgentOutcomeContracts(): Promise { await ensureAgentsLoaded() const contracts: AgentOutcomeContractSnapshot[] = [] for (const entry of listAgentEntries()) { // An artifact agent has no per-agent OUTCOME to type, so it contributes no // contract rather than a fixed one dressed up as its own. if (entry.resultKind === 'artifact') continue const schema = resolveAgentOutcomeZod(entry) if (!schema) continue contracts.push({ agentId: entry.id, resultKind: entry.resultKind, schema }) } return contracts } private parseSubject(raw: unknown): ProcessSubject | null { if (!raw || typeof raw !== 'object') return null const parsed = processSubjectSchema.safeParse(raw) return parsed.success ? parsed.data : null } /** * Resolves the on-behalf-of attribution for an `INVOKE_AGENT` run. Returns the * agent principal's `auth.User` id (actor) + the invoking human (`onBehalfOfUserId`) * when the agent is provisioned and enabled; null otherwise (fail-open — the run * keeps its `userId`-derived attribution until a principal exists). Org-scoped. */ private async resolveRunAs( agentId: string, ctx: InvokeAgentForWorkflowArgs['ctx'], ): Promise { const principal = await resolveAgentPrincipal( this.container, { tenantId: ctx.tenantId, organizationId: ctx.organizationId }, agentId, ) if (!principal || !principal.enabled) return null return { agentUserId: principal.userId, onBehalfOfUserId: ctx.userId ?? null, } } }