/// import { defineAiAgent, type AiAgentDefinition, } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/ai-agent-definition' import type { ZodTypeAny } from 'zod' import { createLogger } from '@open-mercato/shared/lib/logger' import type { AgentType } from '../../data/validators' import { getSkillEntry } from './defineSkill' import type { JsonSchemaNode } from './outcomeSchema' import type { AgentTokenUsage, FileAgentFile } from '../tokens/types' const logger = createLogger('agent_orchestrator').child({ component: 'define-agent' }) /** * What KIND of thing came back. * * `research` enriches the context, `proposal` states an intent a human or a * rule disposes, `artifact` produces a file. All three are typed results the * WORKFLOW decides what to do with — an agent never writes to workflow state * itself, which is what keeps parallel branches safe. */ export type AgentResultKind = 'proposal' | 'research' | 'artifact' /** * Where an agent runs. `'native'` agents are authored with `defineAgent` and * execute via the Vercel AI SDK object mode inside this process on the * `NativeAgentRunner` (lightweight-agent-runtime spec Phase 1). `'in-process'` * is the accepted legacy alias for the same runner — the union stays * ADDITIVE-ONLY per BACKWARD_COMPATIBILITY.md, so entries registered with * `'in-process'` dispatch identically. `'opencode'` agents are authored as * `agents//` file conventions (AGENT.md + OUTCOME.md), registered via * `registerFileAgent`, and run on the OpenCode runtime (deprecation planned — * see the spec's Phase 3/6). */ export type AgentRuntime = 'in-process' | 'native' | 'opencode' | 'external' /** * Runtime labels that execute on the native (in-process) runner. Persisted * `agent_runs.runtime` values from before the `'native'` flip carry * `'in-process'`; new runs stamp `'native'`. Filters, rollups, and dashboards * MUST treat the two labels as ONE cohort (Migration & BC section of the * lightweight-agent-runtime spec). */ export const NATIVE_RUNTIME_VALUES = ['native', 'in-process'] as const /** * Tool id of the built-in delegation tool (declared in agent_orchestrator's * `ai-tools.ts`). An agent that lists `subAgents` automatically gains this tool, * letting it run other (read-only, researcher) agents as sub-agents — and fan * them out in parallel. Sub-agents stay propose-only: they only inform. */ export const DELEGATE_TOOL_ID = 'agent_orchestrator.delegate_agent' export interface DefineAgentInput { /** 'module.agent' — STABLE contract id (see BACKWARD_COMPATIBILITY.md). */ id: string moduleId: string label: string description: string /** System prompt → AiAgentDefinition.systemPrompt. */ instructions: string /** * defineAiTool ids the agent may call. Propose-only stays structural: the agent * is declared read-only, so any `isMutation: true` tool is stripped by the * runtime — only READ tools survive. When non-empty the runtime runs a read-only * tool loop (`runAiAgentObject({ enableTools })`) so the agent can gather data * before proposing; all writes still flow through proposal → disposition → * effector, never the agent directly. */ tools?: string[] /** * Skill ids (see `defineSkill`). Each resolved skill injects its instructions * into the system prompt and unions its read-only tools into the agent's * allowlist. Unknown ids are warned and skipped (the agent still loads). */ skills?: string[] /** * Agent ids this agent may delegate to as sub-agents. When non-empty the agent * gains the `DELEGATE_TOOL_ID` tool and a prompt section listing the ids; the * model calls them (in parallel when independent). Sub-agents MUST be * researcher (they inform; only the parent proposes) and may not themselves * delegate — enforced by the delegate tool, capping the tree depth. */ subAgents?: string[] defaultProvider?: string defaultModel?: string /** Object-safe loop subset only. */ loop?: { maxSteps?: number } /** * Runtime the agent executes on. `defineAgent` only ever produces agents for * the native in-process runner (registered as `'native'`), so this is * reserved for future use and kept additive. */ runtime?: AgentRuntime /** Zod from data/validators.ts; IS the output.schema (single source). */ result: { kind: AgentResultKind; schema: ZodTypeAny } /** * What this agent is FOR (`researcher` | `decision_maker` | `action`). An AUTHORING * declaration, unlike `result.kind`, which describes the shape that comes back. * Optional and additive: an agent that declares none is listed as untyped and * `agent_runs.agent_type` stays null for its runs. */ agentType?: AgentType /** * Per-agent narrowing of the action vocabulary. The catalogue * (`listWorkflowSafeCommands() ∪ workflowActivityTypes()`) is the OUTER limit; this * list only ever INTERSECTS with it and can never widen it. An entry naming * something outside the catalogue is DROPPED with a warning at registration — * fail-closed and loud, because a silently-dropped permission reads as a granted * one. Omitted means "the whole catalogue", which is what every agent had before. */ allowedActions?: string[] /** * Optional example `input` surfaced by the Playground's "Insert sample" * button. Co-located with the agent so each agent ships its own runnable * example instead of the Playground hardcoding per-agent samples. Any JSON * value the agent accepts as input. Additive/BC-safe: agents without it just * hide the button. */ sampleInput?: unknown /** * Optional fact declarations for the Caseload decision panel. Each maps a * labelled dot-path into the run input / proposal payload / run output so the * operator sees the data behind a proposal. Additive/BC-safe: agents without * it fall back to the panel's generic derivation. */ facts?: AgentFact[] } /** * File-plane opt-in (#12) for an OpenCode file agent. When enabled (and the * global `OM_OPENCODE_FILES_ENABLED` kill-switch is on), the runner leases a * per-run sandbox, the frontmatter grants sandbox-scoped `write`/`edit`/`read`, * and outputs written to `out/` are captured as `AgentRunArtifact`s. OpenCode-only. */ export type FileAgentFilesConfig = { enabled: true /** Accept staged attachment inputs (default true when enabled). */ inputs?: boolean /** Capture artifacts from the sandbox `out/` dir (default true when enabled). */ outputs?: boolean /** Allow `bash` in the sandbox (default false — large blast radius). */ bash?: boolean } /** One declared Caseload fact: label + dot-path into a run/proposal source. */ export type AgentFact = { label: string source: 'input' | 'payload' | 'output' path: string format?: 'text' | 'number' | 'boolean' | 'percent' } export interface AgentRegistryEntry { id: string moduleId: string resultKind: AgentResultKind /** Declared agent type (see DefineAgentInput); undefined when the agent declares none. */ agentType?: AgentType /** * Effective action vocabulary after the registration-time intersection with the * platform catalogue (see `lib/runtime/actionVocabulary.ts`). `undefined` means the * agent declared no narrowing and is bounded by the catalogue alone. */ allowedActions?: string[] schema: ZodTypeAny tools: string[] skills: string[] subAgents: string[] label: string description: string /** System prompt the agent runs with. */ instructions: string defaultProvider?: string defaultModel?: string /** Object-safe loop subset (see DefineAgentInput). */ loop?: { maxSteps?: number } /** Runtime the agent executes on. Always set (`'native'` for `defineAgent` agents; `'in-process'` accepted as a legacy alias). */ runtime: AgentRuntime /** Optional Playground "Insert sample" input (see DefineAgentInput). */ sampleInput?: unknown /** * Raw OUTCOME JSON-Schema as authored, for file-defined agents. `schema` above * is the compiled envelope; this is the inner shape the author declared, kept * verbatim so consumers (agents API, workflow INVOKE_AGENT mapping pickers) * read the same subset the generator validated. In-process agents leave it * undefined — their outcome is derived from `schema` instead * (lib/sdk/agentOutcomeContract.ts). */ outcomeSchema?: JsonSchemaNode /** Optional Caseload fact declarations (see DefineAgentInput). */ facts?: AgentFact[] /** File-plane opt-in (#12). OpenCode file agents only; undefined for native agents. */ files?: FileAgentFilesConfig /** * Baked token-usage estimate of the agent's construction files. File-defined * (`runtime: 'opencode'`) agents only; undefined for native agents. */ tokenUsage?: AgentTokenUsage /** * Baked raw content of the agent's construction files, for the read-only Files * tab. File-defined (`runtime: 'opencode'`) agents only; undefined for native. */ sourceFiles?: FileAgentFile[] } const registry = new Map() export function defineAgent(input: DefineAgentInput): AiAgentDefinition { if (registry.has(input.id)) { throw new Error(`[internal] duplicate agent id "${input.id}"`) } // Resolve declared skills from the registry (ai-skills.ts must be imported // before the agents that reference them — ai-agents.ts does this at the top). // Each skill injects real instructions AND contributes its read-only tools. const ownTools = input.tools ?? [] const skillIds = input.skills ?? [] const resolvedSkills = skillIds .map((skillId) => { const skill = getSkillEntry(skillId) if (!skill) { logger.warn('agent references unknown skill; skipping', { agentId: input.id, skillId }) } return skill }) .filter((skill): skill is NonNullable => !!skill) const skillTools = resolvedSkills.flatMap((skill) => skill.tools) // Sub-agents: when declared, the agent gains the delegation tool so it can run // other agents as read-only sub-agents (and fan them out in parallel). const subAgents = input.subAgents ?? [] const delegationTools = subAgents.length > 0 ? [DELEGATE_TOOL_ID] : [] const effectiveTools = Array.from(new Set([...ownTools, ...skillTools, ...delegationTools])) registry.set(input.id, { id: input.id, moduleId: input.moduleId, resultKind: input.result.kind, agentType: input.agentType, // Stored as DECLARED here; `narrowRegisteredActionVocabularies` intersects it with // the platform catalogue once every module has registered, because the catalogue // lives behind an OPTIONAL peer that can only be reached asynchronously. allowedActions: input.allowedActions ? [...input.allowedActions] : undefined, schema: input.result.schema, tools: ownTools, skills: skillIds, subAgents, label: input.label, description: input.description, instructions: input.instructions, defaultProvider: input.defaultProvider, defaultModel: input.defaultModel, loop: input.loop, runtime: 'native', sampleInput: input.sampleInput, facts: input.facts, }) const skillSections = resolvedSkills.map( (skill) => `## Skill: ${skill.label}\n${skill.instructions}`, ) const subAgentSection = subAgents.length ? `## Sub-agents\nYou may delegate independent sub-tasks to these agents by calling the \`${DELEGATE_TOOL_ID}\` tool with { agentId, input }. When several sub-tasks are independent, issue multiple delegate calls in the SAME step so they run in parallel, then combine their results. Available sub-agents: ${subAgents.join(', ')}.` : null const systemPrompt = [input.instructions, ...skillSections, ...(subAgentSection ? [subAgentSection] : [])] .join('\n\n') return defineAiAgent({ id: input.id, moduleId: input.moduleId, label: input.label, description: input.description, systemPrompt, // Effective read-only allowlist = the agent's own tools + every read tool its // skills contribute. Read-only policy still strips any mutation tool. allowedTools: effectiveTools, executionMode: 'object', // Read-only is the propose-only guarantee: the runtime strips every // `isMutation: true` tool, so even a mis-declared write tool can never fire. // Reads are allowed; writes only ever happen via the proposal/effector path. readOnly: true, mutationPolicy: 'read-only', defaultProvider: input.defaultProvider, defaultModel: input.defaultModel, loop: input.loop, output: { schemaName: input.id.replace(/\W+/g, '_'), schema: input.result.schema }, }) } /** * Register a file-defined (OpenCode) agent directly into the SAME registry that * `defineAgent` writes to, so file agents are discoverable via `getAgentEntry`, * `listAgentEntries`, and the Agents list/detail without going through * `defineAiAgent` (file agents don't run in-process, so they need no * `AiAgentDefinition`). Same dup-id guard as `defineAgent`. */ export function registerFileAgent(entry: AgentRegistryEntry): void { if (registry.has(entry.id)) { throw new Error(`[internal] duplicate agent id "${entry.id}"`) } registry.set(entry.id, entry) } export function getAgentEntry(id: string): AgentRegistryEntry | undefined { return registry.get(id) } export function listAgentEntries(): AgentRegistryEntry[] { return [...registry.values()] } let agentsLoadPromise: Promise | null = null /** * Load every module's agents into the registry. `defineAgent` registers by import * side effect, so an agent declared in ANY module is only discoverable once that * module's `ai-agents.ts` has executed. The generated `ai-agents.generated.ts` * aggregator statically imports every module's `ai-agents.ts`; ai_assistant's * `loadAgentRegistry()` imports that aggregator, so calling it runs every * `defineAgent()` across all modules and fills this registry. Packages must not * import the app-generated file directly, so we go through ai_assistant. * * Fallback: if the aggregator is unavailable (tests, fresh checkout before * `yarn generate`), import agent_orchestrator's own `ai-agents.ts` so at least * its built-in agents resolve. */ async function loadAllAgentModules(): Promise { try { const { loadAgentRegistry } = await import( '@open-mercato/ai-assistant/modules/ai_assistant/lib/agent-registry' ) await loadAgentRegistry() } catch { // ignore — fall through to the local import below } if (registry.size === 0) { await import('../../ai-agents') } await loadFileAgents() await narrowRegisteredActionVocabularies() } /** * Apply `effective = catalogue ∩ agent.allowedActions` to every registered agent. * * It runs HERE, at the end of the registry load, rather than inside `defineAgent`: * `defineAgent` is synchronous and the catalogue lives in core `workflows`, an OPTIONAL * peer reachable only through a dynamic import. This is still registration time — no * agent is observable through `getAgentEntry`/`listAgentEntries` until * `ensureAgentsLoaded` resolves — and the check runs AGAIN before the effect * (`executeProposal`), because a tenant may revoke a safe command after registration. */ async function narrowRegisteredActionVocabularies(): Promise { const declaring = [...registry.values()].filter((entry) => entry.allowedActions !== undefined) if (declaring.length === 0) return try { const { loadActionVocabulary, narrowAllowedActions } = await import('../runtime/actionVocabulary') const vocabulary = await loadActionVocabulary() for (const entry of declaring) { entry.allowedActions = narrowAllowedActions(vocabulary, entry.id, entry.allowedActions ?? []) } } catch (err) { logger.warn('action vocabulary narrowing skipped', { error: err instanceof Error ? err.message : String(err), }) } } /** * Load file-defined (OpenCode) agents from the committed, generator-owned * manifest (`generated/file-agents.generated.ts`). The manifest stores PLAIN * data (raw JSON-Schema, not a Zod instance); for each descriptor we recompile * the result schema via `compileOutcome` and register it with `runtime:'opencode'`. * The import is guarded: the manifest may be absent or empty before the first * `yarn generate`, and a descriptor whose schema fails to compile is skipped * (warned) so one bad agent never blocks the rest. */ /** * Resolve a predicate `(toolId) => 'mutating' | 'read' | 'unknown'` for a declared * tool id, used by the load-time propose-only check below. * * This load-time check is an EARLY, advisory rejection — NOT the load-bearing * propose-only boundary. The actual runtime gates (which always hold) are: (1) the * generated OpenCode agent file's `tools` deny-by-default allowlist + its * `permission: { write/edit/bash: deny }` block, and (2) the per-run session-token * ACL the MCP server re-checks on every tool call. The MCP server does NOT strip * `isMutation` tools, so those two gates — not this predicate — are what enforce * propose-only. * * When the registry IS loadable we await `loadAllModuleTools()` first, so a tool * id absent afterward is genuinely unknown (a config error): we fail CLOSED and * report `'unknown'` (the caller rejects the agent). When the registry import * itself is unavailable (e.g. a unit-test harness without the AI tooling) the * check cannot run at all and reports `'skip'`; registration proceeds and the two * runtime gates above remain in force. */ type MutationCheck = (toolId: string) => 'mutating' | 'read' | 'unknown' | 'skip' async function loadMutationToolPredicate(): Promise { try { const { loadAllModuleTools } = await import( '@open-mercato/ai-assistant/modules/ai_assistant/lib/tool-loader' ) const { getToolRegistry } = await import( '@open-mercato/ai-assistant/modules/ai_assistant/lib/tool-registry' ) try { await loadAllModuleTools() } catch { // Tools may already be loaded, or loading is a no-op in this harness. } const registry = getToolRegistry() return (toolId: string): 'mutating' | 'read' | 'unknown' | 'skip' => { const tool = registry.getTool(toolId) as { isMutation?: boolean } | undefined if (!tool) return 'unknown' return tool.isMutation ? 'mutating' : 'read' } } catch { return () => 'skip' } } /** * Resolve the generator-owned file-agent manifest across both layouts. * * 1. Monorepo: the committed manifest sits next to this module's source and the * relative import resolves at build time exactly as it always has. * 2. Standalone: the enterprise module lives under `node_modules/`, which * `yarn install` rewrites, so the generator writes the manifest into the * app's `.mercato/generated/` instead. Inside the Next bundler the `@/` * alias resolves it; in a plain Node process (the `mcp:serve-http` sidecar, * workers, the CLI) the alias is not a real specifier and throws, so we fall * back to locating and compiling the file from disk. * * Mirrors `ai-assistant`'s `importGeneratedAiToolsModule()`, which solves the * same problem for `ai-tools.generated.ts`. Returns `null` when no manifest * exists yet (pre-generate builds, tests). */ async function importFileAgentsManifest(): Promise | null> { try { return (await import('../../generated/file-agents.generated')) as unknown as Record< string, unknown > } catch { // Not the monorepo layout — fall through to the standalone resolution. } try { return (await import('@/.mercato/generated/file-agents.generated')) as Record } catch { // Not inside the Next bundler — fall through to on-disk resolution. } try { const { findGeneratedFile, compileAndImportGenerated } = await import( '@open-mercato/ai-assistant/modules/ai_assistant/lib/generated-registry-loader' ) const tsPath = findGeneratedFile('file-agents.generated.ts') if (!tsPath) return null return await compileAndImportGenerated(tsPath) } catch { return null } } async function loadFileAgents(): Promise { let descriptors: import('../../generated/file-agents.generated').FileAgentDescriptor[] const manifest = await importFileAgentsManifest() if (!manifest) return descriptors = (manifest.fileAgentDescriptors as | import('../../generated/file-agents.generated').FileAgentDescriptor[] | undefined) ?? [] const { compileOutcome } = await import('./outcomeSchema') const { registerAgentSkills } = await import('../runtime/fileAgentSkills') const isMutationTool = await loadMutationToolPredicate() // Flatten each descriptor's nested sub-agents (Phase 4) so they register as // individual file agents too: they are researcher, individually runnable, and // discoverable in the Agents list/detail. A sub-agent flows through the SAME // propose-only mutation-tool gate below; the registry dup-id guard prevents a // duplicate id (a sub-agent that collides with another agent's id is skipped). const allDescriptors = descriptors.flatMap((descriptor) => [ descriptor, ...(descriptor.subAgentDescriptors ?? []), ]) for (const descriptor of allDescriptors) { if (registry.has(descriptor.id)) continue // Propose-only early check (contract C8): a file agent may NEVER declare a // tool registered with `isMutation: true`. This is defense-in-depth on top of // the load-bearing runtime gates (the agent-file read-only allowlist + // write/edit/bash deny, and the per-call session-token ACL — see // `loadMutationToolPredicate`). When the tool registry is resolvable we fail // CLOSED: a declared tool that is `mutating` OR `unknown` (absent after // `loadAllModuleTools`, i.e. a config error) rejects the agent here so it never // reaches registration. `skip` means the registry could not be loaded at all // (test harness without AI tooling) — we let it through and rely on the runtime // gates. A rejected agent is skipped + warned so the rest still load. const offending = descriptor.tools .map((toolId) => ({ toolId, verdict: isMutationTool(toolId) })) .find(({ verdict }) => verdict === 'mutating' || verdict === 'unknown') if (offending) { logger.warn( 'file agent declares a non read-only tool; file agents are propose-only and may only list known read-only tools — skipping registration', { agentId: descriptor.id, verdict: offending.verdict, toolId: offending.toolId }, ) continue } try { const { resultSchema } = compileOutcome({ kind: descriptor.resultKind, schema: descriptor.outcomeSchema, }) registerFileAgent({ id: descriptor.id, moduleId: descriptor.moduleId, resultKind: descriptor.resultKind, schema: resultSchema, tools: descriptor.tools, skills: descriptor.skills, subAgents: descriptor.subAgents, label: descriptor.label, description: descriptor.description, instructions: descriptor.instructions, defaultProvider: descriptor.provider, defaultModel: descriptor.model, loop: descriptor.maxSteps != null ? { maxSteps: descriptor.maxSteps } : undefined, runtime: 'opencode', outcomeSchema: descriptor.outcomeSchema, sampleInput: descriptor.sampleInput, facts: descriptor.facts, tokenUsage: descriptor.tokenUsage, sourceFiles: descriptor.sourceFiles, }) // Phase 3: register the agent's resolved skill content into the runtime // lookup so `load_skill` can return it without fs access. Optional + BC: a // descriptor without `skillsContent` registers no skills (cleared). registerAgentSkills(descriptor.id, descriptor.skillsContent ?? []) } catch (err) { logger.warn('failed to load file agent', { agentId: descriptor.id, error: err instanceof Error ? err.message : String(err), }) } } } /** * Ensure agents from every module have registered before the registry is read. * Idempotent: skips when already populated, and the underlying imports are no-ops * once their modules have executed. Code paths that don't transitively import any * `ai-agents.ts` — notably the workflow background executor and the agents API — * call this first so they never read an empty registry. */ export async function ensureAgentsLoaded(): Promise { // NOTE: do NOT short-circuit on `registry.size > 0`. In-process agents register // via `ai-agents.ts` import side-effects (e.g. the global AiAssistantLauncher // triggers `loadAgentRegistry()` on every backoffice page), so the registry is // often already non-empty before the first call here. File-defined (OpenCode) // agents only load inside `loadAllAgentModules()` → `loadFileAgents()`, so a // size-based early return would skip them entirely. The memoized // `agentsLoadPromise` already guarantees the full load runs exactly once. if (!agentsLoadPromise) { agentsLoadPromise = loadAllAgentModules() .then(() => undefined) .catch((err) => { agentsLoadPromise = null throw err }) } await agentsLoadPromise }