Migrate to uvilo-mono Plan 2
Scope: Build sub-agent spawning as a native AI Tool feature inside uvilo-mono, replacing the need for an external MCP server. Covers Research §4 Option B. This plan is self-contained — it does not reference any external spawn MCP server. Depends on Plan 1 (McpServerManager) for MCP tool integration in sub-agents; native AI tools work independently.
Functional Requirements
The spawn system must provide three tools available to any Bot at inference time:
| Tool | Purpose |
|---|
spawnAgent | Create a sub-agent conversation, send initial message, run inference. Returns result synchronously or asynchronously (job ID). Optionally triggers an Inngest event on completion or runs under a different user’s context. |
checkAgent | Query status and retrieve results from a previously spawned sub-agent. |
abortAgent | Cancel a running sub-agent inference. |
Each tool is a native AI Tool registered in @erikdakoda/ai-tool’s aiToolRegistry, NOT an MCP tool. This means:
- No HTTP calls — sub-agent inference runs in-process via
streamText() directly
- No separate auth — the tool inherits the parent request’s authenticated session
- No separate process management — sub-agents run as async operations within the BotCraft Node.js process
Architecture Overview
Parent Bot (streamText)
│
├─ calls spawnAgent tool
│ │
│ ├─ creates Convo in DB (sub-agent conversation)
│ ├─ resolves Bot config (model, system prompt, tools)
│ ├─ calls streamText() in-process for sub-agent
│ │ │
│ │ ├─ sub-agent may call its own tools (native AI + MCP)
│ │ └─ sub-agent completes (or is aborted)
│ │
│ ├─ sync mode: blocks until done, returns final message
│ └─ async mode: returns job ID immediately
│
├─ calls checkAgent tool (async mode only)
│ └─ returns status + partial/full results
│
└─ calls abortAgent tool
└─ cancels sub-agent's streamText via AbortController
Key principle: Sub-agent inference is a nested streamText() call running in the same process. The parent tool blocks (sync) or detaches (async) while the sub-agent runs its own multi-step inference loop.
Source Repository Locations
All new code goes into the uvilo-mono monorepo:
| Item | Target Package | Path |
|---|
| AgentJob model | @erikdakoda/inference | packages/@erikdakoda/inference/models/AgentJob.zmodel |
| SubAgentManager | @erikdakoda/inference | packages/@erikdakoda/inference/sub-agent/SubAgentManager.ts |
| SubAgentJob | @erikdakoda/inference | packages/@erikdakoda/inference/sub-agent/SubAgentJob.ts |
| Types | @erikdakoda/inference | packages/@erikdakoda/inference/sub-agent/types.ts |
| spawnAgent AI Tool | @erikdakoda/ai-tool | packages/@erikdakoda/ai-tool/ai-tools/spawnAgent.ts |
| checkAgent AI Tool | @erikdakoda/ai-tool | packages/@erikdakoda/ai-tool/ai-tools/checkAgent.ts |
| abortAgent AI Tool | @erikdakoda/ai-tool | packages/@erikdakoda/ai-tool/ai-tools/abortAgent.ts |
| Tool registrations | @erikdakoda/ai-tool | packages/@erikdakoda/ai-tool/ai-server-tools/spawnAgent.ts etc. |
| BotCraft import | apps/bot-craft | apps/bot-craft/src/aiServerTools.ts |
Existing packages used:
@erikdakoda/ai-tool — tool registry (registerAiTool, getAiTools, AiTool type)
@erikdakoda/inference — ChatPostHandler pattern, streamText() call, ConvoForInferenceSelect
@erikdakoda/bot — getLanguageModel(), getModelParameters(), computeBotSystemPrompt(), Bot model
@erikdakoda/convo — Convo model, getNewConvo(), getNewAiMessage(), convertAiMessageMetadata()
@erikdakoda/database — Prisma client (getEnhancedExtendedPrisma()), getServerAuthSession()
@erikdakoda/queue — Inngest client (sendInngestEvent()) for workflow continuation on sub-agent completion
Task 1 — Create AgentJob Database Model
Define the persistent storage for sub-agent jobs. Without a DB table, async job results are lost on server restart, there’s no audit trail, and checkAgent can’t survive across deploys. The in-memory SubAgentManager cache sits on top of this table.
1.1 Create ZenStack model
Create packages/@erikdakoda/inference/models/AgentJob.zmodel:
import "../../auth/models/NamedItem"
import "../../bot/models/Bot"
import "../../convo/models/Convo"
import "../../user/models/User"
enum AgentJobState {
pending
running
completed
failed
aborted
}
enum SpawnMode {
sync
async
}
model AgentJob extends NamedItem {
/** The Bot this sub-agent runs as. */
bot Bot @relation(fields: [botId], references: [id])
botId String
/** The conversation this sub-agent created. */
convo Convo? @relation(fields: [convoId], references: [id])
convoId String?
/** The parent conversation that spawned this job. */
parentConvo Convo? @relation(fields: [parentConvoId], references: [id])
parentConvoId String?
/** The user whose context the sub-agent runs under. */
owner User @relation(fields: [ownerId], references: [id])
/** The user who triggered the spawn (may differ from owner when runAsUserId is used). */
triggeredBy User @relation(fields: [triggeredById], references: [id])
triggeredById String
state AgentJobState @default(pending)
mode SpawnMode @default(sync)
/** Final assistant message text (set on completion). */
result String?
/** Error message (set on failure). */
error String?
/** Token usage from the sub-agent inference. */
inputTokens Int @default(0)
outputTokens Int @default(0)
reasoningTokens Int @default(0)
totalTokens Int @default(0)
/** Number of tool-use steps the sub-agent took. */
steps Int @default(0)
/** Spawn depth (0 = top-level, 1 = first sub-agent, etc.). */
depth Int @default(0)
/** Inngest event name to fire on completion (async mode only). Stored so the event fires even after restart. */
completionEventName String?
/** Additional data for the Inngest completion event (JSON). */
completionEventData Json?
/** Whether the Inngest completion event has been fired. */
completionEventFired Boolean @default(false)
/** The input message that initiated the sub-agent. */
inputMessage String
/** Config overrides applied to this spawn (JSON). */
overrides Json?
/** Bot reference at spawn time (botId or group+handle, JSON). */
botRef Json
startedAt DateTime?
completedAt DateTime?
@@index([ownerId])
@@index([triggeredById])
@@index([state])
@@index([botId])
}
1.2 Add relations to existing models
Add back-references to Bot.zmodel and Convo.zmodel:
In packages/@erikdakoda/bot/models/Bot.zmodel, add to the Bot model:
In packages/@erikdakoda/convo/models/Convo.zmodel, add to the Convo model:
agentJobsAsSubAgent AgentJob[] @relation("AgentJobConvo")
agentJobsAsParent AgentJob[] @relation("AgentJobParentConvo")
In packages/@erikdakoda/auth/models/User.zmodel (or wherever the User model lives), add:
agentJobsAsOwner AgentJob[] @relation("AgentJobOwner")
agentJobsAsTrigger AgentJob[] @relation("AgentJobTrigger")
1.3 Run ZenStack/Prisma generation
cd /workspace/erik/uvilo-mono
pnpm zenstack generate
pnpm prisma generate
1.4 Create migration
pnpm prisma migrate dev --name add_agent_job
1.5 Verify
AgentJob table exists in the database
- Prisma client includes
AgentJob CRUD operations
- Relations to Bot, Convo, and User compile without errors
Task 2 — Define Sub-Agent Types
Establish the TypeScript type definitions for sub-agent jobs, their lifecycle states, and the data structures passed between the spawn tools and the SubAgentManager.
2.1 Create type definitions
Create packages/@erikdakoda/inference/sub-agent/types.ts:
import type { AiMessageForUi } from '@dakoda/convo/types';
import type { IntegrationName, AiModelName } from '@dakoda/bot/shared/aiModels';
import type { ReasoningEffort } from '@dakoda/database';
/** Lifecycle states for a sub-agent job */
export type SubAgentJobState =
| 'pending' // Created, not yet started
| 'running' // Inference in progress
| 'completed' // Finished successfully
| 'failed' // Errored out
| 'aborted'; // Cancelled by parent
/** How the parent waits for the sub-agent */
export type SpawnMode = 'sync' | 'async';
/** Configuration for resolving which Bot the sub-agent runs as */
export type SubAgentBotRef =
| { botId: string } // Direct Bot ID
| { botGroup: string; botHandle: string }; // Lookup by unique group+handle
/** Optional overrides for the sub-agent's inference config */
export interface SubAgentOverrides {
/** Override the Bot's default model. Format: "<integration>:<modelName>" e.g. "openai:gpt-5.4-nano" */
modelOverride?: string;
/** Override the Bot's default system prompt. If set, replaces the computed system prompt entirely. */
systemPromptOverride?: string;
/** Append extra instructions to the Bot's system prompt. */
systemPromptAppend?: string;
/** Override temperature (0–2). If unset, uses Bot's default. */
temperature?: number;
/** Override reasoning effort. If unset, uses Bot's default. */
reasoningEffort?: ReasoningEffort;
/** Maximum number of tool-use steps the sub-agent may take (default: 25) */
maxSteps?: number;
/** Whitelist of tool names the sub-agent may use. Empty = all available tools. */
toolFilter?: string[];
}
/** Input for the spawnAgent tool */
export interface SpawnAgentInput {
/** The Bot to spawn as. Identified by ID or group+handle. */
bot: SubAgentBotRef;
/** The initial user message to send to the sub-agent. */
message: string;
/** Execution mode: 'sync' blocks until done; 'async' returns job ID immediately. Default: 'sync'. */
mode?: SpawnMode;
/** Optional config overrides for this spawn. */
overrides?: SubAgentOverrides;
/**
* An Inngest event to trigger when the sub-agent completes.
* Only valid in async mode. The event is sent with the job result as data,
* enabling downstream Inngest functions to continue a workflow.
* If omitted, no event is fired on completion.
*/
completionEvent?: InngestCompletionEvent;
/**
* User ID whose context the sub-agent should run under.
* If provided, the sub-agent creates the Convo and messages under this user
* instead of the current (parent) user. This enables a user session to
* trigger background agent sessions that run under a different user's account.
* The caller must be authorized to spawn as this user (verified by
* SpawnContext against allowed delegates, or the same user).
* If omitted, defaults to the current user from SpawnContext.
*/
runAsUserId?: string;
}
/** Inngest event payload to send on sub-agent completion */
export interface InngestCompletionEvent {
/** The Inngest event name (e.g., "sub-agent/completed"). */
name: string;
/** Optional additional data to merge into the event payload alongside the job result. */
data?: Record<string, unknown>;
}
/** Input for the checkAgent tool */
export interface CheckAgentInput {
/** The job ID returned by spawnAgent (async mode). */
jobId: string;
/** If true and job is still running, return partial output (messages so far). Default: false. */
includePartial?: boolean;
}
/** Input for the abortAgent tool */
export interface AbortAgentInput {
/** The job ID of the running sub-agent to cancel. */
jobId: string;
}
/** Output for the spawnAgent tool (sync mode) */
export interface SpawnAgentSyncOutput {
mode: 'sync';
jobId: string;
convoId: string;
/** Final assistant message text from the sub-agent. */
result: string;
/** All messages from the sub-agent conversation (user + assistant turns). */
messages: AiMessageForUi[];
/** Token usage from the sub-agent inference. */
usage: {
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
totalTokens: number;
};
/** Number of tool-use steps the sub-agent took. */
steps: number;
/** Final state of the job. */
state: 'completed' | 'failed' | 'aborted';
/** Error message if state is 'failed'. */
error?: string;
}
/** Output for the spawnAgent tool (async mode) */
export interface SpawnAgentAsyncOutput {
mode: 'async';
jobId: string;
convoId: string;
state: 'running';
}
/** Output for the checkAgent tool */
export interface CheckAgentOutput {
jobId: string;
state: SubAgentJobState;
/** Present when state is 'completed'. Final assistant message text. */
result?: string;
/** Present when state is 'completed' or includePartial is true. */
messages?: AiMessageForUi[];
/** Present when state is 'completed'. */
usage?: SpawnAgentSyncOutput['usage'];
/** Present when state is 'completed'. */
steps?: number;
/** Present when state is 'failed'. */
error?: string;
}
/** Output for the abortAgent tool */
export interface AbortAgentOutput {
jobId: string;
state: 'aborted';
/** Partial output captured before abort, if any. */
partialResult?: string;
}
/** Internal job record tracked by SubAgentManager */
export interface SubAgentJobRecord {
jobId: string;
convoId: string;
botId: string;
parentConvoId: string;
mode: SpawnMode;
state: SubAgentJobState;
createdAt: Date;
startedAt?: Date;
completedAt?: Date;
/** The AbortController for cancelling inference. */
abortController: AbortController;
/** Resolved when inference completes. */
completionPromise?: Promise<SpawnAgentSyncOutput>;
/** Resolves/rejects the completion promise. */
completionResolve?: (output: SpawnAgentSyncOutput) => void;
completionReject?: (error: Error) => void;
/** Cached output (set on completion). */
output?: SpawnAgentSyncOutput;
/** Error message (set on failure). */
error?: string;
/** Inngest event to fire on completion (from SpawnAgentInput). */
completionEvent?: InngestCompletionEvent;
/** User ID the sub-agent runs under (from SpawnAgentInput, or parent user). */
effectiveUserId: string;
}
2.2 Verify
- Types compile without errors against existing package types
- No circular dependencies (types.ts imports only from
@dakoda/convo/types, @dakoda/bot/shared/aiModels, @dakoda/database)
Task 3 — Build SubAgentManager
Singleton service that manages the lifecycle of all spawned sub-agents. Uses the AgentJob DB table as the source of truth for persistence, and keeps an in-memory cache of running jobs (with their AbortController) for real-time control.
3.1 Create SubAgentManager
Create packages/@erikdakoda/inference/sub-agent/SubAgentManager.ts:
import { getEnhancedExtendedPrisma } from '@dakoda/database/server/getEnhancedExtendedPrisma';
import { SubAgentJobRecord, SubAgentJobState, type SpawnMode } from './types';
export class SubAgentManager {
private static instance: SubAgentManager;
/** In-memory cache of running jobs (AbortController only lives in memory). */
private runningJobs: Map<string, SubAgentJobRecord> = new Map();
private constructor() {}
static getInstance(): SubAgentManager {
if (!SubAgentManager.instance) {
SubAgentManager.instance = new SubAgentManager();
}
return SubAgentManager.instance;
}
/** Create a job: persist to DB and register in-memory. */
async createJob(params: {
jobId: string;
botId: string;
parentConvoId: string;
ownerId: string;
triggeredById: string;
mode: SpawnMode;
inputMessage: string;
botRef: object;
overrides?: object;
completionEventName?: string;
completionEventData?: object;
depth: number;
}): Promise<SubAgentJobRecord> {
const db = await getEnhancedExtendedPrisma();
// Persist to DB
await db.agentJob.create({
data: {
id: params.jobId,
name: `sub-agent-${params.jobId}`,
botId: params.botId,
parentConvoId: params.parentConvoId,
ownerId: params.ownerId,
triggeredById: params.triggeredById,
mode: params.mode,
inputMessage: params.inputMessage,
botRef: params.botRef,
overrides: params.overrides ?? null,
completionEventName: params.completionEventName ?? null,
completionEventData: params.completionEventData ?? null,
depth: params.depth,
state: 'pending',
},
});
// Register in-memory
const abortController = new AbortController();
let completionResolve!: (output: any) => void;
let completionReject!: (error: Error) => void;
const completionPromise = new Promise((resolve, reject) => {
completionResolve = resolve;
completionReject = reject;
});
const record: SubAgentJobRecord = {
jobId: params.jobId,
convoId: '',
botId: params.botId,
parentConvoId: params.parentConvoId,
mode: params.mode,
state: 'pending',
createdAt: new Date(),
abortController,
completionPromise,
completionResolve,
completionReject,
effectiveUserId: params.ownerId,
};
this.runningJobs.set(params.jobId, record);
return record;
}
/** Get a running job's in-memory record (for AbortController, completion promise, etc.). */
getRunningJob(jobId: string): SubAgentJobRecord | undefined {
return this.runningJobs.get(jobId);
}
/** Get any job from DB (for checkAgent, which may query completed jobs no longer in memory). */
async getJob(jobId: string): Promise<any | null> {
// Check memory first (running jobs have richer data like AbortController)
const running = this.runningJobs.get(jobId);
if (running) return running;
const db = await getEnhancedExtendedPrisma();
return db.agentJob.findUnique({ where: { id: jobId } });
}
/** Update job state in both DB and memory. */
async setJobState(jobId: string, state: SubAgentJobState, error?: string): Promise<void> {
const db = await getEnhancedExtendedPrisma();
const updateData: any = { state };
if (error) updateData.error = error;
if (state === 'running') updateData.startedAt = new Date();
if (state === 'completed' || state === 'failed' || state === 'aborted') {
updateData.completedAt = new Date();
}
await db.agentJob.update({ where: { id: jobId }, data: updateData });
const job = this.runningJobs.get(jobId);
if (job) {
job.state = state;
if (error) job.error = error;
}
}
/** Set job output: persist result to DB and resolve the in-memory completion promise. */
async setJobOutput(jobId: string, output: any): Promise<void> {
const db = await getEnhancedExtendedPrisma();
await db.agentJob.update({
where: { id: jobId },
data: {
state: 'completed',
convoId: output.convoId,
result: output.result,
inputTokens: output.usage.inputTokens,
outputTokens: output.usage.outputTokens,
reasoningTokens: output.usage.reasoningTokens,
totalTokens: output.usage.totalTokens,
steps: output.steps,
completedAt: new Date(),
},
});
const job = this.runningJobs.get(jobId);
if (job) {
job.output = output;
job.convoId = output.convoId;
job.completionResolve?.(output);
}
}
/** Set job error: persist to DB and reject the in-memory completion promise. */
async setJobError(jobId: string, error: Error): Promise<void> {
const db = await getEnhancedExtendedPrisma();
await db.agentJob.update({
where: { id: jobId },
data: { state: 'failed', error: error.message, completedAt: new Date() },
});
const job = this.runningJobs.get(jobId);
if (job) {
job.error = error.message;
job.completionReject?.(error);
}
}
/** Mark Inngest completion event as fired in DB. */
async markCompletionEventFired(jobId: string): Promise<void> {
const db = await getEnhancedExtendedPrisma();
await db.agentJob.update({
where: { id: jobId },
data: { completionEventFired: true },
});
}
/** Abort a running job. */
async abortJob(jobId: string): Promise<boolean> {
const job = this.runningJobs.get(jobId);
if (!job) return false;
if (job.state !== 'running' && job.state !== 'pending') return false;
job.abortController.abort();
job.state = 'aborted';
await this.setJobState(jobId, 'aborted');
return true;
}
/** Remove completed jobs from the in-memory cache (DB records persist for audit). */
evictFromMemory(jobId: string): void {
this.runningJobs.delete(jobId);
}
/** Get all running jobs (for monitoring/health). */
getRunningJobs(): SubAgentJobRecord[] {
return Array.from(this.runningJobs.values());
}
/** Clean up in-memory cache for completed jobs older than maxAgeMs. */
cleanupMemory(maxAgeMs: number = 30 * 60 * 1000): void {
const cutoff = Date.now() - maxAgeMs;
for (const [id, job] of this.runningJobs) {
if (job.completedAt && job.completedAt.getTime() < cutoff) {
this.runningJobs.delete(id);
}
}
}
}
3.2 Key design: DB as source of truth, memory for real-time control
- DB (
AgentJob table): Persists all job data — survives restarts, provides audit trail, powers checkAgent for stale/completed jobs.
- In-memory (
runningJobs Map): Holds the AbortController and completion promise — these cannot be persisted. Only running/pending jobs are in memory.
getJob() checks memory first, then falls back to DB — checkAgent can query any job, even if the server restarted.
evictFromMemory() removes completed jobs from the cache without deleting the DB record.
3.3 Startup recovery: fire unfired Inngest events
On BotCraft startup, SubAgentManager should check for completed async jobs where completionEventFired = false and fire their events. This handles the case where the server restarts between sub-agent completion and event dispatch:
async recoverUnfiredEvents(): Promise<void> {
const db = await getEnhancedExtendedPrisma();
const unfiredJobs = await db.agentJob.findMany({
where: {
completionEventFired: false,
NOT: { completionEventName: null },
state: { in: ['completed', 'failed', 'aborted'] },
},
});
for (const job of unfiredJobs) {
try {
const { sendInngestEvent } = await import('@dakoda/queue/server/sendInngestEvent');
await sendInngestEvent({
name: job.completionEventName!,
data: {
...(job.completionEventData as Record<string, unknown>),
jobId: job.id,
convoId: job.convoId,
result: job.result,
state: job.state,
steps: job.steps,
usage: {
inputTokens: job.inputTokens,
outputTokens: job.outputTokens,
reasoningTokens: job.reasoningTokens,
totalTokens: job.totalTokens,
},
},
});
await this.markCompletionEventFired(job.id);
} catch (err: any) {
console.error(`Recovery: failed to fire event for job ${job.id}:`, err.message);
}
}
}
3.4 Verify
createJob persists to DB and creates in-memory record
getJob returns running jobs from memory, completed jobs from DB
setJobState updates both DB and memory
abortJob fires AbortController and updates DB state
- Server restart preserves completed job data
recoverUnfiredEvents fires pending Inngest events on startup
Task 4 — Build SubAgentJob (Inference Runner)
The core logic that resolves a Bot’s configuration, creates a conversation in the DB, and runs a sub-agent inference via streamText(). This is the in-process equivalent of what an external MCP server would do over HTTP.
4.1 Create SubAgentJob
Create packages/@erikdakoda/inference/sub-agent/SubAgentJob.ts:
This module exports a single function runSubAgent() that:
- Resolves the Bot configuration from the DB
- Creates a Convo record in the DB
- Applies overrides (model, system prompt, temperature, etc.)
- Collects tools (native AI tools + MCP tools if McpServerManager is available)
- Calls
streamText() with the sub-agent’s configuration
- Consumes the stream to completion
- Persists the sub-agent’s messages to the DB
- Returns the final result
Key implementation details:
import { streamText, consumeStream, type LanguageModel } from 'ai';
import { getEnhancedExtendedPrisma } from '@dakoda/database/server/getEnhancedExtendedPrisma';
import { getLanguageModel } from '@dakoda/bot/server/getLanguageModel';
import { computeBotSystemPrompt } from '@dakoda/bot/server/computeBotSystemPrompt';
import getModelParameters from '@dakoda/bot/server/getModelParameters';
import { getAiTools, getSelectedAiTools } from '@dakoda/ai-tool/aiToolRegistry';
import { getNewConvo } from '@dakoda/convo/shared/getNewConvo';
import { getNewAiMessage, convertAiMessageMetadata } from '@dakoda/convo/shared/getNewAiMessage';
import { generateId } from '@dakoda/utils/crypto';
import { SubAgentManager } from './SubAgentManager';
import type {
SubAgentBotRef,
SubAgentOverrides,
SubAgentJobRecord,
SpawnAgentSyncOutput,
} from './types';
import type { AiModelName, IntegrationName } from '@dakoda/bot/shared/aiModels';
import type { AiMessageForUi } from '@dakoda/convo/types';
export interface RunSubAgentParams {
jobId: string;
botRef: SubAgentBotRef;
message: string;
overrides?: SubAgentOverrides;
/** The user whose context the sub-agent runs under. Defaults to parent user if runAsUserId was not provided. */
userId: string;
/** The parent conversation ID (for linking/tracking). */
parentConvoId: string;
/** Inngest event to fire on completion (async mode only). */
completionEvent?: InngestCompletionEvent;
}
export async function runSubAgent(params: RunSubAgentParams): Promise<SpawnAgentSyncOutput> {
const manager = SubAgentManager.getInstance();
const db = await getEnhancedExtendedPrisma();
const job = manager.getJob(params.jobId);
if (!job) throw new Error(`Job ${params.jobId} not found`);
manager.setJobState(params.jobId, 'running');
try {
// ── 1. Resolve Bot ──
const bot = await resolveBot(db, params.botRef);
// ── 2. Create Convo ──
const user = await db.user.findUniqueOrThrow({ where: { id: params.userId } });
const convoInput = getNewConvo(user, bot);
const convo = await db.convo.create({ data: convoInput });
manager.getJob(params.jobId)!.convoId = convo.id;
// ── 3. Save initial user message ──
const userMessage: AiMessageForUi = {
id: generateId('msg'),
role: 'user',
parts: [{ type: 'text', text: params.message }],
createdAt: new Date(),
};
const aiMessageInput = getNewAiMessage(userMessage, {
id: convo.id,
ownerId: params.userId,
spaceId: null,
serviceTier: convo.serviceTier,
});
await db.aiMessage.create({ data: aiMessageInput });
// ── 4. Resolve model + parameters ──
const { integration, modelName } = resolveModel(bot, params.overrides);
const model = getLanguageModel(
integration as IntegrationName,
modelName as AiModelName,
'responses',
{ posthogDistinctId: params.userId, posthogTraceId: params.jobId },
) as LanguageModel;
const mergedConvo = { ...convo, ...resolveOverrides(bot, params.overrides) };
const modelParameters = getModelParameters(mergedConvo);
// ── 5. Resolve system prompt ──
let systemPrompt = await computeBotSystemPrompt(bot);
if (params.overrides?.systemPromptOverride) {
systemPrompt = params.overrides.systemPromptOverride;
}
if (params.overrides?.systemPromptAppend) {
systemPrompt += '\n\n' + params.overrides.systemPromptAppend;
}
// ── 6. Collect tools ──
const tools = resolveTools(params.overrides?.toolFilter);
// ── 7. Run inference ──
const result = streamText({
model,
system: systemPrompt,
messages: [{ role: 'user', content: params.message }],
tools,
maxSteps: params.overrides?.maxSteps ?? 25,
...modelParameters,
abortSignal: job.abortController.signal,
});
// ── 8. Consume stream to completion ──
const fullResult = await result.consumeStream();
const messages = await result.responseMessages;
const usage = result.usage;
const steps = result.steps.length;
// ── 9. Extract final assistant message ──
const finalAssistantMessage = messages.filter(m => m.role === 'assistant').pop();
const resultText = finalAssistantMessage
? extractTextFromMessage(finalAssistantMessage)
: '(no response)';
// ── 10. Persist sub-agent messages to DB ──
const assistantUiMessage: AiMessageForUi = {
id: generateId('msg'),
role: 'assistant',
parts: finalAssistantMessage?.content
? Array.isArray(finalAssistantMessage.content)
? finalAssistantMessage.content
: [{ type: 'text', text: String(finalAssistantMessage.content) }]
: [],
createdAt: new Date(),
inputTokens: usage.promptTokens,
outputTokens: usage.completionTokens,
totalTokens: usage.totalTokens,
};
convertAiMessageMetadata(assistantUiMessage);
const assistantAiMessageInput = getNewAiMessage(assistantUiMessage, {
id: convo.id,
ownerId: params.userId,
spaceId: null,
serviceTier: convo.serviceTier,
});
await db.aiMessage.create({ data: assistantAiMessageInput });
// ── 11. Build output ──
const output: SpawnAgentSyncOutput = {
mode: 'sync',
jobId: params.jobId,
convoId: convo.id,
result: resultText,
messages: [userMessage, assistantUiMessage],
usage: {
inputTokens: usage.promptTokens,
outputTokens: usage.completionTokens,
reasoningTokens: (usage as any).outputTokenDetails?.reasoningTokens ?? 0,
totalTokens: usage.totalTokens,
},
steps,
state: 'completed',
};
manager.setJobOutput(params.jobId, output);
manager.setJobState(params.jobId, 'completed');
// ── 12. Fire Inngest completion event (async mode only) ──
if (params.completionEvent) {
try {
const { sendInngestEvent } = await import('@dakoda/queue/server/sendInngestEvent');
await sendInngestEvent({
name: params.completionEvent.name,
data: {
...params.completionEvent.data,
jobId: params.jobId,
convoId: convo.id,
result: resultText,
state: output.state,
steps,
usage: output.usage,
},
});
} catch (err: any) {
// Log but don't fail — the sub-agent completed successfully;
// the event dispatch is a best-effort side effect.
console.error(`Failed to send Inngest completion event "${params.completionEvent.name}":`, err.message);
}
}
return output;
} catch (err: any) {
if (err.name === 'AbortError') {
manager.setJobState(params.jobId, 'aborted');
} else {
manager.setJobState(params.jobId, 'failed', err.message);
}
// Fire Inngest event even on failure/abort so workflows can handle it
if (params.completionEvent) {
try {
const { sendInngestEvent } = await import('@dakoda/queue/server/sendInngestEvent');
await sendInngestEvent({
name: params.completionEvent.name,
data: {
...params.completionEvent.data,
jobId: params.jobId,
convoId: '',
result: '',
state: err.name === 'AbortError' ? 'aborted' : 'failed',
error: err.message,
},
});
} catch {
// Best-effort
}
}
throw err;
}
}
4.2 Helper: resolveBot
async function resolveBot(db: any, botRef: SubAgentBotRef) {
if ('botId' in botRef) {
return db.bot.findUniqueOrThrow({ where: { id: botRef.botId }, include: { childPrompts: true } });
}
return db.bot.findFirstOrThrow({
where: { group: botRef.botGroup, handle: botRef.botHandle },
include: { childPrompts: true },
});
}
4.3 Helper: resolveModel
function resolveModel(bot: any, overrides?: SubAgentOverrides) {
if (overrides?.modelOverride) {
const [integration, modelName] = overrides.modelOverride.split(':');
if (!integration || !modelName) {
throw new Error(`Invalid modelOverride format: "${overrides.modelOverride}". Expected "integration:modelName" e.g. "openai:gpt-5.4-nano".`);
}
return { integration, modelName };
}
return { integration: bot.integration, modelName: bot.modelName };
}
4.4 Helper: resolveOverrides
Merges override parameters into the convo-like object for getModelParameters:
function resolveOverrides(bot: any, overrides?: SubAgentOverrides) {
return {
integration: bot.integration,
modelName: bot.modelName,
ownerId: bot.ownerId,
temperature: overrides?.temperature ?? bot.temperature,
reasoningEffort: overrides?.reasoningEffort ?? bot.reasoningEffort,
verbosity: bot.verbosity,
serviceTier: bot.serviceTier,
...overrides?.modelOverride ? (() => {
const [integration, modelName] = overrides.modelOverride!.split(':');
return { integration, modelName };
})() : {},
};
}
function resolveTools(toolFilter?: string[]): Record<string, any> {
const allTools = getAiTools();
if (!toolFilter || toolFilter.length === 0) {
return allTools;
}
return getSelectedAiTools(toolFilter);
}
function extractTextFromMessage(message: any): string {
if (typeof message.content === 'string') return message.content;
if (Array.isArray(message.content)) {
return message.content
.filter((part: any) => part.type === 'text')
.map((part: any) => part.text)
.join('\n');
}
return String(message.content ?? '');
}
When McpServerManager from Plan 1 is available, resolveTools should also include MCP tools bound to the sub-agent’s Bot:
// Only if McpServerManager is available (Plan 1 dependency)
import { McpServerManager } from '@erikdakoda/mcp-lifecycle';
async function resolveTools(botId: string, toolFilter?: string[]): Promise<Record<string, any>> {
const nativeTools = getAiTools();
let mcpTools: Record<string, any> = {};
try {
const mcpManager = McpServerManager.getInstance();
if (mcpManager) {
mcpTools = await mcpManager.getToolsForBot(botId);
}
} catch {
// McpServerManager not available (Plan 1 not yet deployed)
}
const allTools = { ...nativeTools, ...mcpTools };
if (!toolFilter || toolFilter.length === 0) {
return allTools;
}
return Object.fromEntries(
Object.entries(allTools).filter(([name]) => toolFilter.includes(name))
);
}
For the initial implementation (before Plan 1 is complete), use the simpler version without MCP tools.
4.8 Verify
runSubAgent() can create a convo, run inference, and return results
- Abort signal correctly cancels inference
- Messages are persisted to DB
- Error states are properly tracked in SubAgentManager
Define the spawnAgent tool schema and wire its execute handler to runSubAgent().
Create packages/@erikdakoda/ai-tool/ai-tools/spawnAgent.ts:
import { tool, zodSchema } from 'ai';
import { z } from 'zod';
import type { AiTool } from '@dakoda/ai-tool/types';
export const spawnAgentInputSchema = z.object({
bot: z.union([
z.object({ botId: z.string().describe('Direct Bot ID to spawn as') }),
z.object({
botGroup: z.string().describe('Bot group (e.g., "forge")'),
botHandle: z.string().describe('Bot handle within group (e.g., "task-runner")'),
}),
]).describe('The Bot to spawn as. Identified by ID or group+handle.'),
message: z.string().describe('The initial user message to send to the sub-agent.'),
mode: z.enum(['sync', 'async']).default('sync')
.describe('sync: block until sub-agent completes. async: return job ID immediately.'),
overrides: z.object({
modelOverride: z.string().optional()
.describe('Override model. Format: "integration:modelName" e.g. "openai:gpt-5.4-nano".'),
systemPromptOverride: z.string().optional()
.describe('Replace the Bot\'s system prompt entirely.'),
systemPromptAppend: z.string().optional()
.describe('Append extra instructions to the Bot\'s system prompt.'),
temperature: z.number().min(0).max(2).optional()
.describe('Override temperature.'),
reasoningEffort: z.enum(['minimal', 'low', 'medium', 'high']).optional()
.describe('Override reasoning effort.'),
maxSteps: z.number().min(1).max(100).default(25)
.describe('Max tool-use steps the sub-agent may take.'),
toolFilter: z.array(z.string()).optional()
.describe('Whitelist of tool names. Empty = all available tools.'),
}).optional(),
completionEvent: z.object({
name: z.string().describe('Inngest event name to fire on completion (e.g., "sub-agent/completed"). Only valid in async mode.'),
data: z.record(z.unknown()).optional()
.describe('Optional additional data to merge into the event payload alongside the job result.'),
}).optional()
.describe('An Inngest event to trigger when the sub-agent completes. Enables downstream Inngest functions to continue a workflow. Only valid in async mode.'),
runAsUserId: z.string().optional()
.describe('User ID whose context the sub-agent should run under. If provided, the sub-agent creates the Convo and messages under this user instead of the current user. This enables user sessions to trigger background agent sessions running under a different user\'s account. If omitted, defaults to the current user.'),
});
export const spawnAgentOutputSchema = z.union([
z.object({
mode: z.literal('sync'),
jobId: z.string(),
convoId: z.string(),
result: z.string(),
messages: z.array(z.any()),
usage: z.object({
inputTokens: z.number(),
outputTokens: z.number(),
reasoningTokens: z.number(),
totalTokens: z.number(),
}),
steps: z.number(),
state: z.enum(['completed', 'failed', 'aborted']),
error: z.string().optional(),
}),
z.object({
mode: z.literal('async'),
jobId: z.string(),
convoId: z.string(),
state: z.literal('running'),
}),
]);
export type SpawnAgentInput = z.infer<typeof spawnAgentInputSchema>;
export type SpawnAgentOutput = z.infer<typeof spawnAgentOutputSchema>;
export const spawnAgent: AiTool<unknown, SpawnAgentInput, SpawnAgentOutput> = {
id: 'spawnAgent',
group: 'sub-agent',
userSelect: false,
tool: tool({
description:
'Spawn a sub-agent that runs as a specified Bot with its own conversation. ' +
'The sub-agent gets its own model, system prompt, and tools, and can execute multi-step workflows. ' +
'In sync mode (default), blocks until the sub-agent completes and returns the result. ' +
'In async mode, returns a job ID immediately; use checkAgent to poll for results. ' +
'Optionally specify completionEvent to trigger an Inngest workflow on completion (async only). ' +
'Optionally specify runAsUserId to run the sub-agent under a different user\'s context.',
inputSchema: zodSchema(spawnAgentInputSchema),
outputSchema: zodSchema(spawnAgentOutputSchema),
}),
};
Create packages/@erikdakoda/ai-tool/ai-server-tools/spawnAgent.ts:
import { spawnAgent } from '../ai-tools/spawnAgent';
import { registerAiTool } from '@dakoda/ai-tool/aiToolRegistry';
import { runSubAgent } from '@dakoda/inference/sub-agent/SubAgentJob';
import { SubAgentManager } from '@dakoda/inference/sub-agent/SubAgentManager';
import { generateId } from '@dakoda/utils/crypto';
spawnAgent.tool.execute = async (input, { messages, abortSignal }) => {
const manager = SubAgentManager.getInstance();
const jobId = generateId('job');
// Extract parent convo context from the execution context.
// The parent's authenticated user ID is passed through a module-level context
// set by ChatPostHandler before the inference call (see Task 7).
const context = getSpawnContext();
// ── Resolve effective user ──
// If runAsUserId is provided, the sub-agent runs under that user's context.
// Authorization: the caller must be the same user, or the context must allow delegation.
const effectiveUserId = input.runAsUserId ?? context.userId;
if (input.runAsUserId && input.runAsUserId !== context.userId) {
// Verify the caller is authorized to spawn as this user.
// Authorization: allow if same user, admin, or in delegateUserIds list.
const isDelegate = context.delegateUserIds?.includes(input.runAsUserId) ?? false;
if (!isDelegate) {
const db = await getEnhancedExtendedPrisma();
const caller = await db.user.findUnique({ where: { id: context.userId } });
if (!caller?.admin) {
return {
mode: 'sync' as const,
jobId: '',
convoId: '',
result: '',
messages: [],
usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, totalTokens: 0 },
steps: 0,
state: 'failed' as const,
error: `Not authorized to spawn sub-agent as user ${input.runAsUserId}.`,
};
}
}
}
// ── Validate completionEvent is only used with async mode ──
if (input.completionEvent && input.mode !== 'async') {
return {
mode: 'sync' as const,
jobId: '',
convoId: '',
result: '',
messages: [],
usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, totalTokens: 0 },
steps: 0,
state: 'failed' as const,
error: 'completionEvent is only valid in async mode.',
};
}
const job = manager.createJob({
jobId,
convoId: '', // will be set by runSubAgent after convo creation
botId: '',
parentConvoId: context.parentConvoId,
mode: input.mode ?? 'sync',
completionEvent: input.completionEvent,
effectiveUserId,
});
const runParams = {
jobId,
botRef: input.bot,
message: input.message,
overrides: input.overrides,
userId: effectiveUserId,
parentConvoId: context.parentConvoId,
completionEvent: input.completionEvent,
};
if (input.mode === 'async') {
// Fire and forget — run in background
runSubAgent(runParams).catch((err) => {
// Error is already tracked in SubAgentManager
});
return {
mode: 'async' as const,
jobId,
convoId: job.convoId || '',
state: 'running' as const,
};
}
// Sync mode: block until sub-agent completes
try {
return await runSubAgent(runParams);
} catch (err: any) {
return {
mode: 'sync' as const,
jobId,
convoId: job.convoId || '',
result: '',
messages: [],
usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, totalTokens: 0 },
steps: 0,
state: 'failed' as const,
error: err.message,
};
}
};
registerAiTool(spawnAgent);
6.3 Verify
- Tool definition compiles
spawnAgent appears in aiToolRegistry after import
- Input schema validates both
botId and group+handle forms
- Sync mode blocks and returns result
- Async mode returns immediately with job ID
Define the checkAgent tool that queries the SubAgentManager for job status and results.
Create packages/@erikdakoda/ai-tool/ai-tools/checkAgent.ts:
import { tool, zodSchema } from 'ai';
import { z } from 'zod';
import type { AiTool } from '@dakoda/ai-tool/types';
export const checkAgentInputSchema = z.object({
jobId: z.string().describe('The job ID returned by spawnAgent (async mode).'),
includePartial: z.boolean().default(false)
.describe('If true and job is still running, return partial output (messages so far).'),
});
export const checkAgentOutputSchema = z.object({
jobId: z.string(),
state: z.enum(['pending', 'running', 'completed', 'failed', 'aborted']),
result: z.string().optional(),
messages: z.array(z.any()).optional(),
usage: z.object({
inputTokens: z.number(),
outputTokens: z.number(),
reasoningTokens: z.number(),
totalTokens: z.number(),
}).optional(),
steps: z.number().optional(),
error: z.string().optional(),
});
export type CheckAgentInput = z.infer<typeof checkAgentInputSchema>;
export type CheckAgentOutput = z.infer<typeof checkAgentOutputSchema>;
export const checkAgent: AiTool<unknown, CheckAgentInput, CheckAgentOutput> = {
id: 'checkAgent',
group: 'sub-agent',
userSelect: false,
tool: tool({
description:
'Check the status and retrieve results from a previously spawned sub-agent. ' +
'Use this after spawnAgent in async mode to poll for completion.',
inputSchema: zodSchema(checkAgentInputSchema),
outputSchema: zodSchema(checkAgentOutputSchema),
}),
};
Create packages/@erikdakoda/ai-tool/ai-server-tools/checkAgent.ts:
import { checkAgent } from '../ai-tools/checkAgent';
import { registerAiTool } from '@dakoda/ai-tool/aiToolRegistry';
import { SubAgentManager } from '@dakoda/inference/sub-agent/SubAgentManager';
checkAgent.tool.execute = async (input) => {
const manager = SubAgentManager.getInstance();
const job = manager.getJob(input.jobId);
if (!job) {
return {
jobId: input.jobId,
state: 'failed' as const,
error: `Job ${input.jobId} not found. It may have expired or never existed.`,
};
}
const output: any = {
jobId: job.jobId,
state: job.state,
};
if (job.state === 'completed' && job.output) {
output.result = job.output.result;
output.messages = job.output.messages;
output.usage = job.output.usage;
output.steps = job.output.steps;
}
if (job.state === 'failed') {
output.error = job.error;
}
if (input.includePartial && job.state === 'running') {
// Note: partial results require additional tracking in SubAgentJob.
// For the initial implementation, partial results are not supported.
// A future enhancement can stream partial messages from the running inference.
output.result = '(sub-agent is still running; partial results not yet available)';
}
return output;
};
registerAiTool(checkAgent);
5.3 Verify
checkAgent returns correct status for completed, running, failed, and unknown jobs
- Output matches the schema
Define the abortAgent tool that cancels a running sub-agent.
Create packages/@erikdakoda/ai-tool/ai-tools/abortAgent.ts:
import { tool, zodSchema } from 'ai';
import { z } from 'zod';
import type { AiTool } from '@dakoda/ai-tool/types';
export const abortAgentInputSchema = z.object({
jobId: z.string().describe('The job ID of the running sub-agent to cancel.'),
});
export const abortAgentOutputSchema = z.object({
jobId: z.string(),
state: z.literal('aborted'),
partialResult: z.string().optional(),
});
export type AbortAgentInput = z.infer<typeof abortAgentInputSchema>;
export type AbortAgentOutput = z.infer<typeof abortAgentOutputSchema>;
export const abortAgent: AiTool<unknown, AbortAgentInput, AbortAgentOutput> = {
id: 'abortAgent',
group: 'sub-agent',
userSelect: false,
tool: tool({
description:
'Abort a running sub-agent inference. The sub-agent will stop at the next opportunity. ' +
'Any partial results or tool calls in progress will be discarded.',
inputSchema: zodSchema(abortAgentInputSchema),
outputSchema: zodSchema(abortAgentOutputSchema),
}),
};
Create packages/@erikdakoda/ai-tool/ai-server-tools/abortAgent.ts:
import { abortAgent } from '../ai-tools/abortAgent';
import { registerAiTool } from '@dakoda/ai-tool/aiToolRegistry';
import { SubAgentManager } from '@dakoda/inference/sub-agent/SubAgentManager';
abortAgent.tool.execute = async (input) => {
const manager = SubAgentManager.getInstance();
const success = manager.abortJob(input.jobId);
if (!success) {
const job = manager.getJob(input.jobId);
return {
jobId: input.jobId,
state: 'aborted' as const,
partialResult: job?.state === 'aborted'
? '(job was already aborted)'
: `(job not found or in state: ${job?.state ?? 'unknown'})`,
};
}
return {
jobId: input.jobId,
state: 'aborted' as const,
};
};
registerAiTool(abortAgent);
6.3 Verify
- Aborting a running job cancels its
streamText() call
- Aborting a completed/unknown job returns gracefully
- The
AbortController signal propagates correctly to streamText()
Task 8 — Implement Spawn Context Passing
The spawn tools need access to the authenticated user ID and parent conversation ID from the parent’s ChatPostHandler invocation. Since tool execution runs within the streamText() call, we need a mechanism to pass this context.
8.1 Problem
spawnAgent.tool.execute is called by Vercel AI SDK during streamText(). It does not receive the authenticated session or conversation ID as parameters. These are only available in ChatPostHandler.
8.2 Solution: AsyncLocalStorage context
Use Node.js AsyncLocalStorage to pass context from ChatPostHandler into tool execution, without modifying the tool signature:
Create packages/@erikdakoda/inference/sub-agent/spawnContext.ts:
import { AsyncLocalStorage } from 'node:async_hooks';
export interface SpawnContext {
userId: string;
parentConvoId: string;
parentConvoOwnerId: string;
parentConvoSpaceId: string | null;
}
const spawnContextStore = new AsyncLocalStorage<SpawnContext>();
export function setSpawnContext(context: SpawnContext): void {
// This is called inside the streamText() call — the store is already active
// We need to set it BEFORE streamText is called
spawnContextStore.enterWith(context);
}
export function getSpawnContext(): SpawnContext {
const context = spawnContextStore.getStore();
if (!context) {
throw new Error(
'SpawnContext not available. spawnAgent can only be used within a ChatPostHandler request.'
);
}
return context;
}
export function runWithSpawnContext<R>(context: SpawnContext, fn: () => R): R {
return spawnContextStore.run(context, fn);
}
8.3 Modify ChatPostHandler
In ChatPostHandler.ts, wrap the streamText() call with the spawn context:
import { runWithSpawnContext } from '@dakoda/inference/sub-agent/spawnContext';
// ... inside ChatPostHandler, before streamText() ...
const spawnContext = {
userId: session.user.id,
parentConvoId: convo.id,
parentConvoOwnerId: convo.ownerId,
parentConvoSpaceId: convo.spaceId,
delegateUserIds: [], // populated from user's delegate allowlist if applicable
spawnDepth: 0,
};
const result = runWithSpawnContext(spawnContext, () =>
streamText({
model,
system: systemPrompt,
messages: modelMessages,
tools: getAiTools(),
...modelParameters,
abortSignal: request.signal,
})
);
Important: runWithSpawnContext must wrap the streamText() CALL, not just its result. The streamText() function starts executing synchronously and returns a stream object — the context must be active during this synchronous phase so that any tool calls that happen during the stream’s execution can access it.
However, streamText() returns a stream object immediately and processes asynchronously. The AsyncLocalStorage.run() context is available to async continuations within the call. Since Vercel AI SDK’s tool execution runs as async callbacks within the stream pipeline, the AsyncLocalStorage context should propagate correctly.
8.4 Sub-agent’s own context
When a sub-agent is spawned in async mode, the runSubAgent() function runs outside the parent’s AsyncLocalStorage context. This is correct — the sub-agent creates its own SpawnContext if it needs to spawn further sub-agents (nested spawning).
For nested spawning, runSubAgent() should set up its own spawn context before calling streamText() for the sub-agent:
// Inside runSubAgent, when calling streamText for the sub-agent:
const subAgentContext = {
userId: params.userId,
parentConvoId: convo.id,
parentConvoOwnerId: params.userId,
parentConvoSpaceId: null,
};
const result = runWithSpawnContext(subAgentContext, () =>
streamText({
model,
system: systemPrompt,
messages: [{ role: 'user', content: params.message }],
tools,
maxSteps: params.overrides?.maxSteps ?? 25,
...modelParameters,
abortSignal: job.abortController.signal,
})
);
This enables nested sub-agent spawning (a sub-agent spawning its own sub-agents).
8.5 Verify
getSpawnContext() returns the correct context within a tool execution
getSpawnContext() throws when called outside a ChatPostHandler request
- Nested spawning works (sub-agent can spawn its own sub-agents)
Task 9 — Prevent Recursive Spawn Loops
Sub-agents should not be able to spawn themselves (directly or indirectly), as this could create infinite loops. Implement depth limiting.
9.1 Add depth tracking to SpawnContext
Update spawnContext.ts:
export interface SpawnContext {
userId: string;
parentConvoId: string;
parentConvoOwnerId: string;
parentConvoSpaceId: string | null;
/** User IDs that the current context user is authorized to delegate to (runAsUserId). Empty = no delegation allowed except self. */
delegateUserIds?: string[];
/** Current spawn depth (0 = top-level, 1 = first sub-agent, etc.) */
spawnDepth: number;
}
/** Maximum nesting depth for sub-agent spawning. */
export const MAX_SPAWN_DEPTH = 3;
9.2 Enforce depth limit in spawnAgent execute
In the spawnAgent.tool.execute handler:
const context = getSpawnContext();
if (context.spawnDepth >= MAX_SPAWN_DEPTH) {
return {
mode: 'sync' as const,
jobId: '',
convoId: '',
result: `Cannot spawn sub-agent: maximum nesting depth (${MAX_SPAWN_DEPTH}) reached.`,
messages: [],
usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, totalTokens: 0 },
steps: 0,
state: 'failed' as const,
error: `Maximum spawn depth (${MAX_SPAWN_DEPTH}) exceeded.`,
};
}
9.3 Increment depth for sub-agent context
In runSubAgent(), when creating the sub-agent’s spawn context:
const subAgentContext = {
userId: params.userId,
parentConvoId: convo.id,
parentConvoOwnerId: params.userId,
parentConvoSpaceId: null,
delegateUserIds: [],
spawnDepth: (getSpawnContext().spawnDepth ?? 0) + 1,
};
9.4 Prevent self-referential spawn
Add a check that the sub-agent is not spawning as the same Bot that is already running (which would create an infinite loop even with depth limits, since each step could re-spawn):
// In spawnAgent execute handler:
const context = getSpawnContext();
// The parent's current bot info is not directly available in the context.
// Instead, we rely on the depth limit to prevent unbounded recursion.
// Self-referential spawning within depth limits is allowed but unusual.
Since the depth limit caps the total nesting, self-referential spawning is bounded and not a critical concern.
8.5 Verify
- Spawning beyond
MAX_SPAWN_DEPTH returns an error
- Depth increments correctly for nested spawns
- Depth 0 (top-level) allows spawning depth 1, etc.
Wire the three spawn tools into BotCraft’s aiServerTools.ts so they are available at inference time.
Update apps/bot-craft/src/aiServerTools.ts:
import '@dakoda/ai-tool/ai-server-tools';
import '@dakoda/user/ai-server-tools';
import '@erikdakoda/memory/ai-server-tools';
import '@dakoda/ai-tool/ai-server-tools/spawnAgent';
import '@dakoda/ai-tool/ai-server-tools/checkAgent';
import '@dakoda/ai-tool/ai-server-tools/abortAgent';
10.2 Verify
- All three tools appear in
aiToolRegistry after BotCraft starts
getAiToolNames() includes spawnAgent, checkAgent, abortAgent
Task 11 — Unit and Integration Tests
Write tests that verify the complete sub-agent lifecycle.
11.1 Unit tests for SubAgentManager
Create packages/@erikdakoda/inference/sub-agent/SubAgentManager.unit.test.ts:
Test cases:
createJob creates a record and it’s retrievable via getJob
setJobState updates the state correctly
setJobOutput stores output and resolves the completion promise
abortJob sets state to aborted and fires the AbortController
cleanup removes old completed jobs
11.2 Unit tests for spawn context
Create packages/@erikdakoda/inference/sub-agent/spawnContext.unit.test.ts:
Test cases:
runWithSpawnContext makes context available via getSpawnContext
getSpawnContext throws when called outside a context
- Context is available in async callbacks within
runWithSpawnContext
- Nested contexts work (inner context overrides outer)
11.3 Integration test for spawn flow
Create packages/@erikdakoda/inference/sub-agent/SubAgentJob.integration.test.ts:
Test cases (may require a test DB or mock):
- Sync spawn:
spawnAgent with mode: 'sync' blocks and returns result
- Async spawn:
spawnAgent with mode: 'async' returns job ID immediately
- Check:
checkAgent returns correct status for running/completed/failed jobs
- Abort:
abortAgent cancels a running sub-agent
- Depth limit: spawning beyond
MAX_SPAWN_DEPTH returns error
- Bot resolution: both
botId and group+handle forms resolve correctly
- Bot resolution: both
botId and group+handle forms resolve correctly
- Model override:
modelOverride changes the model used by the sub-agent
- System prompt override:
systemPromptOverride and systemPromptAppend work
- Inngest completion event:
completionEvent fires on completion (async mode)
- runAsUserId: sub-agent runs under specified user’s context
- runAsUserId authorization: non-admin non-delegate users cannot spawn as another user
11.4 Verify
- All tests pass
- Test coverage ≥ 80% for new code
Task 12 — Add Spawn Metrics to Health Endpoint
Expose sub-agent job stats in BotCraft’s health endpoint for monitoring.
12.1 Extend health endpoint
In apps/bot-craft/src/app/api/health/route.ts (or create if it doesn’t exist):
import { SubAgentManager } from '@dakoda/inference/sub-agent/SubAgentManager';
// In the GET handler:
const subAgentManager = SubAgentManager.getInstance();
const subAgentStats = {
activeJobs: subAgentManager.getAllJobs().filter(j => j.state === 'running').length,
totalJobs: subAgentManager.getAllJobs().length,
pendingJobs: subAgentManager.getAllJobs().filter(j => j.state === 'pending').length,
};
12.2 Verify
/api/health returns sub-agent job counts
Task 13 — Documentation and Final Wiring
Add JSDoc, README, and inline documentation for the sub-agent system.
13.1 Add package README
Create packages/@erikdakoda/inference/sub-agent/README.md documenting:
- Architecture overview
- Tool descriptions (spawnAgent, checkAgent, abortAgent)
- Sync vs. async mode
- Bot resolution
- Overrides
- Depth limiting
- Spawn context passing
- Inngest workflow continuation via
completionEvent
- User delegation via
runAsUserId
13.2 Add inline JSDoc to all public functions and types
13.3 Add sub-agent section to BotCraft’s main README
13.4 Verify
- README is accurate and complete
- All public APIs have JSDoc
Dependency Order
Task 1 (AgentJob model) ─────────────────────────┐
↓
Task 2 (Types) ──────────────────────────────────┤
↓
Task 3 (SubAgentManager) ────────────────────────┤
↓
Task 4 (SubAgentJob / runSubAgent) ──────────────┤
│ ↓
Task 5 (spawnAgent tool) ───────────────────────┤
Task 6 (checkAgent tool) ───────────────────────┤
Task 7 (abortAgent tool) ───────────────────────┤
│ ↓
Task 8 (Spawn context passing) ─────────────────┤
│ ↓
Task 9 (Depth limiting) ────────────────────────┤
│ ↓
Task 10 (BotCraft registration) ────────────────┤
│ ↓
Task 11 (Tests) ─────────────────────────────────┤
│ ↓
Task 12 (Health endpoint) ───────────────────────┤
│ ↓
Task 13 (Documentation) ────────────────────────┘
Task 1 (AgentJob model) must come first — everything depends on the DB table. Tasks 5, 6, 7 can be done in parallel after Task 4 is complete. Tasks 8 and 9 should be done before Task 10. Task 11 depends on all previous tasks. Tasks 12 and 13 are final.
Dependency on Plan 1: This Plan is implementable without Plan 1. Without Plan 1’s McpServerManager, sub-agents use only native AI tools (via getAiTools()). When Plan 1 is complete, Task 4’s resolveTools can be enhanced to include MCP tools — this is a minor enhancement, not a blocker.